diff --git a/.agents/skills/add-permission-group-item/SKILL.md b/.agents/skills/add-permission-group-item/SKILL.md new file mode 100644 index 00000000000..c218ddf7a38 --- /dev/null +++ b/.agents/skills/add-permission-group-item/SKILL.md @@ -0,0 +1,274 @@ +--- +name: add-permission-group-item +description: Add a new governed item to Sim's enterprise permission groups — a boolean restriction, an allowlist, or a denylist — wired end-to-end from the field registry through the capability rule to the server gate that actually refuses. Use when adding a key to `PERMISSION_GROUP_FIELDS` or a capability to `CAPABILITY_RULES`. +argument-hint: +--- + +# Add Permission Group Item Skill + +You are adding one governed item an organization admin can withhold from a cohort of members. One entry in `apps/sim/lib/permission-groups/fields.ts` produces the write schema, the read schema, the `PermissionGroupConfig` type, the defaults, the tolerant parser, and (for a boolean) the admin editor row. + +**The registry does not produce enforcement.** Twelve keys once shipped with a checkbox, a hint, and no server check — an organization that ticked `hideCopilot` believed it had withheld a capability while every route still answered. Hence the `enforcement` field, the required `capability` field on every operation, and `scripts/check-permission-group-enforcement.ts`. You are done when something *refuses*, not when the key parses. + +## Read the system first + +- `lib/permission-groups/fields.ts` — registry, three field builders, `permissionGroupConfigSchema`, `tolerantArray`, `parsePermissionGroupConfig`. There is **no `types.ts`** (folded in here); the DB constraint maps live in `constraints.ts` +- `lib/permission-groups/capabilities.ts` — `CAPABILITY_IDS`, `CAPABILITY_RULES`, `capabilityRefusal`, `refuseCapability`, the static/parameterized split +- `lib/permission-groups/capability-assertions.ts` — the sanctioned assertion API; re-exports `capabilityRefusal`. `capability-error.ts` holds the thrown error, `capability-response.ts` the raw-route 403 +- `lib/permission-groups/integration-allowlist.ts` — the canonicalizing allowlist algebra, over the generated `block-successors.generated.ts` +- `lib/permission-groups/resolve.server.ts` — `resolveWorkspaceGroup`, `resolveVerifiedUserAccessControlContext`, `getUserPermissionConfig`, `getUserPermissionConfigForOrganization`, `mergeEnvAllowlist`. `ee/access-control/utils/permission-check.ts` re-exports it and keeps the executor gates +- `lib/permission-groups/config-scope.server.ts` (`resolvePermissionGroupConfig`, the per-request memo every assertion resolves through) and `request-scope.server.ts` (`withPermissionGroupScope`, deliberately import-free because `withRouteHandler` imports it) +- `lib/core/application/workspace-operation.ts` and `workspace-authorization.ts` — the required `capability` field, and the funnel +- `scripts/check-permission-group-enforcement.ts`, `check-application-graph.ts`, `check-capability-subject.ts` + +(Paths are under `apps/sim/` unless noted.) + +## Step 0: Decide what kind of thing it is + +| Kind | Builder | Default | Semantics | +|---|---|---|---| +| Boolean restriction | `booleanRestriction(enforcement, feature)` | `false` | `true` withholds. Name it `hideX` / `disableX`, never `allowX` | +| Allowlist | `allowlist(item, enforcement, { limited, empty })` | `null` | `null` allows everything; a list names the only permitted members; `[]` permits **none** | +| Denylist | `denylist(item, enforcement, phrasing)` | `[]` | Empty permits everything; members are refused | + +Allowlist when the safe posture is "only what the admin named" and the member set is enumerable (auth modes, connectors, model providers). Denylist when it is "everything except" and the set is open-ended (tool ids, models — an allowlist over a thousand tools grows a hole every time a tool ships). + +**Which mechanism refuses?** The `enforcement` value is a claim the audit checks. + +| Value | Meaning | +|---|---| +| `'capability'` | An operation declares a capability whose rule reads the key; the funnel refuses before the use case runs. Default answer for anything reachable through an application operation | +| `'executor'` | Read per block/tool/model at run time by `assertPermissionsAllowed` in `ee/access-control/utils/permission-check.ts`. Governs what a *run* may do, which no operation gate can express (one API call executes fifty blocks). Only `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` live here. The matching primitives these four keys are compared with live in `lib/permission-groups/` — `block-access.ts` (exemptions, superseded-version resolution), `operation-access.ts` (`createToolAccessGate`), `model-access.ts` (`createModelAccessGate`), `integration-allowlist.ts` — shared so the run-time gate and the editor/Copilot projections cannot drift. `allowedIntegrations` alone is also asserted outside a run, by `assertSelectorIntegrationAllowed` (`lib/selectors/server/integration-access.ts`) ahead of the provider call in `selectors.execute`, against the selector's own `resourceServiceId` / `integrationBlockTypes` rather than the credentials it accepts — reaching a provider API is a use of the integration, so a key here can still need a non-run enforcement site | +| `'ui-only'` | Hides a surface without withholding it. **Almost never right** — nothing ships as `ui-only`. Justify in the `enforcement` comment why a determined caller reaching the data is acceptable, and expect review to question it | + +**Is it per-operation at all?** `personal_api_key.use` is the one capability that is not: it withholds a *principal kind* across every operation, checked in the funnel's `personal_api_key` branch (`workspace-authorization.ts`) and again in `app/api/v1/middleware.ts`, so no operation declares it and its absence from every `capability:` field is correct rather than a hole. + +**Is the decision knowable from the config alone?** A rule needing a request value (an auth mode, a connector id) is *parameterized* and cannot be declared on an operation — see Step 3. + +**Is it a gate or a projection?** A key that withholds *fields from a response* rather than the response is a projection. `hideTraceSpans` and `hideCostInfo` work this way: the logs routes declare `capability: 'none'` and strip fields, because refusing the read would withhold the status and error message too. Projections have one owner — `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), carrying the `permission-group-enforced:` annotations. Add yours there; two copies of a redaction rule is how one of them stops redacting. Corollary: refuse the query that *selects on* a withheld field — otherwise the projection is a filter oracle; `logQuerySelectsCost` / `assertLogCostQueryAllowed` in that same module are the shape. + +## Step 1: Append the field entry — never insert + +```ts + disableWidgetSharing: booleanRestriction('capability', { + id: 'disable-widget-sharing', + label: 'Widget Sharing', + category: 'Collaboration', + hint: 'Prevent sharing a widget outside the workspace.', + }), +``` + +The second argument is the field's `feature` (`PlatformFeatureMeta`); `PLATFORM_FEATURES` spreads it and appends `configKey`, so those four values are what the editor renders. `PLATFORM_FEATURES` is *derived* from the registry in `features.ts`, so a boolean key cannot reach the config without reaching the editor. + +- **Declaration order is the wire order** of `PermissionGroupConfig`, both zod schemas, and every config JSON crossing the API. `fields.test.ts` pins it with a key-order contract test, and `ee/access-control/components/group-detail.tsx` dirty-checks by comparing stringified configs — a moved key fails the suite *and* makes every open editor read as unsaved. Extend the tail; do not tidy the middle. +- **The default must be the permissive value.** Every stored `permission_group.config` row predates your key; `parsePermissionGroupConfig` fills the gap from the default and the update route merges a partial write over the stored config, so a restrictive default silently applies a new restriction to every existing group in every enterprise org. The builders hardcode `false` / `null` / `[]`, so a new key must be *phrased* so the permissive value is falsy: a `requireWidgetApproval` whose safe default is `true` must be inverted before it can use `booleanRestriction`. +- **The checkbox is inverted.** `group-detail.tsx` renders `checked={!editingConfig[feature.configKey]}` — ticked means *allowed*, so an `allowX` name renders backwards. +- **The hint must describe access withheld, never a surface hidden.** A `'capability'` key refuses at the API; "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar while they revoke a module. The same string is read again by `getActivePermissionGroupRestrictions` in `features.ts` as the prose for an *active* restriction — reaching users through the Copilot workspace VFS and the enterprise platform context — where "hide" is simply false. Write "Revoke the Tables module. Members cannot read or write any table." `PlatformFeatureMeta.hint` carries the rule in its TSDoc. +- **The category must be in `PLATFORM_CATEGORY_ORDER`** (`features.ts`): `Modules`, `Knowledge Base`, `Tables`, `Files`, `Deployment`, `Tools`, `Logs`, `Collaboration`, `Credentials & Access`. An unlisted category renders last. Categories name what is withheld — no surface-shaped section like "Sidebar". + +## Step 2: Only booleans get an admin UI for free + +`PLATFORM_FEATURES` filters on `field.kind === 'boolean-restriction'`. An allowlist or denylist renders **nothing** — the key exists, the API accepts it, no admin can set it. + +Nested pickers hang off the `featureExtras` map in `group-detail.tsx`, keyed by the **feature id of the boolean it nests under**, not the allowlist's own config key: + +```ts + const featureExtras: Partial> = { + 'hide-knowledge-base': , + } +``` + +Copy `setKnowledgeConnectors`. Two load-bearing behaviors: + +- **Refuse an empty selection** (`if (values.length === 0) return`) — an emptied allowlist denies everyone while the parent checkbox still reads as allowed. Withholding the whole thing is what the parent is for. +- **Collapse "all selected" back to `null`** (`values.length === ALL.length ? null : values`) — storing the full set freezes the allowlist at today's members. + +Choose the parent deliberately: `allowedKnowledgeConnectors` nests under `hide-knowledge-base`, not `disable-knowledge-base-creation`, because a connector attaches to an *existing* KB — nesting under creation would dim the picker for exactly the cohort it serves. + +## Step 3: Add the capability id and rule + +Skip only for `'executor'` / `'ui-only'`. Add the id to `CAPABILITY_IDS` and the rule to `CAPABILITY_RULES` in `capabilities.ts`, which uses `satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule }` so a new id fails to compile until its rule exists. + +**Never replace that `satisfies` with a type annotation.** Annotating widens every entry to `CapabilityRule`, at which point `StaticPermissionGroupCapability` — derived by filtering the object's own entries for `kind: 'static'` — resolves to **`never`**: no operation can declare any capability, the type system goes quiet about capabilities entirely, and nothing at runtime looks wrong. `AssertsStaticCapabilityResolves` at the bottom of the file exists to catch it. Same reasoning for any of these registries. + +Capability ids are **domain-shaped** (`tables.create`); config keys are **surface-shaped** (`disableTableCreation`). `CAPABILITY_RULES` is the only place the two vocabularies meet. + +```ts + 'widgets.share': { + kind: 'static', + configKeys: ['disableWidgetSharing'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Sharing widgets', + deniedBy: (config) => config.disableWidgetSharing, + }, +``` + +`configKeys` is what the audit reads to prove your key is enforced — it must list every key `deniedBy` reads. `describe` is the subject of one shared sentence, `" is not available under your organization's permission group"`, so make it a singular noun or gerund that agrees with "is". Exactly two functions build it, both defined in `capabilities.ts`: `refuseCapability(cap)` throws it as a `PermissionGroupCapabilityError`; `capabilityRefusal(cap)` returns it as a string for a raw route rendering its own body (`capability-assertions.ts` re-exports it so an inline gate reaches both through one module). Never write the sentence at a call site. + +Use `'PERMISSION_GROUP_CAPABILITY_BLOCKED'` for `detailCode`. Four rules carry a more specific one — `deploy.chat.auth_mode` (`CHAT_AUTH_MODE_NOT_PERMITTED`), `file_share.publish` / `file_share.auth_mode` (`PUBLIC_SHARING_NOT_ALLOWED`), `personal_api_key.use` (`PERSONAL_API_KEYS_DISABLED`) — which is why a call site reads the code off the rule and never spells one out. The set in `lib/core/application/forbidden.ts` is closed **over remedies, not causes** — a new code is warranted only when the remedy differs from "ask an organization admin", and requires an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` (a compile-time gate) plus a new value in the generated OpenAPI 403 description. + +A **parameterized** rule is the same shape with `kind: 'parameterized'` and a `deniedBy` taking the request value second — `'knowledge.connectors'` is `(config, connectorType) => allowlistDenies(config.allowedKnowledgeConnectors, connectorType)`. It **cannot be declared on an operation**: the funnel decides from principal, workspace and operation, never request input, and widening it would touch all ~315 operations for the sake of two keys. `defineWorkspaceOperation` throws at definition time (`Operation declares parameterized capability ; assert it from the use case instead`) rather than letting the operation read as gated while the gate never fires. + +## Step 4: Declare it on the operations it governs, or assert it at the call site + +`capability` is **required on the `ApplicationOperation` base type** (`lib/core/application/operation.ts:31`), typed `StaticPermissionGroupCapability | 'none'` — required there, not only on `defineWorkspaceOperation`, so a bare object literal minted by a domain factory does not compile without it (five OAuth-connection operations once shipped capability-less that way) — *and* guarded at definition time (`Operation declares no capability; name one, or 'none' with a reason`). The guard is not redundant: **`apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` from type-checking** and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. An absent capability does not deny — it throws `Cannot read properties of undefined` inside `capabilityDeniedBy`, and **only for a caller whose organization actually has a permission group**. It passes CI and every personal workspace, then fails in the tenants that bought the feature. + +**Static, and the operation is the whole decision** — set `capability` and write no gate code: + +```ts +export const shareWidget = defineWorkspaceOperation({ + id: 'widgets.share', + minimumRole: 'write', + workspaceApiKey: 'allow', + capability: 'widgets.share', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], +}) +``` + +**The factory trap.** An operation minted by a factory that does not call `defineWorkspaceOperation` — a hand-frozen object — bypasses the required type *and*, once bypassed, the audit; twenty-one operations across six domains were invisible that way, and the file still printed a tick because some other operation in it was counted. The audit now matches the whole `defineOperation` family, resolves a same-file `function` factory (capability fixed in the body or taken as a positional second argument — `lib/table/application/operations.ts` shows both, with **no default** on the positional form so nothing inherits `tables.use` unreviewed), and cross-checks the members of every exported `*Operations` registry against what it parsed. Keep new operations inside an exported `*Operations` registry, mint them through a `define*Operation` builder taking an object literal with a string `id`, and use a `function` factory rather than an arrow const. + +**Static, but no operation to hang it on** — a raw route or an organization-level action. + +| Helper | Use when | +|---|---| +| `assertWorkspaceCapability(userId, workspaceId, cap, organizationId?)` | inside a use case — the thrown `PermissionGroupCapabilityError` is projected to a 403 for you | +| `isWorkspaceCapabilityWithheld(userId, workspaceId, cap, organizationId?)` | a raw handler rendering its own body — pair with `capabilityRefusal(cap)` | +| `isOrganizationCapabilityWithheld(organizationId, cap)` | an action naming an organization rather than a workspace | +| `isCapabilityWithheldForUser(userId, cap, workspaceId?)` (`lib/permission-groups/user-scope.server.ts`) | a user-level act that *may or may not* name a workspace — a personal API key, a CLI device-auth handoff. Resolves the workspace's group when given one, else falls back to the organization's default group rather than going ungoverned. Deliberately outside `capability-assertions.ts`: it reads org membership through the billing graph, and that module is a guarded root of `check:application-graph` | +| `capabilityDeniedBy(cap, config)` | you already hold a resolved config | + +Annotate the call site either way: + +```ts + // permission-group-enforced: logs.export — raw streaming route, no workspace operation to declare it on + if (capabilityDeniedBy('logs.export', permissionConfig)) { + return capabilityRefusalResponse('logs.export') + } +``` + +`capabilityRefusalResponse` (`lib/permission-groups/capability-response.ts`) is the one builder for that 403 — it renders `capabilityRefusal(cap)` *and* reads `details.code` off the rule, so a hand-rolled `NextResponse.json({ error: … }, { status: 403 })` reports the four specifically-coded capabilities as the generic block. v1 is deliberately not converged on it (`resolveCapabilityRefusal` in `app/api/v1/middleware.ts` renders v1's own `{ error: { code, message } }` envelope). + +`isOrganizationCapabilityWithheld` resolves through `getUserPermissionConfigForOrganization`, reading the organization's **default** group — a non-default group targets specific workspaces. It sits outside the per-request memo because that memo is keyed by user and workspace, and this decision is keyed by organization alone. + +**Parameterized** — the helpers above are all typed `StaticPermissionGroupCapability`, so write a module-local wrapper that reads the rule and raises through `refuseCapability`, and annotate the call site. `assertConnectorTypeAllowed` in `lib/knowledge/application/connectors.ts` is the shape: + +```ts +const RULE = CAPABILITY_RULES['knowledge.connectors'] +if (!userId) return +const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) +if (config && RULE.deniedBy(config, connectorType)) refuseCapability('knowledge.connectors') +``` + +Always route through `CAPABILITY_RULES` and raise with `refuseCapability` — a config key spelled out inline silently stops denying when renamed, and a hand-written message drifts from the funnel's. `validatePublicFileSharing` and `validateChatDeployAuth` in `ee/access-control/utils/permission-check.ts` are the other two examples. Return early on a missing `userId`: a permission group is a membership of users, so an actorless caller resolves none, and throwing there turns a scheduled sync into a 500 instead of a refusal anyone can act on. + +**Genuinely ungoverned** — write `capability: 'none'` with a `// permission-group-exempt: ` comment directly above it (`'none'` is spelled out because an absent field cannot be told apart from an unreviewed one). A good reason names why no key applies *and* why a gate would be wrong: *"the executor's own per-run store; no group key names it, and refusing would fail runs the group allows"*. + +### Surfaces that do not go through the funnel + +**Whose group applies — never `userId` off whatever identity is nearest.** Each helper below returns `null` for a caller no group governs (workspace key, internal JWT, executor delegation), and `null` is a *pass*, not a denial. + +| You hold | Helper | +|---|---| +| `Principal` | `capabilityGovernedPrincipalUserId` (`lib/core/application`) — mirrors the funnel exactly, executor exemption included | +| v1 `RateLimitResult` | `capabilityGovernedUserId(rateLimit)` (`app/api/v1/middleware.ts`) — branches on `keyType`, never on the presence of `userId` | +| `TableAccessPrincipal` | `capabilityGovernedUserId(principal)` (`app/api/table/utils.ts`) | +| `AuthResult` from `checkSessionOrInternalAuth` | `capabilityGovernedAuthUserId` (same file) — an internal JWT's `userId` is the run's actor, a bystander | + +When the subject is **persisted and read back later** — the table dispatch pipeline stamps it on `table_run_dispatches` / `table_row_executions` so auto-fired cells run under the person the write was gated for — declare it `capabilityGovernedUserId: string | null`, required with an explicit `null` and never optional. An optional field with a fallback is how every producer that had not been taught the distinction silently inherited `triggeredByUserId`, an *attribution* naming the billed account; making omission a compile error is the whole enforcement. A persisted subject also has a lifecycle: `lib/users/account-deletion.ts` cancels the dispatches stamped with a deleted user. + +- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`. Every route threads a `V1RouteCapability` (`StaticPermissionGroupCapability | 'none'`, required and spelled out) whose value must match what its v2 or internal counterpart declares — v1 gets no mapping of its own. `check-capability-subject.ts` audits v1's subjects only, because the bug has shipped and been fixed twice there. +- **Raw internal table routes** (`/api/table/**`) share one gate in `checkAccess` (`app/api/table/utils.ts`), whose signature takes a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks and only the kind that says so skips the gate. `tableAccessPrincipal(rateLimit)` builds it for v1. +- **The route-wrapper graph.** `withRouteHandler` imports `request-scope.server.ts` and nothing heavier. Import a resolver at the *call site*, never from the wrapper or `lib/core/application` — see Step 6. + +## Step 5: Add it to the golden corpus + +Add the key to **both** the `input` and `expected` objects of the `'a fully populated config'` fixture in `lib/permission-groups/fields.test.ts`, set to a non-default value. That fixture is the pinned coercion corpus: a row that changes in a later diff is a semantic decision someone defends rather than a silent regression. The file's other assertions derive from `DEFAULT_PERMISSION_GROUP_CONFIG` (wire order, idempotence, read-schema acceptance, the 2000-iteration seeded fuzz, write/default/read key-set agreement, boolean-to-`PLATFORM_FEATURES` coverage) and pick your key up for free, as does `features.test.ts`. + +**Give the funnel test a real `workspaceOrganizationId`.** `requireCapability` short-circuits on `context.workspaceOrganizationId === null` (`lib/core/application/workspace-authorization.ts:204`), so a fixture whose workspace context leaves it null passes with the gate present *and* with it removed — a vacuous test that reads as load-bearing. + +Add a case to `capabilities.test.ts` for any rule with logic beyond reading one key. For an allowlist assert all three states — `null` permits every member, a populated list only the named ones, `[]` permits **none** — as `capabilities.test.ts` already does for `knowledge.connectors`. + +## Step 6: Keep the graph light + +`scripts/check-application-graph.ts` walks **runtime** `import` / `export … from` edges (`import type` is erased and allowed) out of five guarded roots: + +| Guarded root | Forbidden | +|---|---| +| `lib/core/application/index.ts`, and `lib/permission-groups/` `capabilities.ts` / `capability-assertions.ts` / `config-scope.server.ts` | `providers/`, `blocks/`, `tools/`, `executor/`, `lib/uploads/`, `lib/workflows/` | +| `lib/core/utils/with-route-handler.ts` | those six **plus** `lib/billing/`, `lib/permission-groups/resolve.server`, `lib/auth`, `lib/copilot/`, `lib/knowledge/` | + +`lib/billing/` stays allowed for the funnel roots because `resolve.server.ts` legitimately reads the subscription to decide whether an organization is on an enterprise plan; the wrapper is a lifecycle shim that opens the memo scope and nothing more. That split is why the scope is two files. + +Breaking this never announces itself — past regressions surfaced only as unrelated tests failing on partial mocks of modules they never meant to load. After adding an import, run this audit first. + +## Step 7: Verify + +```bash +bun run check:permission-group-enforcement +bun run check:application-graph +bun run check:capability-subject +cd apps/sim && bun run type-check +cd apps/sim && bunx vitest run lib/permission-groups +``` + +Also `bun run check:api-validation` if you touched a contract or the group routes. `bun run check:audits` runs all of these; it derives its list from the `check:*` scripts in `package.json`, so a new audit is opted *out* deliberately rather than opted in. + +Read the success lines, not the exit codes — the counts should have grown by your operation and capability: + +``` +✓ permission-group enforcement: 322 operations declare a capability, 35 capabilities all enforced +✅ Application graph clean: 5 roots reach none of 11 forbidden module trees +check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. +``` + +The enforcement audit is all-or-nothing — one success line or findings, no migration mode that exits 0 with work outstanding. Because it reads source text it also refuses success when its own parsers come up empty or disagree with each other; if a self-check fires, teach the parsers the new form rather than working around it. + +The audits prove *reachability*: your capability is named somewhere, your key is read by some rule. They cannot tell whether the rule's logic is right or whether every operation reaching the behavior declares it. Green is not proof the gate fires. + +## Traps + +**An operation carries exactly ONE capability, and a narrower capability must subsume the broader one it replaced.** `knowledge.create` and `knowledge.upload` list `configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab']` and OR both in `deniedBy`, because moving KB creation off `knowledge.use` would otherwise let a group that withheld the whole module still create one through the API. Any time you re-point an operation to a more specific capability, the specific rule must read both keys. + +**`.catch()` on an array field is a fail-open security bug.** `z.array(item).catch(fallback)` is whole-value tolerant: one bad member discards every good one. On an allowlist the fallback is `null`, and `null` means **unrestricted** — a partly corrupt allowlist stops restricting anything. `tolerantArray` in `fields.ts` filters element by element, keeping what parses and failing closed. Never swap it for `.catch()`, never hand-roll a parallel coercion path. + +**`parsePermissionGroupConfig` must keep its `Array.isArray` guard.** `typeof [] === 'object'`, so a truthy-object check alone lets an array through and `z.object().parse([])` throws — reachable, because the column is `jsonb` and a row can genuinely hold `[]`. The guard returns the defaults there instead of taking down the request. `tolerantArray` carries the mirror-image guard. + +**An empty allowlist denies everything; `null` allows everything.** They must never collapse — not in the parser, the UI setter, or a `deniedBy`. `allowlistDenies` encodes it as `allowed !== null && !allowed.includes(member)`; a `?? []` anywhere on this path inverts the unrestricted case. + +**Canonicalize both halves of an integration allowlist *before* intersecting, never after.** `allowedIntegrations` and the deployment's `ALLOWED_INTEGRATIONS` are written independently, so one can name `slack` and the other `slack_v2`; fold only case and they intersect to nothing, hiding an integration both policies allow. Compose `intersectAccessControlAllowlists` / `toAccessControlAllowlist` / `resolveAccessControlBlockType` from `integration-allowlist.ts` — never a hand-rolled `Set` intersection — and successor-resolve the type you test against the result the same way. They resolve through `block-successors.generated.ts`, a projection of the block registry because `check:application-graph` forbids the funnel from importing `blocks/`; `check:block-successors` fails the build when it drifts. Read that map only through `Object.hasOwn`: the ids arriving are admin-supplied jsonb, and a group naming `constructor` otherwise gets back an inherited function and 500s every enforcement path that reads it. + +**Not everyone goes through the funnel.** + +| Principal | Rule | +|---|---| +| **Workspace API key** | Authorizes as the workspace — no user, so no group resolves and `operation.capability` does not apply. **Never substitute the key's creator** (not in the funnel, `checkAccess`, v1, or the log projection): it applies a bystander's group to every caller of a shared key and breaks the key when that person leaves. The escape is closed at the door — minting a workspace key is itself capability-gated | +| **Delegated `executor` with a `sim_user` subject** | **Role only** (`requireCurrentHumanRole`). A run carries the trigger-er's role but not their capabilities: a capability names what a *person* may reach, while a run reaches resources because a block does. Applying capabilities would make "hide Tables" a kill-switch breaking every workflow with a Table block | +| **Actorless deployment run** (delegated executor, `mode: 'deployment'`, no subject) | Passes through — a deployed workflow acts with the workspace's authority, not its author's group. Denying would 403 every scheduled run, webhook and public-API call the moment a group withheld anything | +| **Copilot** | **NOT exempt.** A delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess`, capability check included. Copilot acts *as the person* | + +What a run *does* is still governed by `assertPermissionsAllowed`. An item that must bind a deployed run belongs at `enforcement: 'executor'`. + +**Capability is checked after the role check, on purpose.** `requireCurrentHumanAccess` runs `requirePermission` first. `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface so a non-member cannot learn the resource exists; refusing on capability first hands an outsider an oracle for which capabilities the organization withholds. Do not reorder, and do not add a capability check upstream of the role check in a raw route — the v1 middleware states the same rule in its TSDoc. + +## Checklist Before Finishing + +- [ ] Kind and `enforcement` chosen deliberately; `ui-only` justified in writing if used +- [ ] It is a gate, not a projection — a projection belongs in `lib/logs/log-projection.ts` with `capability: 'none'` on the routes, and still refuses queries that select on the withheld field +- [ ] Entry **appended** to `PERMISSION_GROUP_FIELDS`, permissive default, restriction-phrased name +- [ ] Category present in `PLATFORM_CATEGORY_ORDER`, named after what is withheld +- [ ] `hint` says what access is revoked, never "hide" — it is also the active-restriction prose +- [ ] Non-boolean key has a `featureExtras` picker that refuses empty and collapses "all" to `null` +- [ ] Capability id in `CAPABILITY_IDS`, rule in `CAPABILITY_RULES` under `satisfies`, `configKeys` lists every key `deniedBy` reads +- [ ] A narrower capability replacing a broader one also reads the broader key +- [ ] Declared on every operation it governs, or asserted from the use case with a `// permission-group-enforced:` annotation raising through `refuseCapability` / `capabilityRefusal` +- [ ] New operations minted through a `define*Operation` builder and exported from an `*Operations` registry +- [ ] Any `capability: 'none'` carries a `// permission-group-exempt:` reason +- [ ] Every gate's subject comes from the `capabilityGoverned*` helper for the identity it holds, and a persisted subject is a required `string | null` +- [ ] v1 routes thread the capability through `middleware.ts`; table routes pass a `TableAccessPrincipal` +- [ ] An integration-shaped allowlist canonicalizes through `integration-allowlist.ts` on both sides of every comparison +- [ ] Added to the `'a fully populated config'` fixture in `fields.test.ts`, input and expected +- [ ] Allowlist three-state (`null` / populated / `[]`) covered in `capabilities.test.ts` +- [ ] No new runtime import from a guarded root into a forbidden tree +- [ ] All three audits pass and name your capability; `type-check` clean, `lib/permission-groups` suite green diff --git a/.agents/skills/validate-permission-group-item/SKILL.md b/.agents/skills/validate-permission-group-item/SKILL.md new file mode 100644 index 00000000000..febb6c278ec --- /dev/null +++ b/.agents/skills/validate-permission-group-item/SKILL.md @@ -0,0 +1,157 @@ +--- +name: validate-permission-group-item +description: Audit an existing enterprise permission-group item end-to-end — registry entry, schemas, type, defaults, tolerant parser, admin UI, capability rule, enforcement site, and tests — proving the gate actually refuses rather than assuming it. Use when checking a key in `PERMISSION_GROUP_FIELDS` or a capability in `CAPABILITY_RULES`. +argument-hint: +--- + +# Validate Permission Group Item Skill + +The question is not "does this key exist in the right places" — the registry makes most of that compiler-enforced. It is: + +> **If an organization admin sets this, what refuses, and can I make that refusal happen?** + +Twelve keys once shipped with a checkbox, a hint, and no server check. Every one would have passed a structural audit. Assume nothing enforces until you have found the throw. + +**`add-permission-group-item` owns the procedure and the rationale for every invariant named below.** Read it for *why*; this skill is the checklist. Its "Read the system first" list is the same one — start there. + +## Step 1: Registry entry (`lib/permission-groups/fields.ts`) + +Record the builder, the `enforcement`, and the position. + +- **Default permissive?** The builders hardcode `false` / `null` / `[]`, so the risk is a *name* that inverts the meaning — an `allowX` boolean. The checkbox renders `checked={!editingConfig[feature.configKey]}` (ticked = allowed), so a positively-named boolean renders backwards. +- **Position stable?** Declaration order is the wire order and `fields.test.ts` pins it with a key-order contract test. If `git log -p` shows the key was ever *moved* rather than appended, that shipped as an editor dirty-check regression. +- **Phrasing accurate?** An allowlist's `{ limited, empty }` and a denylist's string are read by `getActivePermissionGroupRestrictions` in `features.ts` and surface to users through the Copilot workspace VFS and the enterprise platform context. Confirm `empty` says "none allowed", not "unrestricted". +- **Does the `hint` tell the truth?** Highest-value read in this step. A `'capability'` key refuses at the API, so a hint saying it hides a tab, module, or nav item "from the sidebar" is a **lie an admin acts on** — they believe they are tidying chrome while withholding a module. The same string is reused as the prose for an *active* restriction, where "hide" is simply false. Any surviving "Hide the …" hint on a `'capability'` key is a finding, not a nit; check `label` and `category` the same way (a "Sidebar" or "Settings Tabs" section makes the claim structurally). + +## Step 2: Schemas, type, defaults, parser + +All derived by `collectFieldProperty` from the same registry. **Do not hand-verify them.** Verify nothing bypasses the derivation: + +```bash +grep -rn "" apps/sim --include='*.ts' --include='*.tsx' \ + | grep -vE 'lib/permission-groups/(fields|resolve\.server|config-scope\.server)\.ts' +``` + +Only the registry and the resolvers are excluded, so `capabilities.ts` stays in the output — its `CAPABILITY_RULES` entry and `deniedBy` are the authoritative reads this step exists to check. Every hit should be a rule's `deniedBy`, an enforcement site, a UI binding, or a test. A route restating the key, a client re-deriving a default, or a second coercion path is a leak. Specifically: + +- **`z.array(...).catch(...)` anywhere on this key's path** — whole-value tolerant, so one bad member discards every good one, and on an allowlist the `null` fallback means unrestricted. That is fail-**open**. `tolerantArray` filters element-wise. Rank a regression here with the enforcement findings. +- **`?? []` applied to an allowlist** — collapses "allows everything" into "allows nothing". +- **A hand-rolled comparison against an integration allowlist.** `allowedIntegrations` and the deployment's `ALLOWED_INTEGRATIONS` are written independently, so one names `slack` where the other names `slack_v2`; anything folding only case intersects them to nothing and hides an integration both allow. Both halves must canonicalize through `integration-allowlist.ts` (`intersectAccessControlAllowlists` / `toAccessControlAllowlist` / `resolveAccessControlBlockType`) *before* intersecting, with the checked type resolved the same way. That module reads `block-successors.generated.ts` through `Object.hasOwn` — a bare bracket lookup answers an admin-supplied `constructor` with an inherited function and 500s every path reading that group; `check:block-successors` catches the map going stale. +- **Any config read not from `parsePermissionGroupConfig` or a `resolvePermissionGroupConfig` caller.** + +Two structural guards must still be present: + +- **`parsePermissionGroupConfig` still tests `Array.isArray(config)`.** `typeof [] === 'object'`, the column is `jsonb` so a row genuinely can hold `[]`, and `z.object().parse([])` throws — the guard is what returns defaults instead of a 500. `tolerantArray` carries the mirror image. +- **`CAPABILITY_RULES` still uses `satisfies`, not an annotation.** An annotation collapses `StaticPermissionGroupCapability` to `never`, silently disabling the type system around capabilities with nothing wrong at runtime. `AssertsStaticCapabilityResolves` catches it; any weakening is a top-tier finding. + +Confirm the assertions at the bottom of `fields.ts` still name a field of this kind (`AssertsAllowlistStaysPrecise`, `AssertsDenylistStaysPrecise`, `AssertsRestrictionStaysPrecise`, `AssertsAuthTypesStayPrecise`, `AssertsParserReturnsTheConfig`) — a zod generic degrading to `unknown` is invisible at runtime and quietly loses every call site's narrowing. + +## Step 3: Admin UI (`ee/access-control/components/group-detail.tsx`) + +- **Boolean:** appears automatically via `PLATFORM_FEATURES`. Confirm its `category` is in `PLATFORM_CATEGORY_ORDER`; an unlisted one renders after every ordered section. +- **Nested allowlist / denylist** (one that qualifies a platform-feature boolean): renders **nothing** unless it is in the `featureExtras` map — keyed by the *parent boolean's feature id*, not the config key. No picker there means no admin can ever set it. Report it. Top-level lists — `allowedIntegrations`, `allowedModelProviders`, `deniedModels`, `deniedTools` — are not in `featureExtras` and must not be reported for it; they render from the dedicated Providers and Blocks sections, so check them there. +- For an **allowlist** picker, check both behaviors: refuses an empty selection (`if (values.length === 0) return`) and collapses a full one back to `null` (otherwise the allowlist freezes at today's members). A **denylist** picker must do neither: clearing every entry is how an admin denies nothing, and a full selection is a real state that denies everything. +- Check the parent is the right one (`allowedKnowledgeConnectors` under `hide-knowledge-base`, not `disable-knowledge-base-creation`). + +## Step 4: Capability rule + +A `'capability'` key must appear in some rule's `configKeys` — the audit asserts this (D) and the converse (E): a key declared `'executor'` or `'ui-only'` that a rule reads is flagged, so a key cannot gain enforcement while staying documented as weaker. Then check what the audit cannot: + +- **`configKeys` lists every key `deniedBy` reads.** The audit parses it textually and never reads the closure; a key read but unlisted is invisible to D and E. +- **`kind` is right.** A rule needing a request value must be `'parameterized'` — and a parameterized rule named on an operation cannot have run in production (`defineWorkspaceOperation` throws at definition time), so something else is wrong. +- **A narrower capability subsumes the broader one it replaced.** An operation carries exactly one capability. Precedent: `knowledge.create` / `knowledge.upload` both read `hideKnowledgeBaseTab`, without which a group withholding the whole module could still create a KB through the API. Check `git log` for a re-pointed `capability:` and verify the narrower rule grew the broader key in the same commit. +- **`detailCode` matches the remedy** — `FORBIDDEN_DETAIL_CODES` is closed over remedies, not causes; otherwise `PERMISSION_GROUP_CAPABILITY_BLOCKED`. Any code in use needs an entry in `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS`, a compile-time gate that also publishes the OpenAPI 403 text. +- **`describe` reads correctly** as the subject of `" is not available under your organization's permission group"` — a singular noun or gerund agreeing with "is". Exactly two functions build that sentence, both defined in `capabilities.ts` (`refuseCapability` throws it, `capabilityRefusal` returns it); any call site writing it out is a drift finding. + +## Step 5: Prove the enforcement — do not assume it + +The step the skill exists for. Find the **actual refusal**, name file and line, and say what a caller sees. + +```bash +grep -rn "''" apps/sim --include='*.ts' --include='*.tsx' +grep -rn "permission-group-enforced: " apps/sim +``` + +The second grep misses a gate whose annotation sits in a TSDoc block above the enclosing statement — read the surrounding function. + +Classify into exactly one of: + +1. **Declared on operations.** The funnel enforces in `requireCurrentHumanAccess` → `requireCapability`. Verify the set is *complete*: enumerate every route and tool reaching the same behavior. One declaring `capability: 'none'` is the hole. +2. **Asserted at a call site** with a `// permission-group-enforced: ` annotation. Verify it goes through `capability-assertions.ts` (`assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, `isOrganizationCapabilityWithheld`, `capabilityDeniedBy`), through `isCapabilityWithheldForUser` (`lib/permission-groups/user-scope.server.ts` — workspace group first, else the organization's default, for a user-level act that may or may not name a workspace; outside `capability-assertions.ts` on purpose because it reads org membership through the billing graph, a guarded root of `check:application-graph`; `app/api/cli/auth/approve/route.ts` is the shape), or a direct `CAPABILITY_RULES[''].deniedBy(...)` rather than reading `config.disableX` inline, **and** that it *raises* through `refuseCapability` / renders `capabilityRefusal(cap)` rather than building its own `ForbiddenOperationError` with a hand-written message — the easy half to miss, because the decision looks right. Use-case shape: `validatePublicFileSharing`, `validateChatDeployAuth` (`ee/access-control/utils/permission-check.ts`), `assertConnectorTypeAllowed` (`lib/knowledge/application/connectors.ts`). Raw-route shape: `app/api/logs/stats/route.ts`, `app/api/table/[tableId]/export/route.ts`. A raw route should render through `capabilityRefusalResponse` (`lib/permission-groups/capability-response.ts`), which reads `details.code` off the rule — a hand-rolled `NextResponse.json({ error: capabilityRefusal(cap) }, { status: 403 })` drops it, reporting the four specifically-coded capabilities (`deploy.chat.auth_mode`, `file_share.publish`, `file_share.auth_mode`, `personal_api_key.use`) as the generic block. Convergence is partial — the inbox, api-keys, oauth-credentials, cli-approve and `logs/export` routes still hand-roll it, harmlessly today because all of their capabilities carry the generic code, so report one only if its capability gains a specific code. v1 is deliberately not converged on it (`resolveCapabilityRefusal` in `app/api/v1/middleware.ts`). +3. **Executor-gated** by `assertPermissionsAllowed`, per block / tool / model, matching through the shared primitives in `lib/permission-groups/` — `block-access.ts`, `operation-access.ts`, `model-access.ts`, `integration-allowlist.ts` — which the editor and Copilot projections read too, so a second copy of a match rule is a finding. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes — `deniedTools` holds block `tools.access` ids verbatim, version suffix included. `allowedIntegrations` is *also* enforced off the run, by `assertSelectorIntegrationAllowed` (`lib/selectors/server/integration-access.ts`), so an executor key's coverage is not complete until every non-run path that reaches the third party is checked too. +4. **A field projection, not a gate.** `logs.trace_spans` and `logs.cost` withhold fields, so the logs routes correctly declare `capability: 'none'`. Single owner: `lib/logs/log-projection.ts` (`resolveLogFieldProjection`, `projectExecutionData`, `projectCostTotal`), which carries both annotations. A **second** implementation of the same redaction is the finding — as is a query that lets a caller filter or sort on a withheld field, which turns the projection into an oracle. +5. **Nothing.** Report as a defect: "an organization that sets this believes it applied a restriction that does not exist". + +Ahead of all five: `personal_api_key.use` fits none of them. It withholds a *principal kind* across every operation — the funnel's `personal_api_key` branch (`lib/core/application/workspace-authorization.ts`) and `app/api/v1/middleware.ts` — so no operation declares it and `disablePersonalApiKeys` being absent from every `capability:` field is correct, not a hole. + +Then **make the refusal happen**: write a failing case, or remove the gate (the `capability:` field, the `deniedBy` body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. **Check the fixture's `workspaceOrganizationId` first**: `requireCapability` short-circuits when it is `null` (`lib/core/application/workspace-authorization.ts:204`), so a context that leaves it unset passes either way and the existing test proves nothing even before you touch it. + +For an allowlist the three states must be tested separately — `null` permits every member, a populated list only the named ones, `[]` permits **none**. `capabilities.test.ts` pins all three for `knowledge.connectors`; less than that elsewhere is a gap. + +### Who the gate runs against + +**Read the subject, not the nearest user id.** Every capability sink must take its subject from the `capabilityGoverned*` helper for the identity the surface holds — `capabilityGovernedPrincipalUserId` for a `Principal` (`lib/core/application`), `capabilityGovernedUserId` for a v1 `RateLimitResult` or a `TableAccessPrincipal`, `capabilityGovernedAuthUserId` for a `checkSessionOrInternalAuth` result. Each returns `null` where no group governs, and `null` is a pass. Reading `rateLimit.userId`, `auth.userId`, `subjectUserId` or `triggeredByUserId` into a sink is the finding: for a workspace key the first is the key's *creator*, for an internal JWT the second is the run's actor, and the last is a billing *attribution*. `check-capability-subject.ts` audits **v1 only**, so every other surface is on you. Where the subject is persisted and read back later (`capabilityGovernedUserId` on `table_run_dispatches` / `table_row_executions`), it must be declared required as `string | null` — an optional field with a fallback is exactly how producers re-inherited `triggeredByUserId`, so a proposal to make it optional is a finding. + +- **`/api/v1`** authorizes in `app/api/v1/middleware.ts`, not through `authorizeWorkspaceOperation`; `capabilityGovernedUserId(rateLimit)` branches on `keyType`, never on the presence of a user id. Each route also threads a required, spelled-out `V1RouteCapability`. +- **Raw internal table routes** gate `tables.use` in `checkAccess` (`app/api/table/utils.ts`) via a `TableAccessPrincipal` union — `{ kind: 'user'; userId }` or `{ kind: 'workspace_api_key'; keyCreatorUserId }` — so a bare id no longer type-checks. `tableAccessPrincipal(rateLimit)` is the one place v1 builds it. +- **The definition-time `undefined` guard** on `defineWorkspaceOperation` is not redundant even though `capability` is required on the `ApplicationOperation` **base type** (`lib/core/application/operation.ts:31`, not merely on the builder — which is what stops a bare-literal factory from compiling): `apps/sim/tsconfig.json` excludes `*.test.ts` / `*.test.tsx` and the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. Without it a capability-less operation defines cleanly and then throws `Cannot read properties of undefined` inside `capabilityDeniedBy` **only for tenants that actually have a permission group**, passing CI and every personal workspace. A proposal to drop it is a finding. + +## Step 6: Tests + +- **`fields.test.ts`** — the key must be in both the `input` and `expected` halves of the `'a fully populated config'` fixture; that corpus is pinned so a changed row is defended rather than slipping through. The rest of the file derives from `DEFAULT_PERMISSION_GROUP_CONFIG` and needs no per-key edit. +- **`capabilities.test.ts`** — a case for any rule with logic beyond reading one key: subsumption, allowlist three-state, auth-mode membership. +- **`features.test.ts`** — no edit for a boolean; a non-boolean key's `limited` / `empty` prose should be pinned here. +- **`config-scope.server.test.ts`** — the per-request memo. A gate resolving the config outside `resolvePermissionGroupConfig` is a Step 2 finding, not one here. + +## Step 7: Run the checks + +```bash +bun run check:permission-group-enforcement +bun run check:application-graph +bun run check:capability-subject +cd apps/sim && bun run type-check && bunx vitest run lib/permission-groups +``` + +All three are inside `check:audits`, which derives its list from the `check:*` scripts in `package.json` — a new audit is opted *out* deliberately. Read the output, not the exit codes. Reference success lines (counts grow): + +``` +✓ permission-group enforcement: 322 operations declare a capability, 35 capabilities all enforced +✅ Application graph clean: 5 roots reach none of 11 forbidden module trees +check:capability-subject — 32 v1 files, 5 capability subjects resolved through capabilityGovernedUserId. +``` + +| Audit | What it catches | +|---|---| +| `check:permission-group-enforcement` | Every operation declares a capability and every capability is enforced. All-or-nothing — no migration mode exits 0 with work outstanding, so do not go looking for a `pending enforcement:` list | +| `check:application-graph` | The funnel roots (`lib/core/application/index.ts`, `capabilities.ts`, `capability-assertions.ts`, `config-scope.server.ts`) and `with-route-handler.ts` reach no heavy module tree at *runtime* (`import type` is erased and allowed). A gate that imports a resolver into a guarded root is a finding even if the gate is correct; past regressions surfaced only as unrelated tests failing on partial mocks | +| `check:capability-subject` | Every v1 capability sink takes its subject from `capabilityGovernedUserId`, no v1 file outside the middleware imports the permission-group modules, and at least one governed sink was found at all | + +Two ways the enforcement audit passes without proving what you want: + +- **Vacuous parse.** It reads source text with regexes, so it refuses success when the three registries parse to nothing, cross-checks rule count against capability count, reports per call any unreadable `id`, fails a file that mints an operation but parses to **zero** declarations, and flags any exported `*Operations` registry member it read no operation from. If one fires the audit is broken, not the code — fix the parsers rather than leaving it green. (That last guard exists because an operation minted by a factory that never calls the builder bypasses the required type *and* the audit; twenty-one operations across six domains were invisible that way while the file still printed a tick.) +- **A capability declared on an operation nothing routes to.** Assertion C is satisfied by the declaration alone. + +The audits prove *reachability*, never correctness — that a capability is named, a key is read by some rule, a subject came from the right helper. Step 5 is what covers the rest. + +## Known gaps — recognize these, do not re-report them + +Each is deliberate and documented in the code; `add-permission-group-item` carries the full reasoning. + +- **A workspace API key resolves no permission group** — it authorizes as the workspace, so there is no user and `operation.capability` does not apply; the same reasoning shapes `TableAccessPrincipal`, `capabilityGovernedUserId` and the log projection. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key when that person left. Minting a workspace key is itself capability-gated. +- **An executor delegation carries role but not capabilities** — a delegated `executor` principal with a `sim_user` subject goes through `requireCurrentHumanRole` only. A capability names what a *person* may reach; applying it to a run makes "hide Tables" a kill-switch for every workflow with a Table block. +- **An actorless deployment run passes through** — a delegated executor principal in `mode: 'deployment'` with no resolvable subject acts with the workspace's authority; denying would 403 every scheduled run, webhook and public-API call. What such a run *does* is still governed by `assertPermissionsAllowed`, which is why the four run-scoped keys carry `enforcement: 'executor'`. +- **Copilot is NOT exempt** — a delegated principal with a `sim_user` subject whose `serviceId` is anything other than `executor` takes the full `requireCurrentHumanAccess`. Copilot acts as the person. A proposal to exempt it is a finding. +- **Capability is checked after the role check** — `NoWorkspaceAccessError` is concealed as a 404 by the v2 surface, so refusing on capability first would hand a non-member an oracle for what the organization withholds. The v1 middleware states the same ordering in its TSDoc. Not a bug. +- **`allowedEgressHosts` does not exist** — there is no network-egress allowlist. Requests for one are a feature, not missing wiring. +- **Nothing currently ships as `ui-only`** — the union member has no user; an absent `ui-only` key is not a gap. + +## Report Format + +1. **Kind and enforcement** — as declared, and whether the declaration is true. +2. **The refusal** — file, line, error thrown, what the caller sees (status, `detailCode`, message). Or: it is a projection, and here is its single owner. Or: nothing refuses. +3. **The subject** — whose user id the gate reads, and that a workspace key reaches it ungated rather than as its creator. +4. **Proof** — the test that fails when the gate is removed, or that no such test exists. +5. **Coverage gaps** — routes, tools, surfaces reaching the same behavior without the gate. +6. **Findings**, ordered: unenforced key > key-creator substituted for the acting principal > fail-open coercion (`.catch()` on an array, a dropped `Array.isArray` guard, `CAPABILITY_RULES` annotated instead of `satisfies`) > incomplete operation coverage > allowlist three-state confusion > **admin copy that misstates the enforcement** > duplicated projection logic > missing admin UI > missing test > cosmetic. + +A hint saying "hide" for a key that 403s is not cosmetic — it is the one defect an admin acts on directly: they tick it believing they hid a link, and members lose the module. Rank it with the enforcement findings. diff --git a/.claude/skills/add-permission-group-item b/.claude/skills/add-permission-group-item new file mode 120000 index 00000000000..37547b94cdb --- /dev/null +++ b/.claude/skills/add-permission-group-item @@ -0,0 +1 @@ +../../.agents/skills/add-permission-group-item \ No newline at end of file diff --git a/.claude/skills/validate-permission-group-item b/.claude/skills/validate-permission-group-item new file mode 120000 index 00000000000..d867334b614 --- /dev/null +++ b/.claude/skills/validate-permission-group-item @@ -0,0 +1 @@ +../../.agents/skills/validate-permission-group-item \ No newline at end of file diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 8649896cb0e..cf11d88abfa 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -452,7 +452,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 696ff4e3623..8808de72923 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2793,7 +2793,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 38eecd23994..3b938370ae0 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -4499,7 +4499,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 6127a605f7b..f3dffa0c13c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -787,7 +787,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 402e5d1dfdd..c23ff476d61 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4372,7 +4372,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 253692f499d..c135a9e68af 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4897,7 +4897,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index d2fab40d5d6..3c3a4828557 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3908,7 +3908,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group." } }, "required": ["code", "message"], diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 80db8ab7a39..282cd6b7d46 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -8,11 +8,14 @@ import { createMockRequest, dbChainMock, dbChainMockFns, + permissionGroupScopeMock, resetDbChainMock, + resetPermissionGroupScopeMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockParseProvider, mockDecodeJwt, mockEq } = vi.hoisted(() => ({ +const { mockParseProvider, mockDecodeJwt, mockEq, mockGetUserOrganization } = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), mockParseProvider: vi.fn(), mockDecodeJwt: vi.fn(), mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), @@ -25,6 +28,8 @@ vi.mock('@sim/db', () => ({ eq: mockEq, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('jose', () => ({ decodeJwt: mockDecodeJwt, })) @@ -33,12 +38,18 @@ vi.mock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + import { GET } from '@/app/api/auth/oauth/connections/route' describe('OAuth Connections API Route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + resetPermissionGroupScopeMock() + mockGetUserOrganization.mockResolvedValue(null) mockParseProvider.mockImplementation((providerId: string) => ({ baseProvider: providerId.split('-')[0] || providerId, diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 66f28ccef82..94f2cf1a7ab 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -7,8 +7,12 @@ import { dbChainMockFns, hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, permissionsMock, + permissionsMockFns, resetDbChainMock, + resetPermissionGroupScopeMock, workflowsUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' @@ -18,10 +22,23 @@ vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn(), })) +const { mockGetCredentialActorContext, mockCanUseCredential } = vi.hoisted(() => ({ + mockGetCredentialActorContext: vi.fn(), + mockCanUseCredential: vi.fn(() => true), +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, + canUseCredential: mockCanUseCredential, +})) + vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/auth/oauth/credentials/route' describe('OAuth Credentials API Route', () => { @@ -33,6 +50,8 @@ describe('OAuth Credentials API Route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + resetPermissionGroupScopeMock() + mockCanUseCredential.mockReturnValue(true) }) it('should handle unauthenticated user', async () => { @@ -126,4 +145,150 @@ describe('OAuth Credentials API Route', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ credentials: [] }) }) + + /** The session/executor split documented on {@link integrationsWithheldFromSession} in the route. */ + describe('integrations.manage', () => { + const INTEGRATIONS_WITHHELD = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + } + + /** + * `mockResolvedValue`, not `...Once`: the missing-provider test above + * returns 400 before authentication runs, so its queued value is never + * consumed and every later `...Once` in this file reads one test stale. + */ + function authenticatedAs(authType: 'session' | 'internal_jwt') { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType, + }) + } + + function governedBy(config: typeof DEFAULT_PERMISSION_GROUP_CONFIG) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(config) + } + + function callWithWorkspace() { + return GET( + createMockRequestWithQuery( + 'GET', + '?provider=google-email&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + } + + beforeEach(() => { + authenticatedAs('session') + permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + }) + + it('refuses a session whose group withholds Integrations', async () => { + governedBy(INTEGRATIONS_WITHHELD) + + const response = await callWithWorkspace() + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: expect.stringContaining("your organization's permission group"), + }) + }) + + /** + * The one that matters. A run resolving its credential must not be refused + * by a group that describes what a person may open. + */ + it('does not refuse the executor under the same withholding group', async () => { + authenticatedAs('internal_jwt') + governedBy(INTEGRATIONS_WITHHELD) + dbChainMockFns.where.mockResolvedValue([]) + + const response = await callWithWorkspace() + + expect(response.status).toBe(200) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('allows a session whose group leaves Integrations alone', async () => { + governedBy(DEFAULT_PERMISSION_GROUP_CONFIG) + dbChainMockFns.where.mockResolvedValue([]) + + const response = await callWithWorkspace() + + expect(response.status).toBe(200) + }) + + /** + * A `credentialId` lookup can arrive with no workspace in the query, so the + * gate above never runs; the credential names the workspace whose group + * governs it. + */ + it('refuses a session credentialId lookup using the credential own workspace', async () => { + governedBy(INTEGRATIONS_WITHHELD) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + displayName: 'Gmail', + providerId: 'google-email', + accountId: 'account-1', + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: 'google-email', + accountScope: 'email', + accountUpdatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + const response = await GET(createMockRequestWithQuery('GET', '?credentialId=credential-1')) + + expect(response.status).toBe(403) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).toHaveBeenCalledWith( + 'user-123', + 'workspace-1', + undefined + ) + }) + + /** + * The asserted `workspaceId` is the caller's to choose. Pairing one their + * group leaves alone with a credential from one it governs must not read + * the credential out. + */ + it('refuses a credential whose own workspace is withheld, whatever workspace is asserted', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockImplementation( + async (_userId: string, workspaceId: string) => + workspaceId === 'workspace-1' ? INTEGRATIONS_WITHHELD : DEFAULT_PERMISSION_GROUP_CONFIG + ) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + displayName: 'Gmail', + providerId: 'google-email', + accountId: 'account-1', + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: 'google-email', + accountScope: 'email', + accountUpdatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + const response = await GET( + createMockRequestWithQuery( + 'GET', + '?credentialId=credential-1&workspaceId=3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + ) + ) + + expect(response.status).toBe(403) + }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index cdf25f7f159..0625008f233 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -6,7 +6,7 @@ import { and, eq, inArray, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { AuthType, type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' @@ -16,6 +16,10 @@ import { getServiceAccountProviderForProviderId, providerIdsForService, } from '@/lib/oauth/utils' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -51,6 +55,29 @@ function toCredentialResponse( } } +/** + * Whether `integrations.manage` is withheld from the caller in `workspaceId`. + * + * Only a session is asked. This route authenticates through + * `checkSessionOrInternalAuth`, so the same handler answers both a person + * opening the credential selector and the executor resolving a credential for a + * running workflow. A permission group describes what a *person* may reach; the + * executor is not that person, and refusing it would stop a deployed workflow + * the group permits — a run failing hours after an admin ticked a box, with + * nothing on the surface connecting the two. So the arm that carries a human + * intent is gated and the machine arm is not, which is the same split + * `principalUserId` makes for a workspace API key. + */ +async function integrationsWithheldFromSession( + authType: AuthTypeValue | undefined, + userId: string, + workspaceId: string | null | undefined +): Promise { + if (authType !== AuthType.SESSION) return false + if (!workspaceId) return false + return isWorkspaceCapabilityWithheld(userId, workspaceId, 'integrations.manage') +} + /** * Get credentials for a specific provider */ @@ -123,6 +150,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } requesterCanAdmin = workspaceAccess.canAdmin + + // permission-group-enforced: integrations.manage — raw handler with inline queries, which the authorization funnel never sees + if ( + await integrationsWithheldFromSession( + authResult.authType, + requesterUserId, + effectiveWorkspaceId + ) + ) { + return NextResponse.json( + { error: capabilityRefusal('integrations.manage') }, + { status: 403 } + ) + } } if (credentialId) { @@ -145,6 +186,26 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .limit(1) if (platformCredential) { + /** + * The credential names the workspace whose group governs it, and that is + * asked unconditionally — not only when the query named no workspace. + * The asserted `workspaceId` is the caller's to choose, so gating on it + * alone let a caller who reaches two workspaces pair the ungoverned one + * with a credential from the workspace whose group withholds + * Integrations, and read it. Both are checked; either withholding is a + * refusal, and the resolver memoizes the repeat when they are the same. + * + * Asked after each branch's own access check, never before: a caller who + * may not reach this credential at all must not learn from the refusal + * wording that the workspace it belongs to is one their group governs. + */ + const credentialScopeWithheld = () => + integrationsWithheldFromSession( + authResult.authType, + requesterUserId, + platformCredential.workspaceId + ) + if (platformCredential.type === 'service_account') { if ( workflowId && @@ -160,6 +221,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } + // permission-group-enforced: integrations.manage — the credentialId path carries its own workspace scope + if (await credentialScopeWithheld()) { + return NextResponse.json( + { error: capabilityRefusal('integrations.manage') }, + { status: 403 } + ) + } + return NextResponse.json( { credentials: [ @@ -192,6 +261,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } + // permission-group-enforced: integrations.manage — the credentialId path carries its own workspace scope + if (await credentialScopeWithheld()) { + return NextResponse.json( + { error: capabilityRefusal('integrations.manage') }, + { status: 403 } + ) + } + if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) { return NextResponse.json({ credentials: [] }, { status: 200 }) } diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index e1dd3aa2eec..f51bf6b2b19 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -12,8 +12,16 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +const { mockGetUserOrganization } = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), +})) + vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + import { POST } from '@/app/api/auth/oauth/disconnect/route' describe('OAuth Disconnect API Route', () => { @@ -21,6 +29,7 @@ describe('OAuth Disconnect API Route', () => { vi.clearAllMocks() resetDbChainMock() dbChainMockFns.where.mockResolvedValue([]) + mockGetUserOrganization.mockResolvedValue(null) }) it('should disconnect provider successfully', async () => { diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 51d0d94b0f0..361bea5f55a 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -21,6 +21,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), @@ -64,19 +65,12 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mocks.checkNeedsRedeployment, })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed based on your permission group settings') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) import { chatDeploymentOperations } from '@/lib/chat-deployments/application' import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' -import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' const CHAT_ID = 'chat-123' const WORKFLOW_ID = 'workflow-1' @@ -477,7 +471,13 @@ describe('internal chat deployment routes', () => { }) it('refuses a mode the permission group blocks', async () => { - mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + mocks.validateChatDeployAuth.mockRejectedValue( + new PermissionGroupCapabilityError( + 'deploy.chat.auth_mode', + 'CHAT_AUTH_MODE_NOT_PERMITTED', + "This chat authentication mode is not available under your organization's permission group" + ) + ) const response = await patch({ authType: 'email', allowedEmails: ['a@example.com'] }) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 581b1af1e13..c7670c123a4 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -18,6 +18,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), @@ -53,18 +54,11 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performChatDeploy: mocks.performChatDeploy, performChatUndeploy: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed based on your permission group settings') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) import { POST } from '@/app/api/chat/route' -import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' const WORKFLOW_ID = 'workflow-1' const WORKSPACE_ID = 'workspace-1' @@ -282,7 +276,13 @@ describe('Chat API Route', () => { it('refuses an auth mode the permission group blocks', async () => { queueChatLookups(null, null) - mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + mocks.validateChatDeployAuth.mockRejectedValue( + new PermissionGroupCapabilityError( + 'deploy.chat.auth_mode', + 'CHAT_AUTH_MODE_NOT_PERMITTED', + "This chat authentication mode is not available under your organization's permission group" + ) + ) const response = await post({ ...validBody, diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index ff7902092a5..2c74b36d44d 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,13 +5,35 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), - mockGetPermissions: vi.fn(), - })) +const { + mockGetSession, + mockCreateApproval, + mockEnforceUserRateLimit, + mockGetPermissions, + mockGetUserPermissionConfig, + mockGetOrgPermissionConfig, + mockResolveVerifiedContext, + mockGetUserOrganization, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), + mockGetUserOrganization: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -42,6 +64,35 @@ describe('POST /api/cli/auth/approve', () => { mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) mockGetPermissions.mockResolvedValue('admin') + mockGetUserPermissionConfig.mockResolvedValue(null) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1' }) + }) + + it('refuses an approver whose permission group disables CLI access', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ disableCliAccess: true }) + + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }) + ) + + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('resolves the governing group from the bound workspace when one is given', async () => { + await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(mockGetUserPermissionConfig).toHaveBeenCalledWith('user-1', 'ws-1') + expect(mockGetOrgPermissionConfig).not.toHaveBeenCalled() }) it('records the approval for the signed-in user', async () => { @@ -158,6 +209,157 @@ describe('POST /api/cli/auth/approve', () => { expect(mockCreateApproval).not.toHaveBeenCalled() }) + /** + * The workspace-key pass-through in the authorization funnel resolves no + * group, so minting one is the only place the regime can be enforced. The + * workspaces API-keys route gates it; these prove the terminal does too. + */ + describe('api_keys.manage', () => { + const REFUSAL = "Managing API keys is not available under your organization's permission group" + + it('refuses to record a workspace-bound approval when the workspace group withholds it', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a personal platform approval when the organization group withholds it', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, scope: 'platform' }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL }) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('reads the organization group for an unbound approval that still names a workspace', async () => { + // The workspace is only the terminal's default here, so the key is + // personal and the organization's group is the one that governs it. + mockGetPermissions.mockResolvedValue('write') + mockGetOrgPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('leaves a copilot approval alone — a separate key space the API-keys surface never manages', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, scope: 'copilot' }) + ) + + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalled() + }) + + it('records the approval when a governing group permits key management', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideApiKeysTab: false }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('leaves a user no group governs unaffected', async () => { + mockGetUserPermissionConfig.mockResolvedValue(null) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockGetUserOrganization.mockResolvedValue(null) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalled() + }) + + it('refuses after the role check, so a non-admin still learns nothing about the group', async () => { + mockGetPermissions.mockResolvedValue('write') + mockGetUserPermissionConfig.mockResolvedValue({ hideApiKeysTab: true }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Workspace admin permission is required to issue a workspace API key', + }) + }) + + it('reports the coarser CLI refusal when the group withholds both', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + disableCliAccess: true, + hideApiKeysTab: true, + }) + + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: "CLI access is not available under your organization's permission group", + }) + }) + }) + it('rejects an unauthenticated caller', async () => { mockGetSession.mockResolvedValue(null) const response = await POST( diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 3c361a9bf45..fa463ab9238 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -6,6 +6,8 @@ import { getSession } from '@/lib/auth' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') @@ -18,9 +20,16 @@ const logger = createLogger('CliAuthApproveAPI') * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. * - * Workspace binding is authorized here rather than at poll time: the poll is - * unauthenticated by necessity, so it has no session to check a permission - * against. Approving is the only moment a human is present. + * Workspace binding, CLI access, and permission to mint the key at all are + * authorized here rather than at poll time: the poll is unauthenticated by + * necessity, so it has no session to check a permission against, and re-checking + * there would duplicate this decision while racing a permission-group change + * made between the two calls. Approving is the only moment a human is present. + * + * That makes the approval record the sole carrier of the decision. The poll body + * is a request id and a secret and nothing else, so it cannot assert a scope, a + * workspace, or a binding of its own — a refusal here writes no record, and a + * poll driven directly against the same request id answers `pending` forever. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -73,6 +82,55 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + /** + * permission-group-enforced: cli.use — gates a device-auth handoff, which owns + * no workspace resource for the authorization funnel to authorize. + * + * `workspaceId` is set only for a platform-scope handoff; a personal-scope + * login falls back to the organization's default group. + */ + if (await isCapabilityWithheldForUser(session.user.id, 'cli.use', workspaceId)) { + logger.warn('CLI authorization blocked by permission group', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + }) + return NextResponse.json({ error: capabilityRefusal('cli.use') }, { status: 403 }) + } + + // The platform scope is the one that redeems for a Sim API key. A copilot + // approval mints from a separate key space that the API-keys surface does not + // manage, so `cli.use` above is the whole gate for it. + if (scope === 'platform') { + const mintWorkspaceId = bindKeyToWorkspace ? workspaceId : undefined + /** + * permission-group-enforced: api_keys.manage — a raw device-auth handler + * with inline queries, which the authorization funnel never sees. + * + * This is the door the workspace-key pass-through depends on. A + * `workspace_api_key` principal resolves no user and therefore no group, so + * the funnel's capability gate never applies to it; the whole safety + * argument for that pass-through (`workspace-authorization.ts`, + * `app/api/table/utils.ts`, `app/api/v1/middleware.ts` all state it) is that + * minting one is itself capability-gated. `/api/workspaces/[id]/api-keys` + * gates it; without this the terminal was the way around, and a member + * denied `api_keys.manage` could mint the identical key with + * `sim login --workspace` and then out-rank every other capability their + * group withholds. + * + * Scoped to the key being minted: a bound key belongs to `workspaceId`, so + * the workspace group governs it, while a personal key is user-global and + * falls back to the organization's default group. + */ + if (await isCapabilityWithheldForUser(session.user.id, 'api_keys.manage', mintWorkspaceId)) { + logger.warn('CLI key mint blocked by permission group', { + userId: session.user.id, + workspaceId: mintWorkspaceId ?? null, + }) + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + } + await createApproval(session.user.id, requestId, challenge, { scope, workspaceId, diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 81709bd411b..1e3ff967414 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -170,6 +170,50 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() }) + /** + * `/api/cli/auth/approve` is where the session exists to check workspace-admin + * permission and the `api_keys.manage` capability, so it must be impossible to + * reach a workspace-key mint by driving this endpoint instead. + */ + describe('cannot be driven past the approval-time capability gate', () => { + it('ignores a workspace binding asserted by the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + + const response = await POST( + pollRequest({ + request: REQUEST, + verifier: VERIFIER, + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + + expect(response.status).toBe(200) + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + await expect(response.json()).resolves.toMatchObject({ + workspaceId: null, + workspaceBound: false, + }) + }) + + it('mints nothing at all when approval was refused, however often it is polled', async () => { + // A refusal at approve writes no record, so the store answers `pending` + // forever — there is no state here for a caller to advance. + mockPollApproval.mockResolvedValue({ status: 'pending' }) + + for (let attempt = 0; attempt < 3; attempt++) { + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: 'pending' }) + } + + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c3e6e8c00c3..dff2be3a62a 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -66,7 +66,10 @@ async function mintForGrant( } // `workspaceId` alone only names the terminal's default workspace; binding the - // key to it is a separate, admin-gated decision made at approval. + // key to it is a separate decision made at approval, where the session exists + // to check workspace-admin permission and the `api_keys.manage` capability. + // Nothing in the poll body can set it, so this branch can only be reached by + // an approval that already passed both. const result = grant.workspaceBound && grant.workspaceId ? await performCreateWorkspaceApiKey({ diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 621256b8ac3..41c090b0185 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -510,6 +510,47 @@ describe('Copilot Checkpoints Revert API Route', () => { expect(responseData.error).toBe('Failed to revert workflow to checkpoint') }) + /** + * A checkpoint can carry a block whose integration the caller's permission + * group withholds; the write refuses that with a 403 naming the block type. + * Collapsed to a 500 with the generic sentence, the member was told the + * revert had crashed and had nothing to act on. + */ + it.each([ + [403, 'The Slack block is not available under your permission group'], + [409, 'Workflow is locked'], + ])( + 'passes a %i refusal from the state write through with its message', + async (status, error) => { + setAuthenticated() + + queueTableRows(schemaMock.workflowCheckpoints, [ + { + id: 'checkpoint-123', + workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', + userId: 'user-123', + workflowState: { blocks: {}, edges: [] }, + }, + ]) + queueTableRows(schemaMock.workflow, [ + { id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', userId: 'user-123' }, + ]) + + mockSaveWorkflowNormalizedState.mockResolvedValueOnce({ success: false, status, error }) + + const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checkpointId: 'checkpoint-123' }), + }) + + const response = await POST(req) + + expect(response.status).toBe(status) + await expect(response.json()).resolves.toEqual({ error }) + } + ) + it('should return 500 when the checkpoint state fails validation', async () => { setAuthenticated() diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 1543372f773..33327757691 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -144,11 +144,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { authorization, }) + /** + * The save's own refusals are the caller's answer, not a Sim fault. It + * classifies them itself — a withheld block type in the checkpoint is a + * 403, a locked workflow a 409 — and collapsing every one to a 500 told a + * member that reverting had crashed when their organization had simply + * withheld an integration the checkpoint uses. Only a genuine 5xx keeps the + * generic sentence; the rest carry the refusal's own status and message. + */ if (!saveResult.success) { + const { status } = saveResult logger.error(`[${tracker.requestId}] Failed to apply checkpoint state: ${saveResult.error}`) return NextResponse.json( - { error: 'Failed to revert workflow to checkpoint' }, - { status: 500 } + { error: status >= 500 ? 'Failed to revert workflow to checkpoint' : saveResult.error }, + { status } ) } diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts index bdc42bd79c1..b533477f309 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.test.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -13,6 +13,7 @@ const { publishToolPermissionDecision, addAutoAllowedTool, addChatAutoAllowedTool, + getUserPermissionConfig, } = vi.hoisted(() => ({ getAsyncToolCall: vi.fn(), getRunSegment: vi.fn(), @@ -20,6 +21,7 @@ const { publishToolPermissionDecision: vi.fn(), addAutoAllowedTool: vi.fn(), addChatAutoAllowedTool: vi.fn(), + getUserPermissionConfig: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -49,6 +51,10 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isCopilotToolPermissionsEnabled: true, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig, +})) + import { POST } from './route' describe('Copilot tool permission API', () => { @@ -69,7 +75,9 @@ describe('Copilot tool permission API', () => { id: 'run-1', userId: 'user-1', chatId: 'chat-1', + workspaceId: 'workspace-1', }) + getUserPermissionConfig.mockResolvedValue(null) recordToolPermissionDecision.mockResolvedValue({ toolCallId: 'tool-1', runId: 'run-1', @@ -136,4 +144,77 @@ describe('Copilot tool permission API', () => { expect(response.status).toBe(200) expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) }) + + describe('when the permission group withholds tool auto-approval', () => { + beforeEach(() => { + getUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: true }) + }) + + it.each(['always_allow', 'allow_chat'] as const)( + 'answers the %s prompt without remembering it', + async (decision) => { + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + // The waiting orchestrator still gets its answer; only the durable + // preference is refused, so the next call prompts again. + expect(response.status).toBe(200) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision }) + ) + expect(addAutoAllowedTool).not.toHaveBeenCalled() + expect(addChatAutoAllowedTool).not.toHaveBeenCalled() + } + ) + + /** + * The row is claimed before this lookup runs, so a rejection that escaped + * would answer 500 with the decision unpublished — and the retry lands on + * the already-answered branch, which does not republish, leaving the turn + * to wait out its permission timeout. + */ + it('answers the prompt when the lookup itself fails, remembering nothing', async () => { + getUserPermissionConfig.mockRejectedValue(new Error('permission group lookup failed')) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'always_allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest('always_allow')) + + expect(response.status).toBe(200) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision: 'always_allow' }) + ) + expect(addAutoAllowedTool).not.toHaveBeenCalled() + }) + + it('remembers it again once the group allows it', async () => { + getUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: false }) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'always_allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + await POST(createRequest('always_allow')) + + expect(addAutoAllowedTool).toHaveBeenCalledWith('user-1', 'run_workflow') + }) + }) }) diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 536f0d1b3cc..8c18edef37c 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -29,6 +29,7 @@ import { import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' const logger = createLogger('CopilotToolPermissionAPI') @@ -74,9 +75,43 @@ async function applyDecision( : null } + /** + * permission-group-enforced: copilot.tool_auto_approval — nothing durable is + * written when the group withholds it. The decision itself stands — this call + * runs the tool the user just allowed — only the memory of it is refused, so + * the next call prompts again. Not a 403: the answer to *this* prompt was + * legitimate, and failing the request would strand the waiting orchestrator. + * + * A failed lookup reads as withheld for the same reason, rather than throwing + * past the publish below. The row is already claimed by the time this runs, + * so an exception here answers 500 while leaving the decision unpublished — + * and the retry lands on the already-answered branch, which deliberately does + * not republish, so the turn waits out its permission timeout over a database + * hiccup. Withholding is also the fail-closed reading of the capability: the + * always-allow is simply not remembered, and the user is asked again. + */ + const mayRemember = run.workspaceId + ? !(await isWorkspaceCapabilityWithheld( + userId, + run.workspaceId, + 'copilot.tool_auto_approval' + ).catch((err) => { + logger.warn('Could not resolve the tool auto-approval capability; not remembering', { + toolCallId, + error: getErrorMessage(err), + }) + return true + })) + : true + // Best-effort: failing to remember the preference must not block the tool the // user just allowed. Worst case they get prompted again next time. - if (decision === TOOL_PERMISSION_DECISION.always_allow) { + if (!mayRemember) { + logger.info('Not persisting an always-allow decision withheld by permission group', { + toolCallId, + toolName: claimed.toolName, + }) + } else if (decision === TOOL_PERMISSION_DECISION.always_allow) { await addAutoAllowedTool(userId, claimed.toolName).catch((err) => { logger.error('Failed to persist always-allow preference', { toolCallId, diff --git a/apps/sim/app/api/invitations/[id]/resend/route.test.ts b/apps/sim/app/api/invitations/[id]/resend/route.test.ts new file mode 100644 index 00000000000..4418ace293b --- /dev/null +++ b/apps/sim/app/api/invitations/[id]/resend/route.test.ts @@ -0,0 +1,278 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockInvitationsNotAllowedError, + mockGetInvitationById, + mockResolveInvitationAdmissionOrganizationId, + mockIsOrganizationOwnerOrAdmin, + mockHasWorkspaceAdminAccess, + mockGetWorkspaceWithOwner, + mockGetWorkspaceInvitePolicy, + mockValidateInvitationsAllowed, + mockSendInvitationEmail, + mockPrepareInvitationResend, + mockPersistInvitationResend, + mockGetOrganizationSubscription, +} = vi.hoisted(() => ({ + MockInvitationsNotAllowedError: class extends Error { + constructor() { + super('Invitations are not allowed based on your permission group settings') + this.name = 'InvitationsNotAllowedError' + } + }, + mockGetInvitationById: vi.fn(), + mockResolveInvitationAdmissionOrganizationId: vi.fn(), + mockIsOrganizationOwnerOrAdmin: vi.fn(), + mockHasWorkspaceAdminAccess: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), + mockGetWorkspaceInvitePolicy: vi.fn(), + mockValidateInvitationsAllowed: vi.fn(), + mockSendInvitationEmail: vi.fn(), + mockPrepareInvitationResend: vi.fn(), + mockPersistInvitationResend: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { INVITATION_RESENT: 'invitation.resent', ORG_INVITATION_RESENT: 'org.resent' }, + AuditResourceType: { WORKSPACE: 'workspace', ORGANIZATION: 'organization' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + InvitationsNotAllowedError: MockInvitationsNotAllowedError, + validateInvitationsAllowed: mockValidateInvitationsAllowed, +})) + +vi.mock('@/lib/invitations/core', () => ({ + getInvitationById: mockGetInvitationById, + resolveInvitationAdmissionOrganizationId: mockResolveInvitationAdmissionOrganizationId, +})) +vi.mock('@/lib/invitations/send', () => ({ + sendInvitationEmail: mockSendInvitationEmail, + prepareInvitationResend: mockPrepareInvitationResend, + persistInvitationResend: mockPersistInvitationResend, +})) +vi.mock('@/lib/billing/core/organization', () => ({ + isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin, +})) +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mockGetOrganizationSubscription, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, + getWorkspaceWithOwner: mockGetWorkspaceWithOwner, +})) +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, +})) + +import { POST } from '@/app/api/invitations/[id]/resend/route' + +const mockGetSession = authMockFns.mockGetSession + +function callResend() { + return POST( + createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/invitations/11111111-1111-4111-8111-111111111111/resend' + ), + { params: Promise.resolve({ id: '11111111-1111-4111-8111-111111111111' }) } + ) +} + +const workspaceInvitation = { + id: '11111111-1111-4111-8111-111111111111', + status: 'pending', + kind: 'workspace', + email: 'invitee@example.com', + role: 'member', + token: 'token-1', + organizationId: 'organization-1', + membershipIntent: 'internal', + grants: [{ workspaceId: 'workspace-1', permission: 'read' }], +} + +/** + * A resend re-delivers a working link and pushes the expiry forward, so it is a + * send: without the gate an organization that has withheld invitations still + * admits every pending invitee, indefinitely. + */ +describe('POST /api/invitations/[id]/resend', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'admin@example.com' } }) + mockGetInvitationById.mockResolvedValue(workspaceInvitation) + mockResolveInvitationAdmissionOrganizationId.mockResolvedValue('organization-1') + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-1', + }) + mockGetWorkspaceInvitePolicy.mockResolvedValue({ allowed: true }) + mockValidateInvitationsAllowed.mockResolvedValue(undefined) + mockPrepareInvitationResend.mockResolvedValue({ + tokenForEmail: 'token-2', + nextToken: 'token-2', + nextExpiresAt: new Date('2026-09-30T00:00:00.000Z'), + }) + mockSendInvitationEmail.mockResolvedValue({ success: true }) + mockPersistInvitationResend.mockResolvedValue(undefined) + }) + + it('resends when no group withholds invitations', async () => { + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + expect(mockSendInvitationEmail).toHaveBeenCalled() + }) + + /** + * The refusal carries the shared capability contract — the same sentence and + * `details.code` every other withheld capability answers with — so a client + * can tell a permission group apart from a role failure without parsing prose. + */ + it('refuses the resend with the shared capability refusal when the group withholds invitations', async () => { + mockValidateInvitationsAllowed.mockRejectedValue(new MockInvitationsNotAllowedError()) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Sending invitations is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) + + /** + * The refusal names an organization setting, so it must never be reached by + * someone with no admin standing to hear it. + */ + it('checks admin standing before the permission group', async () => { + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + mockHasWorkspaceAdminAccess.mockResolvedValue(false) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockValidateInvitationsAllowed).not.toHaveBeenCalled() + }) + + /** + * An organization-kind invitation always admits the invitee to its stamped + * organization, whichever workspaces it also grants — so the organization + * scope is checked as well as, not instead of, the grants. Gating only the + * grants would let an explicit workspace group that permits invitations carry + * a member into an organization whose default group withholds them. + */ + it('checks the organization scope as well as the grants for an organization invitation', async () => { + mockGetInvitationById.mockResolvedValue({ ...workspaceInvitation, kind: 'organization' }) + + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + organizationId: 'organization-1', + }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + }) + + it('refuses an organization invitation the organization default group withholds, even when its granted workspace allows', async () => { + mockGetInvitationById.mockResolvedValue({ ...workspaceInvitation, kind: 'organization' }) + mockValidateInvitationsAllowed.mockImplementation( + async (_userId: string, scope: { organizationId?: string }) => { + if (scope.organizationId) throw new MockInvitationsNotAllowedError() + } + ) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) + + /** + * The scope follows what acceptance would DO, not the invitation's kind. A + * workspace-kind invitation whose granted workspace belongs to an organization + * joins the invitee to that organization exactly as an organization-kind one + * does, so keying the organization check on `kind === 'organization'` left + * every organization-backed workspace invitation performing an ungated + * organization admission. + */ + it('checks the organization an organization-backed workspace invitation admits to', async () => { + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockResolveInvitationAdmissionOrganizationId).toHaveBeenCalledWith(workspaceInvitation) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + organizationId: 'organization-1', + }) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + }) + + it('refuses a workspace invitation whose admitting organization withholds invitations', async () => { + mockValidateInvitationsAllowed.mockImplementation( + async (_userId: string, scope: { organizationId?: string }) => { + if (scope.organizationId) throw new MockInvitationsNotAllowedError() + } + ) + + const response = await callResend() + + expect(response.status).toBe(403) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockPersistInvitationResend).not.toHaveBeenCalled() + }) + + /** + * Nothing to gate at the organization scope when acceptance creates no member + * row there — an external invitation, or a personal workspace's — so the + * grants stay the whole scope rather than borrowing a stamped organization the + * invitee will never join. + */ + it('checks the grants alone when the invitation admits to no organization', async () => { + mockResolveInvitationAdmissionOrganizationId.mockResolvedValue(null) + + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledTimes(1) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + workspaceId: 'workspace-1', + }) + }) + + it('resolves the organization default group for an invitation with no grants', async () => { + mockGetInvitationById.mockResolvedValue({ + ...workspaceInvitation, + kind: 'organization', + grants: [], + }) + mockResolveInvitationAdmissionOrganizationId.mockResolvedValue('organization-1') + mockGetOrganizationSubscription.mockResolvedValue({ status: 'active', plan: 'team' }) + + const response = await callResend() + + expect(response.status).toBe(200) + expect(mockValidateInvitationsAllowed).toHaveBeenCalledWith('user-1', { + organizationId: 'organization-1', + }) + }) +}) diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index dbd34b75014..2d8bb7f511b 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -12,14 +12,19 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getInvitationById } from '@/lib/invitations/core' +import { getInvitationById, resolveInvitationAdmissionOrganizationId } from '@/lib/invitations/core' import { persistInvitationResend, prepareInvitationResend, sendInvitationEmail, } from '@/lib/invitations/send' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getWorkspaceWithOwner, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' +import { + InvitationsNotAllowedError, + validateInvitationsAllowed, +} from '@/ee/access-control/utils/permission-check' const logger = createLogger('InvitationResendAPI') @@ -65,6 +70,55 @@ export const POST = withRouteHandler( ) } + /** + * permission-group-enforced: invitations.send — a resend is a send. + * + * It re-delivers a working link and pushes `expiresAt` forward, so an + * organization that has withheld invitations would otherwise still admit + * new people: every pending invitation stays revivable indefinitely by + * anyone who can reach this route, and each resend mints a fresh token. + * The invitee has not joined yet — resend is the step that gets them in — + * which is why this is not the webhook active-config carve-out, where the + * reachability already exists and the edit only adjusts it. + * + * Each granted workspace resolves the group governing the caller there, + * exactly as creation does. The organization scope is checked *as well*, + * not instead, whenever the invitation ADMITS TO an organization — which + * is not the same question as its `kind`. A workspace-kind invitation + * whose granted workspace belongs to an organization joins the invitee to + * that organization exactly as an organization-kind one does, so keying + * this on the kind left every organization-backed workspace invitation + * performing an ungated organization admission. `resolveInvitationAdmission- + * OrganizationId` answers it from acceptance's own derivation: the live + * organization of the granted workspace for a workspace-kind invitation, + * the stamped one otherwise, and nobody at all when the intent is external + * or the stamped organization refuses the escalation — the three cases + * where acceptance creates no member row. Gating only the grants would let + * an explicit workspace group that permits invitations carry a member into + * an organization whose default group withholds them. + * + * Run after the admin check above, for the reason + * `resolveWorkspaceInvitationContext` records — the refusal names an + * organization setting, so it must not reach someone with no admin reach. + */ + try { + const admissionOrganizationId = await resolveInvitationAdmissionOrganizationId(inv) + if (admissionOrganizationId) { + await validateInvitationsAllowed(session.user.id, { + organizationId: admissionOrganizationId, + }) + } + for (const grant of inv.grants) { + await validateInvitationsAllowed(session.user.id, { workspaceId: grant.workspaceId }) + } + } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + logger.warn('Invitation resend blocked by permission group', { invitationId: id }) + return capabilityRefusalResponse('invitations.send') + } + throw error + } + for (const grant of inv.grants) { const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId) if (!workspaceDetails) { diff --git a/apps/sim/app/api/logs/export/route.test.ts b/apps/sim/app/api/logs/export/route.test.ts index ab31532d74a..0955840a5c3 100644 --- a/apps/sim/app/api/logs/export/route.test.ts +++ b/apps/sim/app/api/logs/export/route.test.ts @@ -16,11 +16,17 @@ const { mockExpandFolderIdsWithDescendants, mockMapWithConcurrency, mockMaterializeExecutionDataForDisplay, + mockGetUserPermissionConfig, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockExpandFolderIdsWithDescendants: vi.fn(), mockMapWithConcurrency: vi.fn(), mockMaterializeExecutionDataForDisplay: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -40,6 +46,7 @@ vi.mock('@/lib/core/utils/concurrency', () => ({ mapWithConcurrency: mockMapWithConcurrency, })) +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' import { GET } from '@/app/api/logs/export/route' const mockGetSession = authMockFns.mockGetSession @@ -88,6 +95,7 @@ describe('GET /api/logs/export', () => { resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true }) + mockGetUserPermissionConfig.mockResolvedValue(null) mockExpandFolderIdsWithDescendants.mockImplementation( async (_workspaceId: string, folderIds: string | undefined) => folderIds ) @@ -236,4 +244,106 @@ describe('GET /api/logs/export', () => { await expect(Promise.all([pendingRead, cancellation])).resolves.toBeDefined() }) + + it('blanks the cost column and span spend when the group withholds cost', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + queueTableRows(workflowExecutionLogs, [ + logRow(0, { + executionData: { + message: 'message-0', + traceSpans: [{ id: 'span-1', name: 'Agent', type: 'agent', cost: { total: 0.01 } }], + }, + }), + ]) + + const response = await GET(makeRequest()) + const lines = (await response.text()).trimEnd().split('\n') + + expect(lines[0]).toContain('costTotal') + expect(lines[1].split(',')[5]).toBe('') + expect(lines[1]).toContain('span-1') + expect(lines[1]).not.toContain('0.01') + }) + + it('refuses the download when the group withholds log export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ disableLogExport: true }) + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('logs.export'), + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + /** + * The decision, pinned: `logs.export` has no admin exemption. The refusal is + * reached without reading a role at all — no organization membership, no + * workspace permission row — so an exemption cannot be added without this + * failing. + */ + it("refuses the download without consulting the caller's role", async () => { + mockGetUserPermissionConfig.mockResolvedValue({ disableLogExport: true }) + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(403) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('exports normally when no group withholds log export', async () => { + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(200) + expect(await response.text()).toContain('execution-0') + }) + + it('refuses a cost-filtered export when the group withholds spend', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ hideCostInfo: true }) + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/logs/export?workspaceId=workspace-1&costOperator=%3E&costValue=0.5' + ) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: capabilityRefusal('logs.cost') }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('answers the same cost-filtered export when no group withholds spend', async () => { + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/logs/export?workspaceId=workspace-1&costOperator=%3E&costValue=0.5' + ) + ) + + expect(response.status).toBe(200) + expect(await response.text()).toContain('execution-0') + }) + + it('keeps the cost column when no group withholds it', async () => { + queueTableRows(workflowExecutionLogs, [logRow(0)]) + + const response = await GET(makeRequest()) + const lines = (await response.text()).trimEnd().split('\n') + + expect(lines[1].split(',')[5]).toBe('0.01') + }) }) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index a2819700aed..f79149e437b 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -9,8 +9,15 @@ import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/co import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { withheldSpendData } from '@/lib/logs/fetch-log-detail' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { logQuerySelectsCost } from '@/lib/logs/log-projection' +import { + capabilityDeniedBy, + capabilityRefusal, +} from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsExportAPI') @@ -94,6 +101,54 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } + /** + * permission-group-enforced: logs.export — one download carries every + * execution payload the workspace ever recorded, including a trace-span + * column, so this is the widest read in the product and the one most worth + * withholding separately from reading a single log. + * + * Checked here rather than in an application use case because this route + * queries directly and predates that boundary; migrating it is worth doing, + * and is not a reason to leave the export ungoverned meanwhile. + * + * No admin exemption, unlike `organization.member_directory`. That one is + * organization-scoped: it reads the org *default* group, which governs the + * admins too, and the page it withholds is the roster an admin needs open to + * change the setting — an exemption there breaks a bootstrap loop. This + * capability has neither half. It resolves the caller's own workspace group, + * so an admin who wants the export edits the group that withheld it, on a + * settings page this refusal does not touch. Exempting admins would instead + * make `disableLogExport` unable to say what it says — that nobody in this + * group downloads the whole workspace's execution payloads — for exactly the + * accounts whose export is widest. + */ + const permissionConfig = await resolvePermissionGroupConfig( + userId, + params.workspaceId, + undefined + ) + if (capabilityDeniedBy('logs.export', permissionConfig)) { + return NextResponse.json({ error: capabilityRefusal('logs.export') }, { status: 403 }) + } + + const hideTraceSpans = capabilityDeniedBy('logs.trace_spans', permissionConfig) + /** + * permission-group-enforced: logs.cost — the `costTotal` column is the same + * run spend the detail view withholds, and a whole-workspace CSV of it is + * the widest disclosure of the two. The column stays in the header so the + * file shape does not depend on who downloaded it; only its values go. + */ + const hideCostInfo = capabilityDeniedBy('logs.cost', permissionConfig) + /** + * The same filter the list refuses. Blanking the column while still + * answering `costOperator`/`costValue` faithfully would leave the CSV itself + * a bisection oracle over the figures it just withheld — one download per + * probe, with the row count as the answer. + */ + if (hideCostInfo && logQuerySelectsCost(params)) { + return NextResponse.json({ error: capabilityRefusal('logs.cost') }, { status: 403 }) + } + const encoder = new TextEncoder() const csvChunks = (async function* () { yield encoder.encode(`${header}\n`) @@ -135,18 +190,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { for (let index = 0; index < chunk.length; index++) { const row = chunk[index] - const executionData = materialized[index] + const materializedRow = materialized[index] + const executionData = hideCostInfo + ? withheldSpendData(materializedRow) + : materializedRow let message: unknown = '' let tracesJson = '' try { - if (executionData.finalOutput) { + /** + * `finalOutput` is one of the payloads the log-detail projection + * deletes for this viewer, so exporting it here would hand back in + * bulk exactly what the detail view withholds one run at a time. + */ + if (executionData.finalOutput && !hideTraceSpans) { message = typeof executionData.finalOutput === 'string' ? executionData.finalOutput : (JSON.stringify(executionData.finalOutput) ?? '') } if (executionData.message) message = executionData.message - if (executionData.traceSpans) { + /** + * The same projection the log detail applies. A group that + * withholds trace spans in the UI would otherwise hand them over + * in bulk here, which is the larger disclosure of the two. + */ + if (executionData.traceSpans && !hideTraceSpans) { tracesJson = JSON.stringify(executionData.traceSpans) ?? '' } } catch (rowError) { @@ -161,7 +229,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { formatCsvValue(row.workflowName), formatCsvValue(row.trigger), formatCsvValue(row.totalDurationMs ?? ''), - formatCsvValue(row.costTotal ?? ''), + formatCsvValue(hideCostInfo ? '' : (row.costTotal ?? '')), formatCsvValue(row.workflowId ?? ''), formatCsvValue(row.executionId), formatCsvValue(message), diff --git a/apps/sim/app/api/logs/stats/route.test.ts b/apps/sim/app/api/logs/stats/route.test.ts new file mode 100644 index 00000000000..669cb2af758 --- /dev/null +++ b/apps/sim/app/api/logs/stats/route.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), + expandFolderIdsWithDescendants: vi.fn(), + readLogStatsBounds: vi.fn(), + readLogStatsSegments: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +vi.mock('@/lib/logs/folder-expansion', () => ({ + expandFolderIdsWithDescendants: mocks.expandFolderIdsWithDescendants, +})) + +vi.mock('@/lib/logs/stats-queries', () => ({ + readLogStatsBounds: mocks.readLogStatsBounds, + readLogStatsSegments: mocks.readLogStatsSegments, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' +import { GET } from '@/app/api/logs/stats/route' + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +function makeRequest(query = '') { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/logs/stats?workspaceId=workspace-1${query}` + ) +} + +describe('GET /api/logs/stats', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true }) + mocks.readLogStatsBounds.mockResolvedValue({ + minStartedAt: new Date('2026-08-01T00:00:00.000Z'), + maxStartedAt: new Date('2026-08-02T00:00:00.000Z'), + }) + mocks.readLogStatsSegments.mockResolvedValue([]) + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('refuses a cost-filtered read when the group withholds spend', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('logs.cost'), + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mocks.readLogStatsBounds).not.toHaveBeenCalled() + }) + + it('answers an unfiltered read under the same group', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(200) + expect(mocks.readLogStatsBounds).toHaveBeenCalled() + }) + + /** + * The refusal needs both conditions, so an unfiltered read can never be + * refused and the config lookup — which re-reads workspace and + * organization/group state — is pure cost on the dashboard's common path. + */ + it('does not consult the group for an unfiltered read', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(200) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + it('answers the same cost-filtered read when no group withholds spend', async () => { + const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) + + expect(response.status).toBe(200) + expect(mocks.readLogStatsBounds).toHaveBeenCalled() + }) + + /** A caller with no workspace access is answered with a zeroed 200, as before. */ + it('does not consult the group for a caller without workspace access', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: false }) + + const response = await GET(makeRequest('&costOperator=%3E&costValue=0.5')) + + expect(response.status).toBe(200) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 7231af0bfd7..a5689d63028 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -9,8 +9,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { logQuerySelectsCost } from '@/lib/logs/log-projection' import { buildDashboardStats, resolveLogStatsWindow } from '@/lib/logs/stats' import { readLogStatsBounds, readLogStatsSegments } from '@/lib/logs/stats-queries' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('LogsStatsAPI') @@ -59,6 +62,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } + /** + * permission-group-enforced: logs.cost — this response carries no spend at + * all, but `costOperator`/`costValue` reach the same indexed column the + * list filters on, and the run counts it answers with are a bisection + * oracle over exactly the figure the group withholds. Refused rather than + * ignored, for the reason given on {@link assertLogCostQueryAllowed}; the + * workspace access check above has already passed, so the caller is a + * member learning about their own group. + */ + if ( + logQuerySelectsCost(params) && + (await isWorkspaceCapabilityWithheld(userId, params.workspaceId, 'logs.cost')) + ) { + return capabilityRefusalResponse('logs.cost') + } + const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) if (params.folderIds) { diff --git a/apps/sim/app/api/mcp/capability-declarations.test.ts b/apps/sim/app/api/mcp/capability-declarations.test.ts new file mode 100644 index 00000000000..083b5b23c33 --- /dev/null +++ b/apps/sim/app/api/mcp/capability-declarations.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + * + * Every raw MCP management route declares the permission-group capability its + * `/api/v2` twin declares in `mcpServerOperations`. + * + * The type system already forces a declaration to exist — `withMcpAuth` takes + * the capability as a required argument — but it cannot say whether the value is + * the right one, and a new sibling reaching for `'none'` because it compiles is + * the exact shape of the bug this closes: twelve routes sat beside a gated + * thirteenth for a release, including the one whose `isPublic` flip strips + * authentication from everything the server publishes. + */ +import { describe, expect, it, vi } from 'vitest' + +const { recorded } = vi.hoisted(() => ({ + recorded: [] as { file: string; level: string; capability: string }[], +})) + +vi.mock('@/lib/mcp/middleware', () => ({ + readMcpJsonBodyWithLimit: (request: Request) => request.json(), + mcpBodyReadErrorResponse: () => null, + withMcpAuth: (level: string, capability: string) => { + /** + * The declaring module, read off the call site. `withMcpAuth` is invoked at + * module scope, so the frame below this one is the route file — which is + * what lets one recording mock speak about thirteen modules at once. + */ + const frame = new Error('capture').stack?.split('\n')[2] ?? '' + const file = frame.match(/app\/api\/mcp\/[^):\s]*route\.ts/)?.[0] ?? frame + recorded.push({ file, level, capability }) + return (handler: unknown) => handler + }, +})) + +import '@/app/api/mcp/workflow-servers/route' +import '@/app/api/mcp/workflow-servers/[id]/route' +import '@/app/api/mcp/workflow-servers/[id]/tools/route' +import '@/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route' +import '@/app/api/mcp/servers/route' +import '@/app/api/mcp/servers/[id]/route' +import '@/app/api/mcp/servers/[id]/refresh/route' +import '@/app/api/mcp/servers/test-connection/route' +import '@/app/api/mcp/tools/discover/route' +import '@/app/api/mcp/tools/stored/route' +import '@/app/api/mcp/oauth/start/route' + +describe('MCP management route capability declarations', () => { + it('gates every handler on a capability, never on nothing', () => { + expect(recorded.length).toBeGreaterThanOrEqual(20) + expect(recorded.filter((entry) => entry.capability === 'none')).toEqual([]) + }) + + /** + * `mcp_servers.workflow_deployments.*` is publishing a workflow *as* an MCP + * server, which is what `hideDeployMcp` names — reads included, so a group + * withholding the surface does not still answer with what is published on it. + */ + it('declares deploy.mcp on every workflow-server route, reads included', () => { + const deployRoutes = recorded.filter((entry) => entry.file.includes('workflow-servers')) + + expect(deployRoutes.length).toBe(10) + expect(deployRoutes.every((entry) => entry.capability === 'deploy.mcp')).toBe(true) + }) + + /** + * `mcp_servers.*` is the workspace's registry of external MCP servers, which + * every one of its operations declares `mcp_tools.use` for. + */ + it('declares mcp_tools.use on every external-server registry route', () => { + const registryRoutes = recorded.filter((entry) => !entry.file.includes('workflow-servers')) + + expect(registryRoutes.length).toBeGreaterThanOrEqual(10) + expect(registryRoutes.every((entry) => entry.capability === 'mcp_tools.use')).toBe(true) + }) +}) diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index 3a936167650..948185368b6 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -93,7 +93,10 @@ function truncate(message: string): string { export const dynamic = 'force-dynamic' export const GET = withRouteHandler( - withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId }) => { + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId }) => { try { const parsed = await parseRequest(startMcpOauthContract, request, {}) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 550aa2f77d3..c4131eec801 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -158,137 +158,138 @@ async function syncToolSchemasToWorkflows( } export const POST = withRouteHandler( - withMcpAuth<{ id: string }>('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const paramsValidation = mcpServerIdParamsSchema.safeParse(await params) - if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error) - const { id: serverId } = paramsValidation.data - logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) - - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + withMcpAuth<{ id: string }>( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const paramsValidation = mcpServerIdParamsSchema.safeParse(await params) + if (!paramsValidation.success) return validationErrorResponse(paramsValidation.error) + const { id: serverId } = paramsValidation.data + logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) + + const [server] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) ) - .limit(1) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) + } - if (!server) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } + let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] } + let discoveredTools: McpTool[] = [] + let discoveryError: string | null = null + const discoveryStartedAt = new Date() - let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] } - let discoveredTools: McpTool[] = [] - let discoveryError: string | null = null - const discoveryStartedAt = new Date() + try { + discoveredTools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + 'force' + ) + logger.info( + `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}` + ) + } catch (error) { + discoveryError = truncate(categorizeError(error).message, 200, '') + logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, { + error: discoveryError, + }) + } + if (discoveryError === null) { try { - discoveredTools = await mcpService.discoverServerTools( - userId, - serverId, + syncResult = await syncToolSchemasToWorkflows( workspaceId, - 'force' - ) - logger.info( - `[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}` + serverId, + discoveredTools, + requestId, + { url: server.url ?? undefined, name: server.name ?? undefined } ) } catch (error) { - discoveryError = truncate(categorizeError(error).message, 200, '') - logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, { - error: discoveryError, + // Discovery already persisted status and cached tools; a workflow-sync + // failure is a secondary propagation and must not fail the refresh with + // a 500. Surface it as zero workflows updated instead. + logger.warn(`[${requestId}] Tool schema sync failed after successful discovery`, { + error: getErrorMessage(error), }) } + } - if (discoveryError === null) { - try { - syncResult = await syncToolSchemasToWorkflows( - workspaceId, - serverId, - discoveredTools, - requestId, - { url: server.url ?? undefined, name: server.name ?? undefined } - ) - } catch (error) { - // Discovery already persisted status and cached tools; a workflow-sync - // failure is a secondary propagation and must not fail the refresh with - // a 500. Surface it as zero workflows updated instead. - logger.warn(`[${requestId}] Tool schema sync failed after successful discovery`, { - error: getErrorMessage(error), - }) - } - } - - const now = new Date() - - /** - * Deliberately leaves `updatedAt` alone, matching the invariant - * `McpService.updateServerStatus` holds: `updatedAt` means "when the - * server's configuration last changed", and it is one of the public - * list's keyset sorts. A refresh stamping it moves the row to the head - * of `sortBy=updatedAt` under an in-flight page, so a caller walking the - * list while anyone presses this button sees servers duplicated across - * pages and others skipped. Refresh liveness is already published - * through `lastToolsRefresh`, `lastConnected`, and `lastError`. - */ - const [refreshedServer] = await db - .update(mcpServers) - .set({ - lastToolsRefresh: now, - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + const now = new Date() + + /** + * Deliberately leaves `updatedAt` alone, matching the invariant + * `McpService.updateServerStatus` holds: `updatedAt` means "when the + * server's configuration last changed", and it is one of the public + * list's keyset sorts. A refresh stamping it moves the row to the head + * of `sortBy=updatedAt` under an in-flight page, so a caller walking the + * list while anyone presses this button sees servers duplicated across + * pages and others skipped. Refresh liveness is already published + * through `lastToolsRefresh`, `lastConnected`, and `lastError`. + */ + const [refreshedServer] = await db + .update(mcpServers) + .set({ + lastToolsRefresh: now, + }) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) ) - .returning({ - connectionStatus: mcpServers.connectionStatus, - lastConnected: mcpServers.lastConnected, - lastError: mcpServers.lastError, - toolCount: mcpServers.toolCount, - }) + ) + .returning({ + connectionStatus: mcpServers.connectionStatus, + lastConnected: mcpServers.lastConnected, + lastError: mcpServers.lastError, + toolCount: mcpServers.toolCount, + }) - let connectionStatus = refreshedServer?.connectionStatus ?? 'error' - let lastError = refreshedServer ? refreshedServer.lastError : discoveryError - const toolCount = refreshedServer?.toolCount ?? discoveredTools.length + let connectionStatus = refreshedServer?.connectionStatus ?? 'error' + let lastError = refreshedServer ? refreshedServer.lastError : discoveryError + const toolCount = refreshedServer?.toolCount ?? discoveredTools.length - if (discoveryError !== null && connectionStatus === 'connected') { - const newerSuccessWonRace = - refreshedServer?.lastConnected != null && - refreshedServer.lastConnected > discoveryStartedAt + if (discoveryError !== null && connectionStatus === 'connected') { + const newerSuccessWonRace = + refreshedServer?.lastConnected != null && + refreshedServer.lastConnected > discoveryStartedAt - if (!newerSuccessWonRace) { - connectionStatus = 'disconnected' - lastError = discoveryError - } - } - - if (connectionStatus === 'connected') { - await mcpService.clearCache(workspaceId) + if (!newerSuccessWonRace) { + connectionStatus = 'disconnected' + lastError = discoveryError } + } - return createMcpSuccessResponse({ - status: connectionStatus, - toolCount, - lastConnected: refreshedServer?.lastConnected?.toISOString() || null, - error: lastError, - workflowsUpdated: syncResult.updatedCount, - updatedWorkflowIds: syncResult.updatedWorkflowIds, - }) - } catch (error) { - logger.error(`[${requestId}] Error refreshing MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) + if (connectionStatus === 'connected') { + await mcpService.clearCache(workspaceId) } + + return createMcpSuccessResponse({ + status: connectionStatus, + toolCount, + lastConnected: refreshedServer?.lastConnected?.toISOString() || null, + error: lastError, + workflowsUpdated: syncResult.updatedCount, + updatedWorkflowIds: syncResult.updatedWorkflowIds, + }) + } catch (error) { + logger.error(`[${requestId}] Error refreshing MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) } - ) + }) ) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index f55d123d245..5a8a4073c5c 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -24,7 +24,10 @@ export const dynamic = 'force-dynamic' * PATCH - Update an MCP server in the workspace (requires write or admin permission) */ export const PATCH = withRouteHandler( - withMcpAuth<{ id: string }>('write')( + withMcpAuth<{ id: string }>( + 'write', + 'mcp_tools.use' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index b542a70a668..daccfa311b0 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -29,172 +29,173 @@ export const dynamic = 'force-dynamic' * GET - List all registered MCP servers for the workspace */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId, permission }) => { - try { - logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - - const rows = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - - /** - * Header values are the upstream credential and are stored unencrypted, so - * they are withheld from anyone who cannot already rewrite them. Editors and - * admins still receive them because the settings form round-trips the - * existing values on save. - */ - const includeHeaderValues = permissionSatisfies(permission, 'write') - const servers = rows.map((row) => projectInternalMcpServer(row, { includeHeaderValues })) - - logger.info( - `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` - ) - return createMcpSuccessResponse({ servers }) - } catch (error) { - logger.error(`[${requestId}] Error listing MCP servers:`, error) - return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500) - } + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId, permission }) => { + try { + logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) + + const rows = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + + /** + * Header values are the upstream credential and are stored unencrypted, so + * they are withheld from anyone who cannot already rewrite them. Editors and + * admins still receive them because the settings form round-trips the + * existing values on save. + */ + const includeHeaderValues = permissionSatisfies(permission, 'write') + const servers = rows.map((row) => projectInternalMcpServer(row, { includeHeaderValues })) + + logger.info( + `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` + ) + return createMcpSuccessResponse({ servers }) + } catch (error) { + logger.error(`[${requestId}] Error listing MCP servers:`, error) + return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500) } - ) + }) ) /** * POST - Register a new MCP server for the workspace (requires write permission) */ export const POST = withRouteHandler( - withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const rawBody = await readMcpJsonBodyWithLimit(request) - const parsedBody = createMcpServerBodySchema.safeParse(rawBody) - - if (!parsedBody.success) { - return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) - } - - const body = parsedBody.data - - logger.info(`[${requestId}] Registering MCP server:`, { - name: body.name, - transport: body.transport, - workspaceId, - }) - - const sourceParam = body.source as string | undefined - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - if (!body.url) { - return createMcpErrorResponse( - new Error('url is required'), - 'Missing required parameter', - 400 - ) - } - const result = await performCreateMcpServer({ - workspaceId, - userId, - actorName: userName, - actorEmail: userEmail, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers, - timeout: body.timeout, - retries: body.retries, - enabled: body.enabled, - source, - authType: body.authType, - oauthClientId: body.oauthClientId || null, - oauthClientIdProvided: body.oauthClientId !== undefined, - oauthClientSecret: body.oauthClientSecret, - oauthClientSecretProvided: body.oauthClientSecret !== undefined, - request, - }) - if (!result.success || !result.serverId) { - return createMcpErrorResponse( - new Error(result.error || 'Failed to register MCP server'), - result.error || 'Failed to register MCP server', - mcpOrchestrationStatus(result.errorCode) - ) - } - - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` - ) + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const rawBody = await readMcpJsonBodyWithLimit(request) + const parsedBody = createMcpServerBodySchema.safeParse(rawBody) + + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } - return createMcpSuccessResponse( - result.updated - ? { serverId: result.serverId, updated: true, authType: result.authType } - : { serverId: result.serverId, authType: result.authType }, - result.updated ? 200 : 201 + const body = parsedBody.data + + logger.info(`[${requestId}] Registering MCP server:`, { + name: body.name, + transport: body.transport, + workspaceId, + }) + + const sourceParam = body.source as string | undefined + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + if (!body.url) { + return createMcpErrorResponse( + new Error('url is required'), + 'Missing required parameter', + 400 + ) + } + const result = await performCreateMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + source, + authType: body.authType, + oauthClientId: body.oauthClientId || null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + if (!result.success || !result.serverId) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to register MCP server'), + result.error || 'Failed to register MCP server', + mcpOrchestrationStatus(result.errorCode) ) - } catch (error) { - const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) - if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error registering MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) } + + logger.info( + `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` + ) + + return createMcpSuccessResponse( + result.updated + ? { serverId: result.serverId, updated: true, authType: result.authType } + : { serverId: result.serverId, authType: result.authType }, + result.updated ? 200 : 201 + ) + } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse + logger.error(`[${requestId}] Error registering MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) } - ) + }) ) /** * DELETE - Delete an MCP server from the workspace (requires write permission) */ export const DELETE = withRouteHandler( - withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const { searchParams } = new URL(request.url) - const queryValidation = deleteMcpServerByQuerySchema.safeParse( - Object.fromEntries(searchParams) - ) - if (!queryValidation.success) return validationErrorResponse(queryValidation.error) - const query = queryValidation.data - const serverId = query.serverId - const sourceParam = query.source - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - if (!serverId) { - return createMcpErrorResponse( - new Error('serverId parameter is required'), - 'Missing required parameter', - 400 - ) - } - - logger.info( - `[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}` + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const { searchParams } = new URL(request.url) + const queryValidation = deleteMcpServerByQuerySchema.safeParse( + Object.fromEntries(searchParams) + ) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const query = queryValidation.data + const serverId = query.serverId + const sourceParam = query.source + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + + if (!serverId) { + return createMcpErrorResponse( + new Error('serverId parameter is required'), + 'Missing required parameter', + 400 ) + } - const result = await performDeleteMcpServer({ - workspaceId, - userId, - actorName: userName, - actorEmail: userEmail, - serverId, - source, - request, - }) - if (!result.success || !result.server) { - return createMcpErrorResponse( - new Error(result.error || 'Failed to delete MCP server'), - result.error || 'Failed to delete MCP server', - mcpOrchestrationStatus(result.errorCode) - ) - } - - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) - } catch (error) { - logger.error(`[${requestId}] Error deleting MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) + logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) + + const result = await performDeleteMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + serverId, + source, + request, + }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to delete MCP server'), + result.error || 'Failed to delete MCP server', + mcpOrchestrationStatus(result.errorCode) + ) } + + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) + + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) } - ) + }) ) diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 6a12a8346ae..3f147f75998 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -84,7 +84,10 @@ function sanitizeConnectionError(error: unknown): string { * POST - Test connection to an MCP server before registering it */ export const POST = withRouteHandler( - withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'write', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = mcpServerTestBodySchema.safeParse(rawBody) diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index a592dc116bb..d1323e5a333 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -51,7 +51,10 @@ async function settleWithConcurrency( } export const GET = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) const queryValidation = mcpToolDiscoveryQuerySchema.safeParse( @@ -107,7 +110,10 @@ export const GET = withRouteHandler( ) export const POST = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = refreshMcpToolsBodySchema.safeParse(rawBody) diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index 3606e05115d..5329f578a5b 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -14,7 +14,10 @@ const logger = createLogger('McpStoredToolsAPI') export const dynamic = 'force-dynamic' export const GET = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'mcp_tools.use' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index d765c4985f7..56eda6a9c5a 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -36,61 +36,65 @@ interface RouteParams { * GET - Get a specific workflow MCP server with its tools */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - - logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) - - const [server] = await db - .select({ - id: workflowMcpServer.id, - workspaceId: workflowMcpServer.workspaceId, - createdBy: workflowMcpServer.createdBy, - name: workflowMcpServer.name, - description: workflowMcpServer.description, - isPublic: workflowMcpServer.isPublic, - createdAt: workflowMcpServer.createdAt, - updatedAt: workflowMcpServer.updatedAt, - }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) + + logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ + id: workflowMcpServer.id, + workspaceId: workflowMcpServer.workspaceId, + createdBy: workflowMcpServer.createdBy, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + isPublic: workflowMcpServer.isPublic, + createdAt: workflowMcpServer.createdAt, + updatedAt: workflowMcpServer.updatedAt, + }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) ) - .limit(1) + ) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const tools = await db - .select() - .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + const tools = await db + .select() + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info( - `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` - ) + logger.info( + `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` + ) - return createMcpSuccessResponse({ server, tools }) - } catch (error) { - logger.error(`[${requestId}] Error getting workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) - } + return createMcpSuccessResponse({ server, tools }) + } catch (error) { + logger.error(`[${requestId}] Error getting workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) } - ) + }) ) /** * PATCH - Update a workflow MCP server */ export const PATCH = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, @@ -147,7 +151,10 @@ export const PATCH = withRouteHandler( * DELETE - Delete a workflow MCP server and all its tools */ export const DELETE = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 1bb3325d489..0105df0a387 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -34,59 +34,63 @@ interface RouteParams { * GET - Get a specific tool */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) - - logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) + + logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) ) - .limit(1) + ) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const [tool] = await db - .select() - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) - ) + const [tool] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.id, toolId), + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt) ) - .limit(1) + ) + .limit(1) - if (!tool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - return createMcpSuccessResponse({ tool }) - } catch (error) { - logger.error(`[${requestId}] Error getting tool:`, error) - return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) + if (!tool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) } + + return createMcpSuccessResponse({ tool }) + } catch (error) { + logger.error(`[${requestId}] Error getting tool:`, error) + return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) } - ) + }) ) /** * PATCH - Update a tool's configuration */ export const PATCH = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, @@ -144,7 +148,10 @@ export const PATCH = withRouteHandler( * DELETE - Remove a tool from an MCP server */ export const DELETE = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 9ddf39ca214..b97d5bb3233 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -33,67 +33,71 @@ interface RouteParams { * GET - List all tools for a workflow MCP server */ export const GET = withRouteHandler( - withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - - logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) + + logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) ) - .limit(1) + ) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const tools = await db - .select({ - id: workflowMcpTool.id, - serverId: workflowMcpTool.serverId, - workflowId: workflowMcpTool.workflowId, - toolName: workflowMcpTool.toolName, - toolDescription: workflowMcpTool.toolDescription, - parameterSchema: workflowMcpTool.parameterSchema, - parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, - createdAt: workflowMcpTool.createdAt, - updatedAt: workflowMcpTool.updatedAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - isDeployed: workflow.isDeployed, - }) - .from(workflowMcpTool) - .leftJoin( - workflow, - and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt)) - ) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + const tools = await db + .select({ + id: workflowMcpTool.id, + serverId: workflowMcpTool.serverId, + workflowId: workflowMcpTool.workflowId, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: workflowMcpTool.parameterSchema, + parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, + createdAt: workflowMcpTool.createdAt, + updatedAt: workflowMcpTool.updatedAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + isDeployed: workflow.isDeployed, + }) + .from(workflowMcpTool) + .leftJoin( + workflow, + and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt)) + ) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) + logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) - return createMcpSuccessResponse({ tools }) - } catch (error) { - logger.error(`[${requestId}] Error listing tools:`, error) - return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) - } + return createMcpSuccessResponse({ tools }) + } catch (error) { + logger.error(`[${requestId}] Error listing tools:`, error) + return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) } - ) + }) ) /** * POST - Add a workflow as a tool to an MCP server */ export const POST = withRouteHandler( - withMcpAuth('write')( + withMcpAuth( + 'write', + 'deploy.mcp' + )( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/route.test.ts b/apps/sim/app/api/mcp/workflow-servers/route.test.ts new file mode 100644 index 00000000000..44042f6a9c5 --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/route.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + * + * The `deploy.mcp` gate this route used to carry inline now lives on + * `withMcpAuth`, where its twelve siblings inherit it — see + * `lib/mcp/middleware.test.ts` for the gate itself and + * `app/api/mcp/capability-declarations.test.ts` for what each route declares. + * What is left here is the handler's own behavior with the gate passed. + */ +import { resetDbChainMock } from '@sim/testing' +import type { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformCreate } = vi.hoisted(() => ({ mockPerformCreate: vi.fn() })) + +vi.mock('@/lib/mcp/middleware', () => ({ + readMcpJsonBodyWithLimit: (request: NextRequest) => request.json(), + mcpBodyReadErrorResponse: () => null, + withMcpAuth: + () => + ( + handler: ( + request: NextRequest, + context: { + userId: string + userName: string + userEmail: string + workspaceId: string + requestId: string + } + ) => Promise + ) => + (request: NextRequest) => + handler(request, { + userId: 'user-1', + userName: 'Test User', + userEmail: 'test@example.com', + workspaceId: 'workspace-1', + requestId: 'request-1', + }), +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: mockPerformCreate, +})) + +import { POST } from '@/app/api/mcp/workflow-servers/route' + +function createRequest() { + return new Request('http://localhost:3000/api/mcp/workflow-servers?workspaceId=workspace-1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Deploy bot', workflowIds: ['workflow-1'] }), + }) as NextRequest +} + +describe('workflow MCP servers POST route', () => { + afterAll(() => { + resetDbChainMock() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockPerformCreate.mockResolvedValue({ + success: true, + server: { id: 'server-1', name: 'Deploy bot' }, + addedTools: [], + }) + }) + + it('creates the server through the orchestration helper', async () => { + const response = await POST(createRequest(), { params: Promise.resolve({}) }) + + expect(response.status).toBe(201) + expect(mockPerformCreate).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1', name: 'Deploy bot' }) + ) + }) +}) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 10398e6eeb4..16722ea657e 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -26,7 +26,10 @@ export const dynamic = 'force-dynamic' * GET - List all workflow MCP servers for the workspace */ export const GET = withRouteHandler( - withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { + withMcpAuth( + 'read', + 'deploy.mcp' + )(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`) @@ -97,56 +100,57 @@ export const GET = withRouteHandler( * POST - Create a new workflow MCP server */ export const POST = withRouteHandler( - withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const rawBody = await readMcpJsonBodyWithLimit(request) - const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody) - - if (!parsedBody.success) { - return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) - } - - const body = parsedBody.data + withMcpAuth( + 'write', + 'deploy.mcp' + )(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const rawBody = await readMcpJsonBodyWithLimit(request) + const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody) - logger.info(`[${requestId}] Creating workflow MCP server:`, { - name: body.name, - workspaceId, - workflowIds: body.workflowIds, - }) + if (!parsedBody.success) { + return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400) + } - const result = await performCreateWorkflowMcpServer({ - workspaceId, - userId, - actorName: userName, - actorEmail: userEmail, - name: body.name, - description: body.description, - isPublic: body.isPublic, - workflowIds: body.workflowIds, - }) - if (!result.success || !result.server) { - return createMcpErrorResponse( - new Error(result.error || 'Failed to create workflow MCP server'), - result.error || 'Failed to create workflow MCP server', - mcpOrchestrationStatus(result.errorCode) - ) - } + const body = parsedBody.data + + logger.info(`[${requestId}] Creating workflow MCP server:`, { + name: body.name, + workspaceId, + workflowIds: body.workflowIds, + }) + + const result = await performCreateWorkflowMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + workflowIds: body.workflowIds, + }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to create workflow MCP server'), + result.error || 'Failed to create workflow MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } - const { server } = result - const addedTools = result.addedTools || [] + const { server } = result + const addedTools = result.addedTools || [] - logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` - ) + logger.info( + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` + ) - return createMcpSuccessResponse({ server, addedTools }, 201) - } catch (error) { - const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) - if (bodyErrorResponse) return bodyErrorResponse - logger.error(`[${requestId}] Error creating workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) - } + return createMcpSuccessResponse({ server, addedTools }, 201) + } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse + logger.error(`[${requestId}] Error creating workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) } - ) + }) ) diff --git a/apps/sim/app/api/organizations/[id]/members/route.test.ts b/apps/sim/app/api/organizations/[id]/members/route.test.ts new file mode 100644 index 00000000000..5eab80f930b --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/members/route.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { member } from '@sim/db/schema' +import { + authMockFns, + createMockRequest, + createSession, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetOrgPermissionConfig, + mockGetUserPermissionConfig, + mockResolveVerifiedContext, + mockGetUsageSnapshot, +} = vi.hoisted(() => ({ + mockGetOrgPermissionConfig: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), + mockGetUsageSnapshot: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin', +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, +})) + +vi.mock('@/lib/billing/core/organization', () => ({ + getOrganizationMemberUsageSnapshot: mockGetUsageSnapshot, +})) + +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { GET } from '@/app/api/organizations/[id]/members/route' + +const mockGetSession = authMockFns.mockGetSession + +const REQUEST_URL = 'http://localhost/api/organizations/org-1/members' + +function request() { + return GET(createMockRequest('GET', undefined, {}, REQUEST_URL), { + params: Promise.resolve({ id: 'org-1' }), + }) +} + +afterAll(resetDbChainMock) + +describe('GET /api/organizations/[id]/members', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) + mockGetOrgPermissionConfig.mockResolvedValue(null) + }) + + it('lists members for an organization member', async () => { + queueTableRows(member, [{ id: 'member-reader', role: 'member' }]) + queueTableRows(member, [ + { + id: 'member-admin', + userId: 'user-admin', + organizationId: 'org-1', + role: 'admin', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + userName: 'Admin User', + userEmail: 'admin@example.com', + }, + ]) + queueTableRows(member, [{ value: 1 }]) + + const response = await request() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.success).toBe(true) + expect(body.data).toHaveLength(1) + expect(body.data[0].userEmail).toBe('admin@example.com') + }) + + it('refuses a member whose permission group hides the member directory', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ hideOrgMemberDirectory: true }) + queueTableRows(member, [{ id: 'member-reader', role: 'member' }]) + + const response = await request() + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('organization.member_directory'), + }) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 894c0204a98..858c33f74ae 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -12,6 +12,10 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' const logger = createLogger('OrganizationMembersAPI') @@ -66,6 +70,29 @@ export const GET = withRouteHandler( const userRole = memberEntry[0].role const hasAdminAccess = isOrgAdminRole(userRole) + /** + * permission-group-enforced: organization.member_directory — an + * organization-scoped read with no workspace for the funnel to authorize. + * + * Admins and owners are exempt. This response is the only source for the + * team-management page and the seat-usage snapshot it renders, so + * withholding it from an admin would take away the page they would use to + * change the setting, and their seat management with it. + */ + if ( + !hasAdminAccess && + (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) + ) { + logger.warn('Organization member directory blocked by permission group', { + organizationId, + userId: session.user.id, + }) + return NextResponse.json( + { error: capabilityRefusal('organization.member_directory') }, + { status: 403 } + ) + } + // Get organization members const memberPageQuery = db .select({ diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index c17f862602b..484fc15ee3a 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -10,7 +10,7 @@ import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permi import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types' +import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { acquirePermissionGroupOrgLock, authorizeOrgAccessControl, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index 3f47477d403..b958a5791dd 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -10,7 +10,7 @@ import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types' +import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' import { type AllMembersConflict, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index d54d44fa803..572d129ddfe 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -10,11 +10,11 @@ import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { - PERMISSION_GROUP_CONSTRAINTS, type PermissionGroupConfig, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' import { type AllMembersConflict, acquirePermissionGroupOrgLock, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index 1029a5b62f3..c53dc9ae8d6 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -15,12 +15,12 @@ import { createPermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { DEFAULT_PERMISSION_GROUP_CONFIG, - PERMISSION_GROUP_CONSTRAINTS, type PermissionGroupConfig, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' import { type AllMembersConflict, acquirePermissionGroupOrgLock, diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index eabd3cf5350..45b2ad9437a 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -17,8 +17,22 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExpireStaleInvitations } = vi.hoisted(() => ({ +const { + mockExpireStaleInvitations, + mockGetOrgPermissionConfig, + mockGetUserPermissionConfig, + mockResolveVerifiedContext, +} = vi.hoisted(() => ({ mockExpireStaleInvitations: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -29,6 +43,7 @@ vi.mock('@/lib/invitations/core', () => ({ expireStalePendingInvitationsForOrganization: mockExpireStaleInvitations, })) +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' import { GET } from '@/app/api/organizations/[id]/roster/route' const mockGetSession = authMockFns.mockGetSession @@ -61,6 +76,23 @@ describe('GET /api/organizations/[id]/roster', () => { vi.clearAllMocks() resetDbChainMock() mockExpireStaleInvitations.mockResolvedValue(undefined) + mockGetOrgPermissionConfig.mockResolvedValue(null) + }) + + it('refuses a member whose permission group hides the member directory', async () => { + mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) + mockGetOrgPermissionConfig.mockResolvedValue({ hideOrgMemberDirectory: true }) + queueTableRows(member, [{ role: 'member' }]) + + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('organization.member_directory'), + }) }) it('returns a redacted roster to a target-organization member', async () => { diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 17bdf76153e..a7d7bba1868 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -21,6 +21,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' const logger = createLogger('OrganizationRosterAPI') @@ -49,6 +53,27 @@ export const GET = withRouteHandler( ) } + /** + * permission-group-enforced: organization.member_directory — an + * organization-scoped read with no workspace for the funnel to authorize. + * + * Admins and owners are exempt, for the reason the members route records: + * this feeds the page an admin would use to change the setting. + */ + if ( + !isOrgAdminRole(callerMembership.role) && + (await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) + ) { + logger.warn('Organization roster blocked by permission group', { + organizationId, + userId: session.user.id, + }) + return NextResponse.json( + { error: capabilityRefusal('organization.member_directory') }, + { status: 403 } + ) + } + const memberRows = await db .select({ memberId: member.id, diff --git a/apps/sim/app/api/selectors/execute/route.test.ts b/apps/sim/app/api/selectors/execute/route.test.ts index 1b0ba3713f7..dc6b53e3aeb 100644 --- a/apps/sim/app/api/selectors/execute/route.test.ts +++ b/apps/sim/app/api/selectors/execute/route.test.ts @@ -77,6 +77,7 @@ import { SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' import { POST } from '@/app/api/selectors/execute/route' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' function project(error: unknown) { const result = mocks.errorPolicy?.project(error) @@ -126,6 +127,22 @@ describe('POST /api/selectors/execute', () => { }) }) + /** + * The one selector failure that names itself. The other three are normalized + * so a caller cannot probe a scope or a credential through them; this one + * reports the caller's own permission group against their own workspace and + * names the remedy, which "Connection unavailable" would hide. + */ + it('projects an integration-allowlist refusal as its own 403', () => { + expect(project(new IntegrationNotAllowedError('gmail_v2'))).toEqual({ + status: 403, + body: { + error: 'Integration "gmail_v2" is not allowed based on your permission group settings', + }, + headers: { 'Cache-Control': 'private, no-store' }, + }) + }) + it('preserves same-workspace forbidden errors', () => { expect( project(new OrchestrationError('forbidden', 'Insufficient workspace permissions')) diff --git a/apps/sim/app/api/selectors/execute/route.ts b/apps/sim/app/api/selectors/execute/route.ts index ff4e9364c07..4c2657eb787 100644 --- a/apps/sim/app/api/selectors/execute/route.ts +++ b/apps/sim/app/api/selectors/execute/route.ts @@ -17,6 +17,7 @@ import { SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const const SELECTOR_SCOPE_NOT_FOUND = 'Selector scope not found' @@ -34,6 +35,17 @@ const selectorOperationErrorPolicy = extendInternalErrorPolicy( PRIVATE_NO_STORE ) } + /** + * The integration allowlist refusal, which is deliberately the one selector + * failure that names itself. The other three are normalized so a caller + * cannot probe a scope or a credential through them; this one reports the + * caller's OWN permission group against their own workspace, tells them the + * remedy is an admin changing the allowlist rather than a broken connection, + * and reveals nothing they could not read off the block toolbar. + */ + if (error instanceof IntegrationNotAllowedError) { + return internalErrorResponse(403, { error: error.message }, PRIVATE_NO_STORE) + } if (error instanceof SelectorOptionsUnavailableError) { return internalErrorResponse( error.status, diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 8b0e8ec373f..5bc5b4bf5ee 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -152,7 +152,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) // Save using existing persistence logic - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData) + const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData, { + /** + * Actorless. The superuser debug import is a platform-operator tool for + * reproducing a customer's workflow, not a member authoring one, so no + * workspace permission group governs it. + */ + workspaceId: null, + subjectUserId: null, + }) if (!saveResult.success) { // Clean up the workflow record if save failed diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts index 73a51011fc4..ecd2ef8e611 100644 --- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -40,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // legacy Filter the runners/persisted payloads still compile. const filter = toLegacyFilter(wireFilter) - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 2b2aa60c131..dff45ad6728 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -44,7 +44,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum if (!validation.success) return validation.response const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -104,7 +104,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (!validation.success) return validation.response const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -161,7 +161,11 @@ export const DELETE = withRouteHandler( if (!validation.success) return validation.response const validated = validation.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index e9140d22a83..3c184e0a65b 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { runColumnContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TableQueryValidationError } from '@/lib/table/errors' @@ -45,7 +45,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // Dual-grammar wire: downgrade a predicate to the legacy Filter the // dispatcher and scheduled runs still compile. const filter = toLegacyFilter(wireFilter) - const access = await checkAccess(tableId, auth.userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId: auth.userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) // Validate the filter up front (the dispatcher reuses it) so a bad field fails fast. @@ -63,6 +63,17 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro limit, requestId, triggeredByUserId: auth.userId, + /** + * Whose group governs the cells this dispatch STARTS, not who is billed + * and not who was gated above — the second of the two questions on + * `capabilityGovernedUserId` in `@/app/api/table/utils`. + * + * Derived from the auth type rather than from the gated principal: + * `checkSessionOrInternalAuth` admits exactly a session and an internal + * JWT here, and the JWT carries the run's actor, whom + * `checkAccess` above may gate on but no dispatch may run as. + */ + capabilityGovernedUserId: capabilityGovernedAuthUserId(auth), }) // Starting a run clears the target group's cells to pending (`bulkClearWorkflowGroupCells`) — a DB diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 83382ea2ddd..96587b83d34 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -61,7 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro throw error } - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access diff --git a/apps/sim/app/api/table/[tableId]/dispatches/route.ts b/apps/sim/app/api/table/[tableId]/dispatches/route.ts index a39d3409402..2043f2c4e4e 100644 --- a/apps/sim/app/api/table/[tableId]/dispatches/route.ts +++ b/apps/sim/app/api/table/[tableId]/dispatches/route.ts @@ -34,7 +34,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const result = await checkAccess(tableId, authResult.userId, 'read') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!result.ok) return accessError(result, requestId, tableId) const rows = await listActiveDispatches(tableId) diff --git a/apps/sim/app/api/table/[tableId]/events/stream/route.ts b/apps/sim/app/api/table/[tableId]/events/stream/route.ts index 2142e4a1d93..3ccc9c39261 100644 --- a/apps/sim/app/api/table/[tableId]/events/stream/route.ts +++ b/apps/sim/app/api/table/[tableId]/events/stream/route.ts @@ -32,7 +32,7 @@ export const GET = withRouteHandler(async (req: NextRequest, context: RouteConte return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const access = await checkAccess(tableId, auth.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: auth.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) return createEventStreamResponse({ diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts index 4922d49041c..b9bc711b714 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -5,10 +5,20 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockMarkTableJobRunning, mockRunTableExport } = vi.hoisted(() => ({ +const { + mockCheckAccess, + mockMarkTableJobRunning, + mockRunTableExport, + mockGetUserPermissionConfig, +} = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockMarkTableJobRunning: vi.fn(), mockRunTableExport: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@sim/utils/id', () => ({ @@ -31,6 +41,7 @@ vi.mock('@/app/api/table/utils', async () => { } }) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/table/[tableId]/export-async/route' function makeRequest(body: unknown, tableId = 'tbl_1') { @@ -59,6 +70,7 @@ describe('POST /api/table/[tableId]/export-async', () => { rowCount: 50000, }), }) + mockGetUserPermissionConfig.mockResolvedValue(null) mockMarkTableJobRunning.mockResolvedValue(true) mockRunTableExport.mockResolvedValue(undefined) }) @@ -112,4 +124,21 @@ describe('POST /api/table/[tableId]/export-async', () => { expect(response.status).toBe(400) expect(mockMarkTableJobRunning).not.toHaveBeenCalled() }) + + it('refuses before claiming a job when the group withholds tables.export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + + const response = await makeRequest(validBody) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Exporting a table is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + expect(mockRunTableExport).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 7ad5cdb251d..29855f25f06 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -9,6 +9,8 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' @@ -45,12 +47,17 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const { tableId } = parsed.data.params const { workspaceId, format } = parsed.data.body - const access = await checkAccess(tableId, authResult.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) if (access.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } + // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary + if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { + return capabilityRefusalResponse('tables.export') + } + const jobId = generateId() const jobPayload: TableExportJobPayload = { format } const claimed = await markTableJobRunning(tableId, jobId, 'export', jobPayload) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts index b977f5ff9f8..119ac118050 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -5,15 +5,21 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockGetTableJob, mockGeneratePresignedDownloadUrl } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockGetTableJob: vi.fn(), - mockGeneratePresignedDownloadUrl: vi.fn(), -})) +const { mockCheckAccess, mockGetTableJob, mockGetUserPermissionConfig, mockPresign } = vi.hoisted( + () => ({ + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), + mockPresign: vi.fn(), + }) +) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, +})) vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) vi.mock('@/lib/uploads/core/storage-service', () => ({ - generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, + generatePresignedDownloadUrl: mockPresign, })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') @@ -24,16 +30,18 @@ vi.mock('@/app/api/table/utils', async () => { } }) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/table/[tableId]/export/download/route' -function makeRequest(query: Record, tableId = 'tbl_1') { - const qs = new URLSearchParams(query).toString() - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export/download?${qs}`) +const table = createTableDefinition({ id: 'tbl_1', workspaceId: 'workspace-1' }) + +function makeRequest(tableId = 'tbl_1') { + const req = new NextRequest( + `http://localhost:3000/api/table/${tableId}/export/download?workspaceId=workspace-1&jobId=job-1` + ) return GET(req, { params: Promise.resolve({ tableId }) }) } -const validQuery = { workspaceId: 'workspace-1', jobId: 'job_1' } - describe('GET /api/table/[tableId]/export/download', () => { beforeEach(() => { vi.clearAllMocks() @@ -42,64 +50,59 @@ describe('GET /api/table/[tableId]/export/download', () => { userId: 'user-1', authType: 'session', }) - mockCheckAccess.mockResolvedValue({ ok: true, table: createTableDefinition() }) + mockCheckAccess.mockResolvedValue({ ok: true, table }) + mockGetUserPermissionConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) mockGetTableJob.mockResolvedValue({ - id: 'job_1', type: 'export', status: 'ready', - payload: { format: 'csv', resultKey: 'workspace/workspace-1/exports/tbl_1/job_1/people.csv' }, + payload: { resultKey: 'exports/tbl_1.csv', fileName: 'tbl_1.csv' }, }) - mockGeneratePresignedDownloadUrl.mockResolvedValue('https://storage.example/signed-url') + mockPresign.mockResolvedValue('https://example.com/signed') }) - it('resolves a ready export to a presigned URL', async () => { - const response = await makeRequest(validQuery) - const data = await response.json() - + it('presigns a ready export', async () => { + const response = await makeRequest() expect(response.status).toBe(200) - expect(data.data).toEqual({ url: 'https://storage.example/signed-url', fileName: 'people.csv' }) - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( - 'workspace/workspace-1/exports/tbl_1/job_1/people.csv', - 'workspace' - ) }) - it('404s when the job is missing or not an export', async () => { - mockGetTableJob.mockResolvedValue({ id: 'job_1', type: 'delete', status: 'ready', payload: {} }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(404) - }) + it('refuses with the structured capability detail when the group withholds tables.export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) - it('409s when the export is not ready yet', async () => { - mockGetTableJob.mockResolvedValue({ - id: 'job_1', - type: 'export', - status: 'running', - payload: { format: 'csv' }, + const response = await makeRequest() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Exporting a table is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(409) + expect(mockGetTableJob).not.toHaveBeenCalled() + expect(mockPresign).not.toHaveBeenCalled() }) - it('410s when the result file is gone from the payload', async () => { - mockGetTableJob.mockResolvedValue({ - id: 'job_1', - type: 'export', - status: 'ready', - payload: { format: 'csv' }, + /** + * An internal executor JWT presents the run's actor, not somebody asking for + * a file: reading it bare would apply that person's group to a delegation the + * executor exemption passes ungated, and refuse the download of an export the + * same run was allowed to start and to list. + */ + it('hands the executor its export without consulting the actor’s group', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(410) - }) - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - const response = await makeRequest(validQuery) - expect(response.status).toBe(401) - }) + const response = await makeRequest() - it('returns 400 on workspace mismatch', async () => { - const response = await makeRequest({ ...validQuery, workspaceId: 'other-ws' }) - expect(response.status).toBe(400) + expect(response.status).toBe(200) + expect(mockGetUserPermissionConfig).not.toHaveBeenCalled() + expect(mockPresign).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts index 988b774a262..e14c770162f 100644 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -2,9 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { exportDownloadContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getTableJob } from '@/lib/table/jobs/service' import type { TableExportJobPayload } from '@/lib/table/types' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' @@ -39,12 +41,33 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou const { tableId } = parsed.data.params const { workspaceId, jobId } = parsed.data.query - const access = await checkAccess(tableId, authResult.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) if (access.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } + /** + * permission-group-enforced: tables.export — the second door to a finished + * export. Gating only the job that produces one leaves this route handing the + * file to anyone who can name a `jobId`, and the workspace job listing names + * every colleague's. + * + * Keyed to the governed subject, which names nobody for an internal-JWT + * executor call, exactly as the listing and the job that produced this file + * are: `authResult.userId` there is the subject the executor embedded, so + * reading it bare would apply the run's actor's group to a delegation the + * executor exemption deliberately passes ungated — and would refuse the + * download of an export the same run was allowed to start. + */ + const governedUserId = capabilityGovernedAuthUserId(authResult) + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.export')) + ) { + return capabilityRefusalResponse('tables.export') + } + const job = await getTableJob(tableId, jobId) if (!job || job.type !== 'export') { return NextResponse.json({ error: 'Export job not found' }, { status: 404 }) diff --git a/apps/sim/app/api/table/[tableId]/export/route.test.ts b/apps/sim/app/api/table/[tableId]/export/route.test.ts index 4e1dd314b3c..90d039f8715 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.test.ts @@ -5,9 +5,14 @@ import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({ +const { mockCheckAccess, mockQueryRows, mockGetUserPermissionConfig } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockQueryRows: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/app/api/table/utils', async () => { @@ -23,6 +28,7 @@ vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/table/[tableId]/export/route' /** Table with an id-native column whose stable id (`col_email`) differs from its display name. */ @@ -56,6 +62,7 @@ describe('table export route — id→name translation', () => { }), }) // Row data is keyed by stable column id (`col_email`), not the display name. + mockGetUserPermissionConfig.mockResolvedValue(null) mockQueryRows.mockResolvedValue({ rows: [{ id: 'r1', data: { col_email: 'a@b.c', legacy: 'x' }, executions: {}, position: 0 }], rowCount: 1, @@ -82,4 +89,19 @@ describe('table export route — id→name translation', () => { expect(parsed).toEqual([{ email: 'a@b.c', legacy: 'x' }]) expect(JSON.stringify(parsed)).not.toContain('col_email') }) + + it('refuses the stream when the group withholds tables.export', async () => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + + const res = await callGet('csv') + + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ + error: "Exporting a table is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + }) }) diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 6cc9a9d50c5..a9fee083ed4 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -5,6 +5,8 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { sanitizeExportFilename } from '@/lib/table/export-format' import { createTableExportStream, exportContentType } from '@/lib/table/export-stream' @@ -37,10 +39,18 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou } const format = formatValidation.data - const access = await checkAccess(tableId, auth.userId, 'read') + const access = await checkAccess(tableId, { kind: 'user', userId: auth.userId }, 'read') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access + // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary + if ( + table.workspaceId && + (await isWorkspaceCapabilityWithheld(userId, table.workspaceId, 'tables.export')) + ) { + return capabilityRefusalResponse('tables.export') + } + // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 900e97d3a4d..6f007f6bc8b 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -38,7 +38,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = parsed.data.body - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index 45de3f210ee..6a15da4fe23 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -38,6 +38,9 @@ vi.mock('@/app/api/table/utils', async () => { const { TableLockedError } = await import('@/lib/table/mutation-locks') return { checkAccess: mockCheckAccess, + /** Mirrors the real helper: only a `user` principal names a governed subject. */ + capabilityGovernedUserId: (principal: { kind: string; userId?: string }) => + principal.kind === 'user' ? (principal.userId ?? null) : null, accessError: (result: { status: number }) => { const message = result.status === 404 ? 'Table not found' : 'Access denied' return NextResponse.json({ error: message }, { status: result.status }) @@ -281,6 +284,47 @@ describe('POST /api/table/[tableId]/import', () => { expect(mockImportReplaceRows).not.toHaveBeenCalled() }) + /** + * The appended rows auto-fire the table's workflow columns, and those cells + * gate their tools on the governed subject. Leaving it null ran the importing + * member's cells with no per-tool gate at all. + */ + it('dispatches the auto-fired cells under the person it just gated', async () => { + await callPost(createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + 'user-1', + 'user-1' + ) + }) + + /** + * `checkSessionOrInternalAuth` also accepts an internal JWT, whose user id is + * the run's actor — potentially the workspace billing owner. Dispatching the + * auto-fired cells under it would run them with that bystander's permission + * group; an executor call must dispatch under nobody. + */ + it('dispatches an internal-JWT import under nobody, not the run actor', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'billing-owner', + authType: 'internal_jwt', + }) + + await callPost(createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + 'billing-owner', + null + ) + }) + it('accepts chunked multipart imports without a content-length header', async () => { const form = createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) const req = new NextRequest('http://localhost:3000/api/table/tbl_1/import', { diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index b7b1d106df7..04e4915a632 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -11,7 +11,7 @@ import { } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' @@ -24,6 +24,7 @@ import { checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, + type TableAccessPrincipal, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -96,7 +97,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro ) } - const accessResult = await checkAccess(tableId, authResult.userId, 'write') + const principal: TableAccessPrincipal = { kind: 'user', userId: authResult.userId } + const accessResult = await checkAccess(tableId, principal, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -156,6 +158,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createColumns, timezone, requestId, + /** + * An append starts the table's workflow columns on every row it lands, so + * those cells are governed by the person behind the request — not left + * ungoverned, which would let an import run tools this member's + * permission group withholds. + * + * Derived from the auth type rather than from `principal`: this route + * also accepts an internal JWT, whose user id is the run's actor, and + * `TableAccessPrincipal` reports one as an ordinary person. An executor + * call must dispatch under nobody. + */ + capabilityGovernedUserId: capabilityGovernedAuthUserId(authResult), }) if (!outcome.success) { diff --git a/apps/sim/app/api/table/[tableId]/job/cancel/route.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts index 677c338f65b..bee06bba32d 100644 --- a/apps/sim/app/api/table/[tableId]/job/cancel/route.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts @@ -39,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const { tableId } = parsed.data.params const { workspaceId, jobId } = parsed.data.body - const access = await checkAccess(tableId, authResult.userId, 'write') + const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!access.ok) return accessError(access, requestId, tableId) if (access.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index 6883b56038c..e455ec187e9 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -35,7 +35,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index c01a25af73c..1d2f3e726c6 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -4,6 +4,8 @@ import { tableIdParamsSchema } from '@/lib/api/contracts/tables' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getTableById } from '@/lib/table' import { performRestoreTable } from '@/lib/table/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -32,6 +34,14 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + // permission-group-enforced: tables.use — raw route that queries directly and predates the operation boundary + if ( + table.workspaceId && + (await isWorkspaceCapabilityWithheld(auth.userId, table.workspaceId, 'tables.use')) + ) { + return capabilityRefusalResponse('tables.use') + } + const result = await performRestoreTable({ tableId, userId: auth.userId, requestId }) if (!result.success) { return orchestrationOutcomeErrorResponse(result, 'Failed to restore table') diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 2e0a6286181..e868b2e3f6d 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -115,7 +115,11 @@ export const PATCH = withRouteHandler( // `write` is the floor for either operation; a `locks` change additionally // requires `admin` (checked below), matching the workflow-lock precedent. - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -125,7 +129,11 @@ export const PATCH = withRouteHandler( } if (validated.locks !== undefined) { - const adminResult = await checkAccess(tableId, authResult.userId, 'admin') + const adminResult = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'admin' + ) if (!adminResult.ok) { return NextResponse.json( { error: 'Admin access required to change table locks' }, @@ -233,7 +241,11 @@ export const DELETE = withRouteHandler( workspaceId: searchParams.get('workspaceId'), }) - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/table/[tableId]/rows/find/route.ts b/apps/sim/app/api/table/[tableId]/rows/find/route.ts index f72c86633f6..de7b8034a0b 100644 --- a/apps/sim/app/api/table/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/find/route.ts @@ -47,7 +47,11 @@ export const GET = withRouteHandler( const validated = findTableRowsQuerySchema.parse({ workspaceId, q, filter, sort }) - const accessResult = await checkAccess(tableId, authResult.userId, 'read') + const accessResult = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'read' + ) if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult diff --git a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts index b6c4774c95d..034d8059296 100644 --- a/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts @@ -34,7 +34,11 @@ export const PATCH = withRouteHandler( const { tableId, viewId } = parsed.data.params const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { @@ -85,7 +89,11 @@ export const DELETE = withRouteHandler( const { tableId, viewId } = parsed.data.params const { workspaceId } = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess( + tableId, + { kind: 'user', userId: authResult.userId }, + 'write' + ) if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/table/[tableId]/views/route.ts b/apps/sim/app/api/table/[tableId]/views/route.ts index 975267d8a84..b191b9bf55f 100644 --- a/apps/sim/app/api/table/[tableId]/views/route.ts +++ b/apps/sim/app/api/table/[tableId]/views/route.ts @@ -33,7 +33,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const { workspaceId } = parsed.data.query - const result = await checkAccess(tableId, authResult.userId, 'read') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { @@ -68,7 +68,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table const { tableId } = parsed.data.params const { workspaceId, name, config } = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') + const result = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/table/capability-gate.test.ts b/apps/sim/app/api/table/capability-gate.test.ts new file mode 100644 index 00000000000..5dd9e2007c3 --- /dev/null +++ b/apps/sim/app/api/table/capability-gate.test.ts @@ -0,0 +1,179 @@ +/** + * @vitest-environment node + * + * Every raw route under `/api/table/**` authorizes through `checkAccess`, which + * predates the operation boundary and so is never reached by the authorization + * funnel that applies `tables.use` to `tableOperations`. These pin the gate + * `checkAccess` now carries, on a write path and a read path, against the real + * `checkAccess` and `accessError` rather than a mock of them. + * + * The write path is column creation on purpose: a TTL column is the only column + * configuration that causes rows to be deleted on a schedule, so a member of a + * group denied Tables driving this route is the worst of them. + */ +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetTableById, mockGetUserEntityPermissions, mockAddTableColumn, mockListTableViews } = + vi.hoisted(() => ({ + mockGetTableById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockAddTableColumn: vi.fn(), + mockListTableViews: vi.fn(), + })) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/table', () => ({ + addTableColumn: mockAddTableColumn, + buildFilterClause: vi.fn(), + createTableView: vi.fn(), + deleteColumn: vi.fn(), + getTableById: mockGetTableById, + listTableViews: mockListTableViews, + TableQueryValidationError: class TableQueryValidationError extends Error {}, + TableViewValidationError: class TableViewValidationError extends Error {}, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() })) +vi.mock('@/lib/table/orchestration', () => ({ performUpdateTableColumn: vi.fn() })) +vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceOrganizationId: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { POST } from '@/app/api/table/[tableId]/columns/route' +import { GET } from '@/app/api/table/[tableId]/views/route' + +const USER_ID = 'user-1' +const TABLE_ID = '22222222-2222-4222-8222-222222222222' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + +const TABLE = { + id: TABLE_ID, + name: 'expenses', + workspaceId: WORKSPACE_ID, + rowCount: 0, + schema: { columns: [] }, +} + +/** A TTL column — the configuration that deletes rows on a schedule. */ +function addTtlColumn() { + return POST( + new NextRequest(`http://localhost/api/table/${TABLE_ID}/columns`, { + method: 'POST', + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + column: { name: 'expires_at', type: 'ttl' }, + }), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve({ tableId: TABLE_ID }) } + ) +} + +function listViews() { + return GET( + new NextRequest(`http://localhost/api/table/${TABLE_ID}/views?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ tableId: TABLE_ID }) } + ) +} + +describe('tables.use gate on the raw /api/table routes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: USER_ID, + authType: 'session', + }) + mockGetTableById.mockResolvedValue(TABLE) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockAddTableColumn.mockResolvedValue({ schema: { columns: [{ name: 'expires_at' }] } }) + mockListTableViews.mockResolvedValue([]) + }) + + describe('when the group withholds Tables', () => { + beforeEach(() => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + }) + + it('refuses adding a TTL column, and never writes the schema', async () => { + const response = await addTtlColumn() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "The Tables module is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockAddTableColumn).not.toHaveBeenCalled() + }) + + it('refuses the read path, and never reads the views', async () => { + const response = await listViews() + + expect(response.status).toBe(403) + expect((await response.json()).details).toEqual({ + code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('still conceals a table the caller cannot reach, rather than naming the capability', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await listViews() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Access denied' }) + }) + + it('still 404s a table that does not exist, rather than naming the capability', async () => { + mockGetTableById.mockResolvedValue(null) + + const response = await listViews() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Table not found' }) + }) + }) + + describe('when no group withholds Tables', () => { + it('lets the TTL column through', async () => { + const response = await addTtlColumn() + + expect(response.status).toBe(200) + expect(mockAddTableColumn).toHaveBeenCalledTimes(1) + }) + + it('lets the read path through', async () => { + const response = await listViews() + + expect(response.status).toBe(200) + expect(mockListTableViews).toHaveBeenCalledTimes(1) + }) + + it('lets a governed group that withholds something else through', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + + const response = await addTtlColumn() + + expect(response.status).toBe(200) + expect(mockAddTableColumn).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/sim/app/api/table/executor-capability-exemption.test.ts b/apps/sim/app/api/table/executor-capability-exemption.test.ts new file mode 100644 index 00000000000..fe9f283edaa --- /dev/null +++ b/apps/sim/app/api/table/executor-capability-exemption.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + * + * The raw `/api/table/**` routes that authenticate with + * `checkSessionOrInternalAuth` accept an internal executor JWT, whose `userId` + * is the subject the executor embedded rather than a person asking for + * anything. Reading it bare applies that person's permission group to a + * delegation the executor exemption deliberately passes ungated — so these pin + * the derivation (`capabilityGovernedAuthUserId`) at each gate, on a group + * whose config would refuse if it were consulted. + */ +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listWorkspaceExportJobs: vi.fn(), + checkWorkspaceAccess: vi.fn(), + getUserEntityPermissions: vi.fn(), + createTable: vi.fn(), + listTables: vi.fn(), + getWorkspaceTableLimits: vi.fn(), + findActiveFolder: vi.fn(), + getUserSettings: vi.fn(), + runDetached: vi.fn(), + runTableImport: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/lib/table/jobs/service', () => ({ + listWorkspaceExportJobs: mocks.listWorkspaceExportJobs, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, + getUserEntityPermissions: mocks.getUserEntityPermissions, +})) +vi.mock('@/lib/table', () => ({ + createTable: mocks.createTable, + deleteTable: vi.fn(), + getWorkspaceTableLimits: mocks.getWorkspaceTableLimits, + listTables: mocks.listTables, + releaseJobClaim: vi.fn(), + sanitizeName: (name: string) => name, + TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 64 }, +})) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mocks.runTableImport })) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/users/queries', () => ({ getUserSettings: mocks.getUserSettings })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.runDetached })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { POST as importAsync } from '@/app/api/table/import-async/route' +import { GET as listJobs } from '@/app/api/table/jobs/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const TABLE_ID = '22222222-2222-4222-8222-222222222222' +const ACTOR_ID = 'run-actor' + +/** The run's actor, embedded in the executor's internal JWT. */ +function authenticateAsExecutor() { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'internal_jwt', + }) +} + +/** The same person, calling the same route from their own browser session. */ +function authenticateAsSession() { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'session', + }) +} + +function getExportJobs() { + return listJobs( + new NextRequest(`http://localhost/api/table/jobs?workspaceId=${WORKSPACE_ID}&type=export`) + ) +} + +function startImport() { + return importAsync( + new NextRequest('http://localhost/api/table/import-async', { + method: 'POST', + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + fileKey: `workspace/${WORKSPACE_ID}/upload.csv`, + fileName: 'upload.csv', + }), + headers: { 'content-type': 'application/json' }, + }) + ) +} + +describe('the subject the raw table routes gate on', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true }) + mocks.getUserEntityPermissions.mockResolvedValue('admin') + mocks.listWorkspaceExportJobs.mockResolvedValue([{ id: 'job-1' }]) + mocks.listTables.mockResolvedValue([]) + mocks.getWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) + mocks.getUserSettings.mockResolvedValue({ timezone: 'UTC' }) + mocks.createTable.mockResolvedValue({ id: TABLE_ID }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + disableTableExport: true, + }) + }) + + describe('an executor delegation carrying the actor’s id', () => { + beforeEach(authenticateAsExecutor) + + it('lists the workspace’s export jobs without consulting the actor’s group', async () => { + const response = await getExportJobs() + + expect(await response.json()).toEqual({ success: true, data: { jobs: [{ id: 'job-1' }] } }) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('starts an import without consulting the actor’s group', async () => { + const response = await startImport() + + expect(response.status).toBe(200) + expect(mocks.createTable).toHaveBeenCalled() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + }) + + describe('the same person on their own session', () => { + beforeEach(authenticateAsSession) + + it('is handed an empty export tray', async () => { + const response = await getExportJobs() + + expect(await response.json()).toEqual({ success: true, data: { jobs: [] } }) + expect(mocks.listWorkspaceExportJobs).not.toHaveBeenCalled() + }) + + it('is refused the import, and no table is created', async () => { + const response = await startImport() + + expect(response.status).toBe(403) + expect(mocks.createTable).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts index 4bb0b65bac2..e7b3fabf786 100644 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + permissionsMock, + permissionsMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -48,7 +55,9 @@ vi.mock('@/lib/core/utils/background', () => ({ ), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/table/import-async/route' function makeRequest(body: unknown): NextRequest { @@ -68,6 +77,7 @@ const validBody = { describe('POST /api/table/import-async', () => { beforeEach(() => { vi.clearAllMocks() + resetPermissionGroupScopeMock() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -153,4 +163,48 @@ describe('POST /api/table/import-async', () => { const response = await POST(makeRequest({ workspaceId: 'workspace-1' })) expect(response.status).toBe(400) }) + + /** + * An import is a table creation, so it is `tables.create` that governs it — + * not `tables.use`. `disableTableCreation` leaves Tables visible and usable, + * which is exactly the configuration a `tables.use` gate would let through. + */ + it('refuses the import when the group disables table creation', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(403) + expect((await response.json()).details).toEqual({ + code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('refuses the import when the group hides Tables entirely', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(403) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('lets the import through when the group withholds something else', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + + const response = await POST(makeRequest(validBody)) + + expect(response.status).toBe(200) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 04039178db7..17a6b0be553 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -3,12 +3,14 @@ import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { importTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, @@ -46,6 +48,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (permission !== 'write' && permission !== 'admin') { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + + /** + * permission-group-enforced: tables.create — raw route that queries directly + * and predates the operation boundary. An import always ends in a new table, + * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: + * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating + * on it still refuses a group that hides Tables entirely. Keyed to the + * governed subject, which names nobody for an internal-JWT executor call — + * the same rule the synchronous `import-csv` route applies. Not re-read before + * `createTable` below: `resolvePermissionGroupConfig` is memoized per request + * (`withPermissionGroupScope`), so a second call in this handler returns the + * promise this one started and could not observe a revocation. + */ + const governedUserId = capabilityGovernedAuthUserId(authResult) + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.create')) + ) { + return capabilityRefusalResponse('tables.create') + } // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a // caller can't import another workspace's uploaded object. if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index dea46a06a3a..136eb4b0f3e 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + permissionsMock, + permissionsMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -63,8 +70,10 @@ vi.mock('@/app/api/table/utils', async () => { } }) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/import-csv/route' @@ -125,6 +134,7 @@ function uploadParts(csv: string): Part[] { describe('POST /api/table/import-csv', () => { beforeEach(() => { vi.clearAllMocks() + resetPermissionGroupScopeMock() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -247,4 +257,87 @@ describe('POST /api/table/import-csv', () => { const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) expect(response.status).toBe(403) }) + + /** + * A CSV import creates a table, so `tables.create` governs it. A group that + * only sets `disableTableCreation` leaves Tables visible and usable, which is + * exactly the configuration a `tables.use` gate would let through. + */ + it('refuses the import when the group disables table creation', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(403) + expect((await response.json()).details).toEqual({ + code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + it('refuses the import when the group hides Tables entirely', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTablesTab: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(403) + expect(mockCreateTable).not.toHaveBeenCalled() + }) + + /** + * `checkSessionOrInternalAuth` also accepts an internal JWT, whose user id is + * the run's actor rather than someone asking for a table. Gating on it would + * refuse an executor call for a bystander's group, and dispatching under it + * would run the table's cells with that bystander's capabilities. + */ + it('leaves an internal-JWT import ungoverned rather than gating on the run actor', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'billing-owner', + authType: 'internal_jwt', + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(200) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(mockBatchInsertRows).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: null }), + expect.anything(), + expect.any(String) + ) + }) + + it('dispatches a session import under the person it gated', async () => { + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(200) + expect(mockBatchInsertRows).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: 'user-1' }), + expect.anything(), + expect.any(String) + ) + }) + + it('lets the import through when the group withholds something else', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + + const response = await POST(makeRequest(uploadParts(csvWithRows(3)))) + + expect(response.status).toBe(200) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index d6cf5fb6440..f3b03e66baa 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -4,11 +4,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { CSV_SYNC_MAX_FILE_SIZE_BYTES } from '@/lib/table' import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' @@ -35,6 +37,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const userId = authResult.userId + /** + * The person whose permission group governs this import, or `null` for the + * internal-JWT caller this route also accepts — an executor's embedded + * subject is the run's actor, not a person asking for a table. One + * derivation for both the gate below and the dispatch subject, so the id + * this route refuses on is the id its writes run under. + */ + const governedUserId = capabilityGovernedAuthUserId(authResult) const oversize = csvProxyBodyCapResponse(request) if (oversize) return oversize @@ -71,6 +81,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + /** + * permission-group-enforced: tables.create — raw route that queries directly + * and predates the operation boundary. An import always ends in a new table, + * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: + * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating + * on it still refuses a group that hides Tables entirely. Keyed to the + * governed subject, which names nobody for an executor call — the same rule + * `createTableImportUseCase` applies on the async import path. + */ + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.create')) + ) { + return capabilityRefusalResponse('tables.create') + } + let folderId: string | null = null if (fields.folderId) { const folderIdResult = csvImportFormSchema.shape.folderId.safeParse(fields.folderId) @@ -118,6 +144,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { folderId, timezone, requestId, + /** + * The person this route already gated `tables.create` against. A table + * created here has no workflow columns yet, so nothing auto-fires today; + * naming the subject anyway keeps the rule "the id the surface gated is + * the id the write dispatches under" with no producer-specific exception + * to re-argue. + */ + capabilityGovernedUserId: governedUserId, }) if (!outcome.success) { diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts index dbe38d3e489..f2225b308cd 100644 --- a/apps/sim/app/api/table/jobs/route.ts +++ b/apps/sim/app/api/table/jobs/route.ts @@ -2,9 +2,10 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { listTableJobsContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { listWorkspaceExportJobs } from '@/lib/table/jobs/service' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -36,6 +37,26 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + /** + * permission-group-enforced: tables.export — this listing is exports and + * nothing else, and each row names a `jobId` that resolves to a finished + * export file. Withheld as an empty list rather than a refusal: the caller has + * no exports they may act on, and erroring the tray would report a failure + * where the honest answer is that there is nothing to show. + * + * Keyed to the governed subject, which names nobody for an internal-JWT + * executor call: `authResult.userId` there is the subject the executor + * embedded, so reading it bare would hand the run's actor's group to a caller + * the executor exemption deliberately passes ungated. + */ + const governedUserId = capabilityGovernedAuthUserId(authResult) + if ( + governedUserId && + (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.export')) + ) { + return NextResponse.json({ success: true, data: { jobs: [] } }) + } + const jobs = await listWorkspaceExportJobs(workspaceId) logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId }) return NextResponse.json({ success: true, data: { jobs } }) diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts index 02713a30442..fb6e06b6e33 100644 --- a/apps/sim/app/api/table/utils.test.ts +++ b/apps/sim/app/api/table/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { capabilityGovernedAuthUserId } from '@/lib/auth/hybrid' import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableRowLimitError } from '@/lib/table/billing' import { TableRowNotFoundError } from '@/lib/table/rows/errors' @@ -180,3 +181,53 @@ describe('orchestrationOutcomeErrorResponse', () => { }) }) }) + +describe('capabilityGovernedAuthUserId', () => { + it('governs a session by its user', () => { + expect( + capabilityGovernedAuthUserId({ success: true, userId: 'user-1', authType: 'session' }) + ).toBe('user-1') + }) + + it('governs a personal API key by its owner', () => { + expect( + capabilityGovernedAuthUserId({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + }) + ).toBe('user-1') + }) + + /** + * The executor embeds the run's actor in the internal JWT — the workspace + * billing owner, or the member who merely triggered the run. Reading a + * governed subject off it applies that bystander's permission group to an + * executor call, which is the substitution the subject exists to remove. + */ + it('names nobody for an internal JWT even though it carries a user id', () => { + expect( + capabilityGovernedAuthUserId({ + success: true, + userId: 'billing-owner', + authType: 'internal_jwt', + }) + ).toBeNull() + }) + + it('names nobody for a workspace API key, whose user id is the key creator', () => { + expect( + capabilityGovernedAuthUserId({ + success: true, + userId: 'key-creator', + authType: 'api_key', + apiKeyType: 'workspace', + }) + ).toBeNull() + }) + + it('names nobody when the credential carries no user at all', () => { + expect(capabilityGovernedAuthUserId({ success: true, authType: 'internal_jwt' })).toBeNull() + }) +}) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index b9a2a855349..daf4ed3d838 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -10,6 +10,12 @@ import { statusForOrchestrationError, } from '@/lib/core/orchestration/types' import type { MultipartError } from '@/lib/core/utils/multipart' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table' import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' @@ -209,20 +215,15 @@ export function multipartErrorResponse(error: MultipartError): NextResponse { return NextResponse.json({ error: message }, { status: 400 }) } -interface TableAccessResult { - hasAccess: true - table: TableDefinition -} - -interface TableAccessDenied { - hasAccess: false - notFound?: boolean - reason?: string -} - -export type TableAccessCheck = TableAccessResult | TableAccessDenied - -export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 } +/** + * A denial carries `capability` when the caller's permission group withheld the + * Tables module, so {@link accessError} can say so rather than reporting the + * role failure it is not. Optional because the other two denials — the table + * does not exist, the role is too low — have no capability to name. + */ +export type AccessResult = + | { ok: true; table: TableDefinition } + | { ok: false; status: 404 | 403; capability?: StaticPermissionGroupCapability } interface ApiErrorResponse { error: string @@ -230,50 +231,105 @@ interface ApiErrorResponse { } /** - * Check if a user has read access to a table. - * Read access requires any workspace permission (read, write, or admin). + * Who is asking, for the purposes of {@link checkAccess}. + * + * A discriminated union rather than a user id, because the two kinds are + * indistinguishable as strings and the gate must treat them differently: + * + * - `user` — a session, a personal API key, or an internal JWT carrying the + * run's actor. The id is answerable for the request, so the workspace role + * check runs against it and this surface's `tables.use` gate applies. See + * {@link capabilityGovernedUserId} for why the JWT case belongs here and + * nonetheless must not be reused to attribute dispatched work. + * - `workspace_api_key` — a shared credential that authorizes as the workspace + * itself. It has no user, so there is no group to resolve. + * `keyCreatorUserId` is the id `authenticateApiKeyFromHeader` reports: the + * person who *minted* the key, a bystander who may not even be the caller. It + * carries the workspace role check that predates this union and nothing else. + * Same rule, and the same reasoning, as `capabilityGovernedPrincipalUserId` in + * `@/lib/core/application`. + * + * Required, and with no permissive default, for the same reason `capability` is + * required on `defineWorkspaceOperation`: an absent declaration cannot be told + * apart from an unreviewed one. A caller holding a workspace key cannot reach + * the gated behavior by passing a bare id, because a bare id no longer + * type-checks — it has to name a kind, and the only kind that skips the gate is + * the one that says so. */ -async function checkTableAccess(tableId: string, userId: string): Promise { - const table = await getTableById(tableId) - - if (!table) { - return { hasAccess: false, notFound: true } - } +export type TableAccessPrincipal = + | { kind: 'user'; userId: string } + | { kind: 'workspace_api_key'; keyCreatorUserId: string } - const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) - if (userPermission !== null) { - return { hasAccess: true, table } - } - - return { hasAccess: false, reason: 'User does not have access to this table' } +/** The id the workspace ROLE check runs against, for either principal kind. */ +function roleSubjectUserId(principal: TableAccessPrincipal): string { + return principal.kind === 'user' ? principal.userId : principal.keyCreatorUserId } /** - * Check if a user has write access to a table. - * Write access requires write or admin workspace permission. + * The id whose permission group governs THIS REQUEST, or `null` when no group + * does. Only a `user` principal has one — see {@link TableAccessPrincipal}. + * + * ## Two questions, two subjects + * + * A table route asks the permission group two things, and they take different + * answers for the same caller. Conflating them is how a run either stops working + * or gains grants it was never given: + * + * 1. MAY THIS REQUEST PROCEED — the role check and the `tables.use` gate in + * {@link checkAccess}. Answered with the id the credential presents, this + * function. An internal JWT presents the run's actor, and applying that + * person's group here is deliberate: the answer can only withhold the table + * from a run whose actor lost Tables, never open one. Failing closed on a + * bystander's group is a conservative read of an id we already trust for the + * role. + * 2. UNDER WHOSE GROUP DOES WORK THIS REQUEST STARTS RUN — the workflow and + * enrichment cells a landed row auto-fires. Answered by + * `capabilityGovernedAuthUserId` in `@/lib/auth/hybrid`, off the auth TYPE, + * which names NOBODY for an internal JWT. Here the actor's group would run + * the other way: it would grant a bystander's tools to an executor call, and + * the executor's own withholding in `tableOperations` is what governs that + * path instead. + * + * So: gate with this, dispatch with `capabilityGovernedAuthUserId`. Exported + * because both the gate and the callers that hand a subject to a batch write + * need question 1 answered the same way — a route must not gate one subject and + * check another. */ -async function checkTableWriteAccess(tableId: string, userId: string): Promise { - const table = await getTableById(tableId) - - if (!table) { - return { hasAccess: false, notFound: true } - } - - const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) - if (permissionSatisfies(userPermission, 'write')) { - return { hasAccess: true, table } - } - - return { hasAccess: false, reason: 'User does not have write access to this table' } +export function capabilityGovernedUserId(principal: TableAccessPrincipal): string | null { + return principal.kind === 'user' ? principal.userId : null } /** * Access check returning `{ ok, table }` or `{ ok: false, status }`. - * Uses workspace permissions only. + * + * The workspace role, then the permission group's `tables.use` capability — the + * one gate every raw table route under `/api/table/**` shares. These routes + * predate the operation boundary and query the table service directly, so the + * authorization funnel that applies `tables.use` to `tableOperations` never + * sees them; without this a member of a group denied Tables could still drive + * all of them. + * + * Capability comes second for the same reason it does in + * `authorizeWorkspaceOperation`: the role failure conceals whether the table + * exists, and refusing on capability first would tell a non-member which + * modules the organization withholds. + * + * The gate applies to a `user` principal only; see {@link TableAccessPrincipal} + * for why `/api/v1/tables/**`, which shares this helper under an API key, must + * reach the table ungated on a workspace key. + * + * Nothing here exempts the executor, and that is question 1 of the two in + * {@link capabilityGovernedUserId}: an internal JWT presents the run's actor, so + * this gate runs against the actor's group and can only refuse more. A workflow + * run that reaches tables through `tableOperations` instead is governed by that + * funnel's delegated-principal branch, which withholds capabilities from an + * executor subject outright. Neither answer is the one question 2 takes — + * a route dispatching cells off this request derives its subject from the auth + * type, not from the principal gated here. */ export async function checkAccess( tableId: string, - userId: string, + principal: TableAccessPrincipal, level: 'read' | 'write' | 'admin' = 'read' ): Promise { const table = await getTableById(tableId) @@ -282,17 +338,40 @@ export async function checkAccess( return { ok: false, status: 404 } } - const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId) - const hasAccess = permissionSatisfies(permission, level) + const permission = await getUserEntityPermissions( + roleSubjectUserId(principal), + 'workspace', + table.workspaceId + ) + if (!permissionSatisfies(permission, level)) { + return { ok: false, status: 403 } + } + + // permission-group-enforced: tables.use — raw routes that query directly and predate the operation boundary + const governedUserId = capabilityGovernedUserId(principal) + if ( + governedUserId && + table.workspaceId && + (await isWorkspaceCapabilityWithheld(governedUserId, table.workspaceId, 'tables.use')) + ) { + return { ok: false, status: 403, capability: 'tables.use' } + } - return hasAccess ? { ok: true, table } : { ok: false, status: 403 } + return { ok: true, table } } export function accessError( - result: { ok: false; status: 404 | 403 }, + result: Extract, requestId: string, context?: string ): NextResponse { + if (result.capability) { + logger.warn( + `[${requestId}] ${capabilityRefusal(result.capability)}${context ? `: ${context}` : ''}` + ) + return capabilityRefusalResponse(result.capability) + } + const message = result.status === 404 ? 'Table not found' : 'Access denied' logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`) return NextResponse.json({ error: message }, { status: result.status }) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index b6776b51db6..88a51d54a7d 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -9,10 +9,27 @@ import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('ApiKeysAPI') +/** + * Whether the caller's permission group withholds personal-key management. + * + * permission-group-enforced: api_keys.manage — a raw handler with inline + * queries, which the authorization funnel never sees. + * + * Personal keys are user-global and so belong to no workspace, which is why no + * workspace is named: {@link isCapabilityWithheldForUser} then resolves the + * organization's default group — the group that governs an organization-level + * action, the same resolution invitations use. + */ +function personalKeyManagementWithheld(userId: string): Promise { + return isCapabilityWithheldForUser(userId, 'api_keys.manage') +} + // GET /api/users/me/api-keys - Get all API keys for the current user export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -23,6 +40,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id + const withheld = await personalKeyManagementWithheld(userId) + if (withheld) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + const keys = await db .select({ id: apiKey.id, @@ -66,6 +88,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = session.user.id + + const withheld = await personalKeyManagementWithheld(userId) + if (withheld) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + const parsed = await parseRequest(createPersonalApiKeyContract, request, {}) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 732c4e4d6fe..3f4290a8b0d 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -141,10 +141,23 @@ export const POST = withRouteHandler( logger.warn('Admin API: normalized imported workflow with warnings', { warnings }) } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, { - ...workflowData, - ...preparedState, - }) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + { + ...workflowData, + ...preparedState, + }, + { + /** + * Actorless. This is the platform-admin surface: the caller is a Sim + * operator restoring data, not a member of the target workspace, so no + * member's permission group governs the write. `check-capability-subject` + * excludes `v1/admin` for the same reason. + */ + workspaceId: null, + subjectUserId: null, + } + ) if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 2284b4e4a7e..33f94c17a08 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -377,10 +377,23 @@ async function importSingleWorkflow( logger.warn(`Admin API: normalized "${dedupedName}" with warnings`, { warnings }) } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, { - ...workflowData, - ...preparedState, - }) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + { + ...workflowData, + ...preparedState, + }, + { + /** + * Actorless. This is the platform-admin surface: the caller is a Sim + * operator restoring data, not a member of the target workspace, so no + * member's permission group governs the write. `check-capability-subject` + * excludes `v1/admin` for the same reason. + */ + workspaceId: null, + subjectUserId: null, + } + ) if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 965d619cee8..3ba25fdbfb1 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -31,6 +31,14 @@ const logger = createLogger('V1AuditLogDetailAPI') export const revalidate = 0 +/** + * GET /api/v1/audit-logs/[id] — Read one audit log entry. + * + * permission-group-exempt: none — same as the list. `audit_logs.read_detail` is + * an organization-admin operation that carries an explicit `capability: 'none'` + * declaration, which is the reviewed answer; an operation that names none at all + * is what the operation validator rejects. + */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 36c12d1019d..e1ddc69d9b1 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -46,6 +46,14 @@ const logger = createLogger('V1AuditLogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 +/** + * GET /api/v1/audit-logs — List an organization's audit log. + * + * permission-group-exempt: none — the counterpart `audit_logs.list` declares + * `capability: 'none'` explicitly, because the surface is already restricted to + * organization admins and owners, who sit above every permission group. There + * is nothing here for a group to withhold. + */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/v1/capability-gate.test.ts b/apps/sim/app/api/v1/capability-gate.test.ts new file mode 100644 index 00000000000..b367393a6a9 --- /dev/null +++ b/apps/sim/app/api/v1/capability-gate.test.ts @@ -0,0 +1,328 @@ +/** + * @vitest-environment node + * + * The v1 public API authorizes in `app/api/v1/middleware.ts` rather than + * through `authorizeWorkspaceOperation`, so none of the capabilities the funnel + * applies to the v2 and internal surfaces reached it. A member of a group that + * withholds Tables was refused on `/api/v2/tables/**` and on every internal + * `/api/table/**` route, and could still do the same work through + * `/api/v1/tables/**` with a personal API key. + * + * These run the real middleware against the real routes — only the credential, + * the rate bucket, the workspace role and the governing group config are + * mocked — so they fail if the capability a route declares is dropped, is + * checked before the role check, or starts applying to a workspace API key. + * + * Scope: capability GATES only. `logs.cost` and `logs.trace_spans` are + * projections rather than gates — a route declaring `'none'` withholds fields + * instead of refusing — and are pinned in `app/api/v1/logs/projection.test.ts`. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, + v1PersonalKeyCredential, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockListTables, + mockListKnowledgeBases, + mockListWorkspaceFiles, + mockListPublicWorkflowLogs, + mockGetDeploymentWorkflowTarget, + mockPerformFullDeploy, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockListTables: vi.fn(), + mockListKnowledgeBases: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockListPublicWorkflowLogs: vi.fn(), + mockGetDeploymentWorkflowTarget: vi.fn(), + mockPerformFullDeploy: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), +})) +vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) +vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: {}, + recordAudit: vi.fn(), +})) +vi.mock('@/lib/table', () => ({ + listTables: mockListTables, + createTable: vi.fn(), + getWorkspaceTableLimits: vi.fn(async () => ({ maxTables: 10 })), + TableConflictError: class TableConflictError extends Error {}, +})) +vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column })) +vi.mock('@/lib/knowledge/service', () => ({ + listWorkspaceAndLegacyKnowledgeBases: mockListKnowledgeBases, + getKnowledgeBaseById: vi.fn(), +})) +vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn() })) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + getWorkspaceFile: vi.fn(), + uploadWorkspaceFile: vi.fn(), + FileConflictError: class FileConflictError extends Error {}, +})) +vi.mock('@/lib/logs/public-queries', () => ({ + listPublicWorkflowLogs: mockListPublicWorkflowLogs, + decodePublicLogCursor: vi.fn(), +})) +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplay: vi.fn(), +})) +vi.mock('@/app/api/v1/logs/meta', async () => { + const { projectUserLimits } = + await vi.importActual('@/app/api/v1/logs/meta') + return { + getUserLimits: vi.fn(async () => ({ usage: {} })), + projectUserLimits, + createApiResponse: (body: unknown) => ({ body, headers: {} }), + } +}) +vi.mock('@/lib/workflows/deployments/queries', () => ({ + getDeploymentWorkflowTarget: mockGetDeploymentWorkflowTarget, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as getFiles } from '@/app/api/v1/files/route' +import { GET as getKnowledge } from '@/app/api/v1/knowledge/route' +import { GET as getLogs } from '@/app/api/v1/logs/route' +import { GET as getTables } from '@/app/api/v1/tables/route' +import { POST as deployWorkflow } from '@/app/api/v1/workflows/[id]/deploy/route' + +const USER_ID = 'user-1' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const WORKFLOW_ID = 'wf-1' + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function get(path: string) { + return new NextRequest(`http://localhost${path}`, { + method: 'GET', + headers: { 'x-api-key': 'sim_test' }, + }) +} + +function deployRequest() { + return [ + new NextRequest(`http://localhost/api/v1/workflows/${WORKFLOW_ID}/deploy`, { + method: 'POST', + headers: { 'x-api-key': 'sim_test', 'content-type': 'application/json' }, + body: JSON.stringify({}), + }), + { params: Promise.resolve({ id: WORKFLOW_ID }) }, + ] as const +} + +const REFUSAL = /is not available under your organization's permission group/ + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(USER_ID)) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockListTables.mockResolvedValue([]) + mockListKnowledgeBases.mockResolvedValue([]) + mockListWorkspaceFiles.mockResolvedValue([]) + mockListPublicWorkflowLogs.mockResolvedValue({ data: [], nextCursor: null }) + mockGetDeploymentWorkflowTarget.mockResolvedValue({ + workflow: { id: WORKFLOW_ID, name: 'wf', isDeployed: false }, + workspaceId: WORKSPACE_ID, + }) +}) + +describe('v1 permission-group capability gate', () => { + describe('refuses a personal key whose group withholds the module', () => { + it('tables — GET /api/v1/tables declares tables.use', async () => { + governedBy({ hideTablesTab: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(body.details).toEqual({ code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + expect(mockListTables).not.toHaveBeenCalled() + }) + + it('knowledge — GET /api/v1/knowledge declares knowledge.use', async () => { + governedBy({ hideKnowledgeBaseTab: true }) + + const response = await getKnowledge(get(`/api/v1/knowledge?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(mockListKnowledgeBases).not.toHaveBeenCalled() + }) + + it('files — GET /api/v1/files declares files.use', async () => { + governedBy({ hideFilesTab: true }) + + const response = await getFiles(get(`/api/v1/files?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('workflows — POST /api/v1/workflows/[id]/deploy declares deploy.api', async () => { + governedBy({ hideDeployApi: true }) + + const response = await deployWorkflow(...deployRequest()) + + /** The deployment routes mask every access failure as 404, so the status + * is the 404 they already return; what the gate changes is that the + * deploy never happens. */ + expect(response.status).toBe(404) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + }) + + describe('exceptions that must keep working', () => { + it('a workspace API key passes through ungated — it has no user, so no group', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideTablesTab: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('a route declaring none is unaffected by a group that withholds everything', async () => { + governedBy({ + hideTablesTab: true, + hideKnowledgeBaseTab: true, + hideFilesTab: true, + hideDeployApi: true, + }) + + const response = await getLogs(get(`/api/v1/logs?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalled() + }) + + it('an ungoverned workspace resolves no config and is never refused', async () => { + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + }) + }) + + /** + * `personal_api_key.use` refuses a *principal kind* rather than a module, so it + * is asserted separately from the capability the route declares — but, like + * every other group key, only after the workspace role check. A workspace key + * is not a personal key, and its creator's group must not decide whether it + * may be used. + */ + describe('personal_api_key.use — the key kind, not the module', () => { + it('refuses a personal key whose group disables personal API keys', async () => { + governedBy({ disablePersonalApiKeys: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(/personal API key/i) + expect(mockListTables).not.toHaveBeenCalled() + }) + + it('passes a workspace key through the same group that would deny its creator', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ disablePersonalApiKeys: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + /** + * The group key runs behind the role check, so a stranger to the workspace + * is answered with the concealed role failure rather than with a refusal + * naming how an organization configured one of its cohorts. Asked the other + * way round, a caller with no reach into the workspace at all learns that + * the workspace's organization runs a group, and that the group withholds + * personal keys. + * + * The workspace COLUMN still answers first — it names no group, needs no + * query, and is the answer whatever the role turns out to be — which is the + * split `authorizeWorkspaceOperation` makes and the next case pins. + */ + it('answers a non-member on role, not on the group that withholds personal keys', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ disablePersonalApiKeys: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe('Access denied') + expect(mockListTables).not.toHaveBeenCalled() + }) + + it("answers a non-member on the workspace's own column, which names no group", async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(/personal API key/i) + expect(mockListTables).not.toHaveBeenCalled() + }) + }) + + it('refuses on role before capability, so a non-member learns nothing about the group', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ hideTablesTab: true }) + + const response = await getTables(get(`/api/v1/tables?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe('Access denied') + expect(body.details).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index ac3759d96fc..970cbab2a81 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -6,6 +6,11 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' * * Deprecated: the v1 headless copilot chat API has been removed. The endpoint * returns 410 Gone for all callers. + * + * permission-group-exempt: none — the route authenticates nobody and reaches no + * resource, so there is no workspace in which to resolve a group. The + * counterpart `chat.send` declares `copilot.use`; when this surface returns + * anything but 410 again, it declares that capability. */ export const POST = withRouteHandler(async () => NextResponse.json( diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index eb767ac5f3c..7b79984c1fc 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -26,7 +26,16 @@ interface FileRouteParams { params: Promise<{ fileId: string }> } -/** GET /api/v1/files/[fileId] — Download file content. */ +/** + * GET /api/v1/files/[fileId] — Download file content. + * + * permission-group-exempt: none — no separate ROUTE-level gate is needed, not + * because the policy is unstated. This handler runs through the application + * funnel: `downloadWorkspaceFileStream` is the `files.download` operation, which + * declares `files.use` and has it applied by `authorizeWorkspaceOperation` for + * the personal-API-key principal. A middleware gate here would check the same + * capability twice. + */ export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { const requestId = generateRequestId() @@ -106,7 +115,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil const { fileId } = parsed.data.params const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'files.use', + 'write' + ) if (accessError) return accessError const fileRecord = await getWorkspaceFile(workspaceId, fileId) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 36adcb392e4..8d8b8659753 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -58,7 +58,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'files.use') if (accessError) return accessError const files = await listWorkspaceFiles(workspaceId) @@ -130,7 +130,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { workspaceId } = formFieldsResult.data - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError if (!file) { @@ -146,7 +146,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'files.use', + 'write' + ) if (accessError) return accessError const buffer = await readFileToBufferWithLimit(file, { diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 33ac4611ffe..b7a363fc11c 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -41,7 +41,8 @@ export const GET = withRouteHandler( knowledgeBaseId, parsed.data.query.workspaceId, userId, - rateLimit + rateLimit, + 'knowledge.use' ) if (result instanceof NextResponse) return result @@ -133,6 +134,7 @@ export const DELETE = withRouteHandler( parsed.data.query.workspaceId, userId, rateLimit, + 'knowledge.use', 'write' ) if (result instanceof NextResponse) return result diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 2a5ddb92f2c..e04b3370cbc 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -54,7 +54,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume parsed.data.query const { id: knowledgeBaseId } = parsed.data.params - const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit) + const result = await resolveKnowledgeBase( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'knowledge.use' + ) if (result instanceof NextResponse) return result const documentsResult = await getDocuments( @@ -159,6 +165,7 @@ export const POST = withRouteHandler( workspaceId, userId, rateLimit, + 'knowledge.upload', 'write' ) if (result instanceof NextResponse) return result diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 373d0951636..3dda06cd9fd 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -41,7 +41,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle if (!parsed.success) return parsed.response const { id } = parsed.data.params - const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit) + const result = await resolveKnowledgeBase( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'knowledge.use' + ) if (result instanceof NextResponse) return result return NextResponse.json({ @@ -70,7 +76,14 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const { id } = parsed.data.params const { workspaceId, name, description, chunkingConfig } = parsed.data.body - const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') + const result = await resolveKnowledgeBase( + id, + workspaceId, + userId, + rateLimit, + 'knowledge.use', + 'write' + ) if (result instanceof NextResponse) return result const outcome = await performUpdateKnowledgeBase({ @@ -120,6 +133,7 @@ export const DELETE = withRouteHandler( parsed.data.query.workspaceId, userId, rateLimit, + 'knowledge.use', 'write' ) if (result instanceof NextResponse) return result diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 9593cc8466e..95edbaa332f 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -40,7 +40,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'knowledge.use' + ) if (accessError) return accessError /** Read only after `validateWorkspaceAccess` authorized this caller; same list the @@ -78,7 +83,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { workspaceId, name, description, chunkingConfig } = parsed.data.body - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'knowledge.create', + 'write' + ) if (accessError) return accessError const outcome = await performCreateKnowledgeBase({ diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index a54a4685b6b..7f331dbe0a4 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -48,7 +48,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { workspaceId, topK, query, tagFilters, searchMode } = parsed.data.body - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'knowledge.use' + ) if (accessError) return accessError const hasBillableQuery = Boolean(query?.trim()) diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts index d87524610cd..9995f7a3144 100644 --- a/apps/sim/app/api/v1/knowledge/utils.ts +++ b/apps/sim/app/api/v1/knowledge/utils.ts @@ -3,22 +3,38 @@ import { NextResponse } from 'next/server' import { validationErrorResponseFromError } from '@/lib/api/server' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' +import { + type RateLimitResult, + type V1RouteCapability, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' const logger = createLogger('V1KnowledgeAPI') /** - * Fetches a KB by ID, validates it exists, belongs to the workspace, - * and the user has permission. Returns the KB or a NextResponse error. + * Fetches a KB by ID, validates it exists, belongs to the workspace, the user + * has permission, and the caller's permission group grants `capability`. + * Returns the KB or a NextResponse error. + * + * `capability` is required rather than defaulted to `knowledge.use` because + * uploading a document is withheld separately (`knowledge.upload`), and a + * default would let a route added later inherit a gate nobody chose for it. */ export async function resolveKnowledgeBase( id: string, workspaceId: string, userId: string, rateLimit: RateLimitResult, + capability: V1RouteCapability, level: 'read' | 'write' = 'read' ): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, level) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + capability, + level + ) if (accessError) return accessError const kb = await getKnowledgeBaseById(id) diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 5d5c3639093..201abd1b0ea 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -5,12 +5,19 @@ import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { + projectCostTotal, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' -import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { createApiResponse, getUserLimits, projectUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, + concealedWorkspaceAccessResponse, createRateLimitResponse, - validateWorkspaceAccess, + resolveWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1LogDetailsAPI') @@ -41,11 +48,21 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId) + const accessError = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId, 'none') if (accessError) { - return NextResponse.json({ error: 'Log not found' }, { status: 404 }) + return concealedWorkspaceAccessResponse(accessError, 'Log not found') } + /** + * `logs.trace_spans` and `logs.cost` are projections, not gates — this + * route declares `'none'` above and withholds the fields here instead, + * through the same helper the internal/v2 detail path uses. + */ + const projection = await resolveLogFieldProjection( + capabilityGovernedUserId(rateLimit), + log.workspaceId + ) + const workflowSummary = { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', @@ -68,21 +85,24 @@ export const GET = withRouteHandler( totalDurationMs: log.totalDurationMs, files: log.files || undefined, workflow: workflowSummary, - executionData: (await materializeExecutionDataForDisplay( - log.executionData as Record | null, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - userId, - } - )) as any, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + executionData: projectExecutionData( + (await materializeExecutionDataForDisplay( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + userId, + } + )) as Record | null, + projection + ) as any, + cost: projectCostTotal(log.costTotal, projection), createdAt: log.createdAt.toISOString(), } // Get user's workflow execution limits and usage - const limits = await getUserLimits(userId) + const limits = projectUserLimits(await getUserLimits(userId), projection) // Create response with limits information const apiResponse = createApiResponse({ data: response }, limits, rateLimit) diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts index 92ee767591e..5a6ef965d8a 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.test.ts @@ -1,30 +1,56 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ checkRateLimit: vi.fn(), - validateWorkspaceAccess: vi.fn(), + resolveWorkspaceAccess: vi.fn(), getPublicWorkflowLog: vi.fn(), getUserLimits: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ + /** + * Mirrors the real `capabilityGovernedUserId`: a workspace key reports its + * creator's `userId` too, so `keyType` — not the presence of a user — is what + * decides whether a permission group governs the caller. + */ + capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, checkRateLimit: mocks.checkRateLimit, + /** Mirrors the real helper: only a post-role group refusal carries `details`. */ + concealedWorkspaceAccessResponse: ( + failure: { status: number; message: string; details?: unknown }, + notFoundMessage: string + ) => + failure.details + ? NextResponse.json( + { error: failure.message, details: failure.details }, + { status: failure.status } + ) + : NextResponse.json({ error: notFoundMessage }, { status: 404 }), createRateLimitResponse: () => NextResponse.json({ error: 'Rate limit' }, { status: 429 }), - validateWorkspaceAccess: mocks.validateWorkspaceAccess, + resolveWorkspaceAccess: mocks.resolveWorkspaceAccess, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('@/lib/logs/public-queries', () => ({ getPublicWorkflowLog: mocks.getPublicWorkflowLog, })) -vi.mock('@/app/api/v1/logs/meta', () => ({ - getUserLimits: mocks.getUserLimits, - createApiResponse: (data: T, limits: L) => ({ body: { ...data, limits }, headers: {} }), -})) +vi.mock('@/app/api/v1/logs/meta', async () => { + const { projectUserLimits } = + await vi.importActual('@/app/api/v1/logs/meta') + return { + getUserLimits: mocks.getUserLimits, + projectUserLimits, + createApiResponse: (data: T, limits: L) => ({ body: { ...data, limits }, headers: {} }), + } +}) /** * Overrides the global stub, whose empty `subBlocks` would let the sanitizer @@ -47,6 +73,7 @@ vi.mock('@/blocks/registry', () => ({ getBlockByToolName: vi.fn(() => undefined), })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/v1/logs/executions/[executionId]/route' const rateLimit = { @@ -86,8 +113,11 @@ describe('GET /api/v1/logs/executions/[executionId]', () => { beforeEach(() => { vi.clearAllMocks() mocks.checkRateLimit.mockResolvedValue(rateLimit) - mocks.validateWorkspaceAccess.mockResolvedValue(null) - mocks.getUserLimits.mockResolvedValue({ usage: { plan: 'free' } }) + mocks.resolveWorkspaceAccess.mockResolvedValue(null) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) + mocks.getUserLimits.mockResolvedValue({ + usage: { plan: 'free', currentPeriodCost: 12.5, limit: 50, isExceeded: false }, + }) mocks.getPublicWorkflowLog.mockResolvedValue({ workflowId: 'workflow-1', workspaceId: 'workspace-1', @@ -130,10 +160,62 @@ describe('GET /api/v1/logs/executions/[executionId]', () => { totalDurationMs: 1000, cost: { total: 0.01 }, }, - limits: { usage: { plan: 'free' } }, + limits: { usage: { plan: 'free', currentPeriodCost: 12.5 } }, }) }) + it("conceals an ordinary access failure behind the surface's not-found", async () => { + mocks.resolveWorkspaceAccess.mockResolvedValueOnce({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const { request, context } = requestFor('execution-1') + const response = await GET(request, context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Workflow execution not found' }) + }) + + /** + * Both group keys answer only after the caller's workspace role verified, so + * the caller is already known to be a member: the refusal names their own + * organization's setting and conceals nothing a 404 would protect. + */ + it('preserves the structured detail of a post-role permission-group refusal', async () => { + mocks.resolveWorkspaceAccess.mockResolvedValueOnce({ + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are disabled for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + + const { request, context } = requestFor('execution-1') + const response = await GET(request, context) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Personal API keys are disabled for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + }) + + it('withholds period spend alongside the run total when the group withholds logs.cost', async () => { + mocks.checkRateLimit.mockResolvedValue({ ...rateLimit, keyType: 'personal' }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + + const { request, context } = requestFor('execution-1') + const body = await (await GET(request, context)).json() + + expect(body.executionMetadata.cost).toBeNull() + expect(body.limits.usage.currentPeriodCost).toBeNull() + expect(body.limits.usage).toMatchObject({ plan: 'free', limit: 50, isExceeded: false }) + }) + it('reports a missing snapshot as not found', async () => { mocks.getPublicWorkflowLog.mockResolvedValueOnce({ workflowId: 'workflow-1', diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index 5bb92f2363f..fede0a24e16 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -3,13 +3,16 @@ import { type NextRequest, NextResponse } from 'next/server' import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { projectCostTotal, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' -import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { createApiResponse, getUserLimits, projectUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, + concealedWorkspaceAccessResponse, createRateLimitResponse, - validateWorkspaceAccess, + resolveWorkspaceAccess, } from '@/app/api/v1/middleware' const logger = createLogger('V1ExecutionAPI') @@ -46,11 +49,22 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + const accessError = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowLog.workspaceId, + 'none' + ) if (accessError) { - return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + return concealedWorkspaceAccessResponse(accessError, 'Workflow execution not found') } + /** `logs.cost` is a projection, not a gate — see `resolveLogFieldProjection`. */ + const projection = await resolveLogFieldProjection( + capabilityGovernedUserId(rateLimit), + workflowLog.workspaceId + ) + /** * The stored snapshot carries `password: true` sub-block values and `oauth-input` * credential ids, so it is redacted before it reaches this public wire — the same @@ -73,7 +87,7 @@ export const GET = withRouteHandler( totalDurationMs: workflowLog.totalDurationMs, // Sourced from the cost_total projection of the usage_log ledger // (the deprecated cost jsonb column was dropped). - cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + cost: projectCostTotal(workflowLog.costTotal, projection), }, } @@ -81,7 +95,7 @@ export const GET = withRouteHandler( logger.debug(`Workflow state contains ${countWorkflowStateBlocks(workflowState)} blocks`) // Get user's workflow execution limits and usage - const limits = await getUserLimits(userId) + const limits = projectUserLimits(await getUserLimits(userId), projection) // Create response with limits information const apiResponse = createApiResponse( diff --git a/apps/sim/app/api/v1/logs/meta.ts b/apps/sim/app/api/v1/logs/meta.ts index 47d374c7a8d..e131ca994da 100644 --- a/apps/sim/app/api/v1/logs/meta.ts +++ b/apps/sim/app/api/v1/logs/meta.ts @@ -3,6 +3,7 @@ import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage' import { RateLimiter } from '@/lib/core/rate-limiter' +import type { LogFieldProjection } from '@/lib/logs/log-projection' export interface UserLimits { workflowExecutionRateLimit: { @@ -20,7 +21,8 @@ export interface UserLimits { } } usage: { - currentPeriodCost: number + /** `null` when the caller's permission group withholds spend — see {@link projectUserLimits}. */ + currentPeriodCost: number | null limit: number plan: string isExceeded: boolean @@ -64,6 +66,28 @@ export async function getUserLimits(userId: string): Promise { } } +/** + * Withholds the caller's period spend from the `limits` envelope when their + * permission group withholds `logs.cost`. + * + * `hideCostInfo` withholds cost and token spend, and on a personal key the + * keyholder IS the governed member: blanking every run's `cost` while the same + * response reports what those runs added up to this period withholds nothing. + * A workspace key resolves no group at all (`resolveLogFieldProjection` returns + * the empty projection for it), so a shared credential still reports the billed + * account's usage. + * + * `limit`, `plan` and `isExceeded` stay: they are the caller's entitlement and + * their own execution eligibility — the reason a run would be refused — not a + * spend figure. + * + * permission-group-enforced: logs.cost + */ +export function projectUserLimits(limits: UserLimits, projection: LogFieldProjection): UserLimits { + if (!projection.hideCostInfo) return limits + return { ...limits, usage: { ...limits.usage, currentPeriodCost: null } } +} + export function createApiResponse( data: T, limits: UserLimits, diff --git a/apps/sim/app/api/v1/logs/projection.test.ts b/apps/sim/app/api/v1/logs/projection.test.ts new file mode 100644 index 00000000000..16eafc243d5 --- /dev/null +++ b/apps/sim/app/api/v1/logs/projection.test.ts @@ -0,0 +1,480 @@ +/** + * @vitest-environment node + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS, not gates — a group + * withholds those fields from the response rather than refusing the read, which + * is why every v1 logs route correctly declares `capability: 'none'`. The + * internal/v2 detail path applies them in `readLogDetail`; the v1 routes built + * their own bodies and applied nothing, so `?details=full&includeTraceSpans=true` + * still handed a governed member the spans and the spend. + * + * These run the real routes against the real `resolveLogFieldProjection` — the + * same helper `readLogDetail` resolves its flags through — so they fail if + * either surface stops projecting. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, + v1PersonalKeyCredential, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockListPublicWorkflowLogs, + mockGetPublicWorkflowLog, + mockMaterialize, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockListPublicWorkflowLogs: vi.fn(), + mockGetPublicWorkflowLog: vi.fn(), + mockMaterialize: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), + getWorkspaceOrganizationId: vi.fn(async () => null), +})) +vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) +vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) +vi.mock('@/lib/logs/public-queries', () => ({ + listPublicWorkflowLogs: mockListPublicWorkflowLogs, + getPublicWorkflowLog: mockGetPublicWorkflowLog, + decodePublicLogCursor: vi.fn(), +})) +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplay: mockMaterialize, +})) +vi.mock('@/lib/logs/snapshot-sanitizer', () => ({ + sanitizeExecutionSnapshotState: (state: unknown) => state, +})) +vi.mock('@/app/api/v1/logs/meta', async () => { + const { projectUserLimits } = + await vi.importActual('@/app/api/v1/logs/meta') + return { + getUserLimits: vi.fn(async () => ({ + usage: { currentPeriodCost: 4.25, limit: 50, plan: 'pro', isExceeded: false }, + })), + projectUserLimits, + createApiResponse: (body: unknown, limits: unknown) => ({ + body: { ...(body as object), limits }, + headers: {}, + }), + } +}) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as getLogDetail } from '@/app/api/v1/logs/[id]/route' +import { GET as getExecution } from '@/app/api/v1/logs/executions/[executionId]/route' +import { GET as listLogs } from '@/app/api/v1/logs/route' + +const USER_ID = 'user-1' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const LOG_ID = 'log-1' + +const EXECUTION_DATA = { + finalOutput: { answer: 'a customer address' }, + workflowInput: { question: 'who?' }, + blockInput: { prompt: 'who?' }, + blockExecutions: [{ blockId: 'b1', cost: { total: 0.2 }, tokens: { total: 90 } }], + traceSpans: [ + { + id: 's1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 120 }, + children: [{ id: 's2', name: 'tool', cost: { total: 0.1 } }], + }, + ], +} + +const LOG_ROW = { + id: LOG_ID, + workflowId: 'wf-1', + workspaceId: WORKSPACE_ID, + executionId: 'exec-1', + deploymentVersionId: null, + level: 'info', + trigger: 'api', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + createdAt: new Date('2026-01-01T00:00:01.000Z'), + workflowState: { blocks: {} }, + totalDurationMs: 1000, + costTotal: '0.75', + files: null, + executionData: EXECUTION_DATA, + workflowName: 'wf', + workflowDescription: null, + workflowFolderId: null, + workflowUserId: USER_ID, + workflowWorkspaceId: WORKSPACE_ID, + workflowCreatedAt: new Date('2026-01-01T00:00:00.000Z'), + workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function apiRequest(path: string) { + return new NextRequest(`http://localhost${path}`, { + method: 'GET', + headers: { 'x-api-key': 'sim_test' }, + }) +} + +function listFull() { + return listLogs( + apiRequest( + `/api/v1/logs?workspaceId=${WORKSPACE_ID}&details=full&includeTraceSpans=true&includeFinalOutput=true` + ) + ) +} + +function readExecution() { + return getExecution(apiRequest('/api/v1/logs/executions/exec-1'), { + params: Promise.resolve({ executionId: 'exec-1' }), + }) +} + +function readDetail() { + return getLogDetail(apiRequest(`/api/v1/logs/${LOG_ID}`), { + params: Promise.resolve({ id: LOG_ID }), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(USER_ID)) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) + mockGetPublicWorkflowLog.mockResolvedValue(LOG_ROW) + mockMaterialize.mockImplementation(async () => structuredClone(EXECUTION_DATA)) +}) + +describe('GET /api/v1/logs?details=full', () => { + it('withholds trace spans and the final output when the group hides them', async () => { + governedBy({ hideTraceSpans: true }) + + const body = await (await listFull()).json() + const [log] = body.data + + expect(log).not.toHaveProperty('traceSpans') + expect(log).not.toHaveProperty('finalOutput') + }) + + it('withholds the run cost when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + + expect(body.data[0].cost).toBeNull() + }) + + it('strips spend from the spans it still returns when only cost is hidden', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + const [span] = body.data[0].traceSpans + + expect(span.name).toBe('agent') + expect(span).not.toHaveProperty('cost') + expect(span).not.toHaveProperty('tokens') + expect(span.children[0]).not.toHaveProperty('cost') + }) + + it('returns both when no group withholds them', async () => { + const body = await (await listFull()).json() + const [log] = body.data + + expect(log.cost).toEqual({ total: 0.75 }) + expect(log.traceSpans[0].cost).toEqual({ total: 0.5 }) + expect(log.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) + + /** + * `withheldExecutionData` strips `traceSpans` and `finalOutput` alike, so + * under `hideTraceSpans` neither opt-in field survives — reading every row's + * blob out of the trace store to delete it is pure cost. + */ + it('neither selects nor materializes execution data it is going to withhold', async () => { + governedBy({ hideTraceSpans: true }) + + await listFull() + + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: false }) + ) + expect(mockMaterialize).not.toHaveBeenCalled() + }) + + it('still materializes for a group that withholds only spend', async () => { + governedBy({ hideCostInfo: true }) + + await listFull() + + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: true }) + ) + expect(mockMaterialize).toHaveBeenCalled() + }) + + /** + * On a personal key the keyholder IS the governed member, so blanking every + * run's cost while the envelope reports what those runs added up to this + * period withholds nothing. `limit`, `plan` and `isExceeded` stay: they are + * the caller's entitlement and eligibility, not a spend figure. + */ + it('withholds the period spend in the limits envelope alongside the run totals', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + + expect(body.limits.usage.currentPeriodCost).toBeNull() + expect(body.limits.usage).toMatchObject({ limit: 50, plan: 'pro', isExceeded: false }) + }) + + it('reports the period spend when no group withholds it', async () => { + const body = await (await listFull()).json() + + expect(body.limits.usage.currentPeriodCost).toBe(4.25) + }) + + it('reports the period spend to a workspace API key, which resolves no group', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideCostInfo: true }) + + const body = await (await listFull()).json() + + expect(body.limits.usage.currentPeriodCost).toBe(4.25) + }) + + it('withholds nothing from a workspace API key, whose creator has no say', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideTraceSpans: true, hideCostInfo: true }) + + const body = await (await listFull()).json() + const [log] = body.data + + expect(log.cost).toEqual({ total: 0.75 }) + expect(log.traceSpans).toHaveLength(1) + }) +}) + +/** + * Blanking `cost` while still answering `minCost`/`maxCost` faithfully leaves + * the list a bisection oracle over the very figure it just withheld: one + * request per probe, with the page as the answer. The filter is therefore + * refused rather than silently dropped — dropping it would answer a question + * nobody asked, and a wrong answer presented as the right one is worse than a + * refusal. + */ +describe('GET /api/v1/logs cost-selective queries', () => { + function listFiltered(query: string) { + return listLogs(apiRequest(`/api/v1/logs?workspaceId=${WORKSPACE_ID}&${query}`)) + } + + it.each([['minCost=0.5'], ['maxCost=0.5'], ['minCost=0.1&maxCost=0.9']])( + 'refuses %s for a group that withholds spend', + async (query) => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered(query) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "Execution cost is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockListPublicWorkflowLogs).not.toHaveBeenCalled() + } + ) + + it('answers the same filter for a group that withholds nothing', async () => { + const response = await listFiltered('minCost=0.5') + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ filters: expect.objectContaining({ minCost: 0.5 }) }) + ) + }) + + /** A workspace key has no user and therefore no group to refuse on behalf of. */ + it('answers the same filter for a workspace API key', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minCost=0.5') + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalled() + }) + + /** + * An unfilled form field is not a question about cost. `?minCost=` reaches + * the schema as `''`, and `z.coerce.number()` reads `Number('')` as a real + * zero — which made an innocent request look like a cost selector and refused + * it. Normalized to omitted before the assertion runs. + */ + it.each([['minCost='], ['maxCost='], ['minCost=&maxCost=']])( + 'answers %s for a group that withholds spend, because it selects nothing', + async (query) => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered(query) + + expect(response.status).toBe(200) + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.objectContaining({ minCost: undefined, maxCost: undefined }), + }) + ) + } + ) + + /** An explicit zero is a bound the caller typed, and still selects on cost. */ + it('still refuses an explicit minCost=0', async () => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minCost=0') + + expect(response.status).toBe(403) + expect(mockListPublicWorkflowLogs).not.toHaveBeenCalled() + }) + + /** Only the spend filter is refused; the rest of the query is unaffected. */ + it('still answers a non-cost filter for a group that withholds spend', async () => { + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minDurationMs=100') + + expect(response.status).toBe(200) + }) + + /** + * The refusal must come from the caller's own membership, not from the door: + * a non-member is told nothing about how the organization configured a group. + */ + it('refuses a non-member for their access before naming the group', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ hideCostInfo: true }) + + const response = await listFiltered('minCost=0.5') + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Access denied' }) + }) +}) + +describe('GET /api/v1/logs/[id]', () => { + it('withholds the execution payloads when the group hides trace spans', async () => { + governedBy({ hideTraceSpans: true }) + + const body = await (await readDetail()).json() + + expect(body.data.executionData).not.toHaveProperty('traceSpans') + expect(body.data.executionData).not.toHaveProperty('blockExecutions') + expect(body.data.executionData).not.toHaveProperty('finalOutput') + expect(body.data.executionData).not.toHaveProperty('workflowInput') + expect(body.data.executionData).not.toHaveProperty('blockInput') + }) + + it('withholds the run cost and per-span spend when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await readDetail()).json() + + expect(body.data.cost).toBeNull() + expect(body.data.executionData.traceSpans[0]).not.toHaveProperty('cost') + expect(body.data.executionData.blockExecutions[0]).not.toHaveProperty('tokens') + }) + + it('returns everything when no group withholds it', async () => { + const body = await (await readDetail()).json() + + expect(body.data.cost).toEqual({ total: 0.75 }) + expect(body.data.executionData.traceSpans[0].cost).toEqual({ total: 0.5 }) + expect(body.data.executionData.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) +}) + +describe('GET /api/v1/logs/executions/[executionId]', () => { + it('withholds the run cost when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const body = await (await readExecution()).json() + + expect(body.executionMetadata.cost).toBeNull() + }) + + it('returns the run cost when no group withholds it', async () => { + const body = await (await readExecution()).json() + + expect(body.executionMetadata.cost).toEqual({ total: 0.75 }) + }) +}) + +/** + * The log surfaces answer "not found" for a workspace the caller cannot reach, + * so a stranger cannot probe which ones exist. A permission-group refusal is + * the one failure with nothing left to conceal: it runs only after the role + * check passed, so the caller is already a known member being told how their + * own organization configured their cohort. + */ +describe('v1 log surfaces and the personal-key group refusal', () => { + beforeEach(() => { + governedBy({ disablePersonalApiKeys: true }) + }) + + it.each([ + ['GET /api/v1/logs/[id]', () => readDetail()], + ['GET /api/v1/logs/executions/[executionId]', () => readExecution()], + ])('%s keeps the structured detail rather than flattening it to 404', async (_name, call) => { + const response = await call() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: expect.any(String), + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + }) + + it.each([ + ['GET /api/v1/logs/[id]', () => readDetail(), 'Log not found'], + [ + 'GET /api/v1/logs/executions/[executionId]', + () => readExecution(), + 'Workflow execution not found', + ], + ])('%s still conceals a caller with no workspace role', async (_name, call, message) => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await call() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: message }) + }) +}) diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 390e7707300..2322a06c34d 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -6,9 +6,18 @@ import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { + assertLogCostQueryAllowed, + projectCostTotal, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' -import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' +import { createApiResponse, getUserLimits, projectUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, @@ -42,9 +51,46 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + params.workspaceId, + 'none', + 'read' + ) if (accessError) return accessError + /** `logs.trace_spans` and `logs.cost` are projections, not gates — see {@link resolveLogFieldProjection}. */ + const projection = await resolveLogFieldProjection( + capabilityGovernedUserId(rateLimit), + params.workspaceId + ) + + /** + * Project the value, then refuse the query that selects on it. `minCost` and + * `maxCost` bisect the very total `projectCostTotal` blanks below, so + * withholding one while answering the other is incoherent. This surface + * orders by `startedAt` alone — it publishes no `sortBy` — so the ordering + * half of the oracle is not reachable here. + * + * It runs after the workspace access check above, so the caller is a member + * being told about their own group rather than an outsider handed an + * organization-configuration oracle. + * + * The assertion throws so every surface refuses in the same words, and this + * route builds its own responses rather than running inside a JSON route + * builder, so the throw is caught here instead of by a shared error + * projection. Caught narrowly on purpose — the handler's outer `catch` + * renders a 500, and letting a 403 fall into it would report an + * organization's policy as a Sim fault. + */ + try { + assertLogCostQueryAllowed({ minCost: params.minCost, maxCost: params.maxCost }, projection) + } catch (error) { + if (!(error instanceof PermissionGroupCapabilityError)) throw error + return capabilityRefusalResponse(error.capability) + } + logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { userId, filters: { @@ -80,15 +126,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { order: params.order, } + /** + * `withheldExecutionData` strips `traceSpans` AND `finalOutput`, so under + * `hideTraceSpans` both opt-in payload fields project to nothing. Reading + * the projection here rather than after the fetch keeps the surface from + * selecting every row's execution blob out of the trace store and + * materializing it only to delete it. + */ + const needsMaterialize = + params.details === 'full' && + (params.includeFinalOutput || params.includeTraceSpans) && + !projection.hideTraceSpans + const { data, nextCursor } = await listPublicWorkflowLogs({ filters, limit: params.limit, - includeExecutionData: params.details === 'full', + includeExecutionData: needsMaterialize, }) - const needsMaterialize = - params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) - const buildBase = (log: (typeof data)[number]) => { const result: any = { id: log.id, @@ -100,7 +155,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { startedAt: log.startedAt.toISOString(), endedAt: log.endedAt?.toISOString() || null, totalDurationMs: log.totalDurationMs, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + cost: projectCostTotal(log.costTotal, projection), files: log.files || null, } @@ -120,7 +175,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { const result = buildBase(log) if (log.executionData) { - const execData = (await materializeExecutionDataForDisplay( + const materialized = (await materializeExecutionDataForDisplay( log.executionData as Record | null, { workspaceId: log.workspaceId, @@ -128,11 +183,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { executionId: log.executionId, userId, } - )) as any - if (params.includeFinalOutput && execData.finalOutput) { + )) as Record | null + const execData = projectExecutionData(materialized, projection) as any + if (params.includeFinalOutput && execData?.finalOutput) { result.finalOutput = execData.finalOutput } - if (params.includeTraceSpans && execData.traceSpans) { + if (params.includeTraceSpans && execData?.traceSpans) { result.traceSpans = execData.traceSpans } } @@ -140,7 +196,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) : data.map(buildBase) - const limits = await getUserLimits(userId) + const limits = projectUserLimits(await getUserLimits(userId), projection) const response = createApiResponse( { diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 2a8e598a7b6..3c92fdb58cd 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -7,7 +7,12 @@ * `used = limit - remaining` gets a negative number. */ -import { createMockRequest } from '@sim/testing' +import { + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' @@ -17,13 +22,23 @@ import { recordRateLimitSnapshot, } from '@/lib/api/server/rate-limit-context' -const { mockAuthenticateV1Request, mockGetSubscription, mockCheckRateLimit, mockGetRateLimit } = - vi.hoisted(() => ({ - mockAuthenticateV1Request: vi.fn(), - mockGetSubscription: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockGetRateLimit: vi.fn(), - })) +const { + mockAuthenticateV1Request, + mockGetSubscription, + mockCheckRateLimit, + mockGetRateLimit, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockGetWorkspaceBilledAccountUserId, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetSubscription: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockGetRateLimit: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), +})) vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request, @@ -40,10 +55,24 @@ vi.mock('@/lib/core/rate-limiter', () => ({ }, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { authenticateRequest, checkRateLimit, + checkWorkspaceScope, createRateLimitResponse, + requireWorkspaceRequestActor, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -274,3 +303,168 @@ describe('rate-limit snapshot context', () => { expect(getRateLimitHeaders(req)).toBeNull() }) }) + +/** + * The table routes authorize with `checkWorkspaceScope` and then a domain + * helper (`checkAccess`) that runs the workspace ROLE check. `checkAccess` + * gates the module (`tables.use`), never the key kind, so `personal_api_key.use` + * has to be asked in the wrapper — which means the wrapper has to order itself + * behind the role, because nothing downstream will. + * + * The workspace column keeps answering first: it names no group, so refusing on + * it tells a stranger only what the workspace itself is set to. + */ +describe('checkWorkspaceScope', () => { + const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + const USER_ID = 'user-1' + + function personalKeyRateLimit() { + return { + allowed: true, + remaining: 1, + limit: 1, + resetAt: new Date(), + userId: USER_ID, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId: USER_ID, keyId: 'key-1' }, + } + } + + function withholdsPersonalKeys() { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + } + + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + }) + + it('refuses a member whose group withholds personal API keys', async () => { + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + expect(response).not.toBeNull() + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringMatching(/personal API key/i), + }) + }) + + it('leaves a non-member to the downstream role check rather than naming the group', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + expect(response).toBeNull() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it("still refuses a non-member on the workspace's own column, which names no group", async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + expect(response).not.toBeNull() + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringMatching(/personal API key/i), + }) + }) + + /** + * The two refusals share their sentence, so without the code a client cannot + * tell "the workspace switched personal keys off" from "your group did" — + * different settings, different people to ask. + */ + it('carries the detail code on the group refusal and not on the column one', async () => { + withholdsPersonalKeys() + const grouped = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + await expect(grouped?.json()).resolves.toMatchObject({ + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + + resetPermissionGroupScopeMock() + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + const column = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID) + + await expect(column?.json()).resolves.not.toHaveProperty('details') + }) + + /** + * The concealment ordering, one level in. The funnel asks this key only after + * `requireCurrentHumanRole(operation.minimumRole)`, so a read-only member on a + * write route is refused on role. Asked at `read` here, the same person was + * told instead how their organization configured personal keys. + */ + it('leaves a read-only member on a write route to the downstream role check', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID, 'write') + + expect(response).toBeNull() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('still refuses that member on a read route, where the role does reach', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + withholdsPersonalKeys() + + const response = await checkWorkspaceScope(personalKeyRateLimit(), WORKSPACE_ID, 'read') + + expect(response?.status).toBe(403) + }) +}) + +describe('requireWorkspaceRequestActor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-user') + }) + + it('substitutes the billed account as the system actor for a workspace key', async () => { + const actor = await requireWorkspaceRequestActor( + { allowed: true, keyType: 'workspace', userId: 'key-creator' } as never, + 'workspace-1' + ) + + expect(actor).toEqual({ ok: true, actorUserId: 'billed-user' }) + }) + + it('keeps the owner for a personal key', async () => { + const actor = await requireWorkspaceRequestActor( + { allowed: true, keyType: 'personal', userId: 'user-1' } as never, + 'workspace-1' + ) + + expect(actor).toEqual({ ok: true, actorUserId: 'user-1' }) + }) + + /** + * An archived or deleted workspace has no billed account to stand in. That is + * a reachable request about an unreachable workspace, not a server fault: the + * call sites used to throw, and the routes' catch-all reported it as a 500. + */ + it('projects an unresolvable actor onto a 400 rather than throwing', async () => { + mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null) + + const actor = await requireWorkspaceRequestActor( + { allowed: true, keyType: 'workspace', userId: 'key-creator' } as never, + 'workspace-gone' + ) + + expect(actor.ok).toBe(false) + if (actor.ok) throw new Error('expected a refusal') + expect(actor.response.status).toBe(400) + await expect(actor.response.json()).resolves.toEqual({ error: 'Invalid workspace ID' }) + }) +}) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 3f8d4878119..dbde4f2837a 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -7,14 +7,24 @@ import { getValidationErrorMessage, isZodError, validationErrorResponse } from ' import { buildRateLimitHeaders, recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' import { PERSONAL_KEY_DENIED, WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import type { ForbiddenDetailCode } from '@/lib/core/application/forbidden' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' +import { + CAPABILITY_RULES, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId, getWorkspaceBillingSettings, } from '@/lib/workspaces/utils' +import type { TableAccessPrincipal } from '@/app/api/table/utils' import { authenticateV1Request } from '@/app/api/v1/auth' const logger = createLogger('V1Middleware') @@ -84,6 +94,41 @@ export function requireRateLimitUserId(rateLimit: RateLimitResult): string { return rateLimit.userId } +/** + * The user whose permission group governs this request, or `null` when none + * does. + * + * The v1 reading of the rule `capabilityGovernedPrincipalUserId` states in + * `@/lib/core/application`: `rateLimit.userId` is present for BOTH key kinds, + * and for a workspace key it is the key's creator. `keyType` is the + * authoritative signal, and this is the one place v1 reads it for that purpose, + * so {@link resolveCapabilityRefusal}, {@link tableAccessPrincipal} and the log + * field projection cannot drift. + * + * `scripts/check-capability-subject.ts` is written in terms of this name and + * asserts every v1 capability subject came from it; rename them together. + */ +export function capabilityGovernedUserId(rateLimit: RateLimitResult): string | null { + return rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null +} + +/** + * The {@link TableAccessPrincipal} for a v1 table request. + * + * `/api/v1/tables/**` shares `checkAccess` with the raw internal table routes, + * which gate `tables.use` inside it. Those routes reject `x-api-key` outright, + * so every caller there is a person; v1's are API keys, and a workspace key must + * reach the table ungated. Built here rather than at each of the fourteen v1 + * handlers, so the decision stays in the same module as every other `keyType` + * policy. + */ +export function tableAccessPrincipal(rateLimit: RateLimitResult): TableAccessPrincipal { + const userId = requireRateLimitUserId(rateLimit) + return capabilityGovernedUserId(rateLimit) + ? { kind: 'user', userId } + : { kind: 'workspace_api_key', keyCreatorUserId: userId } +} + export function requireRateLimitPrincipal( rateLimit: RateLimitResult ): PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal { @@ -226,6 +271,71 @@ export interface WorkspaceAccessError { status: number code: 'FORBIDDEN' message: string + /** + * The detail code a client can branch on, present only when a permission + * group withheld a capability. Read from {@link CAPABILITY_RULES} rather than + * spelled out, so v1 renders the same code as the funnel and the raw internal + * routes for the same refusal. + */ + details?: { code: ForbiddenDetailCode } +} + +/** + * The permission-group capability a v1 route requires, or `'none'` when no + * group governs it. + * + * Required rather than optional wherever it is threaded, and `'none'` spelled + * out rather than omitted, for the same reason `capability` is required on + * `defineWorkspaceOperation`: an absent declaration cannot be told apart from an + * unreviewed one, and unreviewed omission is how twelve config keys shipped + * enforcing nothing. Each route's value is the one its v2 or internal + * counterpart already declares — v1 does not get a mapping of its own. + */ +export type V1RouteCapability = StaticPermissionGroupCapability | 'none' + +/** + * The permission-group gate for a v1 route, as a structured failure. + * + * Only a personal key carries capabilities. A workspace key authorizes as the + * workspace and has no user, so there is no group to resolve — and its + * `rateLimit.userId` is the key's *creator*, a bystander whose group must not + * govern every caller of a shared credential. That is the same reasoning the + * `workspace_api_key` branch of `authorizeWorkspaceOperation` applies; the + * escape is closed at the door instead, because minting a workspace key is + * itself capability-gated. + * + * No `permission-group-enforced:` annotation, because this gate names no + * capability of its own: it applies whichever one the route declares, and every + * one of those is already reachable through the operation its v2 or internal + * counterpart declares. + * + * Never call this before the workspace role check. A capability refusal handed + * to a non-member would confirm the workspace exists and disclose which modules + * the organization withholds; the role failure conceals both. + * + * Takes no caller-supplied user id on purpose: the subject is the guard's own + * return value from {@link capabilityGovernedUserId}, so there is only one id — + * a caller cannot assert the withhold against a different one (the key + * creator's) than the one the guard said was personal. + */ +export async function resolveCapabilityRefusal( + rateLimit: RateLimitResult, + workspaceId: string, + capability: V1RouteCapability +): Promise { + if (capability === 'none') return null + const userId = capabilityGovernedUserId(rateLimit) + if (!userId) return null + + if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, capability))) return null + + logger.warn('v1 request blocked by permission group', { workspaceId, userId, capability }) + return { + status: 403, + code: 'FORBIDDEN', + message: capabilityRefusal(capability), + details: { code: CAPABILITY_RULES[capability].detailCode }, + } } /** @@ -234,6 +344,15 @@ export interface WorkspaceAccessError { * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`). Other surfaces enforcing the same * policy share `PERSONAL_KEY_DENIED`. + * + * Both are properties of the workspace rather than of any group, so both run + * ahead of the role check, exactly as `authorizeWorkspaceOperation` runs the + * `allowPersonalApiKeys` column ahead of `requireCurrentHumanRole`: they need + * no group to resolve, and refusing a key the workspace has switched off is the + * answer whatever the caller's role turns out to be. + * + * The group half of the same policy is NOT here — see + * {@link resolvePersonalKeyGroupRefusal}. */ export async function resolveWorkspaceScope( rateLimit: RateLimitResult, @@ -266,13 +385,83 @@ export async function resolveWorkspaceScope( } /** - * Core workspace-access check (scope + the user's workspace permission level), - * shared by v1 and v2. Returns a structured failure or null on success. + * The group half of the personal-key policy: `personal_api_key.use`, repeated + * here because v1 authorizes in this middleware rather than through the + * application funnel, and without it the same key that v2 refuses would still + * work against v1. + * + * It answers only AFTER the caller's workspace role has been verified, which is + * the ordering `authorizeWorkspaceOperation` uses and the reason + * {@link resolveCapabilityRefusal}'s contract says never to run a group key + * ahead of the role: the refusal names how an organization configured one + * cohort, and handing that to a caller with no reach into the workspace tells a + * stranger about the organization's configuration. The column check above may + * stay early precisely because it names no group. + * + * `roleVerifiedFor` is the user id a caller has already checked, not a boolean, + * so a caller that verified some OTHER subject's role cannot vouch for this + * one. When it does not match, the role is resolved here instead, and a caller + * who does not reach `requiredLevel` is handed back `null` so the surface's own + * role failure — the concealed one — is what it answers with. That second + * lookup is free: `getUserEntityPermissions` for a workspace goes through the + * request-scoped memo the role check itself uses. + * + * `requiredLevel` is the level the SURFACE will demand, not a floor of `read`. + * The funnel runs this key after `requireCurrentHumanRole(operation.minimumRole)`, + * so a read-only member calling a write route is refused on role there. Checked + * at `read` here, the same person on the same route was told instead how their + * organization configured personal keys — the disclosure the ordering exists to + * prevent, one level in. + */ +async function resolvePersonalKeyGroupRefusal( + rateLimit: RateLimitResult, + workspaceId: string, + roleVerifiedFor: string | null, + requiredLevel: PermissionType = 'read' +): Promise { + const governedUserId = capabilityGovernedUserId(rateLimit) + if (!governedUserId) return null + + if (roleVerifiedFor !== governedUserId) { + const permission = await getUserEntityPermissions(governedUserId, 'workspace', workspaceId) + if (!permissionSatisfies(permission, requiredLevel)) return null + } + + // permission-group-enforced: personal_api_key.use — v1 authorizes in this middleware, not through the funnel + if (!(await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'personal_api_key.use'))) { + return null + } + + /** + * The detail code separates this from the workspace-column refusal above, + * which shares the sentence but is a different setting with a different + * remedy. Read off the rule rather than spelled out, exactly as + * {@link resolveCapabilityRefusal} does. + */ + return { + status: 403, + code: 'FORBIDDEN', + message: PERSONAL_KEY_DENIED, + details: { code: CAPABILITY_RULES['personal_api_key.use'].detailCode }, + } +} + +/** + * Core workspace-access check: key scope and the workspace's own columns, then + * the user's workspace permission level, then the two permission-group + * decisions — the personal-key refusal, then the capability the route declares. + * Returns a structured failure or null on success. + * + * Both group keys come after the role, matching `authorizeWorkspaceOperation` — + * see {@link resolveCapabilityRefusal} for why the ordering is load-bearing. + * The personal-key refusal sits first of the two for the reason the funnel + * gives: the remedies differ, and the narrower one is worth naming first. */ export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, + capability: V1RouteCapability, level: PermissionType = 'read' ): Promise { const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) @@ -282,18 +471,75 @@ export async function resolveWorkspaceAccess( if (!permissionSatisfies(permission, level)) { return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } - return null + + const personalKeyRefusal = await resolvePersonalKeyGroupRefusal(rateLimit, workspaceId, userId) + if (personalKeyRefusal) return personalKeyRefusal + + return resolveCapabilityRefusal(rateLimit, workspaceId, capability) } /** - * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body, + * plus the personal-key group refusal that belongs with it. + * + * It deliberately gates no MODULE capability: it runs before the route's role + * check, and a route using it authorizes its resource through a domain helper + * afterwards (the table routes call `checkAccess`, which applies `tables.use` + * itself), so the capability is declared there. + * + * `personal_api_key.use` cannot wait for that helper — `checkAccess` gates the + * module, not the key kind — so it is asked here, and + * {@link resolvePersonalKeyGroupRefusal} resolves the caller's role itself + * before answering rather than relying on a role check this wrapper never runs. + * + * `requiredLevel` must be the level the caller will hand `checkAccess` a moment + * later. Left at `read` on a write route, the group refusal answers a read-only + * member before the role failure that actually applies to them does. */ export async function checkWorkspaceScope( rateLimit: RateLimitResult, - requestedWorkspaceId: string + requestedWorkspaceId: string, + requiredLevel: PermissionType = 'read' ): Promise { - const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) - return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null + const failure = + (await resolveWorkspaceScope(rateLimit, requestedWorkspaceId)) ?? + (await resolvePersonalKeyGroupRefusal(rateLimit, requestedWorkspaceId, null, requiredLevel)) + return failure ? workspaceAccessErrorResponse(failure) : null +} + +/** + * The response a surface that conceals an inaccessible workspace should answer + * a {@link resolveWorkspaceAccess} failure with. + * + * The log surfaces answer "not found" rather than "forbidden" so a stranger + * cannot use them to probe which workspaces and executions exist. A + * permission-group refusal is the one failure that has nothing left to conceal: + * both group keys run only AFTER the caller's workspace role verified, so the + * caller is already known to be a member of the workspace being asked about, + * and the refusal names how their own organization configured their cohort. + * Flattening it into the concealing 404 costs the client the remedy — the key + * looks broken rather than switched off — and buys no secrecy. + * + * `details` is the discriminator because it is set on exactly the two post-role + * group refusals; the pre-role scope failures carry none and keep concealing. + */ +export function concealedWorkspaceAccessResponse( + failure: WorkspaceAccessError, + notFoundMessage: string +): NextResponse { + return failure.details + ? workspaceAccessErrorResponse(failure) + : NextResponse.json({ error: notFoundMessage }, { status: 404 }) +} + +/** Renders a {@link WorkspaceAccessError} as the v1 `{ error, details? }` body. */ +function workspaceAccessErrorResponse(failure: WorkspaceAccessError): NextResponse { + return NextResponse.json( + failure.details + ? { error: failure.message, details: failure.details } + : { error: failure.message }, + { status: failure.status } + ) } /** @@ -311,6 +557,27 @@ export async function resolveWorkspaceRequestActor( return rateLimit.userId ?? null } +/** + * {@link resolveWorkspaceRequestActor} as a route-ready result. + * + * The resolver answers `null` for a real, reachable request: an authenticated + * workspace key whose workspace has since been archived or deleted has no + * billed account to stand in as its system actor. That is the same condition + * the routes already report as a 400 `Invalid workspace ID` for a workspace + * mismatch, so it is reported the same way, from one place. + */ +export async function requireWorkspaceRequestActor( + rateLimit: RateLimitResult, + workspaceId: string +): Promise<{ ok: true; actorUserId: string } | { ok: false; response: NextResponse }> { + const actorUserId = await resolveWorkspaceRequestActor(rateLimit, workspaceId) + if (actorUserId) return { ok: true, actorUserId } + return { + ok: false, + response: NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }), + } +} + /** * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. * Returns null on success, NextResponse on failure. @@ -319,10 +586,11 @@ export async function validateWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, + capability: V1RouteCapability, level: PermissionType = 'read' ): Promise { - const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) - return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, capability, level) + return failure ? workspaceAccessErrorResponse(failure) : null } /** diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index ae8c00affab..2037c566048 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -24,6 +24,7 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -56,10 +57,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -122,10 +123,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -184,10 +185,10 @@ export const DELETE = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts index 7a71898fb9f..d649908cb40 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.test.ts @@ -19,18 +19,47 @@ const { mockGetTableById, mockGetUserEntityPermissions, mockPerformDeleteTable, + mockResolveWorkspaceRequestActor, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockCheckWorkspaceScope: vi.fn(), mockGetTableById: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockPerformDeleteTable: vi.fn(), + mockResolveWorkspaceRequestActor: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, checkWorkspaceScope: mockCheckWorkspaceScope, createRateLimitResponse: () => NextResponse.json({ error: 'Rate limited' }, { status: 429 }), + /** + * Mirrors the real `tableAccessPrincipal`, which branches on `keyType` being + * `'personal'` — NOT on it being `'workspace'`. Only a personal key names a + * person; anything else, an absent `keyType` included, reaches `checkAccess` + * as the workspace so no bystander's permission group is applied to it. + */ + tableAccessPrincipal: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' + ? { kind: 'user', userId: rateLimit.userId } + : { kind: 'workspace_api_key', keyCreatorUserId: rateLimit.userId }, + /** + * Mirrors the real resolver: a workspace key names no human, so the billed + * account stands in as the explicit system actor; anything else keeps its + * owner. The route reads it through `requireWorkspaceRequestActor`, which + * projects an unresolvable actor onto a 400 instead of throwing, so the mock + * reproduces that projection rather than only the raw resolver. + */ + resolveWorkspaceRequestActor: mockResolveWorkspaceRequestActor, + requireWorkspaceRequestActor: async (rateLimit: unknown, workspaceId: string) => { + const actorUserId = await mockResolveWorkspaceRequestActor(rateLimit, workspaceId) + return actorUserId + ? { ok: true, actorUserId } + : { + ok: false, + response: NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }), + } + }, })) vi.mock('@/lib/table', () => ({ @@ -80,8 +109,9 @@ function makeContext() { describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'personal' }) mockCheckWorkspaceScope.mockResolvedValue(null) + mockResolveWorkspaceRequestActor.mockResolvedValue('user-1') mockGetTableById.mockResolvedValue({ id: TABLE_ID, name: 'Table', @@ -90,6 +120,23 @@ describe('DELETE /api/v1/tables/[tableId] — orchestration failure projection', mockGetUserEntityPermissions.mockResolvedValue('admin') }) + /** + * A workspace key whose workspace has since been archived resolves no billed + * account, so there is no system actor to attribute the deletion to. That is + * a reachable request about an unreachable workspace, not a server fault: it + * used to `throw`, and the catch-all reported it as a 500. + */ + it('reports an unresolvable workspace actor as a 400, not a 500', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1', keyType: 'workspace' }) + mockResolveWorkspaceRequestActor.mockResolvedValue(null) + + const response = await DELETE(makeRequest(), makeContext()) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Invalid workspace ID' }) + expect(mockPerformDeleteTable).not.toHaveBeenCalled() + }) + it('renders an unclassified internal failure as a fixed generic message', async () => { mockPerformDeleteTable.mockResolvedValue({ success: false, diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 5d46bdf619b..c9dcd9daae3 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -17,6 +17,8 @@ import { checkRateLimit, checkWorkspaceScope, createRateLimitResponse, + requireWorkspaceRequestActor, + tableAccessPrincipal, } from '@/app/api/v1/middleware' const logger = createLogger('V1TableDetailAPI') @@ -38,7 +40,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1GetTableContract, request, context, { validationErrorResponse: (error) => { const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId')) @@ -60,7 +61,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'read') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -111,7 +112,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DeleteTableContract, request, context, { validationErrorResponse: (error) => { const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId')) @@ -130,17 +130,35 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const { tableId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + /** + * A workspace key names no human, so its creator must not be attributed the + * deletion in audit and analytics. The shared resolver substitutes the + * explicit system actor for a workspace key and keeps the owner for a + * personal one, exactly as the row routes on this table already do. An + * archived or deleted workspace has no billed account to stand in, which is + * a controlled 400 rather than an uncaught throw the catch-all would report + * as a 500. + */ + const actor = await requireWorkspaceRequestActor(rateLimit, workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId + + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + const outcome = await performDeleteTable({ + table: result.table, + userId: actorUserId, + requestId, + request, + }) if (!outcome.success) { return orchestrationOutcomeErrorResponse(outcome, 'Failed to delete table') } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 034fb8cd8f8..40ca9f09ade 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -26,10 +26,12 @@ import { tableLockErrorResponse, } from '@/app/api/table/utils' import { + capabilityGovernedUserId, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -53,7 +55,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1GetTableRowContract, request, context, { validationErrorResponse: () => NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }), @@ -65,7 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'read') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { @@ -125,7 +126,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1UpdateTableRowContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -133,14 +133,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const { tableId, rowId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -159,6 +158,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR data: patchData, workspaceId: validated.workspaceId, actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), secretProvenance: createExactEmptyTableRowSecretProvenance(patchData), }, table, @@ -219,7 +219,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DeleteTableRowContract, request, context, { validationErrorResponse: () => NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }), @@ -228,10 +227,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const { tableId, rowId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId, 'write') if (scopeError) return scopeError - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 9297cc68181..3a497fdb39c 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -33,12 +33,19 @@ import { signalTableRowsChanged } from '@/lib/table/events' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' import { queryRows } from '@/lib/table/rows/service' import { resolveFilterSelectValues } from '@/lib/table/select-values' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' import { + accessError, + checkAccess, + orchestrationErrorResponse, + type TableAccessPrincipal, +} from '@/app/api/table/utils' +import { + capabilityGovernedUserId, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -56,10 +63,12 @@ async function handleBatchInsert( requestId: string, tableId: string, validated: V1BatchInsertTableRowsBody, - userId: string, - actorUserId: string + principal: TableAccessPrincipal, + actorUserId: string, + /** The gate's subject; see {@link BatchInsertData.capabilityGovernedUserId}. */ + governedUserId: string | null ): Promise { - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, principal, 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -87,6 +96,7 @@ async function handleBatchInsert( rows, workspaceId: validated.workspaceId, userId: actorUserId, + capabilityGovernedUserId: governedUserId, secretProvenance: rows.map(createExactEmptyTableRowSecretProvenance), }, table, @@ -127,7 +137,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1ListTableRowsContract, request, context, { validationErrorResponse: (error) => { const hasJsonError = error.issues.some( @@ -147,7 +156,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'read') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'read') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -224,7 +233,6 @@ export const POST = withRouteHandler( return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1CreateTableRowContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -233,30 +241,30 @@ export const POST = withRouteHandler( const { tableId } = parsed.data.params if ('rows' in parsed.data.body) { const batchValidated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor( - rateLimit, - batchValidated.workspaceId + const batchActor = await requireWorkspaceRequestActor(rateLimit, batchValidated.workspaceId) + if (!batchActor.ok) return batchActor.response + const actorUserId = batchActor.actorUserId + return handleBatchInsert( + requestId, + tableId, + batchValidated, + tableAccessPrincipal(rateLimit), + actorUserId, + capabilityGovernedUserId(rateLimit) ) - if (!actorUserId) { - throw new Error( - `Unable to resolve system actor for workspace ${batchValidated.workspaceId}` - ) - } - return handleBatchInsert(requestId, tableId, batchValidated, userId, actorUserId) } const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -282,6 +290,7 @@ export const POST = withRouteHandler( data: rowData, workspaceId: validated.workspaceId, userId: actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), }, table, @@ -325,7 +334,6 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -333,14 +341,13 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult @@ -370,6 +377,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR data: patchData, limit: validated.limit, actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), secretProvenance: createExactEmptyTableRowSecretProvenance(patchData), }, requestId @@ -421,7 +429,6 @@ export const DELETE = withRouteHandler( return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1DeleteTableRowsContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -429,10 +436,10 @@ export const DELETE = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!accessResult.ok) return accessError(accessResult, requestId, tableId) const { table } = accessResult diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 8ac2e5cbc6d..503a072a35a 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -17,10 +17,12 @@ import { tableLockErrorResponse, } from '@/app/api/table/utils' import { + capabilityGovernedUserId, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, - resolveWorkspaceRequestActor, + requireWorkspaceRequestActor, + tableAccessPrincipal, v1ValidationErrorResponse, v1ValidationErrorResponseFromError, } from '@/app/api/v1/middleware' @@ -44,7 +46,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1UpsertTableRowContract, request, context, { validationErrorResponse: v1ValidationErrorResponse, }) @@ -52,14 +53,13 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId, 'write') if (scopeError) return scopeError - const actorUserId = await resolveWorkspaceRequestActor(rateLimit, validated.workspaceId) - if (!actorUserId) { - throw new Error(`Unable to resolve system actor for workspace ${validated.workspaceId}`) - } + const actor = await requireWorkspaceRequestActor(rateLimit, validated.workspaceId) + if (!actor.ok) return actor.response + const actorUserId = actor.actorUserId - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, tableAccessPrincipal(rateLimit), 'write') if (!result.ok) return accessError(result, requestId, tableId) const { table } = result @@ -77,6 +77,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser workspaceId: validated.workspaceId, data: rowData, userId: actorUserId, + capabilityGovernedUserId: capabilityGovernedUserId(rateLimit), conflictTarget: validated.conflictTarget, secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), }, diff --git a/apps/sim/app/api/v1/tables/capability-gate.test.ts b/apps/sim/app/api/v1/tables/capability-gate.test.ts new file mode 100644 index 00000000000..1936de7a5c5 --- /dev/null +++ b/apps/sim/app/api/v1/tables/capability-gate.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + * + * `/api/v1/tables/[tableId]/**` shares `checkAccess` with the raw internal + * `/api/table/**` routes, and `checkAccess` gates `tables.use` inside itself. + * That gate is correct for the internal routes — `checkSessionOrInternalAuth` + * rejects `x-api-key`, so every caller there is a person. v1 authenticates with + * an API key, and a WORKSPACE key reports its creator's user id: gating on that + * id applies a bystander's permission group to every caller of a shared + * credential, which is exactly what `principal-scope.server.ts` and the + * `workspace_api_key` branch of `authorizeWorkspaceOperation` refuse to do. + * + * These run the real middleware and the real `checkAccess` against the real + * route — only the credential, the rate bucket, the workspace role, the table + * row and the governing group config are mocked. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, + v1PersonalKeyCredential, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockGetUserEntityPermissions, + mockGetWorkspaceBillingSettings, + mockGetTableById, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockGetTableById: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, + getWorkspaceBilledAccountUserId: vi.fn(async () => 'billed-user'), + getWorkspaceOrganizationId: vi.fn(async () => null), +})) +vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) +vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) +vi.mock('@/lib/table', () => ({ + getTableById: mockGetTableById, + buildFilterClause: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) +vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: vi.fn() })) +vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as getTable } from '@/app/api/v1/tables/[tableId]/route' + +const MEMBER_ID = 'user-1' +const TABLE_ID = '22222222-2222-4222-8222-222222222222' +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' + +const TABLE = { + id: TABLE_ID, + name: 'expenses', + workspaceId: WORKSPACE_ID, + description: null, + rowCount: 0, + maxRows: 1000, + locks: null, + schema: { columns: [] }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function readTable() { + return getTable( + new NextRequest(`http://localhost/api/v1/tables/${TABLE_ID}?workspaceId=${WORKSPACE_ID}`, { + method: 'GET', + headers: { 'x-api-key': 'sim_test' }, + }), + { params: Promise.resolve({ tableId: TABLE_ID }) } + ) +} + +const REFUSAL = /is not available under your organization's permission group/ + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(MEMBER_ID)) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) + mockGetTableById.mockResolvedValue(TABLE) +}) + +describe('tables.use gate on /api/v1/tables/[tableId]', () => { + it('lets a workspace API key through even when its CREATOR is denied Tables', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideTablesTab: true }) + + const response = await readTable() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.table.id).toBe(TABLE_ID) + }) + + it('never resolves a group for a workspace API key at all', async () => { + mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + governedBy({ hideTablesTab: true }) + + await readTable() + + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('still refuses a personal API key whose group withholds Tables', async () => { + governedBy({ hideTablesTab: true }) + + const response = await readTable() + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toMatch(REFUSAL) + expect(body.details).toEqual({ code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + }) + + it('lets a personal API key through when no group withholds Tables', async () => { + const response = await readTable() + + expect(response.status).toBe(200) + }) + + it('still refuses either key kind on role, before naming the capability', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + governedBy({ hideTablesTab: true }) + + const response = await readTable() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Access denied' }) + }) +}) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index ecd742efb29..5817317f4af 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -50,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { workspaceId } = parsed.data.query - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'tables.use') if (accessError) return accessError const tables = await listTables(workspaceId) @@ -115,6 +115,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { rateLimit, userId, params.workspaceId, + 'tables.create', 'write' ) if (accessError) return accessError diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts index 7ec6df59634..b9130239b1f 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -133,6 +133,7 @@ describe('POST /api/v1/workflows/[id]/deploy', () => { expect.objectContaining({ allowed: true }), 'user-1', 'ws-1', + 'deploy.api', 'admin' ) expect(mockPerformFullDeploy).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 304d69f63d8..7e0a550aee5 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -53,7 +53,7 @@ export const POST = withRouteHandler( return v1ValidationErrorResponse(body.error) } - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id, 'deploy.api') if (!target.ok) return target.response const { workflow, workspaceId } = target @@ -126,7 +126,7 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id, 'deploy.api') if (!target.ok) return target.response const { workflow, workspaceId } = target diff --git a/apps/sim/app/api/v1/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/workflows/[id]/export/route.ts index 6f2c2310169..af48925e638 100644 --- a/apps/sim/app/api/v1/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/export/route.ts @@ -57,7 +57,12 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'none' + ) if (accessError) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts index 2327f71325b..b0b69757b0a 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -229,6 +229,7 @@ describe('POST /api/v1/workflows/[id]/rollback', () => { expect.objectContaining({ allowed: true }), 'user-1', 'ws-1', + 'deploy.api', 'admin' ) expect(mockPerformActivateVersion).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index d015ba4b024..ce88815b2cb 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -52,7 +52,7 @@ export const POST = withRouteHandler( return v1ValidationErrorResponse(body.error) } - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id, 'deploy.api') if (!target.ok) return target.response const { workflow, workspaceId } = target diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 653b833e591..1e01b403a3b 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -50,7 +50,8 @@ export const GET = withRouteHandler( const accessError = await validateWorkspaceAccess( rateLimit, userId, - workflowData.workspaceId! + workflowData.workspaceId!, + 'none' ) if (accessError) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) diff --git a/apps/sim/app/api/v1/workflows/import/route.test.ts b/apps/sim/app/api/v1/workflows/import/route.test.ts index ab3492b0fc0..99e68fb508d 100644 --- a/apps/sim/app/api/v1/workflows/import/route.test.ts +++ b/apps/sim/app/api/v1/workflows/import/route.test.ts @@ -40,6 +40,9 @@ const { })) vi.mock('@/app/api/v1/middleware', () => ({ + /** Mirrors the real helper: only a personal key or session carries a governed subject. */ + capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, checkRateLimit: mockCheckRateLimit, createRateLimitResponse: vi.fn(() => NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -214,6 +217,7 @@ describe('POST /api/v1/workflows/import', () => { expect.anything(), 'user-1', WORKSPACE_ID, + 'none', 'write' ) expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() @@ -268,6 +272,7 @@ describe('POST /api/v1/workflows/import', () => { expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith( 'wf-new', expect.anything(), + { workspaceId: WORKSPACE_ID, subjectUserId: null }, expect.anything() ) }) @@ -371,7 +376,12 @@ describe('POST /api/v1/workflows/import', () => { await POST(makeRequest(validBody())) const tx = { update: mockDbUpdate } - expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith('wf-new', expect.anything(), tx) + expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith( + 'wf-new', + expect.anything(), + { workspaceId: WORKSPACE_ID, subjectUserId: null }, + tx + ) expect(mockDbUpdate).toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v1/workflows/import/route.ts b/apps/sim/app/api/v1/workflows/import/route.ts index 762be48852c..8a59f59a944 100644 --- a/apps/sim/app/api/v1/workflows/import/route.ts +++ b/apps/sim/app/api/v1/workflows/import/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/workflows/operations/import-workflow' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { + capabilityGovernedUserId, checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, @@ -62,7 +63,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { folderId, }) - const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workspaceId, + 'none', + 'write' + ) if (accessError) return accessError const result = await importWorkflowIntoWorkspace({ @@ -72,6 +79,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { description, workflow: parsed.data.body.workflow, userId, + capabilityUserId: capabilityGovernedUserId(rateLimit), requestId, }) diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 3b24eefc55d..7814a85ba8a 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -69,7 +69,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) - const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId) + const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'none') if (accessError) return accessError const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts index f2cb6d059a9..c387f47e510 100644 --- a/apps/sim/app/api/v1/workflows/utils.ts +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -3,7 +3,11 @@ import { type DeploymentWorkflowTarget, getDeploymentWorkflowTarget, } from '@/lib/workflows/deployments/queries' -import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' +import { + type RateLimitResult, + type V1RouteCapability, + validateWorkspaceAccess, +} from '@/app/api/v1/middleware' function workflowNotFoundResponse(): NextResponse { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) @@ -11,21 +15,33 @@ function workflowNotFoundResponse(): NextResponse { /** * Resolves the target workflow for a v1 deployment mutation: loads the active - * record and verifies the caller's admin permission on its workspace. Access - * failures are masked as 404, matching the v1 workflow read surface so - * unauthorized callers cannot probe workflow existence. + * record, verifies the caller's admin permission on its workspace, then applies + * the permission-group capability the caller declares. Access failures are + * masked as 404, matching the v1 workflow read surface so unauthorized callers + * cannot probe workflow existence. + * + * `capability` is required rather than defaulted to `deploy.api`: the deployment + * routes do not all withhold the same thing, and a default would let a route + * added later inherit a gate nobody chose for it. */ export async function resolveV1DeploymentWorkflow( rateLimit: RateLimitResult, userId: string, - workflowId: string + workflowId: string, + capability: V1RouteCapability ): Promise<({ ok: true } & DeploymentWorkflowTarget) | { ok: false; response: NextResponse }> { const target = await getDeploymentWorkflowTarget(workflowId) if (!target) { return { ok: false, response: workflowNotFoundResponse() } } - const accessError = await validateWorkspaceAccess(rateLimit, userId, target.workspaceId, 'admin') + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + target.workspaceId, + capability, + 'admin' + ) if (accessError) { return { ok: false, response: workflowNotFoundResponse() } } diff --git a/apps/sim/app/api/v2/chat-deployments/route.test.ts b/apps/sim/app/api/v2/chat-deployments/route.test.ts index fa44381d548..3f5914f183a 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.test.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.test.ts @@ -61,15 +61,9 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performChatDeploy: mocks.performChatDeploy, performChatUndeploy: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 945e2e8d564..af7f28a3930 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, +} from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -116,6 +120,14 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: false, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +const mockResolvePermissionGroupConfig = + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +import { chatOperations } from '@/lib/copilot/application/operations' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/v2/chat/route' const personalAuth = { @@ -242,7 +254,11 @@ describe('POST /api/v2/chat', () => { mockAuthenticateV2ApiKey.mockResolvedValue(personalAuth) mockCheckPreAuthRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) mockCheckOperationRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() }) - mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: null, allowPersonalApiKeys: true }, + }) + mockResolvePermissionGroupConfig.mockResolvedValue(null) mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) mockRequestExplicitStreamAbort.mockResolvedValue(undefined) mockPersistCopilotChatTurn.mockResolvedValue(undefined) @@ -296,6 +312,124 @@ describe('POST /api/v2/chat', () => { expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) + /** + * `admitV2Request` authenticates and rate-limits but never authorizes, so + * nothing but this route applies the capability `chat.send` declares. + */ + it('answers 403 when the permission group withholds copilot.use', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: "Chat is not available under your organization's permission group", + details: { code: CAPABILITY_RULES[chatOperations.send.capability].detailCode }, + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + /** + * The route only ever runs for a personal API key, and `admitV2Request` never + * authorizes, so both halves of the funnel's personal-key policy have to be + * repeated here. The workspace column is the first half. + */ + it('answers 403 when the workspace has switched personal API keys off', async () => { + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: null, allowPersonalApiKeys: false }, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + /** + * The group half. The column and the key combine with AND, so a workspace + * that allows personal keys still refuses the cohort whose group withholds + * them — the case `copilot.use` alone could never see. + */ + it('answers 403 when the permission group withholds personal_api_key.use', async () => { + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: 'org-1', allowPersonalApiKeys: true }, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + /** A workspace with no organization resolves no group, so the key passes. */ + it('runs one turn for a personal key in a workspace no group governs', async () => { + mockAssertActiveWorkspaceAccess.mockResolvedValue({ + permission: 'admin', + workspace: { organizationId: null, allowPersonalApiKeys: true }, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledTimes(1) + }) + + /** Workspace reach is decided first, so the refusal cannot name a group to an outsider. */ + it('refuses an inaccessible workspace before consulting a permission group', async () => { + mockAssertActiveWorkspaceAccess.mockRejectedValue(new MockWorkspaceAccessDeniedError('denied')) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(403) + expect(mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('runs one turn when a group governs the caller but withholds nothing', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledTimes(1) + }) + it('runs one turn and answers the reply with a server-issued conversation id', async () => { const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index cc646312f44..17932605f92 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' -import type { NextRequest } from 'next/server' +import type { NextRequest, NextResponse } from 'next/server' import { v2ChatContract } from '@/lib/api/contracts/v2/chat' import { parseRequest } from '@/lib/api/server' import { @@ -36,9 +36,20 @@ import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/hea import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { + forbiddenErrorDetails, + PersonalApiKeysDisabledError, + requirePersonalApiKeysAllowed, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, @@ -75,6 +86,34 @@ function deriveConversationTitle(message: string): string | undefined { return truncate(normalized, CHAT_TITLE_MAX_LENGTH) } +/** + * The two personal-API-key checks `authorizeWorkspaceOperation` applies, for the + * one route that never reaches it, or `null` when the key may proceed. + * + * The group half runs through the same {@link requirePersonalApiKeysAllowed} the + * funnel and the billing reads call, so a third wording of the same refusal + * cannot drift in. Its error is projected rather than thrown because this route + * renders its own v2 envelope, and the detail code is read off the error so the + * column refusal and the group refusal answer with one code. + */ +async function personalApiKeyPolicyRefusal( + userId: string, + context: WorkspaceAuthorizationContext +): Promise { + const refuse = (error: PersonalApiKeysDisabledError) => + v2Error('FORBIDDEN', error.message, { details: forbiddenErrorDetails(error) }) + + if (!context.allowPersonalApiKeys) return refuse(new PersonalApiKeysDisabledError()) + + try { + await requirePersonalApiKeysAllowed(userId, context) + } catch (error) { + if (error instanceof PersonalApiKeysDisabledError) return refuse(error) + throw error + } + return null +} + function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -165,6 +204,57 @@ export const POST = withRouteHandler( const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const userPermission = workspaceAccess.permission + /** + * permission-group-enforced: personal_api_key.use — this route only ever + * runs for a personal API key, and `admitV2Request` authenticates one + * without authorizing it, so the funnel's personal-key policy has to be + * repeated here or the same key `authorizeWorkspaceOperation` refuses + * still starts a chat turn. + * + * Both halves, because they combine with AND: the workspace column is the + * coarse switch every workspace has, and the group key narrows it further + * for one cohort inside an enterprise organization. Either one saying no + * is a no, and checking only `copilot.use` applied neither. + * + * Both run after workspace access rather than before it, unlike the + * funnel, which can afford to check the column first because its caller + * has already loaded the workspace. Here the access check is what loads + * it, and answering later only ever conceals more: a caller with no reach + * into the workspace is refused without learning how it is configured. + */ + const personalKeyRefusal = await personalApiKeyPolicyRefusal(userId, { + workspaceId, + workspaceOrganizationId: workspaceAccess.workspace?.organizationId ?? null, + allowPersonalApiKeys: workspaceAccess.workspace?.allowPersonalApiKeys ?? false, + }) + if (personalKeyRefusal) return personalKeyRefusal + + /** + * permission-group-enforced: copilot.use — read off the operation so this + * route and the funnel can never name different capabilities, and the + * error's `detailCode` off the rule for the same reason: a capability + * whose rule reports something other than the generic block (the way + * `personal_api_key.use` reports `PERSONAL_API_KEYS_DISABLED`) would + * otherwise be flattened by a constant spelled out here. + * + * A raw special route: `admitV2Request` authenticates and rate-limits but + * never authorizes, so nothing else on this path applies the capability + * `chatOperations.send` declares. Checked after workspace access, for the + * reason the funnel gives — a caller with no reach into the workspace is + * refused first, so the refusal cannot report which capabilities an + * organization withholds to someone who is not in it — and before a + * conversation is minted or a turn is billed. + */ + const sendCapability = chatOperations.send.capability + if ( + sendCapability !== 'none' && + (await isWorkspaceCapabilityWithheld(userId, workspaceId, sendCapability)) + ) { + return v2Error('FORBIDDEN', capabilityRefusal(sendCapability), { + details: { code: CAPABILITY_RULES[sendCapability].detailCode }, + }) + } + const conversationTitle = deriveConversationTitle(message) // A caller-supplied conversation id is a claim, not an identity: resolve diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts index 8f9b52c07b3..51900d00d90 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts @@ -12,6 +12,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), @@ -63,20 +64,13 @@ vi.mock('@/lib/workflows/orchestration', () => ({ getWorkflowDeploymentSummary: vi.fn(), performFullDeploy: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => { - class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed') - this.name = 'ChatDeployAuthNotAllowedError' - } - } - return { validateChatDeployAuth: mocks.validateChatDeployAuth, ChatDeployAuthNotAllowedError } -}) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateChatDeployAuth: mocks.validateChatDeployAuth, +})) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) import { DELETE, GET, PUT } from '@/app/api/v2/workflows/[workflowId]/deployments/chat/route' -import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -447,7 +441,13 @@ describe('/api/v2/workflows/[workflowId]/deployments/chat', () => { }) it('names a blocked auth mode with an actionable forbidden code', async () => { - mocks.validateChatDeployAuth.mockRejectedValue(new ChatDeployAuthNotAllowedError()) + mocks.validateChatDeployAuth.mockRejectedValue( + new PermissionGroupCapabilityError( + 'deploy.chat.auth_mode', + 'CHAT_AUTH_MODE_NOT_PERMITTED', + "This chat authentication mode is not available under your organization's permission group" + ) + ) const response = await put({ ...validBody, diff --git a/apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts b/apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts new file mode 100644 index 00000000000..292e1d48ca0 --- /dev/null +++ b/apps/sim/app/api/webhooks/[id]/reactivation-gate.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { webhook } from '@sim/db/schema' +import { + auditMock, + createMockRequest, + hybridAuthMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + posthogServerMock, + queueTableRows, + resetDbChainMock, + telemetryMock, + workflowAuthzMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/lib/core/telemetry', () => telemetryMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ cleanupExternalWebhook: vi.fn() })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { PATCH } from '@/app/api/webhooks/[id]/route' + +const ACTOR_ID = 'actor-1' + +function reactivate() { + return PATCH(createMockRequest('PATCH', { isActive: true }), { + params: Promise.resolve({ id: 'webhook-1' }), + }) +} + +/** The single joined read the PATCH handler issues. */ +function queueDormantWebhook(): void { + queueTableRows(webhook, [ + { + webhook: { id: 'webhook-1', isActive: false, failedCount: 0 }, + workflow: { id: 'workflow-1', userId: ACTOR_ID, workspaceId: 'workspace-1' }, + }, + ]) +} + +/** + * `triggers.webhook` is withheld from the actor's group. Flipping a dormant + * webhook back on is the act the key names, so a session belonging to that + * person must be refused — and an executor delegation carrying the same id + * must not be, since it holds the actor's role and none of their capabilities. + */ +describe('the subject the webhook reactivation gate is decided about', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1' }, + workspacePermission: 'write', + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + }) + + it('refuses the actor’s own session', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'session', + }) + queueDormantWebhook() + + const response = await reactivate() + + expect(response.status).toBe(403) + }) + + it('lets an internal executor JWT through without consulting that group', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: ACTOR_ID, + authType: 'internal_jwt', + }) + queueDormantWebhook() + + const response = await reactivate() + + expect(response.status).toBe(200) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 92506cc8627..5b3b7571f06 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -15,10 +15,14 @@ import { updateWebhookContract, } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' @@ -139,6 +143,32 @@ export const PATCH = withRouteHandler( const setClause: Partial = {} if (isActive !== undefined && isActive !== webhooks[0].webhook.isActive) { + /** + * permission-group-enforced: triggers.webhook — reactivating is the act + * the key names, "prevent making a workflow reachable from an inbound + * webhook", so gating creation alone left it reachable by flipping a + * dormant webhook back on. Only this direction: deactivating must stay + * open, or a policy change would strand a member with a live webhook + * they cannot turn off. + * + * Keyed to the governed subject rather than `auth.userId`: an internal + * executor JWT embeds the run's actor, and gating on it would apply that + * person's capabilities to a delegation that carries only their role. + */ + const governedUserId = capabilityGovernedAuthUserId(auth) + if (isActive && governedUserId) { + const withheld = await isWorkspaceCapabilityWithheld( + governedUserId, + webhooks[0].workflow.workspaceId ?? '', + 'triggers.webhook' + ) + if (withheld) { + return NextResponse.json( + { error: capabilityRefusal('triggers.webhook') }, + { status: 403 } + ) + } + } setClause.isActive = isActive } if (failedCount !== undefined && failedCount !== webhooks[0].webhook.failedCount) { diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index 0f8322795de..85c318c1ca2 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -8,6 +8,8 @@ import { createMockRequest, dbChainMockFns, flattenMockConditions, + permissionGroupScopeMock, + permissionGroupScopeMockFns, posthogServerMock, queueTableRows, resetDbChainMock, @@ -26,6 +28,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/core/telemetry', () => telemetryMock) vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@/lib/webhooks/env-resolver', () => ({ @@ -43,6 +46,7 @@ vi.mock('@/lib/webhooks/utils.server', () => ({ findConflictingWebhookPathOwner: mocks.findConflictingWebhookPathOwner, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { POST } from '@/app/api/webhooks/route' describe('POST /api/webhooks polling configuration', () => { @@ -348,3 +352,139 @@ describe('POST /api/webhooks polling configuration', () => { ) }) }) + +describe('POST /api/webhooks triggers.webhook gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'actor-1', name: 'Actor', email: 'actor@example.com' }, + session: { id: 'session-1' }, + }) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1' }, + workspacePermission: 'write', + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) + mocks.findConflictingWebhookPathOwner.mockResolvedValue(null) + mocks.resolveEnvVarsInObject.mockImplementation(async (config) => config) + mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(false) + mocks.getProviderHandler.mockReturnValue({}) + mocks.createExternalWebhookSubscription.mockResolvedValue({ + updatedProviderConfig: {}, + externalSubscriptionCreated: false, + }) + }) + + function upsertRequest() { + return createMockRequest('POST', { + workflowId: 'workflow-1', + path: 'inbound-orders', + provider: 'generic', + providerConfig: {}, + }) + } + + /** The reads the create path makes, in the order the handler issues them. */ + function queueCreatePathRows(): void { + queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }]) + queueTableRows(webhook, []) + } + + /** + * Making a workflow reachable from an inbound webhook is the only external + * exposure with no deploy-tab equivalent, so the group key has to stop it at + * creation or it is not withheld at all. + */ + it('refuses to create a webhook when the group withholds webhook triggers', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + queueCreatePathRows() + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(403) + expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled() + }) + + it('creates the webhook when no group withholds the capability', async () => { + queueCreatePathRows() + + const response = await POST(upsertRequest()) + + expect(response.status).not.toBe(403) + expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1) + }) + + /** The reads the update path makes: the path claim, then the existing row. */ + function queueUpdatePathRows(isActive: boolean): void { + queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }]) + queueTableRows(webhook, [{ id: 'webhook-1' }]) + queueTableRows(webhook, [ + { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'inbound-orders', + provider: 'generic', + providerConfig: {}, + isActive, + }, + ]) + dbChainMockFns.returning.mockImplementationOnce(async () => [ + { id: 'webhook-1', workflowId: 'workflow-1', path: 'inbound-orders', isActive: true }, + ]) + } + + /** + * The upsert always writes `isActive: true`, so re-saving a dormant webhook is + * the same transition `PATCH /api/webhooks/[id]` gates — a workflow becoming + * reachable again — and has to be refused on the same terms. + */ + it('refuses to reactivate a dormant webhook when the group withholds webhook triggers', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + queueUpdatePathRows(false) + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(403) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + /** + * An already-active webhook is already reachable, so re-saving its config adds + * no exposure. Refusing it would strand a member unable to repair a live + * integration — the same reason inbound delivery is never gated. + */ + it('still lets an already-active webhook be reconfigured under the same group', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableWebhookTriggers: true, + }) + queueUpdatePathRows(true) + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, provider: 'generic' }) + ) + }) + + it('reactivates a dormant webhook when no group withholds the capability', async () => { + queueUpdatePathRows(false) + + const response = await POST(upsertRequest()) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: true })) + }) +}) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 5c785c2bc88..e96cb78b084 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -17,6 +17,10 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import { @@ -397,6 +401,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => { existingWebhook = existingRows[0] || null } + /** + * permission-group-enforced: triggers.webhook — a raw upsert handler with no + * application operation to declare the capability on, so it is asserted + * here. + * + * Creation and reactivation, because both end with a workflow newly + * reachable from an inbound webhook: this upsert always writes + * `isActive: true`, so re-saving a dormant webhook turns it back on exactly + * as `PATCH /api/webhooks/[id]` would, and that route already gates the same + * transition. + * + * Re-saving an already-active webhook is not gated. It changes the config of + * an endpoint that is already reachable and adds no exposure, and refusing + * it would strand a member unable to repair a live integration — the same + * reason inbound delivery is never gated. Inbound delivery runs with no + * session to resolve a group against, and refusing there would break live + * integrations at the provider rather than in Sim. Removing existing + * exposure stays a deliberate act of deleting or deactivating the webhook. + */ + if (!existingWebhook || existingWebhook.isActive === false) { + const withheld = workflowRecord.workspaceId + ? await isWorkspaceCapabilityWithheld( + userId, + workflowRecord.workspaceId, + 'triggers.webhook' + ) + : false + if (withheld) { + logger.warn( + `[${requestId}] Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`, + { + userId, + workflowId, + } + ) + return NextResponse.json({ error: capabilityRefusal('triggers.webhook') }, { status: 403 }) + } + } + const shouldRecreateSubscription = existingWebhook && shouldRecreateExternalWebhookSubscription({ diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts new file mode 100644 index 00000000000..2edbc6fdd56 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + * + * The internal run-detail door. `logs.cost` and `logs.trace_spans` withhold + * fields inside a run, and the shared read applies them — but only for the + * subject this route names. `auth.userId` is populated for every credential the + * route accepts, so naming it unconditionally would apply a workspace key + * creator's group to every caller of a shared credential, and the executor's + * actor's group to a delegation that carries no capabilities at all. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockValidateWorkflowAccess, mockGetStatus } = vi.hoisted(() => ({ + mockValidateWorkflowAccess: vi.fn(), + mockGetStatus: vi.fn(), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: mockValidateWorkflowAccess, +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mockGetStatus, +})) + +import { GET } from './route' + +const WORKFLOW_ID = 'b1f0c7e2-0000-4000-8000-00000000000a' +const EXECUTION_ID = 'b1f0c7e2-0000-4000-8000-00000000000b' + +function request() { + return new NextRequest(`https://sim.test/api/workflows/${WORKFLOW_ID}/executions/${EXECUTION_ID}`) +} + +function context() { + return { params: Promise.resolve({ id: WORKFLOW_ID, executionId: EXECUTION_ID }) } +} + +function grantAccess(auth: Record) { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' }, + auth, + }) +} + +describe('internal execution status route projection subject', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetStatus.mockResolvedValue({ + executionId: EXECUTION_ID, + workflowId: WORKFLOW_ID, + status: 'completed', + trigger: 'api', + level: 'info', + startedAt: '2026-08-05T12:00:00.000Z', + endedAt: null, + totalDurationMs: null, + paused: null, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, + }) + }) + + it('names the session user as the projection subject', async () => { + grantAccess({ success: true, userId: 'user-1', authType: 'session' }) + + const response = await GET(request(), context()) + + expect(response.status).toBe(200) + expect(mockGetStatus).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1', viewerUserId: 'user-1' }) + ) + }) + + it('names the personal API key owner as the projection subject', async () => { + grantAccess({ success: true, userId: 'user-1', authType: 'api_key', apiKeyType: 'personal' }) + + await GET(request(), context()) + + expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: 'user-1' })) + }) + + it('names no subject for a workspace API key', async () => { + grantAccess({ + success: true, + userId: 'key-creator-1', + authType: 'api_key', + apiKeyType: 'workspace', + }) + + await GET(request(), context()) + + expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null })) + }) + + it('names no subject for an executor delegation', async () => { + grantAccess({ success: true, userId: 'run-actor-1', authType: 'internal_jwt' }) + + await GET(request(), context()) + + expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null })) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index 0b0e17f6e43..d91f113e2e3 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' +import { capabilityGovernedAuthUserId } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, @@ -11,6 +12,7 @@ import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-s import { validateWorkflowAccess } from '@/app/api/workflows/middleware' const logger = createLogger('WorkflowExecutionStatusAPI') + export const GET = withRouteHandler( async ( request: NextRequest, @@ -33,6 +35,8 @@ export const GET = withRouteHandler( executionId, includeOutput, selectedOutputs, + workspaceId: access.workflow.workspaceId, + viewerUserId: capabilityGovernedAuthUserId(access.auth), }) } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts new file mode 100644 index 00000000000..dc8b2212ab6 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + queueTableRows, + resetDbChainMock, + resetPermissionGroupScopeMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +const { mockGetUserEntityPermissions } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { API_KEY_UPDATED: 'api_key.updated', API_KEY_REVOKED: 'api_key.revoked' }, + AuditResourceType: { API_KEY: 'api_key' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { capabilityRefusal } from '@/lib/permission-groups/capabilities' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { DELETE, PUT } from '@/app/api/workspaces/[id]/api-keys/[keyId]/route' + +const mockGetSession = authMockFns.mockGetSession + +const context = { params: Promise.resolve({ id: 'workspace-1', keyId: 'key-1' }) } + +function renameRequest() { + return createMockRequest( + 'PUT', + { name: 'Renamed key' }, + {}, + 'http://localhost:3000/api/workspaces/workspace-1/api-keys/key-1' + ) +} + +describe('workspace API key by id', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + resetPermissionGroupScopeMock() + mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + }) + + afterAll(() => { + resetDbChainMock() + }) + + /** + * A rename is "managing API keys" like the list and the mint are, and grants + * no access of its own — but neither does the list, and leaving the rename + * open also answers whether a key id exists to a caller the same group + * refuses the listing. + */ + it('refuses a rename when the group withholds API key management', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideApiKeysTab: true, + }) + + const response = await PUT(renameRequest(), context) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: capabilityRefusal('api_keys.manage'), + }) + }) + + it('renames when no group withholds API key management', async () => { + queueTableRows(schemaMock.apiKey, [{ id: 'key-1', name: 'Old name' }]) + queueTableRows(schemaMock.apiKey, []) + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + name: 'Renamed key', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-02T00:00:00.000Z'), + }, + ]) + + const response = await PUT(renameRequest(), context) + + expect(response.status).toBe(200) + }) + + /** + * Revocation stays ungated on purpose: withholding key management must never + * withhold the one act that removes a leaked credential. + */ + it('revokes even when the group withholds API key management', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideApiKeysTab: true, + }) + const response = await DELETE(createMockRequest('DELETE'), context) + + expect(response.status).not.toBe(403) + await expect(response.json()).resolves.not.toEqual({ + error: capabilityRefusal('api_keys.manage'), + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index dab424729cb..1bde6980ea4 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -10,6 +10,10 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -34,6 +38,28 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + /** + * permission-group-enforced: api_keys.manage — raw handler with inline + * queries, which the authorization funnel never sees. + * + * Gated like the list and the mint on the collection route, not exempted + * like the revocations. A rename grants no access — the body carries a + * `name` and nothing else, so no scope, workspace binding or expiry moves + * — but neither does reading the list, and this is the same "managing API + * keys" the group withheld. The revocation carve-out is narrower than it + * looks: it exists because withholding management must not withhold the + * one act that *removes* a credential. Renaming removes nothing, so it + * inherits nothing from it, and leaving it open would also answer whether + * a given key id exists to a caller the same group refuses the list. + * + * After the admin check, like every other capability assertion here: the + * refusal names an organization setting, and a non-admin should not hear + * it. + */ + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + const parsed = await parseRequest(updateWorkspaceApiKeyContract, request, context) if (!parsed.success) return parsed.response const { name } = parsed.data.body @@ -143,6 +169,12 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + /** + * Deliberately not capability-gated, for the reason the bulk delete on the + * collection route records at length: withholding key *management* must + * never withhold key *revocation*, or a group setting becomes the thing + * standing between an admin and a leaked credential. + */ const deletedRows = await db .delete(apiKey) .where( diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index ea9521e37b6..c0923b3a89d 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -16,6 +16,10 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -45,6 +49,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // permission-group-enforced: api_keys.manage — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + const workspaceKeys = await db .select({ id: apiKey.id, @@ -87,6 +96,17 @@ export const GET = withRouteHandler( } ) +/** + * Mints a workspace API key. + * + * The `api_keys.manage` gate here is also what closes the workspace-key + * pass-through: a workspace key authorizes as the workspace and resolves no + * group, so the authorization funnel's capability gate never applies to it. + * Refusing to mint one keeps a governed member from issuing themselves a + * credential that outranks their own group. Keys that already exist keep + * working — revoking those is an admin's call, not something a policy change + * should do silently. + */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -106,6 +126,11 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + // permission-group-enforced: api_keys.manage — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'api_keys.manage')) { + return NextResponse.json({ error: capabilityRefusal('api_keys.manage') }, { status: 403 }) + } + const parsed = await parseRequest(createWorkspaceApiKeyContract, request, context) if (!parsed.success) return parsed.response const { name, source } = parsed.data.body @@ -167,6 +192,13 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + /** + * Deliberately not capability-gated, unlike the read and the mint above. + * Withholding key *management* must never withhold key *revocation*: a + * workspace admin whose group hides the API Keys tab would otherwise be + * unable to revoke a leaked credential, turning a policy into a security + * hazard. The personal-key delete route is ungated for the same reason. + */ const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context) if (!parsed.success) return parsed.response const { keys } = parsed.data.body diff --git a/apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts b/apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts new file mode 100644 index 00000000000..6dcb807185b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/environment/capability-gate.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment node + * + * The Secrets tab reads and writes workspace environment variables through this + * route, which is raw `withRouteHandler` and never reaches the `secrets.*` + * operations — so the authorization funnel that applies `secrets.manage` to + * those operations does not see it. These pin the gate the route now carries on + * all three handlers: the read that would hand back every stored value, and the + * write and the delete that would change them. + */ +import { + authMockFns, + createMockRequest, + environmentUtilsMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetWorkspaceById, + mockGetUserEntityPermissions, + mockGetWorkspaceEnvKeyAdminAccess, + mockGetPersonalEnvKeyRawAccess, +} = vi.hoisted(() => ({ + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), + mockGetPersonalEnvKeyRawAccess: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceById: mockGetWorkspaceById, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/credentials/environment', () => ({ + getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, + getPersonalEnvKeyRawAccess: mockGetPersonalEnvKeyRawAccess, + createWorkspaceEnvCredentials: vi.fn(), + deleteWorkspaceEnvCredentials: vi.fn(), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { DELETE, GET, PUT } from '@/app/api/workspaces/[id]/environment/route' + +const USER_ID = 'user-1' +const WORKSPACE_ID = 'ws-1' + +const mockGetSession = authMockFns.mockGetSession +const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv + +function params() { + return { params: Promise.resolve({ id: WORKSPACE_ID }) } +} + +function readEnvironment() { + return GET(createMockRequest('GET'), params()) +} + +function writeEnvironment() { + return PUT(createMockRequest('PUT', { variables: { OPENAI_API_KEY: 'sk-rotated' } }), params()) +} + +function deleteEnvironment() { + return DELETE(createMockRequest('DELETE', { keys: ['OPENAI_API_KEY'] }), params()) +} + +/** The sentence and detail code every capability refusal in the app uses. */ +const SECRETS_REFUSAL = { + error: "Managing secrets is not available under your organization's permission group", + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, +} + +describe('secrets.manage gate on the raw workspace environment route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret' }, + personalDecrypted: {}, + personalOwners: {}, + conflicts: [], + workspaceUnredactedKeys: [], + }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(), + adminKeys: new Set(), + }) + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(['OPENAI_API_KEY']), + knownKeys: new Set(['OPENAI_API_KEY']), + }) + }) + + describe('when the group withholds Secrets', () => { + beforeEach(() => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSecretsTab: true, + }) + }) + + it('refuses the read, and never decrypts a single value', async () => { + const response = await readEnvironment() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual(SECRETS_REFUSAL) + expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() + }) + + it('refuses the write, and never reaches the secret-admin check', async () => { + const response = await writeEnvironment() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual(SECRETS_REFUSAL) + expect(mockGetWorkspaceEnvKeyAdminAccess).not.toHaveBeenCalled() + }) + + it('refuses the delete, and never reaches the secret-admin check', async () => { + const response = await deleteEnvironment() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual(SECRETS_REFUSAL) + expect(mockGetWorkspaceEnvKeyAdminAccess).not.toHaveBeenCalled() + }) + + /** + * Concealment: the role check runs first, so someone outside the workspace + * gets the same answer they always did rather than being told how the + * organization's permission group is configured. + */ + it('still conceals a workspace the caller has no role in, rather than naming the capability', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await readEnvironment() + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + }) + + it('still 404s a workspace that does not exist, rather than naming the capability', async () => { + mockGetWorkspaceById.mockResolvedValue(null) + + const response = await readEnvironment() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Workspace not found' }) + }) + }) + + describe('when no group governs the caller', () => { + it('lets the read through', async () => { + const response = await readEnvironment() + + expect(response.status).toBe(200) + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) + }) + + it('lets the write through to the secret-admin check', async () => { + await writeEnvironment() + + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledTimes(1) + }) + + it('lets the delete through to the secret-admin check', async () => { + await deleteEnvironment() + + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledTimes(1) + }) + }) + + describe('when a group governs the caller but permits Secrets', () => { + beforeEach(() => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + }) + }) + + it('lets the read through', async () => { + const response = await readEnvironment() + + expect(response.status).toBe(200) + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) + }) + + it('lets the write through to the secret-admin check', async () => { + await writeEnvironment() + + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 9edf6a73889..eadffd8ec92 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -26,6 +26,8 @@ import { getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, } from '@/lib/environment/utils' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, @@ -35,6 +37,31 @@ import { const logger = createLogger('WorkspaceEnvironmentAPI') +/** + * Refuses when the caller's permission group withholds secrets, and `null` when + * it does not. + * + * permission-group-enforced: secrets.manage — this route predates the operation + * boundary and is raw `withRouteHandler`, so the authorization funnel that + * applies the capability to `secretOperations` never sees it. It reads and + * writes the very values the Secrets tab shows, which is what the capability + * describes, so it takes the same one the `secrets.*` operations declare. + * + * Every handler here authenticates with `getSession` alone, so the caller is + * always a user-bearing session principal — a workspace API key cannot reach + * this route, and there is no executor delegation to refuse. Call this only + * after the workspace role check has passed: a caller with no role must learn + * that the workspace is out of reach, not how their organization's group is + * configured. + */ +async function secretsCapabilityRefusal( + userId: string, + workspaceId: string +): Promise { + const withheld = await isWorkspaceCapabilityWithheld(userId, workspaceId, 'secrets.manage') + return withheld ? capabilityRefusalResponse('secrets.manage') : null +} + /** * Reveals a workspace secret only to a workspace administrator, that secret's * credential administrator, or a caller allowed to use a secret explicitly @@ -120,6 +147,9 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const withheld = await secretsCapabilityRefusal(userId, workspaceId) + if (withheld) return withheld + const { workspaceDecrypted, personalDecrypted, @@ -191,6 +221,9 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const withheld = await secretsCapabilityRefusal(userId, workspaceId) + if (withheld) return withheld + const incomingKeys = Object.keys(variables) if (incomingKeys.length === 0) { return NextResponse.json({ success: true }) @@ -347,6 +380,9 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const withheld = await secretsCapabilityRefusal(userId, workspaceId) + if (withheld) return withheld + const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ workspaceId, envKeys: keys, diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts index a3eebf6272c..0c9e4ecf029 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts @@ -6,6 +6,8 @@ import { createMockRequest, dbChainMock, dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, queueTableRows, resetDbChainMock, schemaMock, @@ -17,6 +19,8 @@ const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted mockHasWorkspaceInboxAccess: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@/lib/billing/core/subscription', () => ({ @@ -33,7 +37,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) -import { PATCH } from '@/app/api/workspaces/[id]/inbox/route' +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET, PATCH } from '@/app/api/workspaces/[id]/inbox/route' const context = { params: Promise.resolve({ id: 'workspace-1' }) } @@ -44,6 +51,7 @@ describe('Inbox config secret policy', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') mockHasWorkspaceInboxAccess.mockResolvedValue(true) + resolveGroupConfigMock.mockResolvedValue(null) }) it('updates policy without requiring an inbox lifecycle mutation', async () => { @@ -85,3 +93,132 @@ describe('Inbox config secret policy', () => { ) }) }) + +const REFUSAL = "The inbox is not available under your organization's permission group" + +function patchRequest() { + return createMockRequest( + 'PATCH', + { secretScope: 'all' }, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ) +} + +function getRequest() { + return createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ) +} + +/** The current inbox config row plus the empty task-status rollup the GET handler joins onto it. */ +function queueInboxReadRows() { + queueTableRows(schemaMock.workspace, [ + { + inboxEnabled: true, + inboxAddress: 'tasks@example.com', + inboxProviderId: 'provider-1', + inboxSecretScope: 'all', + inboxMountedSecrets: [], + }, + ]) + queueTableRows(schemaMock.mothershipInboxTask, []) +} + +describe('Inbox inbox.use capability gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + }) + + describe('when the group withholds inbox.use', () => { + beforeEach(() => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideInboxTab: true, + }) + }) + + it('refuses to read the inbox config', async () => { + queueInboxReadRows() + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: REFUSAL, + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + }) + + it('refuses to update the inbox config, leaving the row untouched', async () => { + queueInboxReadRows() + + const response = await PATCH(patchRequest(), context) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: REFUSAL, + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + }) + + describe('when a group governs the user but withholds nothing', () => { + beforeEach(() => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + }) + + it('reads the inbox config', async () => { + queueInboxReadRows() + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + enabled: true, + address: 'tasks@example.com', + }) + }) + + it('updates the inbox config', async () => { + queueInboxReadRows() + + const response = await PATCH(patchRequest(), context) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalled() + }) + }) + + /** A personal workspace, or any non-enterprise organization, is governed by no group. */ + describe('when no permission group governs the user', () => { + beforeEach(() => { + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('reads the inbox config', async () => { + queueInboxReadRows() + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + }) + + it('updates the inbox config', async () => { + queueInboxReadRows() + + const response = await PATCH(patchRequest(), context) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 0bcc27b959d..5b53c629cb1 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -10,6 +10,8 @@ import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxConfigAPI') @@ -27,6 +29,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return capabilityRefusalResponse('inbox.use') + } + const [wsResult, statsResult, entitled] = await Promise.all([ db .select({ @@ -94,6 +101,11 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return capabilityRefusalResponse('inbox.use') + } + const parsed = await parseRequest(updateInboxConfigContract, req, context) if (!parsed.success) return parsed.response const body = parsed.data.body diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index eba711ed188..3530aca8edb 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -8,6 +8,8 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxSendersAPI') @@ -31,6 +33,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return capabilityRefusalResponse('inbox.use') + } + const [senders, members] = await Promise.all([ db .select({ @@ -87,6 +94,11 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return capabilityRefusalResponse('inbox.use') + } + try { const parsed = await parseRequest(addInboxSenderContract, req, context) if (!parsed.success) return parsed.response @@ -146,6 +158,11 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return capabilityRefusalResponse('inbox.use') + } + try { const parsed = await parseRequest(removeInboxSenderContract, req, context) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts new file mode 100644 index 00000000000..7c934e5abf2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/siblings.capability.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + dbChainMock, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockHasWorkspaceInboxAccess: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { + DELETE as DELETE_SENDER, + GET as GET_SENDERS, + POST as POST_SENDER, +} from '@/app/api/workspaces/[id]/inbox/senders/route' +import { GET as GET_TASKS } from '@/app/api/workspaces/[id]/inbox/tasks/route' + +const context = { params: Promise.resolve({ id: 'workspace-1' }) } +const REFUSAL = "The inbox is not available under your organization's permission group" +const BLOCKED = { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' } +const BASE_URL = 'http://localhost:3000/api/workspaces/workspace-1/inbox' + +/** + * The inbox surface spreads one capability over three route files and six + * handlers. Asserted together because a refusal that drifts on one of them is + * invisible to a test that only reads `/inbox` — which is how the sibling + * handlers came to omit `details.code` in the first place. + */ +describe('inbox.use refusals converge across the sibling routes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideInboxTab: true, + }) + }) + + it.each([ + [ + 'GET /inbox/tasks', + () => GET_TASKS(createMockRequest('GET', undefined, undefined, `${BASE_URL}/tasks`), context), + ], + [ + 'GET /inbox/senders', + () => + GET_SENDERS(createMockRequest('GET', undefined, undefined, `${BASE_URL}/senders`), context), + ], + [ + 'POST /inbox/senders', + () => + POST_SENDER( + createMockRequest( + 'POST', + { email: 'someone@example.com' }, + undefined, + `${BASE_URL}/senders` + ), + context + ), + ], + [ + 'DELETE /inbox/senders', + () => + DELETE_SENDER( + createMockRequest('DELETE', { senderId: 'sender-1' }, undefined, `${BASE_URL}/senders`), + context + ), + ], + ])('%s returns the structured capability refusal', async (_name, call) => { + const response = await call() + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: REFUSAL, details: BLOCKED }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index 82a9e12e383..02fd9057214 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -6,6 +6,8 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const GET = withRouteHandler( @@ -34,6 +36,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } + // permission-group-enforced: inbox.use — raw handler with inline queries, which the authorization funnel never sees + if (await isWorkspaceCapabilityWithheld(session.user.id, workspaceId, 'inbox.use')) { + return capabilityRefusalResponse('inbox.use') + } + const queryResult = inboxTasksQuerySchema.safeParse( Object.fromEntries(req.nextUrl.searchParams.entries()) ) diff --git a/apps/sim/app/api/workspaces/route.test.ts b/apps/sim/app/api/workspaces/route.test.ts new file mode 100644 index 00000000000..54fca6aa265 --- /dev/null +++ b/apps/sim/app/api/workspaces/route.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + * + * POST /api/workspaces refuses a workspace-creation-denied group at two + * moments: the preflight policy read, and the revocation race the insert + * detects. Both are the same decision, so both must produce the same body — + * the preflight one used to answer a bare `{ error }` with no + * `details.code`, so a client keying off the code saw the capability refusal + * only in the rarer case. + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetWorkspaceCreationPolicy, mockCreateWorkspace } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetWorkspaceCreationPolicy: vi.fn(), + mockCreateWorkspace: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/auth/session-response', () => ({ + getActiveOrganizationId: () => null, +})) + +vi.mock('@/lib/workspaces/create', () => ({ + createWorkspace: mockCreateWorkspace, +})) + +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: vi.fn(), +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { WORKSPACE_CREATED: 'workspace.created' }, + AuditResourceType: { WORKSPACE: 'workspace' }, +})) + +vi.mock('@/lib/workspaces/policy', async () => { + class WorkspaceCreationCapabilityWithheldError extends Error {} + class WorkspaceCreationContextChangedError extends Error {} + return { + getWorkspaceCreationPolicy: mockGetWorkspaceCreationPolicy, + WorkspaceCreationCapabilityWithheldError, + WorkspaceCreationContextChangedError, + } +}) + +import { WorkspaceCreationCapabilityWithheldError } from '@/lib/workspaces/policy' +import { POST } from '@/app/api/workspaces/route' + +function createRequest() { + return createMockRequest('POST', { name: 'New workspace' }) +} + +const deniedPolicy = { + canCreate: false, + status: 403, + reason: 'Your permission group does not allow creating workspaces.', + blockedReasonCode: 'permission-group-denied', +} + +describe('POST /api/workspaces capability refusal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'A', email: 'a@example.com' }, + }) + }) + + it('answers the preflight denial with the capability refusal envelope', async () => { + mockGetWorkspaceCreationPolicy.mockResolvedValue(deniedPolicy) + + const response = await POST(createRequest()) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + expect(mockCreateWorkspace).not.toHaveBeenCalled() + }) + + it('answers the revocation race with the same envelope', async () => { + mockGetWorkspaceCreationPolicy.mockResolvedValue({ canCreate: true, status: 200 }) + mockCreateWorkspace.mockRejectedValue(new WorkspaceCreationCapabilityWithheldError()) + + const response = await POST(createRequest()) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + }) + }) + + /** A non-capability block keeps its own reason and status. */ + it('leaves an unrelated policy refusal alone', async () => { + mockGetWorkspaceCreationPolicy.mockResolvedValue({ + canCreate: false, + status: 402, + reason: 'Your organization subscription is inactive.', + blockedReasonCode: 'organization-subscription-inactive', + }) + + const response = await POST(createRequest()) + + expect(response.status).toBe(402) + const body = await response.json() + expect(body.error).toBe('Your organization subscription is inactive.') + expect(body.details).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 37d15917946..7c5c1ebecd5 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -10,11 +10,13 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { createWorkspace } from '@/lib/workspaces/create' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspaceCreationPolicy, + WorkspaceCreationCapabilityWithheldError, WorkspaceCreationContextChangedError, } from '@/lib/workspaces/policy' @@ -128,6 +130,18 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (!creationPolicy.canCreate) { + /** + * The preflight refusal and the revocation-race refusal are the same + * decision reached at two moments, so they must be the same body. Without + * this branch the common path — the group already denied `workspace.create` + * when the policy was read — answered a bare `{ error }`, while only the + * race the `catch` below handles carried + * `details.code: PERMISSION_GROUP_CAPABILITY_BLOCKED`. A client that keys + * off the code then saw the capability refusal in the rarer case only. + */ + if (creationPolicy.blockedReasonCode === 'permission-group-denied') { + return capabilityRefusalResponse('workspace.create') + } return NextResponse.json( { error: creationPolicy.reason || 'Workspace creation is not available.' }, { status: creationPolicy.status } @@ -181,6 +195,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ workspace: newWorkspace }) } catch (error) { + if (error instanceof WorkspaceCreationCapabilityWithheldError) { + return capabilityRefusalResponse('workspace.create') + } if (error instanceof WorkspaceCreationContextChangedError) { return NextResponse.json( { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 9c7f21a0d89..c1e97c0355e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -22,6 +22,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import type { ApiKeyScope } from '@/hooks/queries/api-key-list' import { useApiKeys, @@ -106,7 +107,54 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const conflictNames = useMemo(() => new Set(conflicts), [conflicts]) const isLoading = isLoadingKeys - const allowPersonalApiKeys = hostContext?.workspace.allowPersonalApiKeys ?? true + /** + * The raw group config, not `usePermissionConfig` — that hook also projects + * block and model availability, which would pull the block registry into this + * settings page's module graph for one boolean. + */ + const permissionConfigQuery = useUserPermissionConfig(workspaceId) + + /** + * Both layers have to agree. The workspace column is the coarse switch every + * workspace has; the permission group narrows it for one cohort inside an + * enterprise organization. The server combines them the same way, so offering + * a key type here that it would refuse is the only failure worth avoiding — + * which is why the policy fails closed while its query is pending or errored, + * rather than treating an unanswered question as an unrestricted answer. + * `useUserPermissionConfig` retries and refetches on remount, which is what + * makes the gate self-healing rather than sticky. + * + * `isSuccess` and not `isSuccess && !isFetching`, on purpose. A background + * refetch of an already-answered policy keeps the cached answer, and the + * fail-closed window that matters — the first load, where there is nothing + * cached — is already covered because `isSuccess` is false until the first + * response. Re-closing on every refetch would instead blank the personal-key + * affordance and flip the create default to `workspace` on each window focus, + * for a policy that changes on the order of never. This gate is the + * affordance; `/api/workspaces/[id]/api-keys` is the enforcement, and it + * re-reads the group on the request itself, so the seconds of staleness cost + * a user a refused create at worst. + * + * The `!workspaceId` arm covers the account plane, which renders this + * component as `scope='personal'` outside `/workspace/[workspaceId]`: the + * hook is disabled there and nothing reads the result — a personal key is + * not a workspace's to withhold, and `/api/users/me/api-keys` remains the + * enforcement for the user-global `api_keys.manage` policy. + */ + const permissionPolicyReady = !workspaceId || permissionConfigQuery.isSuccess + + /** + * The stored workspace column alone. The admin switch below binds to this, + * not to the combined policy — a group's `disablePersonalApiKeys` must not + * render the stored setting as off, or toggling it "on" fires a successful + * mutation with no visible effect. + */ + const storedAllowPersonalApiKeys = hostContext?.workspace.allowPersonalApiKeys ?? true + + const allowPersonalApiKeys = + storedAllowPersonalApiKeys && + permissionPolicyReady && + !permissionConfigQuery.data?.config?.disablePersonalApiKeys const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) @@ -340,7 +388,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { { try { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx new file mode 100644 index 00000000000..4e6cc481a21 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.test.tsx @@ -0,0 +1,112 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { primaryActions } = vi.hoisted(() => ({ + primaryActions: { current: [] as { label: string; disabled: boolean }[] }, +})) + +vi.mock('@sim/emcn', () => ({ + ButtonGroup: ({ children }: { children: ReactNode }) =>
{children}
, + ButtonGroupItem: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalField: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => ( + onChange?.(event.target.value)} + /> + ), + ChipModalFooter: ({ primaryAction }: { primaryAction: { label: string; disabled: boolean } }) => { + primaryActions.current.push(primaryAction) + return
+ }, + ChipModalHeader: ({ children }: { children: ReactNode }) =>
{children}
, + SecretReveal: () =>
, +})) + +vi.mock('@/hooks/queries/api-keys', () => ({ + useCreateApiKey: () => ({ isPending: false, mutateAsync: vi.fn() }), +})) + +import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal' + +let container: HTMLDivElement +let root: Root + +/** The success dialog renders a second footer, so pick the create one. */ +function latestCreateAction() { + return primaryActions.current.filter((action) => action.label === 'Create').at(-1)! +} + +async function render(props: { + open: boolean + defaultKeyType: 'personal' | 'workspace' + allowPersonalApiKeys: boolean +}) { + await act(async () => { + root.render( + + ) + }) +} + +async function typeName() { + const input = container.querySelector('[data-testid="key-name"]')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set! + setter.call(input, 'CI key') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('CreateApiKeyModal key-type seeding', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + primaryActions.current = [] + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + /** + * The settings page mounts this modal closed, while its permission-group + * query is still pending — so `defaultKeyType` is the fail-closed + * `'workspace'` and only becomes `'personal'` once the policy answers. A + * non-admin has no type selector rendered, so a selection seeded at mount is + * one they can never change and never create with. + */ + it('adopts the default the policy resolved to, not the one present at mount', async () => { + await render({ open: false, defaultKeyType: 'workspace', allowPersonalApiKeys: true }) + await render({ open: false, defaultKeyType: 'personal', allowPersonalApiKeys: true }) + await render({ open: true, defaultKeyType: 'personal', allowPersonalApiKeys: true }) + await typeName() + + expect(latestCreateAction().disabled).toBe(false) + }) + + it('still refuses a workspace key a non-admin may not create', async () => { + await render({ open: false, defaultKeyType: 'workspace', allowPersonalApiKeys: false }) + await render({ open: true, defaultKeyType: 'workspace', allowPersonalApiKeys: false }) + await typeName() + + expect(latestCreateAction().disabled).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx index 789bd75ec1d..6f0127f4b9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx @@ -54,6 +54,29 @@ export function CreateApiKeyModal({ const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) const createApiKeyMutation = useCreateApiKey() + /** + * The form is seeded when the modal OPENS, not when this component mounts. + * + * It mounts with its host page, and on the settings page `defaultKeyType` is + * computed from a permission-group policy that is still loading at that + * moment — so it starts as the fail-closed `'workspace'` and only becomes + * `'personal'` once the query answers. Seeding once at mount left a non-admin + * holding `'workspace'`, which they may not create, with no type selector + * rendered to change it and Create permanently disabled. + * + * Adjusting during render off the previous `open` rather than in an effect: + * there is no external system to synchronize with, only a prop transition. + */ + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) { + setKeyName('') + setKeyType(defaultKeyType) + setCreateError(null) + } + } + const handleCreateKey = async () => { const trimmedName = keyName.trim() if (!trimmedName) return @@ -81,9 +104,6 @@ export function CreateApiKeyModal({ setNewKey(data.key) setShowNewKeyDialog(true) - setKeyName('') - setKeyType(defaultKeyType) - setCreateError(null) onOpenChange(false) onKeyCreated?.(data.key) } catch (error: unknown) { @@ -99,9 +119,6 @@ export function CreateApiKeyModal({ const handleClose = () => { onOpenChange(false) - setKeyName('') - setKeyType(defaultKeyType) - setCreateError(null) } return ( diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index ecb796e9a78..72ed6873e6b 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -192,9 +192,11 @@ export default function WorkspacePage() { description={ blockedPolicy.blockedReasonCode === 'organization-subscription-inactive' ? "Your organization's subscription is inactive, so new workspaces can't be created. Ask an organization owner to reactivate it." - : blockedPolicy.workspaceMode === 'organization' - ? "Your account is linked to an organization, but you don't have access to any of its workspaces. Ask an organization admin for workspace access, then check again — or sign out and back in if you recently left the organization." - : 'Your plan has reached its workspace limit and none of your workspaces are active. Upgrade your plan to create another workspace, or contact support to restore an archived one.' + : blockedPolicy.blockedReasonCode === 'permission-group-denied' + ? "Your permission group doesn't allow creating workspaces, and you don't have access to an existing one. Ask an organization admin for workspace access." + : blockedPolicy.workspaceMode === 'organization' + ? "Your account is linked to an organization, but you don't have access to any of its workspaces. Ask an organization admin for workspace access, then check again — or sign out and back in if you recently left the organization." + : 'Your plan has reached its workspace limit and none of your workspaces are active. Upgrade your plan to create another workspace, or contact support to restore an archived one.' } primaryLabel='Check again' onPrimary={() => window.location.reload()} diff --git a/apps/sim/background/dispatch-cancel-guard.test.ts b/apps/sim/background/dispatch-cancel-guard.test.ts new file mode 100644 index 00000000000..ecf287abb5c --- /dev/null +++ b/apps/sim/background/dispatch-cancel-guard.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readDispatch: vi.fn(), + getTableById: vi.fn(), + getRowById: vi.fn(), + executeWorkflow: vi.fn(), + loadDeployedWorkflowState: vi.fn(), + writeWorkflowGroupState: vi.fn(), + markWorkflowGroupPickedUp: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + stashCellContextForResume: vi.fn(), + classifyWorkflowCellTerminalResult: vi.fn(), +})) + +vi.mock('@/lib/table/dispatcher', () => ({ + readDispatch: mocks.readDispatch, + completeDispatchIfActive: vi.fn(), +})) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ + getRowById: mocks.getRowById, + updateRow: vi.fn(), +})) +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ + executeWorkflow: mocks.executeWorkflow, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: mocks.loadDeployedWorkflowState, +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: (prev: { executionId: string | null; workflowId: string }) => ({ + status: 'cancelled', + executionId: prev.executionId, + jobId: null, + workflowId: prev.workflowId, + error: 'Cancelled', + }), + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: mocks.classifyWorkflowCellTerminalResult, +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, + stashCellContextForResume: mocks.stashCellContextForResume, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { + columns: [], + workflowGroups: [{ id: 'group-1', workflowId: 'workflow-1', outputs: [] }], + }, +} + +const PAYLOAD = { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + dispatchId: 'tdsp_1', + executionTimeoutMs: 10_000, + billingAttribution: { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'user-1', + billingEntity: { type: 'user' as const, id: 'user-1' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + }, +} as Parameters[0] + +describe('the cell guard on its owning dispatch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ id: 'row-1', data: {}, executions: {} }) + mocks.pickNextEligibleGroupForRow.mockReturnValue(null) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up') + mocks.loadDeployedWorkflowState.mockResolvedValue(null) + }) + + /** + * The dispatcher blocks on a whole window, so cancelling its row — which is + * all account deletion does before the user row goes away — leaves the cells + * that window already queued free to invoke tools and write results. + */ + it('refuses to execute a cell whose dispatch was cancelled', async () => { + mocks.readDispatch.mockResolvedValue({ id: 'tdsp_1', status: 'cancelled' }) + + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.readDispatch).toHaveBeenCalledWith('tdsp_1') + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + expect(mocks.markWorkflowGroupPickedUp).not.toHaveBeenCalled() + expect(mocks.writeWorkflowGroupState).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + executionState: expect.objectContaining({ status: 'cancelled' }), + }) + ) + }) + + /** + * `complete` is the ordinary state a dispatch reaches while its final window + * is still finishing — stopping on it would kill the run's last cells. + */ + it('lets a cell of a still-live dispatch past the guard', async () => { + mocks.readDispatch.mockResolvedValue({ id: 'tdsp_1', status: 'complete' }) + + await runRowCascadeLoop(PAYLOAD) + + // It got as far as loading the workflow, which the db mock does not have. + const statuses = mocks.writeWorkflowGroupState.mock.calls.map( + ([, write]) => (write as { executionState: { status: string } }).executionState.status + ) + expect(statuses).toContain('error') + expect(statuses).not.toContain('cancelled') + }) +}) diff --git a/apps/sim/background/drain-governed-subject.test.ts b/apps/sim/background/drain-governed-subject.test.ts new file mode 100644 index 00000000000..cb3d28654ca --- /dev/null +++ b/apps/sim/background/drain-governed-subject.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ +import { resetDbChainMock } from '@sim/testing' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + getRowById: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + writeWorkflowGroupState: vi.fn(), + markWorkflowGroupPickedUp: vi.fn(), + runEnrichment: vi.fn(), + getEnrichment: vi.fn(), + readStampedCapabilitySubject: vi.fn(), + checkAttributedUsageLimits: vi.fn(), + loadTableRowSecretProvenance: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ getRowById: mocks.getRowById, updateRow: vi.fn() })) +vi.mock('@/lib/table/rows/executions', () => ({ + readStampedCapabilitySubject: mocks.readStampedCapabilitySubject, +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, + stashCellContextForResume: vi.fn(), + buildWorkflowGroupExecutionCorrelation: vi.fn(), +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: vi.fn(), +})) +vi.mock('@/enrichments/registry', () => ({ getEnrichment: mocks.getEnrichment })) +vi.mock('@/enrichments/run', () => ({ + runEnrichment: mocks.runEnrichment, + skippedEnrichmentDetail: () => ({}), +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, + checkAttributedUsageLimits: mocks.checkAttributedUsageLimits, + toBillingContext: () => ({}), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + createTableRowSecretProvenanceFromRegistry: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) + +/** + * Unmocked, the pacing loop constructs a real RateLimiter against the global + * db mock and sleeps real jittered backoff between attempts — nondeterministic + * seconds per test, and a timeout under a loaded parallel run. + */ +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) +vi.mock('@/lib/table/dispatcher', () => ({ + readDispatch: vi.fn(async () => ({ id: 'tdsp_carrier', status: 'dispatching' })), + completeDispatchIfActive: vi.fn(), +})) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +function enrichmentGroup(id: string) { + return { + id, + type: 'enrichment', + enrichmentId: 'enrich-1', + workflowId: '', + outputs: [{ blockId: '', path: 'out', columnName: 'out' }], + inputMappings: [{ inputName: 'domain', columnName: 'domain' }], + } +} + +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { + columns: [{ id: 'domain', name: 'domain', type: 'string' }], + workflowGroups: [enrichmentGroup('group-1'), enrichmentGroup('group-2')], + }, +} + +/** The carrier belongs to an actorless auto-fire: no subject, so no tool gate. */ +const CARRIER = { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: '', + enrichmentId: 'enrich-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + dispatchId: 'tdsp_carrier', + executionTimeoutMs: 10_000, + capabilityGovernedUserId: null, + billingAttribution: { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'user-1', + billingEntity: { type: 'user' as const, id: 'user-1' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + }, +} as Parameters[0] + +describe('draining another dispatch’s pre-stamped marker', () => { + /** + * The loop under test resolves its collaborators with dynamic imports, which + * under a loaded parallel run can take whole seconds. Paying that cost inside + * a test's own budget is what made this file flaky: one test timed out + * mid-loop and its continuation spilled calls into the next. Warm the graph + * once, outside any per-test budget. + */ + beforeAll(async () => { + await Promise.all([ + import('@/enrichments/registry'), + import('@/enrichments/run'), + import('@/lib/billing/core/usage-log'), + import('@/lib/table/cell-write'), + import('@/lib/table/dispatcher'), + import('@/lib/table/rows/executions'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/lib/workflows/executor/execute-workflow'), + import('@/lib/workflows/persistence/utils'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getEnrichment.mockReturnValue({ + id: 'enrich-1', + name: 'Enrich', + inputs: [{ id: 'domain', required: true }], + outputs: [{ id: 'out' }], + }) + mocks.checkAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up') + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.loadTableRowSecretProvenance.mockResolvedValue({ + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + byRowId: {}, + }) + mocks.runEnrichment.mockResolvedValue({ result: { out: 'x' }, cost: 0, detail: {} }) + mocks.readStampedCapabilitySubject.mockResolvedValue('requesting-member') + // group-1 completes, then group-2 is picked up carrying an unclaimed marker. + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: { domain: 'example.com' }, + executions: { 'group-2': { status: 'pending', executionId: null, workflowId: '' } }, + }) + mocks.pickNextEligibleGroupForRow + .mockReturnValueOnce(enrichmentGroup('group-2')) + .mockReturnValue(null) + }) + + /** + * The lock owner drains markers it did not stamp. Running them under its own + * subject applies the wrong person's tool denylist — and when the owner is an + * actorless auto-fire, no denylist at all. + */ + it('runs the drained cell under the subject stamped with it', async () => { + await runRowCascadeLoop(CARRIER) + + expect(mocks.readStampedCapabilitySubject).toHaveBeenCalledWith('row-1', 'group-2') + const subjects = mocks.runEnrichment.mock.calls.map( + ([, , ctx]) => (ctx as { userId: string | null }).userId + ) + expect(subjects).toEqual([null, 'requesting-member']) + }, 20_000) +}) diff --git a/apps/sim/background/enrichment-capability-subject.test.ts b/apps/sim/background/enrichment-capability-subject.test.ts new file mode 100644 index 00000000000..1f5361549a4 --- /dev/null +++ b/apps/sim/background/enrichment-capability-subject.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + */ +import { resetDbChainMock } from '@sim/testing' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + getRowById: vi.fn(), + updateRow: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + stashCellContextForResume: vi.fn(), + writeWorkflowGroupState: vi.fn(async () => 'wrote'), + markWorkflowGroupPickedUp: vi.fn(async () => 'wrote'), + createWorkflowCellProgressWriter: vi.fn(), + buildCancelledExecution: vi.fn(), + classifyWorkflowCellTerminalResult: vi.fn(), + getEnrichment: vi.fn(), + runEnrichment: vi.fn(), + skippedEnrichmentDetail: vi.fn(() => ({})), + checkAttributedUsageLimits: vi.fn(async () => ({ isExceeded: false })), + loadTableRowSecretProvenance: vi.fn(async () => ({ scope: null, entries: [] })), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ + getRowById: mocks.getRowById, + updateRow: mocks.updateRow, +})) +vi.mock('@/lib/table/cell-write', () => ({ + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + buildCancelledExecution: mocks.buildCancelledExecution, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: mocks.classifyWorkflowCellTerminalResult, +})) +vi.mock('@/enrichments/registry', () => ({ getEnrichment: mocks.getEnrichment })) +vi.mock('@/enrichments/run', () => ({ + runEnrichment: mocks.runEnrichment, + skippedEnrichmentDetail: mocks.skippedEnrichmentDetail, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: vi.fn((value) => value), + checkAttributedUsageLimits: mocks.checkAttributedUsageLimits, + toBillingContext: vi.fn(() => ({})), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: vi.fn(() => undefined), + createTableRowSecretProvenanceFromRegistry: vi.fn(() => undefined), + loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, +})) +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class { + async importCrossingProvenance() {} + }, +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) + +/** + * Unmocked, the pacing loop constructs a real RateLimiter against the global + * db mock and sleeps real jittered backoff between attempts — nondeterministic + * seconds per test, and a timeout under a loaded parallel run. + */ +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +const GROUP = { + id: 'group-1', + type: 'enrichment' as const, + enrichmentId: 'company-lookup', + workflowId: '', + outputs: [{ columnName: 'col-out', blockId: '', path: '' }], + inputMappings: [{ columnName: 'col-in', inputName: 'domain' }], +} + +const TABLE = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [{ id: 'col-in', name: 'Domain', type: 'string' }], workflowGroups: [GROUP] }, +} + +function payload(capabilityGovernedUserId: string | null, triggeredByUserId?: string) { + return { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: '', + workspaceId: 'workspace-1', + executionId: 'exec-1', + capabilityGovernedUserId, + ...(triggeredByUserId ? { triggeredByUserId } : {}), + billingAttribution: { + /** The meter's subject: the payer a workspace-key run attributes to. */ + actorUserId: triggeredByUserId ?? 'billing-owner', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'billing-owner', + billingEntity: { type: 'user' as const, id: 'billing-owner' }, + billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + payerSubscription: null, + }, + } +} + +/** The `userId` the cell handed the enrichment run — the per-tool gate subject. */ +function gatedUserId(): unknown { + expect(mocks.runEnrichment).toHaveBeenCalledTimes(1) + return (mocks.runEnrichment.mock.calls[0][2] as { userId?: unknown }).userId +} + +describe('enrichment cell capability subject', () => { + /** + * The loop under test resolves its collaborators with dynamic imports, which + * under a loaded parallel run can take whole seconds. Paying that cost inside + * a test's own budget is what made this file flaky: one test timed out + * mid-loop and its continuation spilled calls into the next. Warm the graph + * once, outside any per-test budget. + */ + beforeAll(async () => { + await Promise.all([ + import('@/enrichments/registry'), + import('@/enrichments/run'), + import('@/lib/billing/core/usage-log'), + import('@/lib/table/cell-write'), + import('@/lib/table/dispatcher'), + import('@/lib/table/rows/executions'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/lib/workflows/executor/execute-workflow'), + import('@/lib/workflows/persistence/utils'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: { 'col-in': 'example.com' }, + executions: {}, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + mocks.checkAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mocks.markWorkflowGroupPickedUp.mockResolvedValue('wrote') + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.pickNextEligibleGroupForRow.mockResolvedValue(null) + mocks.getEnrichment.mockReturnValue({ + id: 'company-lookup', + inputs: [{ id: 'domain', required: true }], + providers: [], + }) + mocks.runEnrichment.mockResolvedValue({ result: {}, cost: 0, detail: {} }) + }) + + /** + * A workspace-key write is actorless: nobody's permission group governs it, + * and the billing owner beside it on the payload is a bystander. Handing that + * bystander to the enrichment would run their tool denylist against a request + * they never made. + */ + it('runs a workspace-key dispatch ungated even though the payload names a payer', async () => { + await runRowCascadeLoop(payload(null, 'billing-owner') as never) + expect(gatedUserId()).toBeNull() + }) + + it('governs a session-triggered dispatch by the acting person', async () => { + await runRowCascadeLoop(payload('acting-user', 'acting-user') as never) + expect(gatedUserId()).toBe('acting-user') + }) + + /** + * The shape a pre-0315 dispatch row has after the column is added: no governed + * subject, attribution intact. New code reads that as actorless, which is why + * the migration backfills the legacy subject onto non-terminal old rows rather + * than letting the reader reconstruct it here. + */ + it('does not fall back to the attribution when the governed subject is absent', async () => { + await runRowCascadeLoop(payload(null, 'legacy-trigger-user') as never) + expect(gatedUserId()).toBeNull() + }) +}) diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index d25cb8bb786..9b2f7d4f584 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -17,6 +17,7 @@ import { withCascadeLock } from '@/lib/table/cascade-lock' import { isExecCancelled } from '@/lib/table/deps' import type { RowExecutionMetadata } from '@/lib/table/types' import { classifyWorkflowCellTerminalResult } from '@/lib/table/workflow-cell-result' +import type { CellResumeContext } from '@/lib/table/workflow-columns' import { createResumeAttemptTimeoutController, PauseResumeManager, @@ -390,18 +391,17 @@ async function runResumeAndCellTerminal( } async function continueCascadeAfterResume( - cellContext: { - tableId: string - rowId: string - workspaceId: string - groupId: string - }, + cellContext: Pick< + CellResumeContext, + 'tableId' | 'rowId' | 'workspaceId' | 'groupId' | 'capabilityGovernedUserId' + >, billingAttribution: BillingAttributionSnapshot, signal?: AbortSignal ): Promise { const { getTableById } = await import('@/lib/table/service') const { getRowById } = await import('@/lib/table/rows/service') const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns') + const { readStampedCapabilitySubject } = await import('@/lib/table/rows/executions') const { runRowCascadeLoop } = await import('@/background/workflow-column-execution') const freshTable = await getTableById(cellContext.tableId) @@ -410,6 +410,8 @@ async function continueCascadeAfterResume( if (!freshRow) return const next = pickNextEligibleGroupForRow(freshTable, freshRow, cellContext.groupId) if (!next) return + const nextExec = freshRow.executions?.[next.id] + const isQueuedMarker = nextExec?.status === 'pending' && nextExec.executionId == null await runRowCascadeLoop( { tableId: cellContext.tableId, @@ -420,6 +422,22 @@ async function continueCascadeAfterResume( workflowId: next.workflowId, executionId: generateId(), billingAttribution, + /** + * The person who asked for the run that paused still gates the groups it + * cascades into. Reconstructing this from the resume payload is not + * possible — `payload.userId` is the resumer/attribution, not the gate — + * so it rides the pause snapshot instead. + * + * Unless the next group carries another dispatch's unclaimed pre-stamp: + * that is an explicit request from someone else that this cascade happens + * to be draining, and it runs under the subject persisted with it. The + * same decision both drain points in `workflow-column-execution.ts` make; + * a resume that skipped it would hand a stranger's request the paused + * cell's gate. + */ + capabilityGovernedUserId: isQueuedMarker + ? await readStampedCapabilitySubject(cellContext.rowId, next.id) + : cellContext.capabilityGovernedUserId, }, signal ) diff --git a/apps/sim/background/resume-governed-subject.test.ts b/apps/sim/background/resume-governed-subject.test.ts new file mode 100644 index 00000000000..e4bb051f606 --- /dev/null +++ b/apps/sim/background/resume-governed-subject.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment node + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + task: vi.fn((config) => config), + getPausedExecutionById: vi.fn(), + startResumeExecution: vi.fn(), + snapshotFromJson: vi.fn(), + createResumeAttemptTimeoutController: vi.fn(), + findCellContextByExecutionId: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + withCascadeLock: vi.fn(), + getTableById: vi.fn(), + getRowById: vi.fn(), + writeWorkflowGroupState: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + runRowCascadeLoop: vi.fn(), + readStampedCapabilitySubject: vi.fn(), +})) + +vi.mock('@trigger.dev/sdk', () => ({ task: mocks.task, timeout: { None: 'none' } })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (value: unknown) => value, + billingAttributionsEqual: () => true, +})) +vi.mock('@/lib/table/cascade-lock', () => ({ withCascadeLock: mocks.withCascadeLock })) +vi.mock('@/lib/table/deps', () => ({ isExecCancelled: () => false })) +vi.mock('@/lib/table/workflow-columns', () => ({ + findCellContextByExecutionId: mocks.findCellContextByExecutionId, + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, +})) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ getRowById: mocks.getRowById })) +vi.mock('@/lib/table/rows/executions', () => ({ + readStampedCapabilitySubject: mocks.readStampedCapabilitySubject, +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: vi.fn(), + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + writeWorkflowGroupState: mocks.writeWorkflowGroupState, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: () => ({ status: 'completed', error: null }), +})) +vi.mock('@/background/workflow-column-execution', () => ({ + runRowCascadeLoop: mocks.runRowCascadeLoop, +})) +vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ + createResumeAttemptTimeoutController: mocks.createResumeAttemptTimeoutController, + PauseResumeManager: { + getPausedExecutionById: mocks.getPausedExecutionById, + startResumeExecution: mocks.startResumeExecution, + }, +})) +vi.mock('@/executor/execution/snapshot', () => ({ + ExecutionSnapshot: { fromJSON: mocks.snapshotFromJson }, +})) + +import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' + +const PAYLOAD: ResumeExecutionPayload = { + resumeEntryId: 'resume-entry-1', + resumeExecutionId: 'resume-execution-1', + pausedExecutionId: 'paused-execution-1', + contextId: 'context-1', + resumeInput: {}, + /** The resumer / attribution — deliberately NOT the gate's subject. */ + userId: 'workspace-billing-owner', + workflowId: 'workflow-1', + parentExecutionId: 'parent-execution-1', +} + +const GROUP = { + id: 'group-1', + type: 'workflow', + workflowId: 'workflow-1', + outputs: [], + inputMappings: [], +} +const NEXT_GROUP = { ...GROUP, id: 'group-2' } +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [GROUP, NEXT_GROUP] }, +} + +describe('resuming a paused table cell', () => { + /** The resume worker resolves its collaborators with dynamic imports. */ + beforeAll(async () => { + await Promise.all([ + import('@/lib/table/cell-write'), + import('@/lib/table/rows/executions'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/background/workflow-column-execution'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + mocks.getPausedExecutionById.mockResolvedValue({ executionSnapshot: { snapshot: {} } }) + mocks.createResumeAttemptTimeoutController.mockReturnValue({ + signal: new AbortController().signal, + cleanup: vi.fn(), + abort: vi.fn(), + isTimedOut: () => false, + timeoutMs: 5_000, + }) + mocks.snapshotFromJson.mockReturnValue({ + metadata: { + billingAttribution: { + actorUserId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + }, + }, + }) + mocks.findCellContextByExecutionId.mockResolvedValue({ + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + capabilityGovernedUserId: 'requesting-member', + }) + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ id: 'row-1', data: {}, executions: {} }) + mocks.pickNextEligibleGroupForRow.mockReturnValue(NEXT_GROUP) + mocks.readStampedCapabilitySubject.mockResolvedValue('other-dispatchers-member') + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.createWorkflowCellProgressWriter.mockReturnValue({ + onBlockComplete: vi.fn(), + finish: vi.fn(), + getEventOutputs: () => ({}), + getPendingDataPatch: () => ({}), + getBlockErrors: () => ({}), + getPendingSecretProvenance: () => undefined, + }) + mocks.startResumeExecution.mockResolvedValue({ + success: true, + status: 'completed', + output: {}, + }) + mocks.withCascadeLock.mockImplementation( + async ( + _tableId: string, + _rowId: string, + _executionId: string, + fn: () => Promise + ) => ({ + status: 'ran', + result: await fn(), + }) + ) + }) + + /** + * The scenario the gate exists for: the cell was stamped with the person + * whose group denies a tool, then paused on a wait block. If the subject does + * not survive the pause, the cascade the resume drives runs ungated and the + * denied tool executes. + */ + it('drives the post-resume cascade under the subject stamped before the pause', async () => { + await executeResumeJob(PAYLOAD) + + expect(mocks.runRowCascadeLoop).toHaveBeenCalledTimes(1) + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBe('requesting-member') + expect(cascadePayload.capabilityGovernedUserId).not.toBe(PAYLOAD.userId) + }, 20_000) + + /** + * The next group is not a dependency this cascade satisfied — it carries + * another dispatch's unclaimed pre-stamp, an explicit request from someone + * else that this cascade happens to be draining. Carrying the paused cell's + * subject into it would run a stranger's request under the wrong denylist, + * which is the decision both drain points in `workflow-column-execution.ts` + * already make off the stamp. + */ + it('runs another dispatch’s queued marker under the subject stamped with it', async () => { + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + executions: { 'group-2': { status: 'pending', executionId: null, workflowId: 'workflow-1' } }, + }) + + await executeResumeJob(PAYLOAD) + + expect(mocks.readStampedCapabilitySubject).toHaveBeenCalledWith('row-1', 'group-2') + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBe('other-dispatchers-member') + }, 20_000) + + /** A claimed cell is ordinary dependency work and keeps the paused subject. */ + it('keeps the paused cell’s subject for a marker another worker already claimed', async () => { + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + executions: { + 'group-2': { status: 'pending', executionId: 'execution-9', workflowId: 'workflow-1' }, + }, + }) + + await executeResumeJob(PAYLOAD) + + expect(mocks.readStampedCapabilitySubject).not.toHaveBeenCalled() + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBe('requesting-member') + }, 20_000) + + it('resumes ungated when the paused cell had no acting person', async () => { + mocks.findCellContextByExecutionId.mockResolvedValue({ + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + capabilityGovernedUserId: null, + }) + + await executeResumeJob(PAYLOAD) + + const [cascadePayload] = mocks.runRowCascadeLoop.mock.calls[0] + expect(cascadePayload.capabilityGovernedUserId).toBeNull() + }, 20_000) +}) diff --git a/apps/sim/background/workflow-column-execution.test.ts b/apps/sim/background/workflow-column-execution.test.ts index 306e925133f..43c14535b7f 100644 --- a/apps/sim/background/workflow-column-execution.test.ts +++ b/apps/sim/background/workflow-column-execution.test.ts @@ -283,6 +283,8 @@ describe('table workflow usage-limit clear', () => { data: {}, workspaceId: 'workspace-1', executionsPatch: { 'group-1': null }, + /** Clearing a pre-stamp writes no values, so no acting person governs it. */ + capabilityGovernedUserId: null, cancellationGuard: { groupId: 'group-1', executionId: 'execution-1' }, }) }) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 8bf1eb1544d..907765d65a8 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -229,6 +229,8 @@ export function buildTableUsageLimitClear(args: { secretProvenance: undefined, workspaceId, executionsPatch: { [groupId]: null }, + /** Clearing a pre-stamp writes no cell values and fires no enrichment. */ + capabilityGovernedUserId: null, cancellationGuard: { groupId, executionId }, } } @@ -299,6 +301,7 @@ export async function executeWorkflowGroupCellJob( const { getTableById } = await import('@/lib/table/service') const { getRowById } = await import('@/lib/table/rows/service') const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns') + const { readStampedCapabilitySubject } = await import('@/lib/table/rows/executions') let currentPayload = payload while (true) { @@ -342,6 +345,13 @@ export async function executeWorkflowGroupCellJob( // Re-derive so a workflow group after an enrichment group doesn't keep a stale enrichmentId. enrichmentId: next.enrichmentId, executionId: generateId(), + /** + * The marker was stamped by whichever dispatch requested THIS cell, + * which is not necessarily the one that queued this carrier. Its gate + * belongs to the person who asked for it, so take the subject off the + * stamp rather than carrying our own into someone else's request. + */ + capabilityGovernedUserId: await readStampedCapabilitySubject(rowId, next.id), } } } finally { @@ -360,12 +370,14 @@ export async function runRowCascadeLoop( const { getTableById } = await import('@/lib/table/service') const { getRowById } = await import('@/lib/table/rows/service') const { pickNextEligibleGroupForRow } = await import('@/lib/table/workflow-columns') + const { readStampedCapabilitySubject } = await import('@/lib/table/rows/executions') let currentGroupId = payload.groupId let currentWorkflowId = payload.workflowId // Fresh executionId per iteration: SQL guard rejects writes whose id ≠ // row.executions[gid].executionId, so we need a new claim per group. let currentExecutionId = payload.executionId + let currentCapabilityGovernedUserId = payload.capabilityGovernedUserId while (true) { if (signal?.aborted) { @@ -375,6 +387,7 @@ export async function runRowCascadeLoop( groupId: currentGroupId, workflowId: currentWorkflowId, executionId: currentExecutionId, + capabilityGovernedUserId: currentCapabilityGovernedUserId, }, signal ) @@ -398,6 +411,7 @@ export async function runRowCascadeLoop( groupId: currentGroupId, workflowId: currentWorkflowId, executionId: currentExecutionId, + capabilityGovernedUserId: currentCapabilityGovernedUserId, }, signal, freshTable, @@ -414,6 +428,17 @@ export async function runRowCascadeLoop( if (!freshRow) break const next = pickNextEligibleGroupForRow(freshTable, freshRow, currentGroupId) if (!next) break + const nextExec = freshRow.executions?.[next.id] + /** + * A dep-fill cascade stays under the subject that started it. A group + * carrying an unclaimed pre-stamp is a different thing — an explicit + * request from another dispatch that this cascade is draining — so it runs + * under the subject stamped with that request. + */ + currentCapabilityGovernedUserId = + nextExec?.status === 'pending' && nextExec.executionId == null + ? await readStampedCapabilitySubject(rowId, next.id) + : currentCapabilityGovernedUserId currentGroupId = next.id currentWorkflowId = next.workflowId currentExecutionId = generateId() @@ -467,6 +492,40 @@ async function runWorkflowAndWriteTerminal( secretProvenance, }) + /** + * Dispatch-level cancellation guard. + * + * The dispatcher blocks on a whole window at a time, so cancelling its + * `table_run_dispatches` row stops the NEXT window and nothing that is + * already queued: those cells still invoke tools and write their results. + * That gap is what account deletion falls into — it cancels the departing + * account's dispatches and then deletes the user row, while the cells the + * last window queued keep running under a subject that no longer exists. + * + * The row cannot be reached the other way: `table_row_executions` carries + * no dispatch column, so there is nothing to cancel per cell. Reading the + * owning dispatch here is the dispatch-linked stop, and it costs one + * indexed primary-key read against a whole workflow run. + * + * `cancelled`, or a row that is gone — nothing deletes a dispatch but the + * table cascade, so a missing one means the table it belonged to is gone. + * `complete` deliberately does not stop the cell: it is the ordinary + * terminal state a dispatch reaches while its last window is finishing. + */ + if (dispatchId) { + const { readDispatch } = await import('@/lib/table/dispatcher') + const owningDispatch = await readDispatch(dispatchId) + if (!owningDispatch || owningDispatch.status === 'cancelled') { + logger.info( + `Skipping cell — owning dispatch is cancelled (table=${tableId} row=${rowId} group=${groupId} dispatch=${dispatchId})` + ) + await writeState( + buildCancelledExecution({ executionId, workflowId, blockErrors: undefined }) + ) + return 'cancelled' + } + } + /** Pre-execution cancellation guard: a cell cancelled while it sat in the * queue (e.g. trigger.dev concurrency backlog) must not run once it * dequeues. Reads the already-loaded row's exec — no extra query. */ @@ -626,6 +685,19 @@ async function runWorkflowAndWriteTerminal( tableId, rowId, workspaceId, + /** + * The person who asked, not who pays. `triggeredByUserId` is an + * attribution: for a workspace-API-key run it names the workspace's + * billing owner, and running that bystander's tool denylist against + * an actorless request is wrong in both directions — it fails cells + * nobody meant to govern, and it skips the denylist for the person + * who actually triggered one. The governed subject is carried + * separately from the dispatch. `null` means no per-tool gate + * applies, which is the documented behavior for an actorless run — + * stated, because the field is required precisely so it cannot be + * skipped by omission. + */ + userId: payload.capabilityGovernedUserId ?? null, signal: attemptSignal, resolvedSecretTraceRegistry: enrichmentRegistry, }) @@ -1030,6 +1102,16 @@ async function runWorkflowAndWriteTerminal( actorUserId, { enabled: true, + /** + * The gate, which is not the actor above. `actorUserId` is an + * attribution: for a workspace-API-key run it names the workspace's + * billing owner, so gating the run's tools on it applies a + * bystander's denylist and skips the requester's. Declared + * explicitly — `null` is the actorless run, which the executor + * reads as "no per-tool gate", exactly as the enrichment half of + * this worker already does. + */ + capabilityGovernedUserId: payload.capabilityGovernedUserId, principal: { kind: 'system', serviceId: 'table', @@ -1079,6 +1161,12 @@ async function runWorkflowAndWriteTerminal( groupId, workflowId, workspaceId, + /** + * The gate has to survive the pause. Nothing downstream of a resume + * can re-derive it: the marker this cell was stamped with is long + * claimed, and the resume worker's own payload has no dispatch. + */ + capabilityGovernedUserId: payload.capabilityGovernedUserId, }) return 'paused' } diff --git a/apps/sim/background/workflow-group-governed-subject.test.ts b/apps/sim/background/workflow-group-governed-subject.test.ts new file mode 100644 index 00000000000..5c53a5ad304 --- /dev/null +++ b/apps/sim/background/workflow-group-governed-subject.test.ts @@ -0,0 +1,265 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + getRowById: vi.fn(), + pickNextEligibleGroupForRow: vi.fn(), + stashCellContextForResume: vi.fn(), + writeWorkflowGroupState: vi.fn(), + markWorkflowGroupPickedUp: vi.fn(), + createWorkflowCellProgressWriter: vi.fn(), + loadDeployedWorkflowState: vi.fn(), + executeWorkflow: vi.fn(), + preprocessExecution: vi.fn(), + loadTableRowSecretProvenance: vi.fn(), + findStartBlock: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/rows/service', () => ({ + getRowById: mocks.getRowById, + updateRow: vi.fn(), +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + pickNextEligibleGroupForRow: mocks.pickNextEligibleGroupForRow, + stashCellContextForResume: mocks.stashCellContextForResume, + buildWorkflowGroupExecutionCorrelation: () => ({}), +})) +vi.mock('@/lib/table/cell-write', () => ({ + buildCancelledExecution: (prev: { executionId: string | null; workflowId: string }) => ({ + status: 'cancelled', + executionId: prev.executionId, + jobId: null, + workflowId: prev.workflowId, + error: 'Cancelled', + }), + createWorkflowCellProgressWriter: mocks.createWorkflowCellProgressWriter, + writeWorkflowGroupState: mocks.writeWorkflowGroupState, + markWorkflowGroupPickedUp: mocks.markWorkflowGroupPickedUp, +})) +vi.mock('@/lib/table/workflow-cell-result', () => ({ + classifyWorkflowCellTerminalResult: () => ({ status: 'completed', error: null }), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/table/dispatcher', () => ({ + readDispatch: async () => ({ id: 'tdsp_1', status: 'dispatching' }), + completeDispatchIfActive: vi.fn(), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: mocks.loadDeployedWorkflowState, +})) +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ + executeWorkflow: mocks.executeWorkflow, +})) +vi.mock('@/lib/workflows/triggers/triggers', () => ({ + TriggerUtils: { findStartBlock: mocks.findStartBlock }, +})) +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ flattenWorkflowOutputs: () => [] })) +vi.mock('@/lib/workflows/input-format', () => ({ normalizeInputFormatValue: () => [] })) +vi.mock('@/lib/execution/preprocessing', () => ({ + preprocessExecution: mocks.preprocessExecution, +})) +vi.mock('@/lib/table/admission-retry', () => ({ + retryTableAdmission: (fn: () => Promise) => fn(), +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + createTableRowSecretProvenanceFromRegistry: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mocks.loadTableRowSecretProvenance, +})) +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class { + importCrossingProvenance = vi.fn() + exportCheckpointProvenance = vi.fn(() => undefined) + }, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, + checkAttributedUsageLimits: async () => ({ isExceeded: false }), + toBillingContext: () => ({}), +})) +/** Real pacing would sleep jittered backoff against the global db mock. */ +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitWithSubscription = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) + +import { runRowCascadeLoop } from '@/background/workflow-column-execution' + +const GROUP = { + id: 'group-1', + type: 'workflow', + workflowId: 'workflow-1', + outputs: [], + inputMappings: [], +} +const TABLE = { + id: 'table-1', + name: 'Table', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [GROUP] }, +} + +const BILLING = { + actorUserId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-billing-owner', + billingEntity: { type: 'user' as const, id: 'workspace-billing-owner' }, + billingPeriod: { start: '2026-08-01T00:00:00.000Z', end: '2026-09-01T00:00:00.000Z' }, + payerSubscription: null, +} + +/** + * A workspace-API-key run: the attribution names the workspace's billing owner, + * while the person who actually asked is the governed subject. + */ +const PAYLOAD = { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + dispatchId: 'tdsp_1', + executionTimeoutMs: 10_000, + triggeredByUserId: 'workspace-billing-owner', + capabilityGovernedUserId: 'requesting-member', + billingAttribution: BILLING, +} as Parameters[0] + +describe('the workflow half of a table cell', () => { + /** The cell resolves its collaborators with dynamic imports; warm them once. */ + beforeAll(async () => { + await Promise.all([ + import('@/lib/table/cell-write'), + import('@/lib/table/dispatcher'), + import('@/lib/table/rows/service'), + import('@/lib/table/service'), + import('@/lib/table/workflow-columns'), + import('@/lib/workflows/executor/execute-workflow'), + import('@/lib/workflows/persistence/utils'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + executions: {}, + }) + mocks.pickNextEligibleGroupForRow.mockReturnValue(null) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + mocks.markWorkflowGroupPickedUp.mockResolvedValue('picked-up') + mocks.loadDeployedWorkflowState.mockResolvedValue({ blocks: {}, edges: [] }) + mocks.findStartBlock.mockReturnValue({ blockId: 'start-1', block: { subBlocks: {} } }) + mocks.loadTableRowSecretProvenance.mockResolvedValue({ + scope: { userId: 'workflow-owner', workspaceId: 'workspace-1' }, + byRowId: {}, + }) + mocks.createWorkflowCellProgressWriter.mockReturnValue({ + onBlockStart: vi.fn(), + onBlockComplete: vi.fn(), + finish: vi.fn(), + getEventOutputs: () => ({}), + getPendingDataPatch: () => ({}), + getBlockErrors: () => ({}), + getPendingSecretProvenance: () => undefined, + }) + mocks.preprocessExecution.mockResolvedValue({ + success: true, + actorUserId: 'workspace-billing-owner', + actorSubscription: null, + billingAttribution: BILLING, + }) + mocks.executeWorkflow.mockResolvedValue({ success: true, status: 'completed', output: {} }) + /** The workflow record read. */ + dbChainMockFns.limit.mockResolvedValue([ + { + id: 'workflow-1', + userId: 'workflow-owner', + workspaceId: 'workspace-1', + variables: {}, + }, + ]) + }) + + /** + * The gate and the meter are different people on a workspace-key run. Gating + * on the billing owner applies a bystander's denylist and skips the + * requester's — the exact defect the enrichment half of this worker was fixed + * for. + */ + it('gates on the governed subject while still billing the attributed actor', async () => { + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.executeWorkflow).toHaveBeenCalledTimes(1) + const [workflow, , , actorUserId, options] = mocks.executeWorkflow.mock.calls[0] + expect(options.capabilityGovernedUserId).toBe('requesting-member') + // Untouched: billing actor, credential/env subject, and payer snapshot. + expect(actorUserId).toBe('workspace-billing-owner') + expect(workflow.userId).toBe('workflow-owner') + expect(options.billingAttribution).toBe(BILLING) + }, 20_000) + + it('declares an explicit null for an actorless auto-fire', async () => { + await runRowCascadeLoop({ ...PAYLOAD, capabilityGovernedUserId: null }) + + const [, , , , options] = mocks.executeWorkflow.mock.calls[0] + expect(options.capabilityGovernedUserId).toBeNull() + }, 20_000) + + /** The subject has to survive the pause — nothing downstream can re-derive it. */ + it('stashes the governed subject with the pause context', async () => { + mocks.executeWorkflow.mockResolvedValue({ success: true, status: 'paused', output: {} }) + + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.stashCellContextForResume).toHaveBeenCalledWith( + expect.objectContaining({ + executionId: 'execution-1', + groupId: 'group-1', + capabilityGovernedUserId: 'requesting-member', + }) + ) + }, 20_000) + + /** + * Account deletion terminalizes the departing person's still-unstarted + * markers with the canonical cancel. This is the guard that makes that stick: + * the sibling dispatch that would otherwise drain the marker ungated reads + * the cell's own state before running anything. + */ + it('refuses a marker another path terminalized before pickup', async () => { + mocks.getRowById.mockResolvedValue({ + id: 'row-1', + data: {}, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + executions: { + 'group-1': { + status: 'cancelled', + executionId: null, + jobId: null, + workflowId: 'workflow-1', + error: 'Cancelled', + cancelledAt: '2026-08-28T00:00:00.000Z', + }, + }, + }) + + await runRowCascadeLoop(PAYLOAD) + + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + expect(mocks.markWorkflowGroupPickedUp).not.toHaveBeenCalled() + }, 20_000) +}) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 93352103f8d..4f66aa493f6 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -30,9 +30,14 @@ import { formatDate } from '@sim/utils/formatting' import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { isAccessControlAllowlistRow } from '@/lib/permission-groups/block-access' +import { + isFeatureInertForGroup, + ORGANIZATION_SCOPED_FEATURE_NOTE, + PLATFORM_CATEGORY_ORDER, + PLATFORM_FEATURES, +} from '@/lib/permission-groups/features' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { groupSearchParam, @@ -54,6 +59,7 @@ import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/ import { getAllBlocks } from '@/blocks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import type { BlockConfig } from '@/blocks/types' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { type PermissionGroup, @@ -64,6 +70,11 @@ import { useRemovePermissionGroupMember, useUpdatePermissionGroup, } from '@/ee/access-control/hooks/permission-groups' +import { + allowlistRowsFromStored, + toggleAllowlistRow, + withAllowlistRows, +} from '@/ee/access-control/utils/integration-allowlist-rows' import { SettingRow } from '@/ee/components/setting-row' import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' import { useOrganizationRoster } from '@/hooks/queries/organization' @@ -106,8 +117,23 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION (o) => o.value ) +/** + * Knowledge base connectors an admin can allow or disallow. `null` config = all + * allowed. Sorted by display name because the picker is read alphabetically, + * while the registry is keyed by the snake_case id the server stores. Reads + * {@link CONNECTOR_META_REGISTRY}, the client-safe metadata half of the + * connector split. + */ +const KNOWLEDGE_CONNECTOR_OPTIONS: { value: string; label: string }[] = Object.values( + CONNECTOR_META_REGISTRY +) + .map((meta) => ({ value: meta.id, label: meta.name })) + .sort((a, b) => a.label.localeCompare(b.label)) + type StatusFilter = 'all' | 'enabled' | 'disabled' +const ALL_KNOWLEDGE_CONNECTORS: string[] = KNOWLEDGE_CONNECTOR_OPTIONS.map((o) => o.value) + const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ { value: 'all', label: 'Show all' }, { value: 'enabled', label: 'Show enabled' }, @@ -136,21 +162,21 @@ function StatusFilterChip({ value, onChange }: StatusFilterChipProps) { ) } -interface AuthModeFieldProps { +interface AllowlistFieldProps { label: string - value: ShareAuthType[] + value: string[] onChange: (values: string[]) => void - options: { value: ShareAuthType; label: string }[] + options: { value: string; label: string }[] disabled: boolean } /** - * The allowed-auth-modes multi-select nested under a platform toggle. Dims and + * The allowed-values multi-select nested under a platform toggle. Dims and * disables together with the toggle that owns it. The left padding lines both * children up with the parent's label text — row gutter (8) + checkbox (16) + * gap (8) = 32 — so the field reads as subordinate rather than as a sibling row. */ -function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFieldProps) { +function AllowlistField({ label, value, onChange, options, disabled }: AllowlistFieldProps) { const labelId = useId() const triggerId = useId() return ( @@ -169,6 +195,9 @@ function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFi onChange={onChange} options={options} disabled={disabled} + // An empty allow-list denies every option, so the multi-select's default + // empty label — 'All' — states the opposite of what the server enforces. + allLabel='None allowed' matchTriggerWidth={false} className='w-[200px]' /> @@ -770,9 +799,15 @@ export function GroupDetail({ * otherwise a null→partial transition by a non-revealed admin would silently * drop a preview block from the stored allowlist and deny it to revealed * users already running it. + * + * EXCLUDES superseded blocks. They are hidden and so are never rendered, but + * they used to be materialized into the allowlist all the same — so an admin + * narrowing a previously-unrestricted allowlist by unchecking `slack_v2` wrote + * `slack` into it, which the runtime resolves back to `slack_v2` and allows. + * A decision about a retired version is made on its successor's row. */ const allBlocks = useMemo(() => { - const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type)) + const blocks = getAllBlocks().filter((b) => isAccessControlAllowlistRow(b.type)) return blocks.sort((a, b) => { const catA = BLOCK_CATEGORY_ORDER[a.category] ?? 3 const catB = BLOCK_CATEGORY_ORDER[b.category] ?? 3 @@ -862,17 +897,16 @@ export function GroupDetail({ const guard = useSettingsUnsavedGuard({ isDirty: hasChanges }) + const allBlockTypes = useMemo(() => allBlocks.map((b) => b.type), [allBlocks]) + /** * `null` means "everything allowed". Indexing the allow-lists once keeps the * per-row membership checks O(1) — they run for every one of the ~200 block * rows on each render, and again in the section-wide `every(...)` scans. */ const allowedIntegrationSet = useMemo( - () => - editingConfig.allowedIntegrations === null - ? null - : new Set(editingConfig.allowedIntegrations), - [editingConfig.allowedIntegrations] + () => allowlistRowsFromStored(allBlockTypes, editingConfig.allowedIntegrations), + [allBlockTypes, editingConfig.allowedIntegrations] ) const allowedProviderSet = useMemo( @@ -975,17 +1009,7 @@ export function GroupDetail({ const toggleIntegration = useCallback( (blockType: string) => { setEditingConfig((prev) => { - const current = prev.allowedIntegrations - let nextAllowed: string[] | null - if (current === null) { - nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType) - } else if (current.includes(blockType)) { - const updated = current.filter((t) => t !== blockType) - nextAllowed = updated.length === allBlocks.length ? null : updated - } else { - const updated = [...current, blockType] - nextAllowed = updated.length === allBlocks.length ? null : updated - } + const nextAllowed = toggleAllowlistRow(allBlockTypes, prev.allowedIntegrations, blockType) return { ...prev, allowedIntegrations: nextAllowed, @@ -993,22 +1017,19 @@ export function GroupDetail({ } }) }, - [allBlocks, pruneDeniedTools] + [allBlockTypes, pruneDeniedTools] ) /** Allow or deny a whole section's blocks at once, respecting the active filter. */ const setBlocksAllowed = useCallback( (blocks: BlockConfig[], allowed: boolean) => { setEditingConfig((prev) => { - const allTypes = allBlocks.map((b) => b.type) - const current = - prev.allowedIntegrations === null ? new Set(allTypes) : new Set(prev.allowedIntegrations) - for (const block of blocks) { - if (allowed) current.add(block.type) - else current.delete(block.type) - } - const nextArr = allTypes.filter((t) => current.has(t)) - const nextAllowed = nextArr.length === allTypes.length ? null : nextArr + const nextAllowed = withAllowlistRows( + allBlockTypes, + prev.allowedIntegrations, + blocks.map((block) => block.type), + allowed + ) return { ...prev, allowedIntegrations: nextAllowed, @@ -1016,7 +1037,7 @@ export function GroupDetail({ } }) }, - [allBlocks, pruneDeniedTools] + [allBlockTypes, pruneDeniedTools] ) const isToolAllowed = useCallback( @@ -1191,13 +1212,32 @@ export function GroupDetail({ })) }, []) + const knowledgeConnectorValue = useMemo( + () => editingConfig.allowedKnowledgeConnectors ?? ALL_KNOWLEDGE_CONNECTORS, + [editingConfig.allowedKnowledgeConnectors] + ) + + /** + * At least one connector must stay allowed while the Knowledge Base module is + * visible — an empty allow-list would silently block every connector while + * the add-connector button still offered them. To withhold connectors along + * with the rest of the module, uncheck Knowledge Base instead. + */ + const setKnowledgeConnectors = useCallback((values: string[]) => { + if (values.length === 0) return + setEditingConfig((prev) => ({ + ...prev, + allowedKnowledgeConnectors: values.length === ALL_KNOWLEDGE_CONNECTORS.length ? null : values, + })) + }, []) + /** * Nested controls rendered under a platform feature's checkbox, keyed by * feature id. Kept out of `PLATFORM_FEATURES` so that array stays pure data. */ const featureExtras: Partial> = { 'hide-deploy-chatbot': ( - ), 'disable-public-file-sharing': ( - ), + /** + * Nested under Knowledge Base rather than Knowledge Base Creation: a + * connector attaches to an existing knowledge base, so the allow-list still + * governs a group that may sync but never create. Hanging it off creation + * would dim the picker for exactly the group it was written for. + */ + 'hide-knowledge-base': ( + + ), } /** Persists the editing buffer — name/description are only sent when they changed. */ @@ -1389,7 +1444,20 @@ export function GroupDetail({ const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id)) const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type)) const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type)) - const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey]) + /** + * Rows this group cannot decide: an organization-scoped key is read from the + * organization's *default* group, so setting it on any other group changes + * nothing. They render inert, and every bulk action skips them — "Select All" + * writing a value the server would never read is the same false promise as + * the checkbox itself. + */ + const editablePlatformFeatures = useMemo( + () => + filteredPlatformFeatures.filter((f) => !isFeatureInertForGroup(f, viewingGroup.isDefault)), + [filteredPlatformFeatures, viewingGroup.isDefault] + ) + + const platformAllAllowed = editablePlatformFeatures.every((f) => !editingConfig[f.configKey]) return ( <> @@ -1727,13 +1795,13 @@ export function GroupDetail({ setEditingConfig((prev) => ({ ...prev, ...Object.fromEntries( - filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible]) + editablePlatformFeatures.map((f) => [f.configKey, platformAllAllowed]) ), })) } - disabled={filteredPlatformFeatures.length === 0} + disabled={editablePlatformFeatures.length === 0} > - {platformAllVisible ? 'Deselect All' : 'Select All'} + {platformAllAllowed ? 'Deselect All' : 'Select All'}
{platformCategorySections.length === 0 && ( @@ -1744,32 +1812,46 @@ export function GroupDetail({ {platformCategorySections.map(({ category, features }) => (
- {features.map((feature) => ( -
-
- - - {feature.hint} - + {features.map((feature) => { + const inert = isFeatureInertForGroup(feature, viewingGroup.isDefault) + return ( +
+
+ + + {inert + ? `${feature.hint} ${ORGANIZATION_SCOPED_FEATURE_NOTE}` + : feature.hint} + +
+ {featureExtras[feature.id]}
- {featureExtras[feature.id]} -
- ))} + ) + })}
))} diff --git a/apps/sim/ee/access-control/hooks/permission-groups.test.tsx b/apps/sim/ee/access-control/hooks/permission-groups.test.tsx new file mode 100644 index 00000000000..aac6df3f7cd --- /dev/null +++ b/apps/sim/ee/access-control/hooks/permission-groups.test.tsx @@ -0,0 +1,193 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +import { ApiClientError } from '@/lib/api/client/errors' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' + +const WORKSPACE_ID = 'ws-1' + +const CONFIG_RESPONSE = { + entitled: true, + permissionGroupId: null, + config: null, +} + +/** + * Mounts the hook in a real React root under a real `QueryClientProvider`, the + * way `hooks/queries/unsubscribe.test.tsx` does (the repo has no + * `@testing-library/react`). + * + * The client deliberately overrides only `retryDelay`, so the hook's own + * `retry` and `retryOnMount` are the options under test rather than the + * harness's. `gcTime: Infinity` keeps the failed query in the cache across the + * unmount/remount that `retryOnMount` is about. + */ +function makeHarness() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retryDelay: 0, gcTime: Number.POSITIVE_INFINITY }, + mutations: { retry: false }, + }, + }) + + function mount(useHook: () => T) { + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest: T + + function Probe() { + latest = useHook() + return null + } + + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + + act(() => { + root.render( + + + + ) + }) + + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } + } + + return { queryClient, mount } +} + +/** Drives React and the query observer until `predicate` holds, or gives up. */ +async function settle(predicate: () => boolean) { + for (let attempt = 0; attempt < 200; attempt++) { + if (predicate()) return + await act(async () => { + await Promise.resolve() + await sleep(0) + }) + } +} + +function serverError() { + return new ApiClientError({ status: 500, message: 'boom' }) +} + +function refusal() { + return new ApiClientError({ status: 403, message: 'Forbidden' }) +} + +/** + * Consumers of this query fail CLOSED — the API-keys page withholds the create + * button until the read succeeds. The app's own query defaults (`retry: 1`, + * `retryOnMount: false`, no focus refetch on the web) would leave one transient + * failure disabling that button for the rest of the session, so the retry + * policy is the thing that keeps a fail-closed gate from wedging. + */ +describe('useUserPermissionConfig retry policy', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('retries a transient failure three times before giving up', async () => { + mockRequestJson.mockRejectedValue(serverError()) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isError) + + expect(result().isError).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(4) + + unmount() + }) + + it('recovers when a retry succeeds, so the gate is never left unanswered', async () => { + mockRequestJson + .mockRejectedValueOnce(serverError()) + .mockResolvedValueOnce(structuredClone(CONFIG_RESPONSE)) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isSuccess) + + expect(result().isSuccess).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(2) + + unmount() + }) + + it('does not retry a refusal, which asking again cannot change', async () => { + mockRequestJson.mockRejectedValue(refusal()) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isError) + + expect(result().isError).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(1) + + unmount() + }) + + /** + * `requestJson` raises an `ApiClientError` carrying the response status when a + * 2xx body fails contract validation, so the failure arrives as a `200`. + * Asking again produces the same body; a "not a 4xx" test sent four. + */ + it('does not retry a contract-validation failure, which arrives as a 200', async () => { + mockRequestJson.mockRejectedValue( + new ApiClientError({ status: 200, message: 'Invalid response' }) + ) + + const { mount } = makeHarness() + const { result, unmount } = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => result().isError) + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + + unmount() + }) + + it('retries again on remount, so reopening settings is a real retry', async () => { + mockRequestJson.mockRejectedValue(refusal()) + + const { mount } = makeHarness() + const first = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => first.result().isError) + expect(mockRequestJson).toHaveBeenCalledTimes(1) + first.unmount() + + mockRequestJson.mockReset() + mockRequestJson.mockResolvedValue(structuredClone(CONFIG_RESPONSE)) + + const second = mount(() => useUserPermissionConfig(WORKSPACE_ID)) + await settle(() => second.result().isSuccess) + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + expect(second.result().isSuccess).toBe(true) + + second.unmount() + }) +}) diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index 0f1b7f68ce9..cef9d86278e 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -1,9 +1,12 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { + type BulkAddPermissionGroupMembersBody, bulkAddPermissionGroupMembersContract, + type CreatePermissionGroupBody, createPermissionGroupContract, deletePermissionGroupContract, getUserPermissionConfigContract, @@ -13,11 +16,12 @@ import { type PermissionGroup, type PermissionGroupMember, type PermissionGroupWorkspaceRef, + type RemovePermissionGroupMemberQuery, removePermissionGroupMemberContract, + type UpdatePermissionGroupBody, type UserPermissionConfig, updatePermissionGroupContract, } from '@/lib/api/contracts' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' export const PERMISSION_GROUP_MEMBERS_STALE_TIME = 30 * 1000 export const PERMISSION_GROUPS_STALE_TIME = 60 * 1000 @@ -93,6 +97,38 @@ export function useOrganizationWorkspaces(organizationId?: string, enabled = tru }) } +/** + * How many times a failed policy read is retried before the UI is left with no + * answer, and the app default this raises it from. + * + * Consumers of this query fail CLOSED — the API-keys page withholds the create + * button until the read succeeds, because offering a key type the server would + * refuse is the only failure worth avoiding. That makes an unanswered question + * a withheld capability, and the client's default query options give it no way + * back: `retry: 1` on the web, `retryOnMount: false`, and `refetchOnWindowFocus` + * off outside the desktop app. One transient failure would otherwise disable + * the button for the rest of the session with nothing to say why. + * + * The read is a small, idempotent, cacheable GET, so retrying it is close to + * free — cheap enough to justify self-healing rather than a page reload. + */ +const USER_PERMISSION_CONFIG_RETRIES = 3 + +/** + * A refusal will not heal by asking again: the caller's session or membership + * is what the server disagrees with, and three more requests spend latency to + * arrive at the same 4xx. Only a transport failure or a 5xx is worth a retry. + * + * Stated as "a 5xx and nothing else" rather than "not a 4xx". `requestJson` + * raises an `ApiClientError` carrying the response status for a body that fails + * contract validation too, and that status is a `200` — deterministic, and + * outside the 4xx band the narrower test would have let through. + */ +function retryUserPermissionConfig(failureCount: number, error: Error): boolean { + if (isApiClientError(error) && (error.status < 500 || error.status >= 600)) return false + return failureCount < USER_PERMISSION_CONFIG_RETRIES +} + export function useUserPermissionConfig(workspaceId?: string) { return useQuery({ queryKey: permissionGroupKeys.userConfig(workspaceId), @@ -105,23 +141,24 @@ export function useUserPermissionConfig(workspaceId?: string) { }, enabled: Boolean(workspaceId), staleTime: PERMISSION_GROUPS_STALE_TIME, + retry: retryUserPermissionConfig, + /** + * The self-heal. Without it a query left in error stays there for the life + * of the browser session, because nothing else remounts it back to life: + * `refetchOnWindowFocus` is off on the web, and the settings modal + * unmounting and reopening is exactly the moment a user retries by hand. + */ + retryOnMount: true, }) } -export interface CreatePermissionGroupData { - organizationId: string - name: string - description?: string - config?: Partial - isDefault?: boolean - workspaceIds?: string[] -} +type CreatePermissionGroupVariables = CreatePermissionGroupBody & { organizationId: string } export function useCreatePermissionGroup() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ organizationId, ...data }: CreatePermissionGroupData) => { + mutationFn: async ({ organizationId, ...data }: CreatePermissionGroupVariables) => { return requestJson(createPermissionGroupContract, { params: { id: organizationId }, body: data, @@ -135,21 +172,16 @@ export function useCreatePermissionGroup() { }) } -export interface UpdatePermissionGroupData { +type UpdatePermissionGroupVariables = UpdatePermissionGroupBody & { id: string organizationId: string - name?: string - description?: string | null - config?: Partial - isDefault?: boolean - workspaceIds?: string[] } export function useUpdatePermissionGroup() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ id, organizationId, ...data }: UpdatePermissionGroupData) => { + mutationFn: async ({ id, organizationId, ...data }: UpdatePermissionGroupVariables) => { return requestJson(updatePermissionGroupContract, { params: { id: organizationId, groupId: id }, body: data, @@ -164,7 +196,7 @@ export function useUpdatePermissionGroup() { }) } -export interface DeletePermissionGroupParams { +interface DeletePermissionGroupVariables { permissionGroupId: string organizationId: string } @@ -173,7 +205,7 @@ export function useDeletePermissionGroup() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ permissionGroupId, organizationId }: DeletePermissionGroupParams) => { + mutationFn: async ({ permissionGroupId, organizationId }: DeletePermissionGroupVariables) => { return requestJson(deletePermissionGroupContract, { params: { id: organizationId, groupId: permissionGroupId }, }) @@ -184,15 +216,16 @@ export function useDeletePermissionGroup() { }) } +type RemovePermissionGroupMemberVariables = RemovePermissionGroupMemberQuery & { + organizationId: string + permissionGroupId: string +} + export function useRemovePermissionGroupMember() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (data: { - organizationId: string - permissionGroupId: string - memberId: string - }) => { + mutationFn: async (data: RemovePermissionGroupMemberVariables) => { return requestJson(removePermissionGroupMemberContract, { params: { id: data.organizationId, groupId: data.permissionGroupId }, query: { memberId: data.memberId }, @@ -204,18 +237,20 @@ export function useRemovePermissionGroupMember() { }) } -export interface BulkAddMembersData { +type BulkAddPermissionGroupMembersVariables = BulkAddPermissionGroupMembersBody & { organizationId: string permissionGroupId: string - userIds?: string[] - addAllOrganizationMembers?: boolean } export function useBulkAddPermissionGroupMembers() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ organizationId, permissionGroupId, ...data }: BulkAddMembersData) => { + mutationFn: async ({ + organizationId, + permissionGroupId, + ...data + }: BulkAddPermissionGroupMembersVariables) => { return requestJson(bulkAddPermissionGroupMembersContract, { params: { id: organizationId, groupId: permissionGroupId }, body: data, diff --git a/apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts b/apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts new file mode 100644 index 00000000000..ec834ae696f --- /dev/null +++ b/apps/sim/ee/access-control/utils/integration-allowlist-rows.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + * + * The universe below is the editor's row set, which excludes superseded blocks + * (`isAccessControlAllowlistRow`). Every id is a real one, so the assertions + * rest on the repository's own lifecycle facts: `slack` was replaced by + * `slack_v2`. + */ +import { describe, expect, it } from 'vitest' +import { + allowlistRowsFromStored, + toggleAllowlistRow, + withAllowlistRows, +} from '@/ee/access-control/utils/integration-allowlist-rows' + +const UNIVERSE = ['agent', 'notion_v2', 'slack_v2'] as const + +describe('allowlistRowsFromStored', () => { + it('keeps an unrestricted allowlist unrestricted', () => { + expect(allowlistRowsFromStored(UNIVERSE, null)).toBeNull() + }) + + /** + * The runtime resolves a stored `slack` to `slack_v2` and allows it, so the + * row has to render checked or the editor is lying about what is permitted. + */ + it('reads a stored retired id as the row it actually governs', () => { + const rows = allowlistRowsFromStored(UNIVERSE, ['slack']) + + expect(rows?.has('slack_v2')).toBe(true) + expect(rows?.has('slack')).toBe(false) + }) + + it('drops an id no row corresponds to', () => { + expect([...(allowlistRowsFromStored(UNIVERSE, ['agent', 'retired_thing']) ?? [])]).toEqual([ + 'agent', + ]) + }) +}) + +describe('toggleAllowlistRow', () => { + /** + * The bug this closes. The editor renders only current blocks, so narrowing a + * previously-unrestricted allowlist used to materialize the hidden `slack` + * alongside the rows — and the runtime resolves `slack` back to `slack_v2`, + * re-allowing the integration the admin had just denied. + */ + it('does not leave a superseded id behind when a row is denied', () => { + const next = toggleAllowlistRow(UNIVERSE, null, 'slack_v2') + + expect(next).toEqual(['agent', 'notion_v2']) + expect(allowlistRowsFromStored(UNIVERSE, next)?.has('slack_v2')).toBe(false) + }) + + /** A stored retired id must follow its successor's fate, not outlive it. */ + it('denies a row a stored retired id was granting', () => { + const next = toggleAllowlistRow(UNIVERSE, ['agent', 'slack'], 'slack_v2') + + expect(next).toEqual(['agent']) + }) + + it('grants a row that was not allowed', () => { + expect(toggleAllowlistRow(UNIVERSE, ['agent'], 'notion_v2')).toEqual(['agent', 'notion_v2']) + }) + + /** Permitting everything is stored as "no restriction", not as a frozen list. */ + it('collapses back to unrestricted when the last row is granted', () => { + expect(toggleAllowlistRow(UNIVERSE, ['agent', 'notion_v2'], 'slack_v2')).toBeNull() + }) +}) + +describe('withAllowlistRows', () => { + it('denies a whole section at once', () => { + expect(withAllowlistRows(UNIVERSE, null, ['notion_v2', 'slack_v2'], false)).toEqual(['agent']) + }) + + it('emits rows in universe order however the section is ordered', () => { + expect(withAllowlistRows(UNIVERSE, [], ['slack_v2', 'agent'], true)).toEqual([ + 'agent', + 'slack_v2', + ]) + }) + + it('collapses to unrestricted when a section grant covers every row', () => { + expect(withAllowlistRows(UNIVERSE, ['agent'], [...UNIVERSE], true)).toBeNull() + }) +}) diff --git a/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts b/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts new file mode 100644 index 00000000000..38b14ea10cd --- /dev/null +++ b/apps/sim/ee/access-control/utils/integration-allowlist-rows.ts @@ -0,0 +1,54 @@ +import { toAccessControlAllowlist } from '@/lib/permission-groups/integration-allowlist' + +/** + * The stored `allowedIntegrations` re-expressed as editor rows: successor- + * resolved the way the runtime resolves it, then projected onto the universe of + * rows the editor actually offers. `null` stays `null` — everything allowed. + * + * A stored list can name a retired id (written before the universe excluded + * superseded blocks, or through the API directly). The runtime resolves it to + * its successor, so a stored `slack` allows `slack_v2`; reading the raw strings + * would render that row unchecked, and toggling it would "enable" an + * integration that was already permitted. Resolving first makes the checkbox + * tell the truth, and the projection drops the stale id on the next write. + */ +export function allowlistRowsFromStored( + universe: readonly string[], + stored: readonly string[] | null +): Set | null { + const resolved = toAccessControlAllowlist(stored) + return resolved === null ? null : new Set(universe.filter((type) => resolved.has(type))) +} + +/** + * The stored allowlist after a set of rows is allowed or denied. + * + * Always emitted in universe order and collapsed back to `null` — unrestricted + * — when every row survives, so a group that ends up permitting everything is + * stored as "no restriction" rather than as a list that silently freezes out + * every integration added later. + */ +export function withAllowlistRows( + universe: readonly string[], + stored: readonly string[] | null, + blockTypes: readonly string[], + allowed: boolean +): string[] | null { + const rows = allowlistRowsFromStored(universe, stored) ?? new Set(universe) + for (const blockType of blockTypes) { + if (allowed) rows.add(blockType) + else rows.delete(blockType) + } + const next = universe.filter((type) => rows.has(type)) + return next.length === universe.length ? null : next +} + +/** {@link withAllowlistRows} for one row, flipping whatever it is now. */ +export function toggleAllowlistRow( + universe: readonly string[], + stored: readonly string[] | null, + blockType: string +): string[] | null { + const rows = allowlistRowsFromStored(universe, stored) + return withAllowlistRows(universe, stored, [blockType], !(rows === null || rows.has(blockType))) +} diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 3a47110594f..02baa8ee855 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -12,44 +12,14 @@ import { import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getBlock } from '@/blocks/registry' -const { - DEFAULT_PERMISSION_GROUP_CONFIG, - mockIsOrganizationOnEnterprisePlan, - mockGetWorkspaceWithOwner, - mockGetProviderFromModel, -} = vi.hoisted(() => ({ - DEFAULT_PERMISSION_GROUP_CONFIG: { - allowedIntegrations: null, - allowedModelProviders: null, - deniedModels: [], - deniedTools: [], - hideTraceSpans: false, - hideKnowledgeBaseTab: false, - hideTablesTab: false, - hideCopilot: false, - hideIntegrationsTab: false, - hideSecretsTab: false, - hideApiKeysTab: false, - hideInboxTab: false, - hideFilesTab: false, - disableMcpTools: false, - disableCustomTools: false, - disableSkills: false, - disableInvitations: false, - disablePublicApi: false, - disablePublicFileSharing: false, - allowedFileShareAuthTypes: null, - hideDeployApi: false, - hideDeployMcp: false, - hideDeployChatbot: false, - allowedChatDeployAuthTypes: null, - }, - mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), - mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), - mockGetProviderFromModel: vi.fn<(model: string) => string>(), -})) - -vi.mock('@/lib/billing', () => ({ +const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner, mockGetProviderFromModel } = + vi.hoisted(() => ({ + mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), + mockGetWorkspaceWithOwner: vi.fn<() => Promise<{ organizationId: string | null } | null>>(), + mockGetProviderFromModel: vi.fn<(model: string) => string>(), + })) + +vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, })) @@ -57,14 +27,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -vi.mock('@/lib/permission-groups/types', () => ({ - DEFAULT_PERMISSION_GROUP_CONFIG, - parsePermissionGroupConfig: (config: unknown) => { - if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG - return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config } - }, -})) - vi.mock('@/providers/utils', () => ({ isFunctionToolCall: (toolCall: unknown) => typeof toolCall === 'object' && @@ -74,23 +36,21 @@ vi.mock('@/providers/utils', () => ({ getProviderFromModel: mockGetProviderFromModel, })) +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' import { assertPermissionsAllowed, - ChatDeployAuthNotAllowedError, CustomToolsNotAllowedError, getUserPermissionConfig, IntegrationNotAllowedError, McpToolsNotAllowedError, ModelNotAllowedError, ProviderNotAllowedError, - PublicFileSharingNotAllowedError, - resolveUserAccessControlContext, resolveVerifiedUserAccessControlContext, SkillsNotAllowedError, ToolNotAllowedError, validateBlockType, validateChatDeployAuth, - validateMcpToolsAllowed, validateModelProvider, validatePublicFileSharing, } from './permission-check' @@ -187,13 +147,18 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled() }) + /** + * The env list is written by hand against whatever ids its author knew, so it + * is canonicalized on the way in: `slack` and `slack_v2` are the same policy, + * and the merged config carries the id every gate resolves a block type to. + */ it('still applies the env allowlist on a no-org workspace', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null }) mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) const config = await getUserPermissionConfig('user-123', 'workspace-1') - expect(config?.allowedIntegrations).toEqual(['slack']) + expect(config?.allowedIntegrations).toEqual(['slack_v2']) }) it('returns null when the organization is not on an enterprise plan', async () => { @@ -231,27 +196,37 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { }) }) -describe('resolveUserAccessControlContext', () => { +describe('access control context resolution', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) }) - it('describes a personal workspace without changing the config-only result', async () => { + it('loads the workspace to find its organization, archived workspaces included', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null }) - await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ - organizationId: null, - entitled: false, - permissionGroup: null, - config: null, - }) await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull() + expect(mockGetWorkspaceWithOwner).toHaveBeenCalledWith('workspace-1', { + includeArchived: true, + }) + expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled() }) - it('returns the explicit governing group and its effective config', async () => { + it('resolves the group through the organization the workspace lookup returned', async () => { setEnterpriseOrgWorkspace() + queueGroupResolution([ + { id: 'g', config: { disableMcpTools: true }, isMember: true, hasMembers: true }, + ]) + + await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toMatchObject({ + disableMcpTools: true, + }) + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-1', 'throw') + }) + + it('returns the explicit governing group and its effective config', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) queueGroupResolution([ { id: 'group-explicit', @@ -262,7 +237,9 @@ describe('resolveUserAccessControlContext', () => { }, ]) - await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + await expect( + resolveVerifiedUserAccessControlContext('user-123', 'workspace-1', 'org-1') + ).resolves.toEqual({ organizationId: 'org-1', entitled: true, permissionGroup: { @@ -274,8 +251,19 @@ describe('resolveUserAccessControlContext', () => { }) }) + it('describes a personal workspace as unentitled and ungoverned', async () => { + await expect( + resolveVerifiedUserAccessControlContext('user-123', 'workspace-1', null) + ).resolves.toEqual({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + }) + it('identifies an all-members governing group', async () => { - setEnterpriseOrgWorkspace() + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) queueGroupResolution([ { id: 'group-all-members', @@ -286,7 +274,11 @@ describe('resolveUserAccessControlContext', () => { }, ]) - const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-1' + ) expect(context.permissionGroup).toEqual({ id: 'group-all-members', @@ -314,7 +306,7 @@ describe('resolveUserAccessControlContext', () => { ) expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() - expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified') + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified', 'throw') expect(context).toMatchObject({ organizationId: 'org-verified', entitled: true, @@ -326,8 +318,14 @@ describe('resolveUserAccessControlContext', () => { }) }) + /** + * The group and the deployment name the same integrations by different + * vintages — the editor only offers current ids, `ALLOWED_INTEGRATIONS` is + * hand-written. Intersecting them textually left Slack out of a policy both + * layers permit, so both sides are successor-resolved first. + */ it('identifies the default group and preserves the environment allowlist', async () => { - setEnterpriseOrgWorkspace() + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) queueGroupResolution( [], @@ -335,19 +333,23 @@ describe('resolveUserAccessControlContext', () => { { id: 'group-default', name: 'Organization default', - config: { allowedIntegrations: ['slack', 'github'] }, + config: { allowedIntegrations: ['slack_v2', 'github'] }, }, ] ) - const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-1' + ) expect(context.permissionGroup).toEqual({ id: 'group-default', name: 'Organization default', resolution: 'default', }) - expect(context.config?.allowedIntegrations).toEqual(['slack']) + expect(context.config?.allowedIntegrations).toEqual(['slack_v2']) }) }) @@ -454,6 +456,26 @@ describe('validateBlockType', () => { await validateBlockType('user-123', 'workspace-1', 'slack') }) + /** + * Registry keys are lowercase, so a mixed-case block type must be folded + * *before* the successor lookup. Resolving first makes `getBlock('Slack')` + * miss, the successor answer `Slack`, and the comparison fall back to + * `slack` — refusing a block the allowlist permits as `slack_v2`. + */ + it('resolves a superseded block supplied with different casing', async () => { + setEnterpriseOrgWorkspace() + mockGetBlock.mockImplementation((type: string) => + type === 'slack' + ? { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } } + : type === 'slack_v2' + ? {} + : undefined + ) + queueGroupResolution([{ config: { allowedIntegrations: ['slack_v2'] } }]) + + await validateBlockType('user-123', 'workspace-1', 'Slack') + }) + it('still rejects a block absent from a mixed-case stored allowlist', async () => { setEnterpriseOrgWorkspace() queueGroupResolution([{ config: { allowedIntegrations: ['Slack'] } }]) @@ -489,12 +511,17 @@ describe('validateBlockType', () => { await validateBlockType(undefined, undefined, 'start_trigger') }) + /** + * `thinking` is a real retired block with no successor: it has no editor row + * and nothing to be permitted *as*, so it is exempt. A retired block that + * does have one — `notion` — is judged as `notion_v2` instead and is not. + */ it('always allows legacy blocks hidden from the toolbar', async () => { mockGetBlock.mockImplementation((type) => - type === 'notion' ? { hideFromToolbar: true } : undefined + type === 'thinking' ? { hideFromToolbar: true } : undefined ) - await validateBlockType(undefined, undefined, 'notion') + await validateBlockType(undefined, undefined, 'thinking') }) it('does NOT treat preview blocks as exempt — preview is not legacy', async () => { @@ -602,7 +629,7 @@ describe('validateModelProvider', () => { }) }) -describe('validateMcpToolsAllowed', () => { +describe('assertPermissionsAllowed (MCP tools)', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -613,15 +640,19 @@ describe('validateMcpToolsAllowed', () => { it('throws McpToolsNotAllowedError when disableMcpTools is set', async () => { queueGroupResolution([{ config: { disableMcpTools: true } }]) - await expect(validateMcpToolsAllowed('user-123', 'workspace-1')).rejects.toBeInstanceOf( - McpToolsNotAllowedError - ) + await expect( + assertPermissionsAllowed({ userId: 'user-123', workspaceId: 'workspace-1', toolKind: 'mcp' }) + ).rejects.toBeInstanceOf(McpToolsNotAllowedError) }) it('no-ops when disableMcpTools is false', async () => { queueGroupResolution([{ config: {} }]) - await validateMcpToolsAllowed('user-123', 'workspace-1') + await assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolKind: 'mcp', + }) }) }) @@ -637,14 +668,14 @@ describe('validatePublicFileSharing', () => { queueGroupResolution([{ config: { disablePublicFileSharing: true } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'password') - ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) it('throws when the auth type is not in the allow-list', async () => { queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]) await expect( validatePublicFileSharing('user-123', 'workspace-1', 'public') - ).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) it('allows an auth type that is in the allow-list', async () => { @@ -661,6 +692,17 @@ describe('validatePublicFileSharing', () => { queueGroupResolution([{ config: { allowedFileShareAuthTypes: ['password'] } }]) await validatePublicFileSharing('user-123', 'workspace-1') }) + + it('resolves the group once per request scope, not once per assertion', async () => { + queueGroupResolution([{ config: { allowedFileShareAuthTypes: null } }]) + + await withPermissionGroupScope(async () => { + await validatePublicFileSharing('user-123', 'workspace-1', 'password') + await validatePublicFileSharing('user-123', 'workspace-1', 'email') + }) + + expect(mockGetWorkspaceWithOwner).toHaveBeenCalledTimes(1) + }) }) describe('validateChatDeployAuth', () => { @@ -675,7 +717,7 @@ describe('validateChatDeployAuth', () => { queueGroupResolution([{ config: { allowedChatDeployAuthTypes: ['password', 'sso'] } }]) await expect( validateChatDeployAuth('user-123', 'workspace-1', 'public') - ).rejects.toBeInstanceOf(ChatDeployAuthNotAllowedError) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) }) it('allows an auth type that is in the allow-list', async () => { @@ -743,13 +785,13 @@ describe('assertPermissionsAllowed', () => { it('exempts legacy blocks from the integration allowlist', async () => { queueGroupResolution([{ config: { allowedIntegrations: ['slack'] } }]) mockGetBlock.mockImplementation((type) => - type === 'notion' ? { hideFromToolbar: true } : undefined + type === 'thinking' ? { hideFromToolbar: true } : undefined ) await assertPermissionsAllowed({ userId: 'user-123', workspaceId: 'workspace-1', - blockType: 'notion', + blockType: 'thinking', }) }) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index fdb7c5cfb9e..6a3c189c95c 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -1,28 +1,47 @@ -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 { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + CAPABILITY_RULES, + refuseCapability, + type StaticCapabilityRule, +} from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { - DEFAULT_PERMISSION_GROUP_CONFIG, - type PermissionGroupConfig, - parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' + getUserPermissionConfig, + getUserPermissionConfigForOrganization, + mergeEnvAllowlist, +} from '@/lib/permission-groups/resolve.server' import type { ExecutionContext } from '@/executor/types' import { getProviderFromModel } from '@/providers/utils' +/** + * The permission-group resolution layer lives in `@/lib/permission-groups` + * because ~24 domain `operations.ts` modules reach it through the authorization + * funnel, and none of them may pull in this module's provider, block-registry + * and billing imports. Re-exported here so the surfaces that already read the + * validators from one place keep doing so. + */ +export { + getUserPermissionConfig, + getUserPermissionConfigForOrganization, + type ResolvedPermissionGroup, + resolveVerifiedUserAccessControlContext, + resolveWorkspaceGroup, + type UserAccessControlContext, +} from '@/lib/permission-groups/resolve.server' + const logger = createLogger('PermissionCheck') export class ProviderNotAllowedError extends Error { @@ -94,338 +113,113 @@ export class PublicApiNotAllowedError extends Error { } } -export class PublicFileSharingNotAllowedError extends Error { - constructor() { - super('Public file sharing is not allowed based on your permission group settings') - this.name = 'PublicFileSharingNotAllowedError' - } -} - -export class ChatDeployAuthNotAllowedError extends Error { - constructor() { - super('This chat authentication mode is not allowed based on your permission group settings') - this.name = 'ChatDeployAuthNotAllowedError' - } -} - -/** - * Merges the env allowlist into a permission config. - * - * Returns null only when neither layer restricts anything. Otherwise the group's - * own allowlist is intersected with the env one by - * {@link intersectIntegrationAllowlists}, which case-folds both sides — callers - * compare against a lowercased block type, and a stored config reaches here - * straight off the wire, where the contract permits any casing. - */ -function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null { - const envAllowlist = getAllowedIntegrationsFromEnv() - if (config === null && envAllowlist === null) return null - - const base = config ?? DEFAULT_PERMISSION_GROUP_CONFIG - return { - ...base, - allowedIntegrations: intersectIntegrationAllowlists(base.allowedIntegrations, envAllowlist), - } -} - -/** - * 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 - resolution: 'explicit-member' | 'all-members' | 'default' - config: PermissionGroupConfig -} - -export interface UserAccessControlContext { - organizationId: string | null - entitled: boolean - permissionGroup: { - id: string - name: string - resolution: ResolvedPermissionGroup['resolution'] - } | null - config: PermissionGroupConfig | null -} - -function inactiveUserAccessControlContext(organizationId: string | null): UserAccessControlContext { - return { - organizationId, - entitled: false, - permissionGroup: null, - config: mergeEnvAllowlist(null), - } -} - -/** The organization's single default group (`isDefault`), or `null`. */ -async function resolveDefaultGroup( - organizationId: string -): Promise { - 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, - resolution: 'default', - 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`). + * Refuses a public file share the caller's permission group withholds — the + * master switch, and then — when `authType` is given — the auth mode the share + * would carry. No-op when access control doesn't apply (non-enterprise / + * disabled), so non-governed organizations are unaffected. * - * 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 { - const rows = await db - .select({ - id: permissionGroup.id, - name: permissionGroup.name, - config: permissionGroup.config, - isMember: sql`exists ( - select 1 from ${permissionGroupMember} - where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} - and ${permissionGroupMember.userId} = ${userId} - )`, - hasMembers: sql`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 explicitMemberGroup = rows.find((row) => row.isMember) - const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers) - - if (winner) { - return { - permissionGroupId: winner.id, - groupName: winner.name, - resolution: explicitMemberGroup ? 'explicit-member' : 'all-members', - 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. - */ -async function resolveUserAccessControlContextForOrganization( - userId: string, - workspaceId: string, - organizationId: string | null -): Promise { - if (!organizationId) return inactiveUserAccessControlContext(null) - - const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) - if (!isEnterprise) { - return inactiveUserAccessControlContext(organizationId) - } - - const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId) - return { - organizationId, - entitled: true, - permissionGroup: resolved - ? { - id: resolved.permissionGroupId, - name: resolved.groupName, - resolution: resolved.resolution, - } - : null, - config: mergeEnvAllowlist(resolved?.config ?? null), - } -} - -/** - * Resolves Access Control from an organization ID obtained from an already - * access-checked workspace. This function does not independently authorize the - * user for the workspace; callers must establish that boundary first. - */ -export async function resolveVerifiedUserAccessControlContext( - userId: string, - workspaceId: string, - organizationId: string | null -): Promise { - if (!isHosted && !isAccessControlEnabled) { - return inactiveUserAccessControlContext(null) - } - return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) -} - -export async function resolveUserAccessControlContext( - userId: string, - workspaceId: string -): Promise { - if (!isHosted && !isAccessControlEnabled) { - return inactiveUserAccessControlContext(null) - } - - const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - return resolveUserAccessControlContextForOrganization( - userId, - workspaceId, - workspace?.organizationId ?? null - ) -} - -export async function getUserPermissionConfig( - userId: string, - workspaceId: string -): Promise { - return (await resolveUserAccessControlContext(userId, workspaceId)).config -} - -/** - * 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. + * Kept as one helper because these two rules are always asked together: a share + * is refused if the group withholds sharing at all, or if it sanctions sharing + * but not this way of gating it. */ +/** permission-group-enforced: file_share.publish — asserted where a share is created, not per operation */ +/** permission-group-enforced: file_share.auth_mode — needs the request auth mode, which the funnel never sees */ export async function validatePublicFileSharing( userId: string, workspaceId: string, authType?: ShareAuthType ): Promise { - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config) { return } - if (config.disablePublicFileSharing) { - throw new PublicFileSharingNotAllowedError() + if (CAPABILITY_RULES['file_share.publish'].deniedBy(config)) { + refuseCapability('file_share.publish') } - if ( - authType && - config.allowedFileShareAuthTypes !== null && - !config.allowedFileShareAuthTypes.includes(authType) - ) { + if (authType && CAPABILITY_RULES['file_share.auth_mode'].deniedBy(config, authType)) { logger.warn('File share auth type blocked by permission group', { userId, workspaceId, authType, }) - throw new PublicFileSharingNotAllowedError() + refuseCapability('file_share.auth_mode') } } /** - * Throws {@link ChatDeployAuthNotAllowedError} if the user's effective permission - * group for the workspace doesn't allow the chat deployment's `authType` (i.e. it - * isn't in the group's `allowedChatDeployAuthTypes` allow-list; `null` allows all). + * Refuses a chat deployment auth mode the caller's permission group withholds. * No-op when access control doesn't apply (non-enterprise / disabled), so - * non-governed orgs are unaffected. + * non-governed organizations are unaffected. + * + * Callers ask only when the mode actually changes, so a grandfathered mode + * already saved on a chat survives an edit to some other field. That asymmetry + * belongs to them — it reads the stored deployment, which this never sees. */ +/** permission-group-enforced: deploy.chat.auth_mode — needs the request auth mode, which the funnel never sees */ export async function validateChatDeployAuth( userId: string, workspaceId: string, authType: ShareAuthType ): Promise { - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config) { return } - if ( - config.allowedChatDeployAuthTypes !== null && - !config.allowedChatDeployAuthTypes.includes(authType) - ) { + if (CAPABILITY_RULES['deploy.chat.auth_mode'].deniedBy(config, authType)) { logger.warn('Chat deploy auth type blocked by permission group', { userId, workspaceId, authType, }) - throw new ChatDeployAuthNotAllowedError() + refuseCapability('deploy.chat.auth_mode') } } /** - * 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. + * The person a run's group gates are decided about. + * + * A run's actor and its gate subject are not the same id. `userId` is the + * billing/rate actor and the credential subject, and for a trigger with no + * acting person — a table cell dispatched by a workspace API key — it names the + * workspace's billing owner. Gating on that bystander denies tools nobody meant + * to deny and skips the denylist of whoever actually asked, so a trigger that + * knows its acting person declares it on the run's metadata instead. + * + * `undefined` there means "not declared": the surface has always had exactly + * one person, and the actor stays the subject. A declared `null` is the + * actorless run — no group, hence no group gate. */ -export async function getUserPermissionConfigForOrganization( - organizationId: string -): Promise { - 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) +function governedSubjectUserId( + actorUserId: string | undefined, + ctx: ExecutionContext | undefined +): string | undefined { + const declared = ctx?.metadata?.capabilityGovernedUserId + if (declared === undefined) return actorUserId + return declared ?? undefined } /** * 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. + * + * The subject is resolved HERE rather than by each caller, because the memo is + * keyed by nothing but the context. `validateModelProvider` and + * `validateBlockType` take the actor's id positionally, so a run declaring a + * different gate subject had the first model check fill the cache with the + * BILLING actor's group — and every later `assertPermissionsAllowed`, having + * correctly resolved the governed subject, was handed that stale entry. Doing + * the derivation at the one place the config is loaded makes the memo correct + * by construction: within a run `capabilityGovernedUserId` is fixed, so every + * path resolves and caches the same person. */ async function getPermissionConfig( - userId: string | undefined, + actorUserId: string | undefined, workspaceId: string | undefined, ctx?: ExecutionContext ): Promise { + const userId = governedSubjectUserId(actorUserId, ctx) if (!userId || !workspaceId) { return mergeEnvAllowlist(null) } @@ -456,69 +250,79 @@ function isModelDenied(config: PermissionGroupConfig, model: string): boolean { return config.deniedModels.some((denied) => denied.toLowerCase() === normalized) } -export async function validateModelProvider( - userId: string | undefined, - workspaceId: string | undefined, - model: string, - ctx?: ExecutionContext -): Promise { - if (!userId || !workspaceId) { - return - } - - const config = await getPermissionConfig(userId, workspaceId, ctx) - - if (!config) { - return - } +/** Identifies the caller in a log line; never used for a decision. */ +interface PermissionSubject { + userId: string | undefined + workspaceId: string | undefined +} +/** + * Refuses `model` when the config withholds its provider or names it outright. + * + * Takes a loaded config rather than loading one, so the single-gate entry point + * and {@link assertPermissionsAllowed} share one copy of the decision. Two + * copies is how an allowlist stops matching in one of them. + */ +function assertModelAllowed( + config: PermissionGroupConfig, + model: string, + subject: PermissionSubject +): void { 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, - }) + logger.warn('Model provider blocked by permission group', { ...subject, model, providerId }) throw new ProviderNotAllowedError(providerId, model) } } if (isModelDenied(config, model)) { - logger.warn('Model blocked by permission group', { userId, workspaceId, model }) + logger.warn('Model blocked by permission group', { ...subject, model }) throw new ModelNotAllowedError(model) } } -export async function validateBlockType( - userId: string | undefined, - workspaceId: string | undefined, +/** + * Refuses `blockType` when the config's integration allowlist does not name it. + * + * Shared with {@link assertPermissionsAllowed} for the reason + * {@link assertModelAllowed} is. Callers screen out exempt block types first — + * the exemption also decides whether they need a config at all. + */ +function assertBlockTypeAllowed( + config: PermissionGroupConfig, blockType: string, - ctx?: ExecutionContext -): Promise { - if (isBlockTypeAccessControlExempt(blockType)) { + subject: PermissionSubject +): void { + if (config.allowedIntegrations === null) { return } - const config = - userId && workspaceId - ? await getPermissionConfig(userId, workspaceId, ctx) - : mergeEnvAllowlist(null) - - if (!config || config.allowedIntegrations === null) { - return - } + /** + * A superseded version is judged as its successor, so an allowlist naming the + * current block covers every retired version of the same integration. The + * editor only offers current ids, so without this an admin could not deny a + * legacy block even knowing it existed. + * + * Lowercased *before* resolving, not after: registry keys are lowercase, so + * `getBlock('Slack')` misses and the successor lookup answers `Slack` — which + * then compares as `slack` against an allowlist holding `slack_v2` and + * refuses a block both policies allow. `blockType` reaches here from + * persisted workflow state and from an agent block's `tool.type`, neither of + * which is case-normalized upstream. `toAccessControlAllowlist` normalizes + * the policy side the same way. + */ + const allowlistType = resolveAccessControlBlockType(blockType.toLowerCase()) - if (!config.allowedIntegrations.includes(blockType.toLowerCase())) { - const envAllowlist = getAllowedIntegrationsFromEnv() - const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase()) + if (!toAccessControlAllowlist(config.allowedIntegrations)?.has(allowlistType)) { + const envAllowlist = toAccessControlAllowlist(getAllowedIntegrationsFromEnv()) + const blockedByEnv = envAllowlist !== null && !envAllowlist.has(allowlistType) logger.warn( blockedByEnv ? 'Integration blocked by env allowlist' : 'Integration blocked by permission group', - { userId, workspaceId, blockType } + { ...subject, blockType } ) throw new IntegrationNotAllowedError( blockType, @@ -527,9 +331,10 @@ export async function validateBlockType( } } -export async function validateMcpToolsAllowed( +export async function validateModelProvider( userId: string | undefined, workspaceId: string | undefined, + model: string, ctx?: ExecutionContext ): Promise { if (!userId || !workspaceId) { @@ -537,58 +342,39 @@ export async function validateMcpToolsAllowed( } 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() - } + assertModelAllowed(config, model, { userId: governedSubjectUserId(userId, ctx), workspaceId }) } -export async function validateCustomToolsAllowed( +export async function validateBlockType( userId: string | undefined, workspaceId: string | undefined, + blockType: string, ctx?: ExecutionContext ): Promise { - if (!userId || !workspaceId) { + if (isBlockTypeAccessControlExempt(blockType)) { return } - const config = await getPermissionConfig(userId, workspaceId, ctx) + const config = + userId && workspaceId + ? await getPermissionConfig(userId, workspaceId, ctx) + : mergeEnvAllowlist(null) if (!config) { return } - if (config.disableCustomTools) { - logger.warn('Custom tools blocked by permission group', { userId, workspaceId }) - throw new CustomToolsNotAllowedError() - } + assertBlockTypeAllowed(config, blockType, { + userId: governedSubjectUserId(userId, ctx), + workspaceId, + }) } -export async function validateSkillsAllowed( - userId: string | undefined, - workspaceId: string | undefined, - ctx?: ExecutionContext -): Promise { - 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() - } -} +const INVITATIONS_RULE = CAPABILITY_RULES['invitations.send'] /** * Validates if the user is allowed to send invitations. Pass one of: @@ -598,6 +384,7 @@ export async function validateSkillsAllowed( * user's group in that organization (explicit or the org default) has `disableInvitations`. * - neither — only the global feature flag is checked. */ +/** permission-group-enforced: invitations.send — organization-scoped, so it resolves the default group rather than a workspace one */ export async function validateInvitationsAllowed( userId: string | undefined, scope: string | { workspaceId?: string; organizationId?: string } = {} @@ -615,8 +402,8 @@ export async function validateInvitationsAllowed( typeof scope === 'string' ? { workspaceId: scope, organizationId: undefined } : scope if (workspaceId) { - const config = await getUserPermissionConfig(userId, workspaceId) - if (config?.disableInvitations) { + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) + if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group', { userId, workspaceId }) throw new InvitationsNotAllowedError() } @@ -625,7 +412,7 @@ export async function validateInvitationsAllowed( if (organizationId) { const config = await getUserPermissionConfigForOrganization(organizationId) - if (config?.disableInvitations) { + if (config && INVITATIONS_RULE.deniedBy(config)) { logger.warn('Invitations blocked by permission group (organization-wide)', { userId, organizationId, @@ -640,6 +427,7 @@ export async function validateInvitationsAllowed( * workspace. Also checks the global feature flag. When `workspaceId` is * omitted only the feature-flag check runs (no permission-group gate). */ +/** permission-group-enforced: public_api.use — gates the public execution surface, which has no workspace operation */ export async function validatePublicApiAllowed( userId: string | undefined, workspaceId?: string @@ -653,19 +441,50 @@ export async function validatePublicApiAllowed( return } - const config = await getUserPermissionConfig(userId, workspaceId) + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) if (!config) { return } - if (config.disablePublicApi) { + if (CAPABILITY_RULES['public_api.use'].deniedBy(config)) { logger.warn('Public API blocked by permission group', { userId, workspaceId }) throw new PublicApiNotAllowedError() } } -export type ToolKind = 'mcp' | 'custom' | 'skill' +type ToolKind = 'mcp' | 'custom' | 'skill' + +/** + * What each tool kind is gated on. The decision reads + * {@link CAPABILITY_RULES}, so a renamed config key breaks the build here + * rather than silently ceasing to deny anything. + * + * These keep their own error classes rather than raising the funnel's + * capability refusal: they surface inside a run, where the executor reports + * them as the failing block's error, and `lib/mcp` branches on + * {@link McpToolsNotAllowedError} by identity. + */ +const TOOL_KIND_GATES = { + mcp: { + rule: CAPABILITY_RULES['mcp_tools.use'], + error: McpToolsNotAllowedError, + blocked: 'MCP tools blocked by permission group', + }, + custom: { + rule: CAPABILITY_RULES['custom_tools.use'], + error: CustomToolsNotAllowedError, + blocked: 'Custom tools blocked by permission group', + }, + skill: { + rule: CAPABILITY_RULES['skills.use'], + error: SkillsNotAllowedError, + blocked: 'Skills blocked by permission group', + }, +} as const satisfies Record< + ToolKind, + { rule: StaticCapabilityRule; error: new () => Error; blocked: string } +> interface PermissionAssertion { userId: string | undefined @@ -685,15 +504,20 @@ interface PermissionAssertion { /** * 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 + * (model provider, block type, tool id, tool kind) against it, throwing the * 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. + * This decides what a *run* may do, which is not what the authorization funnel + * decides: the funnel refuses an operation up front, while a run reaches here + * once per block, model and tool it actually touches, and a deployed workflow + * with no acting user has no group for the funnel to consult at all. */ +/** permission-group-enforced: mcp_tools.use — gates tool invocation during a run, not an operation */ +/** permission-group-enforced: custom_tools.use — gates tool invocation during a run, not an operation */ +/** permission-group-enforced: skills.use — gates skill loading during a run, not an operation */ export async function assertPermissionsAllowed(req: PermissionAssertion): Promise { - const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req + const { workspaceId, model, blockType, toolId, toolKind, ctx } = req + const userId = governedSubjectUserId(req.userId, ctx) const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false @@ -706,44 +530,14 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis ? 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) - } - } + const subject = { userId, workspaceId } - if (isModelDenied(config, model)) { - logger.warn('Model blocked by permission group', { userId, workspaceId, model }) - throw new ModelNotAllowedError(model) - } + if (model && config) { + assertModelAllowed(config, model, subject) } - 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 (blockType && !blockTypeExempt && config) { + assertBlockTypeAllowed(config, blockType, subject) } if (toolId && !createToolAccessGate(config?.deniedTools)(toolId)) { @@ -752,17 +546,10 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis } 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() + const gate = TOOL_KIND_GATES[toolKind] + if (gate.rule.deniedBy(config)) { + logger.warn(gate.blocked, { userId, workspaceId }) + throw new gate.error() } } } diff --git a/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts new file mode 100644 index 00000000000..5c1cfdfb3bb --- /dev/null +++ b/apps/sim/ee/access-control/utils/permission-gate-subject.test.ts @@ -0,0 +1,166 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, + getUserPermissionConfigForOrganization: vi.fn(), + mergeEnvAllowlist: (config: unknown) => config, + resolveVerifiedUserAccessControlContext: vi.fn(), + resolveWorkspaceGroup: vi.fn(), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn() })) +vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: () => false, + getProviderFromModel: () => 'openai', +})) + +import type { ExecutionContext } from '@/executor/types' +import { + assertPermissionsAllowed, + ToolNotAllowedError, + validateModelProvider, +} from './permission-check' + +/** + * A run's own metadata carries its gate subject. Only a trigger whose acting + * person differs from the one it bills declares one; everything else omits the + * field and keeps gating on the caller. + */ +function runDeclaring(capabilityGovernedUserId?: string | null): ExecutionContext { + return { metadata: { capabilityGovernedUserId } } as unknown as ExecutionContext +} + +describe('the subject a run’s permission gate is decided about', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ deniedTools: ['exa_search'] }) + }) + + /** + * A table cell dispatched by a workspace API key attributes to the + * workspace's billing owner. Loading that bystander's group runs a denylist + * nobody asked for and skips the one belonging to whoever actually asked. + */ + it('resolves the declared subject’s group, not the billing actor’s', async () => { + await expect( + assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx: runDeclaring('requesting-member'), + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('requesting-member', 'workspace-1') + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalledWith( + 'workspace-billing-owner', + 'workspace-1' + ) + }) + + /** A declared `null` is the actorless run: there is no group to consult. */ + it('consults no group when the run declares no acting person', async () => { + await assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx: runDeclaring(null), + }) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + }) + + /** Every surface with exactly one person declares nothing and is unchanged. */ + it('keeps gating on the caller when the run declares nothing', async () => { + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx: runDeclaring(), + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('user-123', 'workspace-1') + }) + + it('keeps gating on the caller when there is no run context at all', async () => { + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'exa_search', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledWith('user-123', 'workspace-1') + }) +}) + +/** + * The run-scoped memo on `ExecutionContext` is keyed by nothing but the + * context, so whichever check loads first decides whose group every later check + * on that run reads. `validateModelProvider` takes the actor positionally, and + * the agent handler calls it before the skill gate — so a delegated run whose + * gate subject differs from its billing actor had the model check cache the + * bystander's group and hand it to `assertPermissionsAllowed`, which had + * correctly resolved the governed subject and then never used it. + */ +describe('the group a run’s later gates read from its cache', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedModelProviders: ['openai'], + deniedTools: ['exa_search'], + }) + }) + + it('is the governed subject’s, even when a model check loaded it first', async () => { + const ctx = runDeclaring('requesting-member') + + await validateModelProvider('workspace-billing-owner', 'workspace-1', 'gpt-4', ctx) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledExactlyOnceWith( + 'requesting-member', + 'workspace-1' + ) + + await expect( + assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx, + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + + expect(mocks.getUserPermissionConfig).toHaveBeenCalledExactlyOnceWith( + 'requesting-member', + 'workspace-1' + ) + }) + + /** An actorless run consults no group, whichever check runs first. */ + it('is nobody’s when the run declares no acting person', async () => { + const ctx = runDeclaring(null) + + await validateModelProvider('workspace-billing-owner', 'workspace-1', 'gpt-4', ctx) + await assertPermissionsAllowed({ + userId: 'workspace-billing-owner', + workspaceId: 'workspace-1', + toolId: 'exa_search', + ctx, + }) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 11696d24ee5..db75eac97c4 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -706,7 +706,21 @@ export async function copyWorkflowStateIntoTarget( parallels: newParallels, variables: remappedVariables, } - const saved = await saveWorkflowToNormalizedTables(targetWorkflowId, remappedState, tx) + const saved = await saveWorkflowToNormalizedTables( + targetWorkflowId, + remappedState, + { + /** + * Actorless. A fork copies rows that already exist in the source + * workspace; the blocks are not chosen by whoever triggered the fork, and + * governing the copy by their group would leave a fork that silently + * dropped part of the source graph. + */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) if (!saved.success) { throw new Error(`Failed to write forked workflow ${targetWorkflowId}: ${saved.error}`) } diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 31ccf6e12bf..99b90845748 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -436,7 +436,16 @@ export async function createFork(params: CreateForkParams): Promise { mockExecuteTool.mockReset() @@ -253,3 +253,36 @@ describe('runEnrichment cascade detail', () => { expect(outcome.detail.providers.map((p) => p.status)).toEqual(['error', 'no_match']) }) }) + +/** + * The per-tool permission gate keys off the acting user, and skips entirely + * when a tool call carries none. An enrichment that omitted the user therefore + * sent row data — names, emails, company domains — to its provider with the + * workspace's `deniedTools` denylist silently not applied. + */ +describe('runEnrichment tool attribution', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('names the acting user on the provider call', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { email: 'a@b.c' } }) + + await runEnrichment( + config([prov('p1')]), + {}, + { + workspaceId: 'workspace-1', + userId: 'user-1', + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'tool_p1', + expect.objectContaining({ + _context: { workspaceId: 'workspace-1', userId: 'user-1' }, + }), + expect.anything() + ) + }) +}) diff --git a/apps/sim/enrichments/run.ts b/apps/sim/enrichments/run.ts index 97f19f69d51..93a1e6e7eac 100644 --- a/apps/sim/enrichments/run.ts +++ b/apps/sim/enrichments/run.ts @@ -112,7 +112,7 @@ export async function runEnrichment( try { const response = await executeTool( provider.toolId, - { ...params, _context: { workspaceId: ctx.workspaceId } }, + { ...params, _context: { workspaceId: ctx.workspaceId, userId: ctx.userId ?? undefined } }, { signal: ctx.signal, resolvedSecretTraceRegistry: ctx.resolvedSecretTraceRegistry, diff --git a/apps/sim/enrichments/types.ts b/apps/sim/enrichments/types.ts index 64ad738b7eb..eba5cba3fc0 100644 --- a/apps/sim/enrichments/types.ts +++ b/apps/sim/enrichments/types.ts @@ -30,6 +30,18 @@ export interface EnrichmentRunContext { tableId?: string rowId?: string workspaceId: string + /** + * The person the run acts for, or `null` for a deliberately actorless run. + * + * Load-bearing, not decorative: the per-tool permission gate is skipped + * entirely when a tool call carries no user, so a run without one sends row + * data to its provider with the workspace's `deniedTools` denylist silently + * not applied. Required, and explicitly nullable, so that is a decision a + * caller states rather than one it falls into by leaving a field off — the + * only actorless caller is a system-triggered table dispatch, which has no + * person to name and must not borrow the billing owner instead. + */ + userId: string | null signal?: AbortSignal /** Isolated provenance for the exact mapped row inputs used by this run. */ resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index 5c4e546f87e..3cd2d9072aa 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -335,4 +335,48 @@ describe('serializePauseSnapshot', () => { expect(serialized.metadata.includeThinking).toBeUndefined() expect(serialized.metadata.includeToolCalls).toBeUndefined() }) + + /** + * A table cell dispatched by a workspace API key bills the workspace's + * billing owner and is gated on the member who asked. Losing the gate's + * subject on the way into the snapshot would resume the run against that + * bystander's group — `governedSubjectUserId` reads an absent field as "not + * declared" and falls back to the actor. + */ + it('preserves a gate subject that differs from the billing actor', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + userId: 'workspace-billing-owner', + capabilityGovernedUserId: 'requesting-member', + }, + }) + + const snapshot = serializePauseSnapshot(context, ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.userId).toBe('workspace-billing-owner') + expect(serialized.metadata.capabilityGovernedUserId).toBe('requesting-member') + }) + + /** A declared `null` is the actorless run, and is not the same as absence. */ + it('preserves a declared actorless gate subject as null', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + userId: 'workspace-billing-owner', + capabilityGovernedUserId: null, + }, + }) + + const serialized = JSON.parse(serializePauseSnapshot(context, ['next-block']).snapshot) + + expect(serialized.metadata.capabilityGovernedUserId).toBeNull() + }) + + it('declares nothing for a run whose caller is its only person', () => { + const serialized = JSON.parse(serializePauseSnapshot(createContext(), ['next-block']).snapshot) + + expect(serialized.metadata.capabilityGovernedUserId).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index 11c327f4db8..784502a1bc5 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -271,6 +271,15 @@ export function serializePauseSnapshot( workflowId: context.workflowId, workspaceId, userId: metadataFromContext?.userId ?? '', + /** + * The gate's subject, tri-state and carried verbatim. `userId` above is the + * billing/rate actor, and for a trigger with no acting person it names the + * workspace's billing owner — so a rebuild that dropped this would resume a + * paused run gating on that bystander (`governedSubjectUserId` reads an + * absent field as "not declared" and falls back to the actor). A declared + * `null` is the actorless run and must survive as `null`, not as absence. + */ + capabilityGovernedUserId: metadataFromContext?.capabilityGovernedUserId, principal, billingAttribution: metadataFromContext?.billingAttribution, sessionUserId: metadataFromContext?.sessionUserId, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 6ecc3a03a84..59e1679209c 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -25,6 +25,15 @@ export interface ExecutionMetadata { workflowId: string workspaceId: string userId: string + /** + * Person whose permission group gates this run — the gate, separate from + * {@link userId}, which is the billing/rate actor and the credential subject. + * Spread onto the execution context (and so onto the pause snapshot) so a + * trigger with no acting person to charge does not end up gating on the + * bystander it bills. Tri-state; see the field of the same name on the + * context's `ExecutionMetadata` in `@/executor/types`. + */ + capabilityGovernedUserId?: string | null /** Original authenticated caller. Billing and executor user IDs never replace it. */ principal: WorkflowExecutionPrincipal /** Immutable actor/payer decision captured before execution. */ diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 3664fb82227..fa346f84466 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -41,11 +41,9 @@ import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/cus import type { BlockOutput } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import { + assertPermissionsAllowed, validateBlockType, - validateCustomToolsAllowed, - validateMcpToolsAllowed, validateModelProvider, - validateSkillsAllowed, } from '@/ee/access-control/utils/permission-check' import { AGENT, BlockType, DEFAULTS, stripCustomToolPrefix } from '@/executor/constants' import { memoryService } from '@/executor/handlers/agent/memory' @@ -361,7 +359,12 @@ export class AgentBlockHandler implements BlockHandler { const skillInputs = filteredInputs.skills ?? [] let skillMetadata: Array<{ name: string; description: string }> = [] if (skillInputs.length > 0 && ctx.workspaceId) { - await validateSkillsAllowed(ctx.userId, ctx.workspaceId, ctx) + await assertPermissionsAllowed({ + userId: ctx.userId, + workspaceId: ctx.workspaceId, + toolKind: 'skill', + ctx, + }) skillMetadata = await resolveSkillMetadata(skillInputs, ctx.workspaceId) if (skillMetadata.length > 0) { const skillNames = skillMetadata.map((s) => s.name) @@ -595,11 +598,21 @@ export class AgentBlockHandler implements BlockHandler { const hasCustomTools = tools.some((t) => t.type === 'custom-tool') if (hasMcpTools) { - await validateMcpToolsAllowed(ctx.userId, ctx.workspaceId, ctx) + await assertPermissionsAllowed({ + userId: ctx.userId, + workspaceId: ctx.workspaceId, + toolKind: 'mcp', + ctx, + }) } if (hasCustomTools) { - await validateCustomToolsAllowed(ctx.userId, ctx.workspaceId, ctx) + await assertPermissionsAllowed({ + userId: ctx.userId, + workspaceId: ctx.workspaceId, + toolKind: 'custom', + ctx, + }) } } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index b3e98b2c497..d3e2fc366e0 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -1,7 +1,7 @@ import type { WorkflowExecutionAuthority, WorkflowExecutionPrincipal } from '@sim/auth/principal' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { TraceSpan } from '@/lib/logs/types' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import type { BlockOutput } from '@/blocks/types' import type { ChildWorkflowContext, @@ -333,6 +333,24 @@ interface ExecutionMetadata { depth: number } userId?: string + /** + * Person whose permission group gates what this run's tools, models and + * blocks may do — the *gate*, deliberately separate from {@link userId}, + * which is the billing/rate actor and the credential subject. + * + * The two coincide for a session-triggered run and diverge whenever the + * trigger has no acting person to charge: a table cell dispatched by a + * workspace API key attributes to the workspace's billing owner, and gating + * on that bystander is wrong in both directions — it applies a denylist + * nobody meant to apply, and it skips the one belonging to whoever actually + * asked. + * + * Tri-state on purpose. `undefined` means the trigger declares no separate + * gate, so the gate stays on {@link userId} (every surface that has always + * had one acting person). A declared `string` gates on that person; a + * declared `null` is an actorless run and applies no group gate at all. + */ + capabilityGovernedUserId?: string | null principal?: WorkflowExecutionPrincipal executionId?: string triggerType?: string diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index da494ece028..36dc856180b 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -15,13 +15,16 @@ import { resolveIntegrationAvailabilityStateForVisibility, } from '@/lib/integrations/availability' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { createModelAccessGate } from '@/lib/permission-groups/model-access' -import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' +import { + intersectAccessControlAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' +import { createModelAccessGate } from '@/lib/permission-groups/model-access' +import { createToolAccessGate } from '@/lib/permission-groups/operation-access' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' @@ -83,10 +86,31 @@ export function usePermissionConfig(): PermissionConfigResult { const isInPermissionGroup = !!permissionData?.permissionGroupId - const mergedAllowedIntegrations = useMemo(() => { - const envAllowlist = envAllowlistData?.allowedIntegrations ?? null - return intersectIntegrationAllowlists(config.allowedIntegrations, envAllowlist) - }, [config.allowedIntegrations, envAllowlistData]) + /** + * Both sides of the membership test are judged as the current block, so a + * policy naming a retired id — `ALLOWED_INTEGRATIONS=slack` — still permits + * the successor the editor offers. + * + * Each policy is canonicalized *before* the two are intersected, not after: a + * group naming `slack` and an env allowlist naming `slack_v2` intersect to + * nothing textually, hiding an integration both policies allow. This is the + * same helper the server gates use — `mergeEnvAllowlist` for the config the + * catalog reads, `allowedIntegrationTypes` for the block and selector gates — + * so what this hook shows and what the server permits cannot disagree. + */ + const allowedAccessControlTypes = useMemo( + () => + intersectAccessControlAllowlists( + config.allowedIntegrations, + envAllowlistData?.allowedIntegrations ?? null + ), + [config.allowedIntegrations, envAllowlistData] + ) + + const mergedAllowedIntegrations = useMemo( + () => (allowedAccessControlTypes === null ? null : [...allowedAccessControlTypes]), + [allowedAccessControlTypes] + ) const integrationAvailability = useMemo(() => { const visibility = overlayVisibility() @@ -116,10 +140,10 @@ export function usePermissionConfig(): PermissionConfigResult { return false } if (isBlockTypeAccessControlExempt(blockType)) return true - if (mergedAllowedIntegrations === null) return true - return mergedAllowedIntegrations.includes(normalizedBlockType) + if (allowedAccessControlTypes === null) return true + return allowedAccessControlTypes.has(resolveAccessControlBlockType(normalizedBlockType)) } - }, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations]) + }, [hostContext?.features?.credentialGroups, integrationAvailability, allowedAccessControlTypes]) const isModelUsable = useMemo( () => diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts index 9534d44ec59..4c77a0d0dec 100644 --- a/apps/sim/lib/api-key/application/operations.ts +++ b/apps/sim/lib/api-key/application/operations.ts @@ -1,6 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' -import { defineWorkspaceOperation } from '@/lib/core/application' +import { assertOperationCapability, defineWorkspaceOperation } from '@/lib/core/application' export type OrganizationByokPrincipal = Extract @@ -16,6 +16,7 @@ export interface OrganizationByokOperation function defineOrganizationByokOperation( operation: OrganizationByokOperation ): OrganizationByokOperation { + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) @@ -26,40 +27,49 @@ export const apiKeyOperations = { id: 'api_keys.copilot.create', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'api_keys.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), } as const export const byokKeyOperations = { + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead listOrganization: defineOrganizationByokOperation({ id: 'byok_keys.organization.list', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], entitlement: 'cleanup_allowed', }), + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead saveOrganization: defineOrganizationByokOperation({ id: 'byok_keys.organization.save', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], entitlement: 'required', }), + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead deleteOrganization: defineOrganizationByokOperation({ id: 'byok_keys.organization.delete', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], entitlement: 'cleanup_allowed', }), + // permission-group-exempt: BYOK is its own entitlement-gated section, and api_keys.manage names the Sim API Keys tab instead readInheritedStatus: defineWorkspaceOperation({ id: 'byok_keys.inherited_status.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), } as const diff --git a/apps/sim/lib/api/application/operations.ts b/apps/sim/lib/api/application/operations.ts index 619bad28adf..c524a401368 100644 --- a/apps/sim/lib/api/application/operations.ts +++ b/apps/sim/lib/api/application/operations.ts @@ -10,8 +10,10 @@ import { defineOperation } from '@/lib/core/application' * than hand-rolled inside the use case. */ export const v2MetaOperations = { + // permission-group-exempt: the resource is the API key the caller already proved it holds, and reporting what that key can do withholds nothing a group could read: defineOperation({ id: 'meta.capabilities.read', + capability: 'none', principalKinds: ['personal_api_key', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/api/contracts/permission-groups.test.ts b/apps/sim/lib/api/contracts/permission-groups.test.ts index e3460557529..6359763d60f 100644 --- a/apps/sim/lib/api/contracts/permission-groups.test.ts +++ b/apps/sim/lib/api/contracts/permission-groups.test.ts @@ -10,7 +10,7 @@ import { import { DEFAULT_PERMISSION_GROUP_CONFIG, parsePermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' describe('createPermissionGroupBodySchema', () => { it('accepts a name-only body (scope is resolved and validated server-side)', () => { diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index e3a531d8b43..6d80ee68d18 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -1,35 +1,20 @@ import { z } from 'zod' import { organizationIdSchema } from '@/lib/api/contracts/primitives' -import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' -import { permissionGroupConfigSchema } from '@/lib/permission-groups/types' +import { + permissionGroupConfigSchema, + permissionGroupReadShape, +} from '@/lib/permission-groups/fields' -export const permissionGroupFullConfigSchema = z.object({ - allowedIntegrations: z.array(z.string()).nullable(), - allowedModelProviders: z.array(z.string()).nullable(), - deniedModels: z.array(z.string()).default([]), - deniedTools: z.array(z.string()).default([]), - hideTraceSpans: z.boolean(), - hideKnowledgeBaseTab: z.boolean(), - hideTablesTab: z.boolean(), - hideCopilot: z.boolean(), - hideIntegrationsTab: z.boolean(), - hideSecretsTab: z.boolean(), - hideApiKeysTab: z.boolean(), - hideInboxTab: z.boolean(), - hideFilesTab: z.boolean(), - disableMcpTools: z.boolean(), - disableCustomTools: z.boolean(), - disableSkills: z.boolean(), - disableInvitations: z.boolean(), - disablePublicApi: z.boolean(), - disablePublicFileSharing: z.boolean(), - allowedFileShareAuthTypes: z.array(shareAuthTypeSchema).nullable(), - hideDeployApi: z.boolean(), - hideDeployMcp: z.boolean(), - hideDeployChatbot: z.boolean(), - allowedChatDeployAuthTypes: z.array(shareAuthTypeSchema).nullable(), -}) +/** + * The wire shape of a resolved config: every key present, in registry order. + * + * Built from the same field registry as the write schema and the tolerant + * parser, because the group editor's dirty check compares stringified configs — + * a key this schema omitted, or ordered differently, would read as an unsaved + * change forever. + */ +export const permissionGroupFullConfigSchema = z.object(permissionGroupReadShape) export const addPermissionGroupMemberBodySchema = z.object({ userId: z.string().min(1), @@ -39,12 +24,14 @@ export const addPermissionGroupMemberBodySchema = z.object({ export const permissionGroupParamsSchema = z.object({ id: organizationIdSchema, }) +export type PermissionGroupParams = z.input /** Route params for a single permission group (`id` = organizationId, `groupId` = permission group id). */ export const permissionGroupDetailParamsSchema = z.object({ id: organizationIdSchema, groupId: z.string().min(1), }) +export type PermissionGroupDetailParams = z.input /** A workspace a permission group targets (id + display name). */ export const permissionGroupWorkspaceRefSchema = z.object({ @@ -154,6 +141,7 @@ export const createPermissionGroupBodySchema = z workspaceIds: workspaceIdsSchema.optional(), }) .superRefine(refineWorkspaceScope) +export type CreatePermissionGroupBody = z.input export const updatePermissionGroupBodySchema = z .object({ @@ -164,15 +152,22 @@ export const updatePermissionGroupBodySchema = z workspaceIds: workspaceIdsSchema.optional(), }) .superRefine(refineWorkspaceScope) +export type UpdatePermissionGroupBody = z.input export const removePermissionGroupMemberQuerySchema = z.object({ memberId: z.string().min(1), }) +export type RemovePermissionGroupMemberQuery = z.input< + typeof removePermissionGroupMemberQuerySchema +> export const bulkAddPermissionGroupMembersBodySchema = z.object({ userIds: z.array(z.string()).optional(), addAllOrganizationMembers: z.boolean().optional(), }) +export type BulkAddPermissionGroupMembersBody = z.input< + typeof bulkAddPermissionGroupMembersBodySchema +> const successResponseSchema = z.object({ success: z.literal(true), diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 280df88de61..28c78731278 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -7,6 +7,7 @@ import { customPatternSchema, isCanonicalBase64, MAX_ID_LENGTH, + optionalNumberQuerySchema, organizationIdSchema, organizationRoleSchema, piiStagePolicySchema, @@ -345,3 +346,29 @@ describe('withMissingFieldMessage', () => { expect(retrofitted.safeParse('ok').success).toBe(true) }) }) + +describe('optionalNumberQuerySchema', () => { + it('keeps a real numeric bound, including an explicit zero', () => { + expect(optionalNumberQuerySchema.parse('0')).toBe(0) + expect(optionalNumberQuerySchema.parse('2.5')).toBe(2.5) + expect(optionalNumberQuerySchema.parse(7)).toBe(7) + }) + + it('drops an omitted or present-but-empty value', () => { + expect(optionalNumberQuerySchema.parse(undefined)).toBeUndefined() + expect(optionalNumberQuerySchema.parse('')).toBeUndefined() + expect(optionalNumberQuerySchema.parse(' ')).toBeUndefined() + }) + + /** + * `z.coerce.number()` reads `null` as `0`, which would turn a client that + * spells an unset bound as `null` into a caller asking a cost question. + */ + it('drops null rather than coercing it to a zero bound', () => { + expect(optionalNumberQuerySchema.parse(null)).toBeUndefined() + }) + + it('still rejects a non-numeric value', () => { + expect(optionalNumberQuerySchema.safeParse('abc').success).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 21717e04184..82f06553bdf 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -555,3 +555,27 @@ export const booleanQueryFlagSchema = z.preprocess( }, z.boolean({ error: 'must be a boolean (true/false)' }) ) + +/** + * An optional numeric query parameter that treats a present-but-empty value as + * omitted. + * + * `z.coerce.number().optional()` does not: a query string carrying `?minCost=` + * reaches the schema as `''`, `Number('')` is `0`, and the parameter arrives as + * a real zero. That is wrong twice — `maxCost=` silently narrows the page to + * free runs, and `minCost=` reads as a cost *selector*, which is what + * `assertLogCostQueryAllowed` refuses for a member whose group withholds spend. + * An empty value is a caller sending an unfilled form field, not a question + * about cost. + * + * `null` is dropped for the same reason and by the same arithmetic: a client + * that spells an unset bound as `null` rather than by omitting the key — + * `requestJson` parses the query object client-side, so a `null` field reaches + * this schema as itself — would otherwise be handed `Number(null) === 0`. + * + * An explicit `0` is preserved: `?minCost=0` is a real bound the caller typed. + */ +export const optionalNumberQuerySchema = z.preprocess((value) => { + if (value === null) return undefined + return typeof value === 'string' && value.trim() === '' ? undefined : value +}, z.coerce.number().optional()) diff --git a/apps/sim/lib/api/contracts/v1/logs.ts b/apps/sim/lib/api/contracts/v1/logs.ts index 87279d8d72a..4597c1d60b8 100644 --- a/apps/sim/lib/api/contracts/v1/logs.ts +++ b/apps/sim/lib/api/contracts/v1/logs.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + booleanQueryFlagSchema, + optionalNumberQuerySchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' export const v1LogParamsSchema = z.object({ @@ -19,10 +23,10 @@ export const v1ListLogsQuerySchema = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), executionId: z.string().optional(), - minDurationMs: z.coerce.number().optional(), - maxDurationMs: z.coerce.number().optional(), - minCost: z.coerce.number().optional(), - maxCost: z.coerce.number().optional(), + minDurationMs: optionalNumberQuerySchema, + maxDurationMs: optionalNumberQuerySchema, + minCost: optionalNumberQuerySchema, + maxCost: optionalNumberQuerySchema, model: z.string().optional(), details: z.enum(['basic', 'full']).optional().default('basic'), includeTraceSpans: booleanQueryFlagSchema.optional().default(false), diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts index 9502e57ee5f..9c7235c0b50 100644 --- a/apps/sim/lib/api/contracts/v1/shared.ts +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -22,7 +22,8 @@ export const v1UserLimitsSchema = z.object({ }), }), usage: z.object({ - currentPeriodCost: z.number(), + /** `null` when the caller's permission group withholds spend (`logs.cost`). */ + currentPeriodCost: z.number().nullable(), limit: z.number(), plan: z.string(), isExceeded: z.boolean(), diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 12b0747856b..958ff25f862 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -52,7 +52,9 @@ export const workspaceCreationPolicySchema = z.object({ * Machine-readable discriminant for blocked states whose correct user-facing * copy the workspace mode alone cannot determine. */ - blockedReasonCode: z.literal('organization-subscription-inactive').optional(), + blockedReasonCode: z + .enum(['organization-subscription-inactive', 'permission-group-denied']) + .optional(), }) export type WorkspaceCreationPolicy = z.output diff --git a/apps/sim/lib/audit-logs/application/operations.ts b/apps/sim/lib/audit-logs/application/operations.ts index 3621f6732d1..cbb82f2fc7c 100644 --- a/apps/sim/lib/audit-logs/application/operations.ts +++ b/apps/sim/lib/audit-logs/application/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export type AuditLogPrincipal = Extract @@ -16,21 +17,26 @@ function defineAuditLogOperation( if ((operation.principalKinds as readonly string[]).includes('workspace_api_key')) { throw new Error(`Organization-admin operation ${operation.id} cannot allow workspace API keys`) } + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) } export const auditLogOperations = { + // permission-group-exempt: the organization audit trail is authorized by organization admin or owner role, a scope no workspace-shaped permission group can name list: defineAuditLogOperation({ id: 'audit_logs.list', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session', 'personal_api_key'], }), + // permission-group-exempt: same organization-admin authority as the list it expands; no group key names the audit trail readDetail: defineAuditLogOperation({ id: 'audit_logs.read_detail', + capability: 'none', authority: 'organization_admin', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index f5531234007..819301f9e55 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -42,6 +42,23 @@ export interface AuthResult { error?: string } +/** + * The id whose permission group governs a request authenticated by + * `checkSessionOrInternalAuth`, or `null` when none does. + * + * An internal JWT's `auth.userId` is the subject the executor embedded, so + * keying on its presence would hand the run's actor's capabilities to a caller + * the executor exemption deliberately passes ungated. `authType` is the + * authoritative signal, and `apiKeyType` covers the personal-key case. The + * principal rules live on `capabilityGovernedPrincipalUserId` in + * `@/lib/core/application`; this reads the same decision off an `AuthResult`. + */ +export function capabilityGovernedAuthUserId(auth: AuthResult | undefined): string | null { + if (!auth?.userId) return null + if (auth.authType === AuthType.SESSION) return auth.userId + return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null +} + /** * Resolves userId from a verified internal JWT token. * Only trusts the userId embedded in the JWT payload — never from user-controlled sources. diff --git a/apps/sim/lib/auth/sso/application/operations.ts b/apps/sim/lib/auth/sso/application/operations.ts index ca63085acb5..1d68afdc7fe 100644 --- a/apps/sim/lib/auth/sso/application/operations.ts +++ b/apps/sim/lib/auth/sso/application/operations.ts @@ -5,7 +5,9 @@ import { defineOperation } from '@/lib/core/application/operation' * exists. Workspace-role authorization cannot apply yet; the use case instead * proves the exact provider link, verified domain, and provider-bound target. */ +// permission-group-exempt: SSO admission runs before any organization membership exists, so no group can govern the identity being admitted yet export const ssoJitAdmissionOperation = defineOperation({ id: 'sso.jit-admit', principalKinds: ['session'] as const, + capability: 'none', }) diff --git a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts index 0d4cc811e62..5a6105d6cf1 100644 --- a/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -7,7 +7,7 @@ import type { BillingReadOperation, BillingReadPrincipal, } from '@/lib/billing/application/operations' -import type { OperationUseCase } from '@/lib/core/application' +import { type OperationUseCase, requirePersonalApiKeysAllowed } from '@/lib/core/application' import { InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, @@ -16,6 +16,7 @@ import { WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { type ActiveWorkspaceApplicationContext, loadActiveWorkspaceApplicationContext, @@ -64,6 +65,29 @@ async function resolveBillingReadScope( throw new WorkspaceApiKeyScopeAuthorizationError() } } else if (!requestedWorkspaceId) { + /** + * permission-group-enforced: personal_api_key.use — the account-scoped read + * names no workspace, so nothing above resolved a group for it and the + * workspace branch's check below never runs. + * + * `personal_api_key.use` refuses a *principal kind* rather than a capability + * of the resource, so it applies to every operation a personal key can + * reach — including the one that happens to carry no workspace. Left out, + * the narrower scope would be the guarded one: the same key an organization + * withholds from `GET /billing?workspaceId=…` would still read the account's + * plan, balance and usage by omitting the parameter. + * + * Resolved from the organization's default group, the fallback + * {@link isCapabilityWithheldForUser} defines for a user-global action — + * the same one the personal-API-key and CLI mint paths use. A no-op when the + * caller is in no organization or no group governs them. + */ + if ( + principal.kind === 'personal_api_key' && + (await isCapabilityWithheldForUser(principal.userId, 'personal_api_key.use')) + ) { + throw new PersonalApiKeysDisabledError() + } return { kind: 'account', userId: principal.userId } } @@ -87,6 +111,18 @@ async function resolveBillingReadScope( if (!permissionSatisfies(permission, operation.workspaceMinimumRole)) { throw new InsufficientWorkspacePermissionsError() } + /** + * permission-group-enforced: personal_api_key.use — this path resolves its + * own workspace scope instead of running through + * `authorizeWorkspaceOperation`, so the funnel's personal-key refusal has to + * be repeated here or the same key the funnel refuses still reads billing. + * + * After the role check, like the funnel: it answers with a 403 naming how an + * organization configured one cohort, and running it ahead of the concealed + * no-access refusal would hand that to a caller with no reach into the + * workspace at all. + */ + await requirePersonalApiKeysAllowed(principal.userId, workspace) } return { kind: 'workspace', workspace } diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 4d615a46ddd..0665c709b26 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -2,8 +2,15 @@ * @vitest-environment node */ import type { SessionPrincipal } from '@sim/auth/principal' +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + const mocks = vi.hoisted(() => ({ loadWorkspace: vi.fn(), resolvePermission: vi.fn(), @@ -26,6 +33,11 @@ const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), canUserManageWorkspaceBilling: vi.fn(), canUserManageBillingEntity: vi.fn(), + isCapabilityWithheldForUser: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/user-scope.server', () => ({ + isCapabilityWithheldForUser: mocks.isCapabilityWithheldForUser, })) vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ @@ -78,6 +90,8 @@ vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) import { getBillingStatus } from '@/lib/billing/application/get-billing-status' import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' +import { PersonalApiKeysDisabledError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const workspaceContext = { workspaceId: 'workspace-1', @@ -99,10 +113,12 @@ const workspacePrincipal = { describe('billing application use cases', () => { beforeEach(() => { vi.clearAllMocks() + resetPermissionGroupScopeMock() mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) mocks.canUserManageBillingEntity.mockResolvedValue(false) + mocks.isCapabilityWithheldForUser.mockResolvedValue(false) mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false }) mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false }) mocks.toUsageLimitSubscription.mockReturnValue(null) @@ -194,6 +210,50 @@ describe('billing application use cases', () => { expect(mocks.canUserManageWorkspaceBilling).not.toHaveBeenCalled() }) + /** + * The billing reads resolve their own workspace scope instead of running + * through `authorizeWorkspaceOperation`, so the funnel's personal-key refusal + * has to be repeated here — otherwise the same key v2 refuses everywhere else + * still reads a workspace's plan and ledger. + */ + it('refuses a personal key whose group withholds personal keys', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + }) + + /** + * The account-scoped read names no workspace, so the workspace branch's + * `personal_api_key.use` check never runs on it. Without a gate of its own, + * the same key an organization withholds from the workspace-scoped read still + * reads the account's plan, balance and usage by dropping the parameter. + */ + it('refuses an account-scoped personal key through the organization default group', async () => { + mocks.isCapabilityWithheldForUser.mockResolvedValue(true) + + await expect( + getBillingStatus.execute({ principal: personalPrincipal, input: {} }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + + expect(mocks.isCapabilityWithheldForUser).toHaveBeenCalledWith('user-1', 'personal_api_key.use') + }) + + it('never applies the account-scoped personal-key gate to a workspace key', async () => { + mocks.isCapabilityWithheldForUser.mockResolvedValue(true) + + await expect( + getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) + ).resolves.toBeDefined() + }) + it('never reads the payer storage pool it may not disclose', async () => { await getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) await getBillingStatus.execute({ diff --git a/apps/sim/lib/billing/application/operations.ts b/apps/sim/lib/billing/application/operations.ts index 7fe8bc4fca6..f8a116fb6cd 100644 --- a/apps/sim/lib/billing/application/operations.ts +++ b/apps/sim/lib/billing/application/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export type BillingReadPrincipal = Extract< Principal, @@ -19,20 +20,25 @@ function defineBillingReadOperation( if (operation.workspaceMinimumRole !== 'read') { throw new Error(`Billing read operation ${operation.id} exceeds its workspace-key ceiling`) } + assertOperationCapability(operation) Object.freeze(operation.principalKinds) return Object.freeze(operation) } export const billingOperations = { + // permission-group-exempt: a personal account reading its own plan and balance; permission groups scope a workspace, not the billing account that owns it readStatus: defineBillingReadOperation({ id: 'billing.status.read', + capability: 'none', accountScope: 'personal_self', workspaceMinimumRole: 'read', workspaceApiKey: 'workspace_only', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: the same personal billing account reading its own usage records; no group key names it listLogs: defineBillingReadOperation({ id: 'billing.logs.list', + capability: 'none', accountScope: 'personal_self', workspaceMinimumRole: 'read', workspaceApiKey: 'workspace_only', diff --git a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts index 745f56b40fd..890da903e0b 100644 --- a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts +++ b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export type OrganizationBillingSummaryPrincipal = Extract @@ -13,16 +14,19 @@ export interface OrganizationBillingSummaryOperation function defineOrganizationBillingSummaryOperation( operation: OrganizationBillingSummaryOperation ): OrganizationBillingSummaryOperation { + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) } export const organizationBillingSummaryOperations = { + // permission-group-exempt: an organization-admin surface — admins and owners sit above every group, and no group key names organization billing read: defineOrganizationBillingSummaryOperation({ id: 'organization_billing.summary.read', organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }), } as const diff --git a/apps/sim/lib/billing/application/organization-usage/operations.ts b/apps/sim/lib/billing/application/organization-usage/operations.ts index a98aa7d9c6f..75a234a5358 100644 --- a/apps/sim/lib/billing/application/organization-usage/operations.ts +++ b/apps/sim/lib/billing/application/organization-usage/operations.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' /** * Session only. @@ -27,6 +28,7 @@ function defineOrganizationUsageOperation( `Organization usage operation ${operation.id} may only be performed by a session` ) } + assertOperationCapability(operation) Object.freeze(operation.organizationRoles) Object.freeze(operation.principalKinds) return Object.freeze(operation) @@ -37,17 +39,37 @@ const BASE = { organizationRoles: ['admin', 'owner'], workspaceApiKey: 'deny', principalKinds: ['session'], -} as const satisfies Omit +} as const satisfies Omit +/** + * Every one takes `capability: 'none'`, written out at each call site rather than + * folded into `BASE`: `check:permission-group-enforcement` reads the literal at + * the call site, and a capability arriving through a spread is a capability + * nothing outside the type system ever sees. + */ export const organizationUsageOperations = { - readSummary: defineOrganizationUsageOperation({ id: 'organization_usage.summary.read', ...BASE }), + // permission-group-exempt: the organization's pooled ledger is authorized by organization billing-admin authority, which no workspace-shaped group key names + readSummary: defineOrganizationUsageOperation({ + id: 'organization_usage.summary.read', + capability: 'none', + ...BASE, + }), + // permission-group-exempt: the same pooled ledger, broken down; organization billing-admin authority governs it readBreakdown: defineOrganizationUsageOperation({ id: 'organization_usage.breakdown.read', + capability: 'none', + ...BASE, + }), + // permission-group-exempt: organization billing events, governed by organization billing-admin authority rather than a workspace group + listEvents: defineOrganizationUsageOperation({ + id: 'organization_usage.events.list', + capability: 'none', ...BASE, }), - listEvents: defineOrganizationUsageOperation({ id: 'organization_usage.events.list', ...BASE }), + // permission-group-exempt: exports the same organization billing events; logs.export names workflow run logs, not the billing ledger exportEvents: defineOrganizationUsageOperation({ id: 'organization_usage.events.export', + capability: 'none', ...BASE, }), } as const diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 31060f810ef..7edb6d3ccf2 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -69,6 +69,7 @@ import { hasPaidSubscription, hasWorkspaceLiveSyncAccess, hasWorkspaceSandboxAccess, + isOrganizationOnEnterprisePlan, isWorkspaceOnEnterprisePlan, resolveOrganizationPlan, syncSubscriptionPlan, @@ -474,3 +475,60 @@ describe('resolveOrganizationPlan', () => { ) }) }) + +describe('isOrganizationOnEnterprisePlan', () => { + const ORGANIZATION_ID = 'org-1' + + beforeEach(() => { + vi.clearAllMocks() + /** An earlier describe leaves billing disabled, which short-circuits this gate to true. */ + setEnvFlags({ isBillingEnabled: true, isHosted: true }) + mockIsOrganizationBillingBlocked.mockResolvedValue(false) + mockCheckEnterprisePlan.mockReturnValue(true) + }) + + it('accepts an organization holding a usable enterprise plan', async () => { + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'active' }]) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID)).resolves.toBe(true) + }) + + /** + * The lenient default is what every feature gate reads — a hidden button is + * the worst outcome there — so a read failure must keep resolving `false` + * rather than starting to reject through those callers. + */ + it('keeps failing closed to false for the default policy', async () => { + dbChainMockFns.limit.mockRejectedValue(new Error('billing database unavailable')) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID)).resolves.toBe(false) + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID, 'return-false')).resolves.toBe( + false + ) + }) + + /** + * The subscription read soft-fails to `null` on its own, so without the + * policy threaded through it an outage arrives as an ordinary "no usable + * subscription" and returns a successful `false`. For Access Control that + * `false` means `config: null` — every capability allowed — so it has to + * propagate. + */ + it('propagates a failed subscription read when the caller asked to throw', async () => { + dbChainMockFns.limit.mockRejectedValue(new Error('billing database unavailable')) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID, 'throw')).rejects.toThrow( + 'billing database unavailable' + ) + }) + + it('propagates a failed block-state read the same way', async () => { + mockIsOrganizationBillingBlocked.mockRejectedValue(new Error('userStats unavailable')) + dbChainMockFns.limit.mockResolvedValue([{ plan: 'enterprise', status: 'active' }]) + + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID)).resolves.toBe(false) + await expect(isOrganizationOnEnterprisePlan(ORGANIZATION_ID, 'throw')).rejects.toThrow( + 'userStats unavailable' + ) + }) +}) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 1140e654dbe..228c212ae59 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -446,7 +446,29 @@ export function isSubscriptionBackedEntitlement(): boolean { return isBillingEnabled && !(isAccessControlEnabled && !isHosted) } -async function resolveOrganizationEnterprisePlan(organizationId: string): Promise { +/** + * What a billing-read failure resolves to for the Enterprise gate. + * + * `'return-false'` (the default) fails closed for a *feature* gate: the feature + * is hidden, and the worst outcome is a button that is briefly missing. + * + * `'throw'` is for callers where "no Enterprise plan" is not a smaller answer + * but a different regime. Access Control resolves to `config: null` when the + * organization is not entitled, and `null` means *every* capability allowed and + * every allowlist off — so a swallowed subscription-read failure would silently + * disable the whole permission-group regime for the request instead of + * surfacing an error. Those callers must pass `'throw'`. + * + * A primitive rather than an options object on purpose: `cache()` keys on the + * argument list, and a fresh object literal per call would miss the memo every + * time. + */ +export type EnterprisePlanErrorPolicy = 'return-false' | 'throw' + +async function resolveOrganizationEnterprisePlan( + organizationId: string, + onError: EnterprisePlanErrorPolicy = 'return-false' +): Promise { try { if (!isBillingEnabled) { return true @@ -460,11 +482,23 @@ async function resolveOrganizationEnterprisePlan(organizationId: string): Promis return false } - const orgSub = await getOrganizationSubscriptionUsable(organizationId) + /** + * The subscription read soft-fails to `null` by default, which would arrive + * here as an ordinary "no usable subscription" and return a successful + * `false` — the catch below never sees it. A caller that asked to throw + * needs that failure propagated too. + */ + const orgSub = await getOrganizationSubscriptionUsable( + organizationId, + onError === 'throw' ? { onError: 'throw' } : {} + ) return !!orgSub && checkEnterprisePlan(orgSub) } catch (error) { logger.error('Error checking organization enterprise plan status', { error, organizationId }) + if (onError === 'throw') { + throw error + } return false } } @@ -537,7 +571,15 @@ export async function resolveOrganizationPlan( * Used for Access Control (Permission Groups) feature gating * * Request-memoized: a settings render gates several sections on the same - * organization's plan, and it cannot change mid-render. + * organization's plan, and it cannot change mid-render. `cache()` keys on the + * whole argument list, so the default and `'throw'` policies memoize + * separately — a request that mixes both pays for two reads, and a rejection is + * replayed to every later caller that asked for the same policy, which is the + * fail-closed behavior those callers want. + * + * Pass `'throw'` from any caller for which a swallowed read failure would read + * as a *permissive* answer rather than a restrictive one — see + * {@link EnterprisePlanErrorPolicy}. */ export const isOrganizationOnEnterprisePlan = cache(resolveOrganizationEnterprisePlan) diff --git a/apps/sim/lib/catalog/application/catalog-context.ts b/apps/sim/lib/catalog/application/catalog-context.ts index 859e76cb1a6..f06375a630e 100644 --- a/apps/sim/lib/catalog/application/catalog-context.ts +++ b/apps/sim/lib/catalog/application/catalog-context.ts @@ -4,6 +4,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { type ActiveWorkspaceApplicationContext, @@ -74,7 +75,7 @@ export function isBlockVisibleToCaller(block: BlockConfig, gate: CatalogGate): b export function isBlockTypeAllowed(blockType: string, gate: CatalogGate): boolean { if (gate.allowedIntegrations === null) return true if (isBlockTypeAccessControlExempt(blockType)) return true - return gate.allowedIntegrations.has(blockType.toLowerCase()) + return gate.allowedIntegrations.has(resolveAccessControlBlockType(blockType).toLowerCase()) } /** diff --git a/apps/sim/lib/catalog/application/catalog-reads.test.ts b/apps/sim/lib/catalog/application/catalog-reads.test.ts index 74a4e73e0a1..fa9b0b6f346 100644 --- a/apps/sim/lib/catalog/application/catalog-reads.test.ts +++ b/apps/sim/lib/catalog/application/catalog-reads.test.ts @@ -378,8 +378,13 @@ describe('catalog block and tool reads', () => { ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) }) + /** + * The gate answers in the resolved vocabulary — `slack` is judged as + * `slack_v2` on both sides — so the allowlist naming the successor is what + * keeps the legacy block visible. + */ it('drops a block the permission-group allowlist excludes, from list and detail alike', async () => { - mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack'])) + mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack_v2'])) const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) diff --git a/apps/sim/lib/catalog/application/operations.test.ts b/apps/sim/lib/catalog/application/operations.test.ts index 21e4fa1ae93..7b1ce4d4421 100644 --- a/apps/sim/lib/catalog/application/operations.test.ts +++ b/apps/sim/lib/catalog/application/operations.test.ts @@ -1,8 +1,26 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + import { catalogOperations } from '@/lib/catalog/application/operations' +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' /** * Operation metadata is executable policy, not documentation: it decides which @@ -53,3 +71,50 @@ describe('catalogOperations', () => { } }) }) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** + * The connector-type catalog is the one entry with a capability, so the split + * is pinned from both sides: hiding knowledge bases must close it, and must not + * close the block and tool catalogs the editor needs to render at all. + */ +describe('catalog operations under a group that hides knowledge bases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideKnowledgeBaseTab: true, + }) + }) + + it('refuses the connector-type catalog', async () => { + await expect( + authorizeWorkspaceOperation( + sessionPrincipal, + catalogOperations.listConnectorTypes as WorkspaceOperation, + context + ) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + it('still answers the block and tool catalogs', async () => { + for (const operation of [ + catalogOperations.listBlocks, + catalogOperations.readBlock, + catalogOperations.listTools, + catalogOperations.readTool, + ]) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/catalog/application/operations.ts b/apps/sim/lib/catalog/application/operations.ts index 19a85eddbfa..8ee9e1453d5 100644 --- a/apps/sim/lib/catalog/application/operations.ts +++ b/apps/sim/lib/catalog/application/operations.ts @@ -13,36 +13,57 @@ import { defineWorkspaceOperation } from '@/lib/core/application' * No `delegated` principal kind: Copilot reads these catalogs through its own * tools, which share the projection rather than the use case, so adding one * would widen authorization for a caller that does not exist. + * + * The block and tool catalogs name no capability. They are the set of things + * the editor can render at all, already filtered per workspace, and the + * capabilities that matter — MCP tools, custom tools, skills — are enforced on + * the operations that use those tools rather than on the list that describes + * them. Withholding the catalog would empty the builder rather than withhold + * anything a group names. */ export const catalogOperations = { + // permission-group-exempt: the block catalog is what the editor renders; emptying it hides the product rather than restricting it listBlocks: defineWorkspaceOperation({ id: 'catalog.blocks.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: one entry of the same catalog listBlocks returns, so it cannot be governed differently readBlock: defineWorkspaceOperation({ id: 'catalog.blocks.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: describes which tools exist; whether a member may call one is decided on that tool's own operation listTools: defineWorkspaceOperation({ id: 'catalog.tools.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + // permission-group-exempt: one entry of the same catalog listTools returns, so it cannot be governed differently readTool: defineWorkspaceOperation({ id: 'catalog.tools.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + /** + * The only catalog with a capability: it enumerates knowledge-base connector + * types and nothing else, so it exists to configure a knowledge base. A group + * with `hideKnowledgeBaseTab` set has no use for the list. + */ listConnectorTypes: defineWorkspaceOperation({ id: 'catalog.connector_types.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), } as const diff --git a/apps/sim/lib/chat-deployments/application/operations.ts b/apps/sim/lib/chat-deployments/application/operations.ts index 6776bbe9926..0ea237e3977 100644 --- a/apps/sim/lib/chat-deployments/application/operations.ts +++ b/apps/sim/lib/chat-deployments/application/operations.ts @@ -10,6 +10,10 @@ import { defineWorkspaceOperation } from '@/lib/core/application' * by its own id; the resource and its policy are the same either way, so the * operation is too. * + * Every one declares `deploy.chat`, the capability behind `hideDeployChatbot`. + * `list` takes it too: a group with the chat deployment surface withheld should + * not still be told which workflows are published on it. + * * `workflows.chat.deploy` and `workflows.chat.undeploy` remain the entry points * for the surfaces that name a workflow and ask for it to be published — the * internal deploy route and the Copilot tool. They converge on the same domain @@ -42,30 +46,35 @@ export const chatDeploymentOperations = { id: 'chat_deployments.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_LIST_POLICY, }), replace: defineWorkspaceOperation({ id: 'chat_deployments.replace', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), read: defineWorkspaceOperation({ id: 'chat_deployments.read', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), update: defineWorkspaceOperation({ id: 'chat_deployments.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), delete: defineWorkspaceOperation({ id: 'chat_deployments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...CHAT_DEPLOYMENT_ADMIN_POLICY, }), } as const diff --git a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts index 0f6b5b2e990..a9d5e26d138 100644 --- a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts +++ b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts @@ -20,15 +20,12 @@ import { updateChatDeploymentRow, } from '@/lib/chat-deployments/queries' import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' -import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, performFullDeploy } from '@/lib/workflows/orchestration' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' +import { validateChatDeployAuth } from '@/ee/access-control/utils/permission-check' const logger = createLogger('UpdateChatDeployment') @@ -192,14 +189,7 @@ export const updateChatDeployment = defineAuthorizedWorkspaceUseCase({ * be re-saved by a title-only edit without a refusal. */ if (input.authType && input.authType !== existing.authType) { - try { - await validateChatDeployAuth(actingUserId, context.workspaceId, input.authType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) - } - throw error - } + await validateChatDeployAuth(actingUserId, context.workspaceId, input.authType) } if (input.identifier && input.identifier !== existing.identifier) { diff --git a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts index 995271bd7df..5ed60936a85 100644 --- a/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts +++ b/apps/sim/lib/chat-deployments/application/workflow-chat-deployment.ts @@ -25,13 +25,10 @@ import { getLiveChatDeploymentForWorkflow, } from '@/lib/chat-deployments/queries' import { buildChatDeploymentUrl } from '@/lib/chat-deployments/urls' -import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' +import { validateChatDeployAuth } from '@/ee/access-control/utils/permission-check' /** * The chat singleton of a workflow. @@ -121,18 +118,11 @@ async function assertAuthModePermitted( authType: ChatAuthType ): Promise { if (authType === context.chatDeployment?.authType) return - try { - await validateChatDeployAuth( - requirePrincipalSubjectUserId(principal), - context.workspaceId, - authType - ) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) - } - throw error - } + await validateChatDeployAuth( + requirePrincipalSubjectUserId(principal), + context.workspaceId, + authType + ) } /** diff --git a/apps/sim/lib/copilot/application/application-adapter.test.ts b/apps/sim/lib/copilot/application/application-adapter.test.ts index 0ef223344bf..add82de2876 100644 --- a/apps/sim/lib/copilot/application/application-adapter.test.ts +++ b/apps/sim/lib/copilot/application/application-adapter.test.ts @@ -18,6 +18,7 @@ const operation = defineWorkspaceOperation({ workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot'], + capability: 'files.use', }) const delegation = { @@ -87,6 +88,7 @@ describe('Copilot application adapter', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot'], + capability: 'files.use', }) const sameIdDifferentPolicy = defineWorkspaceOperation({ id: operation.id, @@ -94,6 +96,7 @@ describe('Copilot application adapter', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot'], + capability: 'files.use', }) expect(() => @@ -121,6 +124,7 @@ describe('Copilot application adapter', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['executor'], + capability: 'files.use', }) const execute = vi.fn() const executeCopilotUseCase = createCopilotApplicationAdapter< diff --git a/apps/sim/lib/copilot/application/operations.ts b/apps/sim/lib/copilot/application/operations.ts index 2fc9830226b..7a910261cb5 100644 --- a/apps/sim/lib/copilot/application/operations.ts +++ b/apps/sim/lib/copilot/application/operations.ts @@ -11,6 +11,7 @@ export const chatOperations = { id: 'chat.send', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'copilot.use', principalKinds: ['personal_api_key'], }), } as const diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index e22c92651d2..8d3da676cc6 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -187,6 +187,8 @@ export async function getRunSegment(runId: string) { workflowId: copilotRuns.workflowId, // Needed to scope an "allow for this chat" decision to its chat. chatId: copilotRuns.chatId, + // Needed to resolve the deciding user's permission group. + workspaceId: copilotRuns.workspaceId, }) .from(copilotRuns) .where(eq(copilotRuns.id, runId)) diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 9b6274051ec..4f7f3b7de8f 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -134,7 +134,7 @@ vi.mock('@/lib/integrations/availability.server', () => ({ isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index bf888de5a7b..795cc1f7f36 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -160,8 +160,10 @@ export async function buildIntegrationToolSchemas( // what the entry caches: a user-tool schema per exposed integration tool. let permissionConfig: IntegrationGateConfig | null = null if (workspaceId) { - const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check') - permissionConfig = await getUserPermissionConfig(userId, workspaceId) + const { resolvePermissionGroupConfig } = await import( + '@/lib/permission-groups/config-scope.server' + ) + permissionConfig = await resolvePermissionGroupConfig(userId, workspaceId, undefined) } const cacheKey = getIntegrationToolSchemaCacheKey( userId, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index f7f71a24733..7e178dfe1e8 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -5,6 +5,8 @@ import { authMockFns, environmentUtilsMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, permissionsMock, permissionsMockFns, resetDbChainMock, @@ -59,6 +61,43 @@ const { releaseChatSendClaim: vi.fn(), })) +/** + * The root span, captured so a test can assert what a refused turn exported. + * `withCopilotSpan` is a pass-through here — the nesting it provides is not + * under test and a real tracer would need an exporter to observe. + */ +const { setInputMessages, setUserMessagePreview, startCopilotOtelRoot } = vi.hoisted(() => ({ + setInputMessages: vi.fn(), + setUserMessagePreview: vi.fn(), + startCopilotOtelRoot: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/otel', async () => { + const { ROOT_CONTEXT, trace } = await import('@opentelemetry/api') + const span = () => trace.getTracer('post-test').startSpan('post-test') + startCopilotOtelRoot.mockImplementation(() => ({ + span: span(), + context: ROOT_CONTEXT, + requestId: 'req-1', + finish: vi.fn(), + setUserMessagePreview, + setInputMessages, + setOutputMessages: vi.fn(), + setRequestShape: vi.fn(), + })) + return { + startCopilotOtelRoot, + withCopilotSpan: ( + _name: string, + _attrs: Record | undefined, + fn: (child: ReturnType) => unknown, + _context?: unknown + ) => fn(span()), + } +}) + +const resolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + const getSession = authMockFns.mockGetSession const billingAttribution = { actorUserId: 'user-1', @@ -129,12 +168,16 @@ vi.mock('@/lib/copilot/resources/persistence', () => ({ persistChatResources, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('@/lib/copilot/chat-status', () => ({ chatPubSub: { publishStatusChanged: mockPublishStatusChanged, }, })) +import { chatOperations } from '@/lib/copilot/application/operations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { handleUnifiedChatPost } from './post' describe('handleUnifiedChatPost', () => { @@ -155,6 +198,7 @@ describe('handleUnifiedChatPost', () => { storeChatSendResult.mockResolvedValue(true) releaseChatSendClaim.mockResolvedValue(undefined) getSession.mockResolvedValue({ user: { id: 'user-1' } }) + resolvePermissionGroupConfig.mockResolvedValue(null) resolveWorkflowIdForUser.mockResolvedValue({ status: 'resolved', workflowId: 'wf-1', @@ -900,3 +944,218 @@ describe('handleUnifiedChatPost', () => { }) }) }) + +describe('handleUnifiedChatPost copilot.use capability gate', () => { + const REFUSAL = "Chat is not available under your organization's permission group" + /** + * The body every raw capability refusal renders, detail code included. This + * route builds it through the shared `capabilityRefusalResponse`, so a client + * cannot tell a group refusal here apart from one raised by the funnel. + */ + const REFUSAL_BODY = { + error: REFUSAL, + details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, + } + + function chatRequest(body: Record = {}) { + return new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ message: 'Hello', workspaceId: 'ws-1', ...body }), + }) + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + getSession.mockResolvedValue({ user: { id: 'user-1' } }) + atomicallyClaimChatSend.mockResolvedValue({ + claimed: true, + normalizedKey: 'chat-send:user-message:msg-1:userId=user-1', + storageMethod: 'database', + claimToken: 'claim-1', + }) + storeChatSendResult.mockResolvedValue(true) + releaseChatSendClaim.mockResolvedValue(undefined) + resolveWorkflowIdForUser.mockResolvedValue({ + status: 'resolved', + workflowId: 'wf-1', + workspaceId: 'ws-1', + workflowName: 'Workflow One', + }) + getUserEntityPermissions.mockResolvedValue('write') + resolveBillingAttribution.mockResolvedValue(billingAttribution) + getEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) + generateWorkspaceSnapshot.mockResolvedValue({ markdown: '', snapshot: { workflows: [] } }) + processContextsServer.mockResolvedValue([]) + resolveActiveResourceContext.mockResolvedValue(null) + buildCopilotRequestPayload.mockImplementation(async (params: Record) => params) + createSSEStream.mockReturnValue(new ReadableStream()) + acquirePendingChatStream.mockResolvedValue(true) + getPendingChatStreamId.mockResolvedValue(null) + releasePendingChatStream.mockResolvedValue(undefined) + resolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1' }, + conversationHistory: [], + isNew: true, + }) + }) + + /** + * Refused before a chat exists or a run is created, so a refused request + * leaves nothing behind for a resume stream to replay. The send claim is + * taken first and released by the handler's `finally`, so a retry is free to + * start a turn. + */ + /** + * The capability this raw handler asserts is the one `chatOperations.send` + * declares, not a literal restated beside it — a declarative surface would + * enforce the declaration, and this one must agree with it. + */ + it('enforces the capability the chat operation declares', () => { + expect(chatOperations.send.capability).toBe('copilot.use') + }) + + it('refuses the send when the group withholds copilot.use', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual(REFUSAL_BODY) + expect(resolveOrCreateChat).not.toHaveBeenCalled() + expect(createSSEStream).not.toHaveBeenCalled() + expect(releaseChatSendClaim).toHaveBeenCalledTimes(1) + }) + + /** + * `workflowId` resolves the workflow's own workspace and ignores any + * `workspaceId` beside it, so gating on the request's copy would let a member + * skip the check entirely by simply not sending one. + */ + it('gates on the workflow workspace when the request names no workspace', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ message: 'Hello', workflowId: 'wf-1', createNewChat: true }), + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual(REFUSAL_BODY) + expect(resolvePermissionGroupConfig).toHaveBeenCalledWith('user-1', 'ws-1', undefined) + expect(createSSEStream).not.toHaveBeenCalled() + }) + + /** The same escape aimed elsewhere: a workspace the chat never lands in. */ + it('ignores a workspaceId that disagrees with the resolved workflow workspace', async () => { + resolvePermissionGroupConfig.mockImplementation(async (_userId: string, workspaceId: string) => + workspaceId === 'ws-1' + ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true } + : DEFAULT_PERMISSION_GROUP_CONFIG + ) + + const response = await handleUnifiedChatPost( + chatRequest({ workflowId: 'wf-1', workspaceId: 'ws-unrestricted', createNewChat: true }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual(REFUSAL_BODY) + expect(resolvePermissionGroupConfig).not.toHaveBeenCalledWith( + 'user-1', + 'ws-unrestricted', + undefined + ) + expect(createSSEStream).not.toHaveBeenCalled() + }) + + it('streams the send when a group governs the user but withholds nothing', async () => { + resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(200) + expect(createSSEStream).toHaveBeenCalledTimes(1) + }) + + /** A personal workspace, or any non-enterprise organization, is governed by no group. */ + it('streams the send when no permission group governs the user', async () => { + resolvePermissionGroupConfig.mockResolvedValue(null) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(200) + expect(createSSEStream).toHaveBeenCalledTimes(1) + }) + + /** + * Prompt content is exported only once the turn is going to run. GenAI + * message capture is gated on whether capture is enabled at all, not on + * whether this caller may send, so capturing at span start exported the + * message of every turn the gate then refused. + */ + it('exports no part of the prompt when the send is refused', async () => { + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(403) + expect(setInputMessages).not.toHaveBeenCalled() + expect(setUserMessagePreview).not.toHaveBeenCalled() + expect(startCopilotOtelRoot).toHaveBeenCalledWith( + expect.not.objectContaining({ userMessagePreview: expect.anything() }) + ) + }) + + it('captures the prompt once the send is allowed to run', async () => { + resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true })) + + expect(response.status).toBe(200) + expect(setUserMessagePreview).toHaveBeenCalledWith('Hello') + expect(setInputMessages).toHaveBeenCalledWith({ userMessage: 'Hello' }) + }) + + /** A branch that lands in no workspace at all is governed by no group. */ + it('does not consult a permission group when the branch resolves no workspace', async () => { + resolveWorkflowIdForUser.mockResolvedValue({ + status: 'resolved', + workflowId: 'wf-1', + workspaceId: undefined, + workflowName: 'Workflow One', + }) + resolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCopilot: true, + }) + + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ message: 'Hello', workflowId: 'wf-1', createNewChat: true }), + }) + ) + + expect(response.status).toBe(200) + expect(resolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 90b4b3b283c..112efea4011 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -10,6 +10,7 @@ import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { chatOperations } from '@/lib/copilot/application/operations' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, @@ -64,6 +65,8 @@ import { import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import type { AtomicClaimResult } from '@/lib/core/idempotency' import { chatSendIdempotency } from '@/lib/core/idempotency' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -1051,6 +1054,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { typeof session.user.name === 'string' ? session.user.name : undefined const body = ChatMessageSchema.parse(await req.json()) + const userMetadata = { ...(authenticatedUserName ? { name: authenticatedUserName } : {}), ...(authenticatedUserEmail ? { email: authenticatedUserEmail } : {}), @@ -1069,7 +1073,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { executionId, runId, transport: CopilotTransport.Stream, - userMessagePreview: body.message, }) if (otelRoot.requestId) { requestId = otelRoot.requestId @@ -1084,10 +1087,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { if (authenticatedUserEmail) { otelRoot.span.setAttribute(TraceAttr.UserEmail, authenticatedUserEmail) } - // `setInputMessages` is internally gated on - // OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT; safe to call. - otelRoot.setInputMessages({ userMessage: body.message }) - // Wrap the rest of the handler so nested spans attach to the // root via AsyncLocalStorage (otherwise they orphan into new traces). const activeOtelRoot = otelRoot @@ -1119,6 +1118,54 @@ export async function handleUnifiedChatPost(req: NextRequest) { return branch } + /** + * permission-group-enforced: copilot.use — Chat is a raw handler rather + * than a workspace operation, so the authorization funnel never sees it. + * The capability is read off `chatOperations.send` rather than restated, + * so the assertion and the refusal cannot drift from the declaration a + * declarative surface would enforce — including the `'none'` case, where + * a declarative surface asserts nothing and so does this. + * + * Gated on the workspace the turn actually lands in, which is the one + * `resolveBranch` just resolved rather than the one the request asked + * for. A send naming `workflowId` resolves the workflow's own workspace + * and ignores any `workspaceId` beside it, so reading the request's copy + * would aim the check at a workspace the chat never touches — or, with + * no `workspaceId` sent at all, skip it entirely. A branch that resolves + * no workspace is governed by no group. + * + * Still ahead of everything durable: no chat is resolved, no pending + * stream lock is taken and no run is created, which also settles the + * resume stream — with no run there is nothing to replay. The send claim + * taken above is released by the `finally`, so a refused send leaves a + * later retry free to start a turn. + */ + const chatCapability = chatOperations.send.capability + if ( + branch.workspaceId && + chatCapability !== 'none' && + (await isWorkspaceCapabilityWithheld( + authenticatedUserId, + branch.workspaceId, + chatCapability + )) + ) { + activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, 403) + activeOtelRoot.finish('error') + return capabilityRefusalResponse(chatCapability) + } + + /* Prompt content is captured only once the turn is going to run. Both + calls are internally gated on + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, but the gate is on + whether capture is enabled at all, not on whether this caller may send + — so stamping them at span start exported the message of every turn the + capability check above then refused. Every refusal ahead of this point + (a rejected branch, a withheld `copilot.use`) now records the shape of + the request and none of its content. */ + activeOtelRoot.setUserMessagePreview(body.message) + activeOtelRoot.setInputMessages({ userMessage: body.message }) + let currentChat: ChatLoadResult['chat'] = null let conversationHistory: unknown[] = [] let chatIsNew = false diff --git a/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts b/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts new file mode 100644 index 00000000000..e12f73bbde3 --- /dev/null +++ b/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + * + * Copilot's `@log` mention context, projected for the chatting user. + * + * `logs.cost` and `logs.trace_spans` are PROJECTIONS, not gates, and Copilot is + * deliberately not exempt from them: it acts as the person, so a run inlined as + * mention context must be withheld exactly as the person's own log surfaces + * withhold it. This path resolved the run row directly with a role-only + * authorization and inlined the run total, every span's own cost, and the whole + * block overview — so a member withheld all three on `/api/logs/**` read them by + * typing `@` in chat. + * + * Kept in its own file because it mocks `config-scope.server`, which the sibling + * `process-contents.test.ts` deliberately leaves real so its integration-allowlist + * tests exercise `getUserPermissionConfig`. + */ +import { + dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, + workflowAuthzMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatContext } from '@/stores/panel' + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { processContextsServer } from '@/lib/copilot/chat/process-contents' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +function queueRun(): void { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: { + traceSpans: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + duration: 500, + cost: { total: 0.04 }, + children: [ + { id: 'span-2', name: 'tool', type: 'tool', duration: 10, cost: { total: 0.01 } }, + ], + }, + ], + }, + costTotal: '0.05', + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) +} + +async function mentionSummary(userId?: string) { + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + userId as string, + 'hello', + 'ws-1' + ) + expect(result).toHaveLength(1) + return JSON.parse(result[0].content) +} + +describe('@log mention context projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + }) + + it('inlines spend whole for a member no group governs', async () => { + queueRun() + + const summary = await mentionSummary('user-1') + + expect(summary.cost).toEqual({ total: 0.05 }) + expect(summary.overview[0].cost).toEqual({ total: 0.04 }) + expect(summary.overview[0].children[0].cost).toEqual({ total: 0.01 }) + }) + + it('withholds the run total and every span cost when the group hides spend', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueRun() + + const summary = await mentionSummary('user-1') + + expect(summary.cost).toBeUndefined() + expect(summary.overview).toHaveLength(1) + expect(summary.overview[0].name).toBe('Agent 1') + expect(summary.overview[0].cost).toBeUndefined() + expect(summary.overview[0].children[0].cost).toBeUndefined() + }) + + /** + * The overview is derived from `traceSpans`, which is on the withheld list the + * log-detail path strips outright — so it is withheld entirely rather than + * merely thinned. The run's identity, level and timings stay: these are + * projections, not gates. + */ + it('withholds the whole block overview when the group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + queueRun() + + const summary = await mentionSummary('user-1') + + expect(summary.overview).toBeUndefined() + expect(summary.cost).toEqual({ total: 0.05 }) + expect(summary.executionId).toBe('exec-1') + expect(JSON.stringify(summary)).not.toContain('Agent 1') + }) + + /** No subject, no group — never a bystander's. */ + it('resolves no group when the mention carries no subject', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + hideTraceSpans: true, + }) + queueRun() + + const summary = await mentionSummary(undefined) + + expect(summary.cost).toEqual({ total: 0.05 }) + expect(summary.overview).toHaveLength(1) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 3e29c8b4046..cc0482b272b 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -45,7 +45,7 @@ const { vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry })) vi.mock('@/lib/copilot/block-visibility', () => ({ getBlockVisibilityForCopilot })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ getUserPermissionConfig })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig })) vi.mock('@/lib/integrations/availability.server', () => ({ isIntegrationDeploymentAvailableForVisibility: isIntegrationDeploymentAvailable, })) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index b719abbe917..6b4207c6fc7 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -32,12 +32,21 @@ import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilit import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' +import { + projectCostTotal, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' import { mcpService } from '@/lib/mcp/service' import { createMcpToolId } from '@/lib/mcp/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { + intersectIntegrationAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { getColumnId } from '@/lib/table/column-keys' import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' @@ -47,7 +56,6 @@ import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' @@ -600,7 +608,7 @@ async function processBlockMetadata( ): Promise { try { const [permissionConfig, visibility] = await Promise.all([ - userId && workspaceId ? getUserPermissionConfig(userId, workspaceId) : null, + userId && workspaceId ? resolvePermissionGroupConfig(userId, workspaceId, undefined) : null, userId ? getBlockVisibilityForCopilot(userId, workspaceId) : null, ]) const allowedIntegrations = intersectIntegrationAllowlists( @@ -614,7 +622,7 @@ async function processBlockMetadata( if ( allowedIntegrations != null && !isBlockTypeAccessControlExempt(blockId) && - !allowedIntegrations.includes(blockId.toLowerCase()) + !allowedIntegrations.includes(resolveAccessControlBlockType(blockId.toLowerCase())) ) { logger.debug('Block not allowed by integration allowlist', { blockId, userId }) return null @@ -753,11 +761,35 @@ async function processExecutionLogFromDb( } } + /** + * Copilot is deliberately not exempt: it acts as the person, so the run it + * inlines is withheld exactly as the person's own log surfaces withhold it. + * `userId` here is the chatting user — both callers of + * `processContextsServer` pass the request's authenticated subject, and this + * is session context rather than an executor delegation — so it is the right + * subject for the projection, and an absent one reads whole. + * + * `logs.trace_spans` withholds the overview entirely rather than merely + * thinning it: the tree is derived from `traceSpans`, which is on the + * withheld list that the log-detail route strips outright. + * `logs.cost` blanks the run total AND every span's own `cost`, through the + * shared projector — a viewer who can sum the spans has been withheld + * nothing. + * + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + */ + const projection = await resolveLogFieldProjection(userId, log.workspaceId) + const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') - const executionData = (await materializeExecutionData( + const materialized = (await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - )) as { traceSpans?: TraceSpan[] } | undefined + )) as Record | null | undefined + const executionData = projectExecutionData(materialized ?? null, projection) as + | { traceSpans?: TraceSpan[] } + | null + | undefined const overview = executionData?.traceSpans?.length ? toOverview(executionData.traceSpans) : undefined @@ -772,7 +804,7 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, + cost: projectCostTotal(log.costTotal, projection) ?? undefined, overview, note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`, } diff --git a/apps/sim/lib/copilot/integration-tool-projection.test.ts b/apps/sim/lib/copilot/integration-tool-projection.test.ts index c12eb30732f..1276bbb3341 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.test.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.test.ts @@ -108,7 +108,8 @@ describe('projectIntegrationToolsForViewer', () => { deniedTools: ['slack_canvas_v1'], }) - expect(projection.allowedBlockTypes).toEqual(new Set(['slack'])) + /** The set is in the resolved vocabulary, which is what the gate compares against. */ + expect(projection.allowedBlockTypes).toEqual(new Set(['slack_v2'])) expect(projection.isToolAllowed('slack_canvas_v1')).toBe(false) expect(projection.isToolAllowed('slack_message_v1')).toBe(true) }) diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/copilot/integration-tool-projection.ts index ba96dead1de..de4efa9b5f5 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.ts +++ b/apps/sim/lib/copilot/integration-tool-projection.ts @@ -6,9 +6,11 @@ import { import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { intersectIntegrationAllowlists, - toAllowedIntegrationTypes, + resolveAccessControlBlockType, + toAccessControlAllowlist, } from '@/lib/permission-groups/integration-allowlist' import { collectDeniedOperationIds, @@ -17,7 +19,6 @@ import { type IsToolAllowed, NO_DENIED_OPERATIONS, } from '@/lib/permission-groups/operation-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' /** The slice of a permission group the integration gate reads. */ @@ -54,7 +55,7 @@ export function projectIntegrationToolsForViewer( vis: BlockVisibilityState | null, permissionConfig: IntegrationGateConfig | null | undefined ): ViewerIntegrationProjection { - const allowedBlockTypes = toAllowedIntegrationTypes( + const allowedBlockTypes = toAccessControlAllowlist( intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() @@ -67,7 +68,8 @@ export function projectIntegrationToolsForViewer( vis, (owner) => isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) && - (allowedBlockTypes === null || allowedBlockTypes.has(owner.blockType.toLowerCase())), + (allowedBlockTypes === null || + allowedBlockTypes.has(resolveAccessControlBlockType(owner.blockType).toLowerCase())), isToolAllowed ) diff --git a/apps/sim/lib/copilot/mcp-tools.test.ts b/apps/sim/lib/copilot/mcp-tools.test.ts index defa40f1507..31a8fa49505 100644 --- a/apps/sim/lib/copilot/mcp-tools.test.ts +++ b/apps/sim/lib/copilot/mcp-tools.test.ts @@ -3,20 +3,20 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { discoverServerTools, validateMcpToolsAllowed } = vi.hoisted(() => ({ +const { discoverServerTools, assertPermissionsAllowed } = vi.hoisted(() => ({ discoverServerTools: vi.fn(), - validateMcpToolsAllowed: vi.fn(), + assertPermissionsAllowed: vi.fn(), })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateMcpToolsAllowed })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed })) import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' describe('mothership MCP tool schemas', () => { beforeEach(() => { vi.clearAllMocks() - validateMcpToolsAllowed.mockResolvedValue(undefined) + assertPermissionsAllowed.mockResolvedValue(undefined) }) it('discovers tools only for explicitly tagged servers', async () => { diff --git a/apps/sim/lib/copilot/mcp-tools.ts b/apps/sim/lib/copilot/mcp-tools.ts index 24856365e08..f3fde00fa6b 100644 --- a/apps/sim/lib/copilot/mcp-tools.ts +++ b/apps/sim/lib/copilot/mcp-tools.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import type { ToolSchema } from '@/lib/copilot/chat/payload' import type { McpTool, McpToolSchema } from '@/lib/mcp/types' import { createMcpToolId } from '@/lib/mcp/utils' -import { validateMcpToolsAllowed } from '@/ee/access-control/utils/permission-check' +import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-check' import type { ToolInput } from '@/executor/handlers/agent/types' const logger = createLogger('CopilotMcpTools') @@ -78,7 +78,7 @@ export async function buildTaggedMcpToolSchemas( const uniqueServerIds = [...new Set(serverIds.filter(Boolean))] if (uniqueServerIds.length === 0) return [] - await validateMcpToolsAllowed(userId, workspaceId) + await assertPermissionsAllowed({ userId, workspaceId, toolKind: 'mcp' }) const discovered = await Promise.all( uniqueServerIds.map((serverId) => discoverServerTools(userId, workspaceId, serverId)) ) @@ -104,7 +104,7 @@ export async function buildSelectedMcpToolSchemas( ) if (selected.length === 0) return [] - await validateMcpToolsAllowed(userId, workspaceId) + await assertPermissionsAllowed({ userId, workspaceId, toolKind: 'mcp' }) const discoveredByServer = new Map>() const resolved = await Promise.all( selected.map(async (selection) => { diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 1fd556a76bf..2ba04104dd0 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -28,7 +28,7 @@ export function createStreamingContext(overrides?: Partial): S errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { enabled: false, autoAllowed: new Set(), autoAllowPermitted: true }, ...overrides, } } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index b8659cb2d6c..bcc441d02ee 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -35,6 +35,7 @@ function makeContext(): StreamingContext { toolPermissions: { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 991570b1343..d27a1482891 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -143,6 +143,7 @@ function createStreamingContext(): StreamingContext { toolPermissions: { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, }, } } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 87321713554..8111871bb79 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -134,6 +134,7 @@ describe('sse-handlers tool lifecycle', () => { toolPermissions: { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, }, } execContext = { @@ -238,6 +239,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: true, autoAllowed: new Set(), + autoAllowPermitted: true, } const event = { @@ -275,6 +277,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: false, autoAllowed: new Set(), + autoAllowPermitted: true, } const event = { @@ -302,6 +305,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: true, autoAllowed: new Set(), + autoAllowPermitted: true, } const event = { @@ -331,6 +335,7 @@ describe('sse-handlers tool lifecycle', () => { context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_as_api']), + autoAllowPermitted: true, } const event = { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 891fb1b00bb..5e698416cba 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -19,6 +19,7 @@ const { mockRunStreamLoop, mockPendingToolWaitBudgetMs, mockGetAutoAllowedTools, + mockGetUserPermissionConfig, mockFilterModelSafeWorkspaceFileAttachments, mockUpdateRunStatus, mockEnv, @@ -32,6 +33,7 @@ const { mockRunStreamLoop: vi.fn(), mockPendingToolWaitBudgetMs: vi.fn((_toolCall?: { name?: string; status?: string }) => 60_000), mockGetAutoAllowedTools: vi.fn(async () => new Set()), + mockGetUserPermissionConfig: vi.fn(async () => null), mockFilterModelSafeWorkspaceFileAttachments: vi.fn(async (attachments: unknown[]) => attachments), mockUpdateRunStatus: vi.fn(), mockEnv: { @@ -112,6 +114,10 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, +})) + vi.mock('@/lib/copilot/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, })) @@ -161,6 +167,7 @@ describe('runCopilotLifecycle', () => { isCopilotToolPermissionsEnabled: false, }) mockGetAutoAllowedTools.mockResolvedValue(new Set()) + mockGetUserPermissionConfig.mockResolvedValue(null) mockPendingToolWaitBudgetMs.mockImplementation(() => 60_000) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) @@ -1064,6 +1071,51 @@ describe('runCopilotLifecycle', () => { expect(mockGetAutoAllowedTools).toHaveBeenCalledWith('user-1', 'chat-1') }) + /** + * The gate itself stays armed — every call still prompts. Only the memory + * that would silence it is withheld, and the stored list is not read at + * all, so an entry saved before the key was set cannot outlive it. + */ + it('arms the gate but ignores stored auto-allows when the group withholds them', async () => { + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + mockGetUserPermissionConfig.mockResolvedValue({ disableToolAutoApproval: true }) + mockGetAutoAllowedTools.mockResolvedValue(new Set(['terminal_run'])) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.autoAllowPermitted).toBe(false) + expect(captured?.toolPermissions.autoAllowed.size).toBe(0) + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + + /** + * A failed lookup is the endpoint's reading — withheld — not a rejection. + * Letting it throw would abort the turn before any card is drawn, over a + * database hiccup, on the one surface that has a human to ask. + */ + it('reads a failed capability lookup as withheld instead of aborting the turn', async () => { + setEnvFlags({ isCopilotToolPermissionsEnabled: true }) + mockGetUserPermissionConfig.mockRejectedValue(new Error('permission group lookup failed')) + mockGetAutoAllowedTools.mockResolvedValue(new Set(['terminal_run'])) + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementation(async (_u, _o, context: StreamingContext) => { + captured = context + }) + + await runMothershipTurn() + + expect(mockRunStreamLoop).toHaveBeenCalledOnce() + expect(captured?.toolPermissions.enabled).toBe(true) + expect(captured?.toolPermissions.autoAllowPermitted).toBe(false) + expect(captured?.toolPermissions.autoAllowed.size).toBe(0) + expect(mockGetAutoAllowedTools).not.toHaveBeenCalled() + }) + it('stays off for the workflow-scoped copilot even with the flag on', async () => { // That panel has no permission card, so gating there would hang the turn // on a prompt nothing draws. diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 7add7419cd2..1e48ca9e1d9 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,7 +1,7 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import type { PermissionType } from '@sim/platform-authz/workspace' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { interruptibleSleep, sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { omit } from '@sim/utils/object' @@ -64,6 +64,7 @@ import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copil import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -206,8 +207,45 @@ async function resolveToolPermissions( isCopilotToolPermissionsEnabled && options.interactive !== false && (options.goRoute ?? '').startsWith('/api/mothership') - if (!enabled) return { enabled: false, autoAllowed: new Set() } - return { enabled: true, autoAllowed: await getAutoAllowedTools(options.userId, options.chatId) } + if (!enabled) return { enabled: false, autoAllowed: new Set(), autoAllowPermitted: true } + + /** + * permission-group-enforced: copilot.tool_auto_approval — read at the point + * the decision is made, not only where one is saved. A member who clicked + * "always allow" before the key was set would otherwise keep the prompt + * silenced forever, so the stored list is not even loaded once the group + * withholds the capability. + * + * A failed lookup reads as withheld, matching the decision endpoint: letting + * it reject would abort the whole turn here, before any card is drawn, over a + * database hiccup — the turn is interactive by construction at this point, so + * there is a human to ask. Withholding keeps the capability fail-closed + * without wedging the turn: no stored always-allow is loaded, nothing durable + * is remembered, and every gated call still asks its one-time question. + */ + const withheld = + options.userId && options.workspaceId + ? await isWorkspaceCapabilityWithheld( + options.userId, + options.workspaceId, + 'copilot.tool_auto_approval' + ).catch((error) => { + logger.warn('Could not resolve the tool auto-approval capability; prompting every time', { + workspaceId: options.workspaceId, + error: getErrorMessage(error), + }) + return true + }) + : false + if (withheld) { + return { enabled: true, autoAllowed: new Set(), autoAllowPermitted: false } + } + + return { + enabled: true, + autoAllowed: await getAutoAllowedTools(options.userId, options.chatId), + autoAllowPermitted: true, + } } export async function runCopilotLifecycle( diff --git a/apps/sim/lib/copilot/request/otel.ts b/apps/sim/lib/copilot/request/otel.ts index 291ea303d88..fb7828f7bd2 100644 --- a/apps/sim/lib/copilot/request/otel.ts +++ b/apps/sim/lib/copilot/request/otel.ts @@ -360,7 +360,6 @@ interface CopilotOtelScope { runId?: string streamId?: string transport: 'headless' | 'stream' - userMessagePreview?: string } // Dashboard-column width; long enough for triage disambiguation. @@ -368,11 +367,6 @@ const USER_MESSAGE_PREVIEW_MAX_CHARS = 500 function buildAgentSpanAttributes( scope: CopilotOtelScope & { requestId: string } ): Record { - // Gated behind the same env var as full GenAI message capture — a - // 500-char preview is still user prompt content. - const preview = isGenAIMessageCaptureEnabled() - ? truncateUserMessagePreview(scope.userMessagePreview) - : undefined return { [TraceAttr.GenAiAgentName]: 'mothership', [TraceAttr.GenAiAgentId]: @@ -388,7 +382,6 @@ function buildAgentSpanAttributes( ...(scope.executionId ? { [TraceAttr.CopilotExecutionId]: scope.executionId } : {}), ...(scope.runId ? { [TraceAttr.RunId]: scope.runId } : {}), ...(scope.streamId ? { [TraceAttr.StreamId]: scope.streamId } : {}), - ...(preview ? { [TraceAttr.CopilotUserMessagePreview]: preview } : {}), } } @@ -432,6 +425,15 @@ interface CopilotOtelRoot { error?: unknown, cancelReason?: CopilotRequestCancelReasonValue ) => void + /** + * Stamp the triage preview of the user's prompt. + * + * Gated behind the same env var as full GenAI message capture — a 500-char + * preview is still user prompt content — and separate from span creation so + * that a turn refused before it starts (a capability the caller's permission + * group withholds, a rejected branch) exports no part of the prompt. + */ + setUserMessagePreview: (raw: string | undefined) => void setInputMessages: (input: CopilotAgentInputMessages) => void setOutputMessages: (output: CopilotAgentOutputMessages) => void setRequestShape: (shape: CopilotOtelRequestShape) => void @@ -503,6 +505,11 @@ export function startCopilotOtelRoot( context: rootContext, requestId, finish, + setUserMessagePreview: (raw) => { + if (!isGenAIMessageCaptureEnabled()) return + const preview = truncateUserMessagePreview(raw) + if (preview) span.setAttribute(TraceAttr.CopilotUserMessagePreview, preview) + }, setInputMessages: (input) => setAgentInputMessages(span, input), setOutputMessages: (output) => setAgentOutputMessages(span, output), setRequestShape: (shape) => applyRequestShape(span, shape), diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 35268410ab4..07765132b18 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -36,6 +36,7 @@ function makeContext() { context.toolPermissions = { enabled: true, autoAllowed: new Set(), + autoAllowPermitted: true, } context.trace = new TraceCollector() return context @@ -413,3 +414,44 @@ describe('runGatedToolExecution', () => { expect(call?.payload).toMatchObject({ status: 'executing', toolCallId: 'call-1' }) }) }) + +describe('when the permission group withholds tool auto-approval', () => { + /** + * The stored list is what `toolCallNeedsApproval` reads, so an entry saved + * before an admin set the key would otherwise keep silencing the prompt for + * as long as it sat in the table. Turning the key on has to take effect on + * the next call, not on the next entry. + */ + it('prompts for a tool the user already always-allowed', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('terminal') + context.toolPermissions.autoAllowPermitted = false + + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true) + }) + + it('leaves an ungated tool ungated', () => { + toolRequiresApproval.mockReturnValue(false) + const context = makeContext() + context.toolPermissions.autoAllowPermitted = false + + expect(toolCallNeedsApproval('gmail_read_v2', context, {}, false)).toBe(false) + toolRequiresApproval.mockReturnValue(true) + }) + + it('does not let an always-allow answer suppress the rest of the turn', async () => { + const context = makeContext() + context.toolPermissions.autoAllowPermitted = false + const toolCall = makeToolCall() + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'always_allow', + }) + + await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), []) + + // The answer still ran the tool; only its memory is refused. + expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(false) + expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 08e751dc204..1e050b67e45 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -88,6 +88,13 @@ export function toolCallNeedsApproval( } } + /** + * permission-group-enforced: copilot.tool_auto_approval — the stored list is + * consulted here, so honouring the key only where an entry is written would + * leave every entry saved before the policy changed still silencing prompts. + */ + if (!context.toolPermissions.autoAllowPermitted) return true + return !context.toolPermissions.autoAllowed.has(toolName) } @@ -271,10 +278,25 @@ export function runGatedToolExecution( span.setAttribute(TraceAttr.CopilotAsyncToolPermissionDecision, decision.decision) - if (decisionSuppressesFuturePrompts(decision.decision)) { - // Same-turn effect: a second call to this tool later in the turn must - // not re-prompt. The durable write (chat row or user settings) happens - // in the endpoint. + if ( + decisionSuppressesFuturePrompts(decision.decision) && + context.toolPermissions.autoAllowPermitted + ) { + /** + * Same-turn effect: a second call to this tool later in the turn must + * not re-prompt. The durable write (chat row or user settings) happens + * in the endpoint, which refuses it under the same capability. + * + * `autoAllowPermitted` is the snapshot the turn opened with, and it is + * deliberately not re-read here. Re-reading would not see a key revoked + * mid-turn anyway: `resolvePermissionGroupConfig` memoizes per request + * scope, so every read inside one turn answers with the value that + * scope resolved first. Revocation therefore takes effect on the next + * turn — seconds to minutes away — and in the meantime silences only + * prompts this user answered in person, never persisting one: the + * endpoint reads the capability on its own request and refuses the + * durable write. + */ context.toolPermissions.autoAllowed.add(toolName) } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index f11a9b15a4d..c293c5b8140 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -186,6 +186,12 @@ export interface StreamingContext { toolPermissions: { enabled: boolean autoAllowed: Set + /** + * Whether this user may silence a confirmation at all. False when the + * permission group withholds `copilot.tool_auto_approval`, in which case + * every gated call prompts however the stored list reads. + */ + autoAllowPermitted: boolean } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index cd5b1ec5cd1..87d0197e822 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -64,7 +64,6 @@ vi.mock('@/app/api/chat/utils', () => ({ })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ - ChatDeployAuthNotAllowedError: class ChatDeployAuthNotAllowedError extends Error {}, validateChatDeployAuth: vi.fn(), })) diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts index 6192fd377e3..35a3e66ab23 100644 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts @@ -1,7 +1,7 @@ import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { projectIntegrationToolsForViewer } from '@/lib/copilot/integration-tool-projection' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { stripVersionSuffix } from '@/tools/utils' export async function executeListIntegrationTools( @@ -17,7 +17,7 @@ export async function executeListIntegrationTools( // gated (preview / kill-switched) integrations stay undiscoverable. const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) const permissionConfig = context.workspaceId - ? await getUserPermissionConfig(context.userId, context.workspaceId) + ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) : null const { tools: all } = projectIntegrationToolsForViewer(vis, permissionConfig) const service = stripVersionSuffix(raw.toLowerCase()) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index 0d4d6d2f31b..ae1b44dab75 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -114,6 +114,7 @@ vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() }) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -278,6 +279,42 @@ describe('executeMaterializeFile - workflow import', () => { 'PRIVATE WORKFLOW DESCRIPTION' ) }) + + /** + * Copilot is a surface adapter, not an exemption. The imported graph comes + * from a file the user uploaded, so it is exactly the caller-supplied + * whole-graph write the integration allowlist judges — and the subject is the + * person chatting. + */ + it('names the chatting user as the subject the permission group governs', async () => { + await executeMaterializeFile({ fileNames: ['workflow.json'], operation: 'import' }, context) + + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + { workspaceId: 'ws-1', subjectUserId: 'user-1' } + ) + }) + + it('surfaces the shared write refusal and rolls the shell workflow row back', async () => { + saveWorkflowToNormalizedTablesMock.mockRejectedValue( + new OrchestrationError( + 'forbidden', + 'Block type "gmail" is not allowed by your organization\'s permission group' + ) + ) + + const result = await executeMaterializeFile( + { fileNames: ['workflow.json'], operation: 'import' }, + context + ) + + expect(result.success).toBe(false) + expect( + (result.output as { failed: { fileName: string; error: string }[] }).failed[0].error + ).toContain('gmail') + expect(dbChainMockFns.delete).toHaveBeenCalled() + }) }) describe('executeMaterializeFile - save storage transition', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3ebfb5e2941..aefdec1dabc 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -27,6 +27,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { getServePathPrefix } from '@/lib/uploads' import { ArchiveError, @@ -303,7 +304,29 @@ async function executeImport( variables: {}, }) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData) + let saveResult: Awaited> + try { + /** + * Copilot is a surface adapter, not an exemption. The graph here comes from + * a JSON file the user uploaded, so it names whatever block types the file + * names — exactly the whole-graph write the workspace's integration + * allowlist exists to judge — and the subject is the person chatting, never + * the workflow's billing owner. + * + * The shared write refuses a withheld type by throwing, so the shell row + * inserted above is rolled back here the same way a failed save is; + * otherwise a refusal would leave an empty workflow behind. + */ + saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData, { + workspaceId, + subjectUserId: userId, + }) + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + const classified = asOrchestrationError(error) + if (classified) return { success: false, error: classified.message } + throw error + } if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, workflowId)) return { success: false, error: `Failed to save workflow state: ${saveResult.error}` } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts index 3af8978ef76..e0e2a5688ca 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({ isDeploymentAvailable: vi.fn(() => true), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts index ef9572fe4cc..3fb868b0731 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -9,7 +9,7 @@ const { mockGetUserPermissionConfig, mockIsIntegrationDeploymentAvailable } = vi mockIsIntegrationDeploymentAvailable: vi.fn(() => true), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 5cdd43af72a..0c0796bc55e 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -18,7 +18,11 @@ import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-f import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { + intersectIntegrationAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { collectDeniedOperationIds, createToolAccessGate, @@ -29,7 +33,6 @@ import { import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' /** * The block shape this tool reports, projected by the shared catalog projection @@ -193,7 +196,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< const permissionConfig = context?.userId && context?.workspaceId - ? await getUserPermissionConfig(context.userId, context.workspaceId) + ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) : null const allowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, @@ -213,7 +216,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< allowedIntegrations != null && !specialBlock && !isBlockTypeAccessControlExempt(blockId) && - !allowedIntegrations.includes(blockId.toLowerCase()) + !allowedIntegrations.includes(resolveAccessControlBlockType(blockId.toLowerCase())) ) { logger.debug('Block not allowed by permission group', { blockId }) continue diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts index 8f699d66183..f93858ab953 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts @@ -21,7 +21,7 @@ vi.mock('@/blocks/registry', () => ({ getBlock: mockGetBlock, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index d27bd205015..389dbc334d2 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -4,10 +4,13 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { + intersectIntegrationAllowlists, + resolveAccessControlBlockType, +} from '@/lib/permission-groups/integration-allowlist' import { getAllBlocks } from '@/blocks/registry' import { overlayVisibility } from '@/blocks/visibility/context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export const GetTriggerBlocksInput = z.object({}) export const GetTriggerBlocksResult = z.object({ @@ -27,7 +30,7 @@ export const getTriggerBlocksServerTool: BaseServerTool< const permissionConfig = context?.userId && context?.workspaceId - ? await getUserPermissionConfig(context.userId, context.workspaceId) + ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) : null const allowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, @@ -44,7 +47,7 @@ export const getTriggerBlocksServerTool: BaseServerTool< if ( allowedIntegrations != null && !isBlockTypeAccessControlExempt(blockType) && - !allowedIntegrations.includes(blockType.toLowerCase()) + !allowedIntegrations.includes(resolveAccessControlBlockType(blockType.toLowerCase())) ) continue diff --git a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts index 8b7a2a3d7ef..822059863c5 100644 --- a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts +++ b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts @@ -44,6 +44,7 @@ export const enrichmentRunServerTool: BaseServerTool ({ getAllowedIntegrationsFromEnv: vi.fn(() => null), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: getUserPermissionConfigMock, })) @@ -186,8 +187,21 @@ describe('getCredentialsServerTool', () => { checkWorkspaceAccessMock.mockResolvedValue({ canAdmin: false }) createIntegrationCredentialVisibilityMock.mockImplementation( ({ allowedIntegrationTypes, oauthServices }) => { - const isAllowed = (service: { serviceId: string }) => - allowedIntegrationTypes === null || allowedIntegrationTypes.has(service.serviceId) + /** + * Mirrors `isOAuthServiceAllowedByIntegrationTypes`: the gate holds + * *block types*, so the service is mapped through the deployment + * catalog rather than compared to its own id. A service the catalog + * does not name maps to no block type and stays visible, as it does in + * production. + */ + const isAllowed = (service: { serviceId: string }) => { + if (allowedIntegrationTypes === null) return true + const blockTypes = getIntegrationTypesForOAuthServiceId(service.serviceId) + return ( + blockTypes.length === 0 || + blockTypes.some((blockType) => allowedIntegrationTypes.has(blockType)) + ) + } const isOAuthServiceVisible = (service: { serviceId: string; providerId: string }) => isAllowed(service) && isOAuthServiceDeploymentAvailableMock(service.providerId) return { diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index 059be3c0e13..9b5398201c5 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -17,10 +17,10 @@ import { credentialProviderMatchesService, getAllOAuthServices, } from '@/lib/oauth' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { overlayVisibility } from '@/blocks/visibility/context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' interface GetCredentialsParams { workflowId?: string @@ -80,7 +80,9 @@ export const getCredentialsServerTool: BaseServerTool .limit(1) const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null - const permissionConfig = workspaceId ? await getUserPermissionConfig(userId, workspaceId) : null + const permissionConfig = workspaceId + ? await resolvePermissionGroupConfig(userId, workspaceId, undefined) + : null const configuredAllowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 83722e4330a..5e2d841568d 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -142,10 +142,11 @@ import { } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' import { intersectIntegrationAllowlists, - toAllowedIntegrationTypes, + toAccessControlAllowlist, } from '@/lib/permission-groups/integration-allowlist' import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' import { @@ -203,10 +204,7 @@ import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockConfig, BlockIcon } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' -import { - getUserPermissionConfig, - resolveVerifiedUserAccessControlContext, -} from '@/ee/access-control/utils/permission-check' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' @@ -941,7 +939,7 @@ export class WorkspaceVFS { const blockVisibility = overlayVisibility() const permissionConfigPromise = timed( 'permissions', - getUserPermissionConfig(userId, workspaceId) + resolvePermissionGroupConfig(userId, workspaceId, undefined) ) const sandboxEntitlementPromise = timed( 'sandbox_entitlement', @@ -3164,7 +3162,7 @@ export class WorkspaceVFS { private async materializeEnvironment( workspaceId: string, userId: string, - permissionConfigPromise: ReturnType, + permissionConfigPromise: ReturnType, blockVisibility: BlockVisibilityState | null, secretMountPolicy?: SecretMountPolicy ): Promise<{ @@ -3182,7 +3180,7 @@ export class WorkspaceVFS { permissionConfigPromise, ]) const credentialVisibility = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: toAllowedIntegrationTypes( + allowedIntegrationTypes: toAccessControlAllowlist( intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index bd9b0655d11..5ead0766352 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -41,6 +41,7 @@ const operation = defineWorkspaceOperation({ minimumRole: 'write', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }) const delegatedOperation = defineWorkspaceOperation({ @@ -49,6 +50,7 @@ const delegatedOperation = defineWorkspaceOperation({ workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['executor'], + capability: 'none', }) const workspaceKeyOperation = defineWorkspaceOperation({ @@ -56,6 +58,7 @@ const workspaceKeyOperation = defineWorkspaceOperation({ minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['workspace_api_key'], + capability: 'none', }) const resourcePolicyOperation = defineWorkspaceOperation({ @@ -67,6 +70,7 @@ const resourcePolicyOperation = defineWorkspaceOperation({ resourceType: 'credential_group', action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, }, + capability: 'none', }) interface TestInput { diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index 6cf9c176bc6..4d0bbedad0b 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -127,25 +127,21 @@ export function defineAuthorizedWorkspaceUseCase< R, >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { const resourceAuthorization = (() => { - if ('resourcePolicy' in definition.operation && definition.operation.resourcePolicy) { - const authorizeResource = definition.authorizeResource - if (!authorizeResource) { - throw new Error( - `Operation ${definition.operation.id} requires resource policy authorization` - ) - } - const resourcePolicy = definition.operation.resourcePolicy as ResourcePolicyForOperation - return (executionContext: AuthorizedWorkspaceUseCaseContext) => - authorizeResource({ - ...executionContext, - resourcePolicy, - } as AuthorizedWorkspaceResourceUseCaseContext) + const { authorizeResource, operation } = definition + const resourcePolicy = ('resourcePolicy' in operation ? operation.resourcePolicy : undefined) as + | ResourcePolicyForOperation + | undefined + + if (resourcePolicy && !authorizeResource) { + throw new Error(`Operation ${operation.id} requires resource policy authorization`) } - const authorizeResource = definition.authorizeResource - return authorizeResource - ? (executionContext: AuthorizedWorkspaceUseCaseContext) => - authorizeResource(executionContext as AuthorizedWorkspaceResourceUseCaseContext) - : undefined + if (!authorizeResource) return undefined + + return (executionContext: AuthorizedWorkspaceUseCaseContext) => + authorizeResource({ + ...executionContext, + resourcePolicy, + } as AuthorizedWorkspaceResourceUseCaseContext) })() /** diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index bc8da9e36f1..07ab19a680f 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -60,6 +60,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'CHAT_AUTH_MODE_NOT_PERMITTED', /** The resource is owned by a knowledge base connector and cannot be edited directly. */ 'CONNECTOR_MANAGED_RESOURCE_READ_ONLY', + /** The caller's permission group withholds a capability this operation needs. */ + 'PERMISSION_GROUP_CAPABILITY_BLOCKED', ] as const export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] @@ -105,6 +107,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { }) it('rejects an operation that names no principal', () => { - expect(() => defineOperation({ id: 'meta.empty', principalKinds: [] })).toThrow( - 'Operation meta.empty must allow at least one principal kind' - ) + expect(() => + defineOperation({ id: 'meta.empty', capability: 'none', principalKinds: [] }) + ).toThrow('Operation meta.empty must allow at least one principal kind') }) it('rejects a duplicated principal kind', () => { expect(() => - defineOperation({ id: 'meta.duplicate', principalKinds: ['session', 'session'] }) + defineOperation({ + id: 'meta.duplicate', + capability: 'none', + principalKinds: ['session', 'session'], + }) ).toThrow('Operation meta.duplicate declares duplicate principal kinds') }) }) @@ -59,3 +64,53 @@ describe('assertOperationPrincipal', () => { expect(thrown).not.toHaveProperty('code') }) }) + +/** + * The guard the type cannot give, because `apps/sim/tsconfig.json` excludes test + * files: a fixture is the one construction site no static check reads. + */ +describe('assertOperationCapability', () => { + it('refuses an operation that declares no capability', () => { + expect(() => + // @ts-expect-error a fixture is the one place an absent capability can be written + defineOperation({ id: 'meta.uncapped', principalKinds: ['session'] }) + ).toThrow("Operation meta.uncapped declares no capability; name one, or 'none' with a reason") + }) + + it('refuses a capability the registry does not declare', () => { + expect(() => + defineOperation({ + id: 'meta.unknown', + // @ts-expect-error the point of the test is a capability outside the registry + capability: 'meta.invented', + principalKinds: ['session'], + }) + ).toThrow('Operation meta.unknown names unknown capability meta.invented') + }) + + it('refuses a parameterized capability the funnel cannot apply', () => { + expect(() => + defineOperation({ + id: 'meta.parameterized', + // @ts-expect-error a parameterized capability is not a StaticPermissionGroupCapability + capability: 'deploy.chat.auth_mode', + principalKinds: ['session'], + }) + ).toThrow( + 'Operation meta.parameterized declares parameterized capability deploy.chat.auth_mode; assert it from the use case instead' + ) + }) + + it('refuses the principal-wide capability the funnel applies to every operation', () => { + expect(() => + defineOperation({ + id: 'meta.principal_wide', + // @ts-expect-error personal_api_key.use is not an OperationDeclarableCapability + capability: 'personal_api_key.use', + principalKinds: ['session'], + }) + ).toThrow( + "Operation meta.principal_wide declares principal-wide capability personal_api_key.use; the authorization funnel's personal-key branch already applies it to every operation" + ) + }) +}) diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 8742c338a1e..dc712d55a50 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -1,8 +1,64 @@ import type { Principal } from '@sim/auth/principal' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { + CAPABILITY_RULES, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' + +/** + * A capability that governs the PRINCIPAL rather than any one operation. + * + * `personal_api_key.use` asks whether this caller may hold a personal API key + * at all. It is answered once per request in the authorization funnel's + * personal-key branch — and in `resolvePersonalKeyGroupRefusal` for v1, which + * authorizes in its own middleware — for every operation alike, ahead of and + * independently of whatever module capability the operation names. + * + * Excluded from {@link OperationDeclarableCapability} because an operation that + * named it would be wrong either way: withheld, it would double-apply a refusal + * the funnel has already made in the caller's own words; and a session caller + * holding no API key at all would be refused an ordinary operation over a + * setting about credentials they are not using. + */ +export type PrincipalWideCapability = 'personal_api_key.use' + +/** + * The capabilities an operation may name — every static rule except the + * principal-wide ones, which no operation declares because the funnel asks them + * for all operations at once. + */ +export type OperationDeclarableCapability = Exclude< + StaticPermissionGroupCapability, + PrincipalWideCapability +> + +/** The runtime half of {@link PrincipalWideCapability}, for the builders' guard. */ +const PRINCIPAL_WIDE_CAPABILITIES: readonly PrincipalWideCapability[] = ['personal_api_key.use'] export interface ApplicationOperation { readonly id: Id + /** + * The capability a permission group must not have withheld, or `'none'` when + * no group governs this operation. + * + * `'none'` is spelled out rather than left as an omission, because an absent + * field cannot be told apart from an unreviewed one — and unreviewed omission + * is exactly how twelve config keys shipped with an admin checkbox and no + * server gate. + * + * It lives on the base rather than on {@link WorkspaceOperation} because + * requiring it only there is what let five OAuth-connection operations ship + * with no capability at all: their domain minted them from a bare object + * literal that satisfied `ApplicationOperation`, so neither the builder's + * definition-time guard nor the type reached them. Declared here, an operation + * that answers the question nowhere does not compile. + * + * The type is not the whole guarantee — `apps/sim/tsconfig.json` excludes test + * files, so a fixture can still construct one — which is why + * `defineWorkspaceOperation` and {@link defineOperation} keep runtime guards + * and `check:permission-group-enforcement` keeps reading the source. + */ + readonly capability: OperationDeclarableCapability | 'none' } /** @@ -37,6 +93,36 @@ export interface PrincipalScopedOperation< readonly principalKinds: PrincipalKinds } +/** + * Refuses a capability the registry does not know, and one whose rule needs a + * request value the funnel never sees. + * + * Shared by every builder, because the guard has to hold wherever an operation + * is minted: a domain builder that skipped it is how the hole opened last time. + */ +export function assertOperationCapability(operation: ApplicationOperation): void { + if (operation.capability === undefined) { + throw new Error( + `Operation ${operation.id} declares no capability; name one, or 'none' with a reason` + ) + } + if (operation.capability === 'none') return + if (PRINCIPAL_WIDE_CAPABILITIES.includes(operation.capability as PrincipalWideCapability)) { + throw new Error( + `Operation ${operation.id} declares principal-wide capability ${operation.capability}; the authorization funnel's personal-key branch already applies it to every operation` + ) + } + const rule = CAPABILITY_RULES[operation.capability] + if (!rule) { + throw new Error(`Operation ${operation.id} names unknown capability ${operation.capability}`) + } + if (rule.kind !== 'static') { + throw new Error( + `Operation ${operation.id} declares parameterized capability ${operation.capability}; assert it from the use case instead` + ) + } +} + export function defineOperation< const Id extends string, const PrincipalKinds extends readonly UndelegatedPrincipalKind[], @@ -49,6 +135,7 @@ export function defineOperation< if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) } + assertOperationCapability(operation) Object.freeze(operation.principalKinds) Object.freeze(operation) return operation diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index c53e266ca76..b19260d860b 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -3,15 +3,19 @@ */ import type { DelegatedPrincipal, + PersonalApiKeyPrincipal, SessionPrincipal, WorkspaceApiKeyPrincipal, } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (actual: string, required: string) => { const rank = { read: 1, write: 2, admin: 3 } as const @@ -20,21 +24,28 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + import { authorizeWorkspaceOperation, + capabilityGovernedPrincipalUserId, defineWorkspaceOperation, InsufficientWorkspacePermissionsError, NoWorkspaceAccessError, + PermissionGroupCapabilityError, + PersonalApiKeysDisabledError, PrincipalKindAuthorizationError, WorkspaceApiKeyAuthorizationError, WorkspaceApiKeyScopeAuthorizationError, } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const writeOperation = defineWorkspaceOperation({ id: 'test.write', minimumRole: 'write', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }) const principal: SessionPrincipal = { @@ -48,6 +59,7 @@ const workspaceKeyOperation = defineWorkspaceOperation({ minimumRole: 'write', workspaceApiKey: 'allow', principalKinds: ['workspace_api_key'], + capability: 'none', }) const workspaceKeyPrincipal: WorkspaceApiKeyPrincipal = { @@ -62,6 +74,7 @@ const executorOperation = defineWorkspaceOperation({ workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['executor'], + capability: 'none', }) function executorPrincipal( @@ -264,3 +277,351 @@ describe('authorizeWorkspaceOperation', () => { ) }) }) + +const capabilityOperation = defineWorkspaceOperation({ + id: 'test.capability-read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['executor'], + capability: 'tables.use', +}) + +const copilotCapabilityOperation = defineWorkspaceOperation({ + id: 'test.copilot-capability-read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + capability: 'tables.use', +}) + +const personalKeyPrincipal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-personal-1', +} + +const scopedWorkspaceKeyPrincipal: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', +} + +/** A config that withholds the capability the operation above declares. */ +function withholdingConfig() { + return { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideTablesTab: true } +} + +describe('authorizeWorkspaceOperation permission-group capability', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('refuses a session whose group withholds the capability', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + it('names the capability and a code a caller can branch on', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + const error = await authorizeWorkspaceOperation(principal, capabilityOperation, context).catch( + (thrown: unknown) => thrown + ) + + expect(error).toBeInstanceOf(PermissionGroupCapabilityError) + expect((error as PermissionGroupCapabilityError).capability).toBe('tables.use') + expect((error as PermissionGroupCapabilityError).detailCode).toBe( + 'PERMISSION_GROUP_CAPABILITY_BLOCKED' + ) + }) + + it('refuses a personal API key the same way', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, capabilityOperation, context) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + /** + * A run carries the triggering user's role but not their capabilities. The + * alternative would make "hide Tables from the sidebar" a runtime kill-switch + * for every workflow with a Table block, which is not what the checkbox says + * and not what an admin ticking it intends. + */ + it('does not apply to an executor run, even one carrying a user subject', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + subjectUserId: 'user-1', + }, + capabilityOperation, + context, + executorAuthorization + ) + ).resolves.toBeUndefined() + }) + + it('still enforces the workspace role for an executor run', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + subjectUserId: 'user-1', + }, + capabilityOperation, + context, + executorAuthorization + ) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + }) + + /** + * Copilot acts as the person, so it must not reach what the person may not. + */ + it('does apply to a Copilot delegation, which acts as the person', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + serviceId: 'copilot', + subjectUserId: 'user-1', + }, + copilotCapabilityOperation, + context, + executorAuthorization + ) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + }) + + /** + * A workspace API key authorizes as the workspace, so no group resolves for + * it. Documented policy rather than an oversight — the escape is closed by + * capability-gating key creation, not by guessing a user here. + */ + it('does not apply to a workspace API key, which has no user', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(scopedWorkspaceKeyPrincipal, capabilityOperation, context) + ).resolves.toBeUndefined() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + /** + * A deployment run has no subject, so denying here would 403 every schedule + * and webhook in the organization. What the run does is still gated by the + * executor. + */ + it('does not apply to an actorless deployment run', async () => { + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation( + executorPrincipal(undefined, { workflowId: 'current-workflow-1', mode: 'deployment' }), + capabilityOperation, + context, + executorAuthorization + ) + ).resolves.toBeUndefined() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + it('allows the operation when the group permits the capability', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).resolves.toBeUndefined() + }) + + it('allows the operation when no group governs the user', async () => { + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).resolves.toBeUndefined() + }) + + it('skips the lookup entirely for a workspace with no organization', async () => { + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, { + ...context, + workspaceOrganizationId: null, + }) + ).resolves.toBeUndefined() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + it('refuses on role before capability, so a non-member learns nothing about the group', async () => { + mocks.resolvePermission.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue(withholdingConfig()) + + await expect( + authorizeWorkspaceOperation(principal, capabilityOperation, context) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) +}) + +const personalKeyOperation = defineWorkspaceOperation({ + id: 'test.personal-key-read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + capability: 'none', +}) + +/** + * The workspace column and the group key combine with AND. The column is the + * coarse switch every workspace has; the group narrows it for one cohort inside + * an enterprise organization. + */ +describe('authorizeWorkspaceOperation personal API key policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('refuses when the permission group withholds personal keys', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + }) + + it('refuses when the workspace withholds them, without consulting the group', async () => { + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, { + ...context, + allowPersonalApiKeys: false, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + it('allows when both layers permit', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) + ).resolves.toBeUndefined() + }) + + /** + * A `NoWorkspaceAccessError` is concealed as a `404`, so it has to come first: + * a distinct `PersonalApiKeysDisabledError` would tell a caller with no reach + * into the workspace that it exists and that its organization withholds + * personal keys. + */ + it('conceals the workspace before refusing the group personal-key setting', async () => { + mocks.resolvePermission.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, context) + ).rejects.toBeInstanceOf(NoWorkspaceAccessError) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + /** + * The workspace column keeps its fail-fast: it is a property of the workspace + * rather than of any group, so it is not the organization-configuration + * oracle the group key would be, and three call-site suites pin the ordering. + */ + it('keeps refusing the workspace personal-key column before the role lookup', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + authorizeWorkspaceOperation(personalKeyPrincipal, personalKeyOperation, { + ...context, + allowPersonalApiKeys: false, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + + it('leaves a session principal alone', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalApiKeys: true, + }) + + await expect( + authorizeWorkspaceOperation(principal, personalKeyOperation, context) + ).resolves.toBeUndefined() + }) +}) + +/** + * The sites that cannot ride on an operation's `capability` — they need the + * resource in hand — read the governed person from here. Pinned against the + * funnel above, because the tempting alternatives are all bystanders: an + * attribution helper's billing owner, a workspace key's creator, or the subject + * of an executor run the funnel exempts. + */ +describe('capabilityGovernedPrincipalUserId', () => { + it('names the person for a session and a personal key', () => { + expect(capabilityGovernedPrincipalUserId(principal)).toBe('user-1') + expect( + capabilityGovernedPrincipalUserId({ + kind: 'personal_api_key', + userId: 'user-2', + keyId: 'key-1', + }) + ).toBe('user-2') + }) + + it('names nobody for a workspace key, which has no user', () => { + expect(capabilityGovernedPrincipalUserId(workspaceKeyPrincipal)).toBeNull() + }) + + it('names nobody for an executor run, subject or not', () => { + expect(capabilityGovernedPrincipalUserId(executorPrincipal(undefined))).toBeNull() + expect( + capabilityGovernedPrincipalUserId({ + ...executorPrincipal(undefined), + subjectUserId: 'user-1', + }) + ).toBeNull() + }) + + it('names the person a non-executor delegation acts as', () => { + expect( + capabilityGovernedPrincipalUserId({ + ...executorPrincipal(undefined), + serviceId: 'copilot', + subjectUserId: 'user-3', + }) + ).toBe('user-3') + }) +}) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index afbd6c5342f..167a7972234 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -15,6 +15,44 @@ import type { WorkspaceOperation, } from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + assertWorkspaceCapability, + capabilityDeniedBy, +} from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' + +/** + * The person whose permission group governs `principal`, or `null` when no + * group applies to it. + * + * THE one statement of that rule, for the checks that cannot ride on an + * operation's `capability` because they need the resource in hand — an import's + * block allowlist, a bulk download's item count. Those sites otherwise reach for + * whatever user id is nearest, and the nearest one is usually a bystander: a + * workspace key's creator, or the billing owner an attribution helper + * substituted. Both would apply a stranger's group to a caller the funnel + * deliberately passes ungated. + * + * Mirrors {@link authorizeWorkspaceOperation} exactly, including its executor + * exemption: a run carries the role of whoever triggered it but not their + * capabilities. + */ +export function capabilityGovernedPrincipalUserId(principal: Principal): string | null { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'workspace_api_key': + case 'system': + case 'credential_group_enrollment': + return null + case 'delegated': { + if (principal.serviceId === 'executor') return null + const subject = resolvePrincipalSubject(principal) + return subject?.kind === 'sim_user' ? subject.userId : null + } + } +} export interface WorkspaceAuthorizationContext { workspaceId: string @@ -149,7 +187,68 @@ function requirePermission(permission: PermissionType | null, required: Permissi } } -async function requireCurrentHumanPermission( +/** + * Refuses an operation whose capability the caller's permission group withholds. + * + * Runs only for a principal that stands for a person, because a permission + * group is a membership of users — see {@link authorizeWorkspaceOperation} for + * why an actorless caller passes through rather than being denied. + */ +async function requireCapability( + userId: string, + context: WorkspaceAuthorizationContext, + operation: WorkspaceOperation +): Promise { + const capability = operation.capability + if (capability === 'none') return + if (context.workspaceOrganizationId === null) return + + await assertWorkspaceCapability( + userId, + context.workspaceId, + capability, + context.workspaceOrganizationId + ) +} + +/** + * Refuses a personal API key the caller's permission group withholds. + * + * Separate from {@link requireCapability} because it is not a property of the + * operation: no operation opts into it, and every operation a personal key can + * reach is subject to it. + * + * Exported for the one authorization path that does not run through + * {@link authorizeWorkspaceOperation} — the billing reads, which resolve their + * own workspace scope. One copy, or the same key the funnel refuses keeps + * working somewhere. + */ +export async function requirePersonalApiKeysAllowed( + userId: string, + context: WorkspaceAuthorizationContext +): Promise { + if (context.workspaceOrganizationId === null) return + + const config = await resolvePermissionGroupConfig( + userId, + context.workspaceId, + context.workspaceOrganizationId + ) + if (capabilityDeniedBy('personal_api_key.use', config)) throw new PersonalApiKeysDisabledError() +} + +/** + * The workspace role check, then the permission-group capability check. + * + * Capability comes second on purpose. `requirePermission` throws + * {@link NoWorkspaceAccessError}, which the v2 surface conceals as a `404` so a + * non-member cannot learn the resource exists; refusing on capability first + * would tell a complete outsider which capabilities the organization withholds. + * It is also the cheaper check, and it names the remedy a caller can act on — + * raising a role, rather than chasing an admin about a group setting that is + * not why they were refused. + */ +async function requireCurrentHumanRole( userId: string, context: C, required: PermissionType, @@ -165,6 +264,16 @@ async function requireCurrentHumanPermission( + userId: string, + context: C, + operation: WorkspaceOperation, + options?: WorkspaceAuthorizationOptions +): Promise { + await requireCurrentHumanRole(userId, context, operation.minimumRole, options) + await requireCapability(userId, context, operation) +} + export async function authorizeWorkspaceOperation( principal: Principal, operation: WorkspaceOperation, @@ -175,14 +284,48 @@ export async function authorizeWorkspaceOperation { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot', 'executor'], + capability: 'none', }) expect(operation.delegatedServices).toEqual(['copilot', 'executor']) @@ -26,6 +27,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], + capability: 'none', } as never) ).toThrow('Operation test.missing_service_policy has inconsistent delegated service policy') }) @@ -38,6 +40,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { workspaceApiKey: 'deny', principalKinds: ['session'], delegatedServices: ['copilot'], + capability: 'none', } as never) ).toThrow('Operation test.unused_service_policy has inconsistent delegated service policy') }) @@ -50,6 +53,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { workspaceApiKey: 'deny', principalKinds: ['delegated'], delegatedServices: ['copilot', 'copilot'], + capability: 'none', } as never) ).toThrow('Operation test.duplicate_service_policy declares duplicate delegated services') }) @@ -65,6 +69,7 @@ describe('defineWorkspaceOperation delegated service policy', () => { resourceType: 'credential_group', action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, }, + capability: 'none', }) expect(operation.resourcePolicy).toEqual({ @@ -86,7 +91,69 @@ describe('defineWorkspaceOperation delegated service policy', () => { resourceType: 'credential_group', action: 'credentials.invalid', }, + capability: 'none', } as never) ).toThrow('Action credentials.invalid does not apply to resource policy type credential_group') }) }) + +/** + * The one construction site the static checks cannot see. + * + * `apps/sim/tsconfig.json` excludes test files, and + * `check-permission-group-enforcement.ts` walks past them, so a fixture can + * omit `capability` and nothing complains — which is how every fixture in this + * file used to be written. These assert the runtime guard that stands in for + * the type here, so deleting it as unreachable turns this suite red. + */ +describe('defineWorkspaceOperation capability policy', () => { + it('refuses an operation that declares no capability', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.no_capability', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + } as never) + ).toThrow( + "Operation test.no_capability declares no capability; name one, or 'none' with a reason" + ) + }) + + it('refuses a capability the registry does not define', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.unknown_capability', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'tables.definitely_not_a_capability', + } as never) + ).toThrow('Operation test.unknown_capability names unknown capability') + }) + + it('refuses a parameterized capability, which the funnel could never apply', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.parameterized', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + // @ts-expect-error a parameterized capability is not assignable to the field + capability: 'deploy.chat.auth_mode', + }) + ).toThrow(/parameterized capability/) + }) + + it('accepts an explicit opt-out', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.ungoverned', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + capability: 'none', + }) + ).not.toThrow() + }) +}) diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index 45f35de22d1..fa14e5d291b 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,6 +1,7 @@ import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation, PrincipalKind } from '@/lib/core/application/operation' +import { assertOperationCapability } from '@/lib/core/application/operation' import { type ResourcePolicyBinding, requireResourcePolicyBinding, @@ -8,8 +9,6 @@ import { type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' -export type { PrincipalKind } - type WorkspaceOperationPrincipal = Extract type NonDelegatedPrincipalForOperation< @@ -108,6 +107,22 @@ export function defineWorkspaceOperation< if (operation.resourcePolicy) requireResourcePolicyBinding(operation.resourcePolicy) + /** + * `capability` is required, so refusing an absent one reads as unreachable. + * It is not. `apps/sim/tsconfig.json` excludes test files from type-checking, + * and `check-permission-group-enforcement.ts` walks past them too, so a test + * fixture is the one construction site no static check reads — and fixtures + * are where an operation is written from memory rather than from the + * surrounding domain. + * + * Left to reach authorization, an absent capability does not deny; it throws + * `Cannot read properties of undefined` from inside `capabilityDeniedBy`, and + * only for a caller whose organization has a permission group. It would pass + * every personal workspace and every non-enterprise test, then fail in the + * tenants that bought the feature. Named here instead, at definition time. + */ + assertOperationCapability(operation) + Object.freeze(operation.principalKinds) if (operation.delegatedServices) Object.freeze(operation.delegatedServices) if (operation.resourcePolicy) Object.freeze(operation.resourcePolicy) diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 86e41967ada..3e225c3b9d4 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -5,6 +5,7 @@ import { NextResponse } from 'next/server' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' const logger = createLogger('RouteHandler') @@ -114,7 +115,7 @@ export function withRouteHandler( return runWithRequestContext({ requestId, method, path, traceId }, async () => { let response: NextResponse | Response try { - response = await handler(request, context) + response = await withPermissionGroupScope(() => handler(request, context)) } catch (error) { const duration = Date.now() - startTime const message = getErrorMessage(error, 'Unknown error') diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts index b53788bda45..eb17a758bca 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-operations.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -1,4 +1,5 @@ import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' export interface CredentialGroupEnrollmentOperation extends ApplicationOperation { @@ -9,24 +10,33 @@ function defineCredentialGroupEnrollmentOperation( operation: CredentialGroupEnrollmentOperation ): CredentialGroupEnrollmentOperation { if (!operation.id.trim()) throw new Error('Credential Group enrollment operation ID is required') + assertOperationCapability(operation) return Object.freeze(operation) } export const credentialGroupEnrollmentOperations = { + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it read: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.read', + capability: 'none', principalKind: 'credential_group_enrollment', }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it startOAuth: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.oauth.start', + capability: 'none', principalKind: 'credential_group_enrollment', }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it completeOAuth: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.oauth.complete', + capability: 'none', principalKind: 'credential_group_enrollment', }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it complete: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.complete', + capability: 'none', principalKind: 'credential_group_enrollment', }), } as const diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index 9339f5dfd9f..409920b039e 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -1,111 +1,159 @@ import { defineWorkspaceOperation } from '@/lib/core/application' +/** + * Credential groups collect OAuth credentials from people outside the workspace + * so a workflow can act as them — a distinct, entitlement-gated settings + * section, not part of the Integrations tab. + * + * None of them declares a capability. `integrations.manage` names the + * Integrations tab, where a member connects their own accounts, and + * `credentials.personal` withholds exactly that; both describe a member acting + * for themselves, which is the opposite of this section. Every operation here + * already requires workspace `admin`, and the executor-delegated reads run + * inside a workflow whose credential access is decided by the group's own + * enrollment rows. Borrowing a key that names a different surface would make + * hiding the Integrations tab silently disable an unrelated admin section. + */ export const credentialGroupOperations = { + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section listSettings: defineWorkspaceOperation({ id: 'credential_groups.settings.list', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section create: defineWorkspaceOperation({ id: 'credential_groups.create', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section readSettings: defineWorkspaceOperation({ id: 'credential_groups.settings.read', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section update: defineWorkspaceOperation({ id: 'credential_groups.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section readAccess: defineWorkspaceOperation({ id: 'credential_groups.access.read', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section updateAccess: defineWorkspaceOperation({ id: 'credential_groups.access.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section delete: defineWorkspaceOperation({ id: 'credential_groups.delete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section inviteBatch: defineWorkspaceOperation({ id: 'credential_groups.invites.send_batch', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section resendEnrollment: defineWorkspaceOperation({ id: 'credential_groups.enrollments.resend', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section deleteEnrollment: defineWorkspaceOperation({ id: 'credential_groups.enrollments.delete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listCredentials: defineWorkspaceOperation({ id: 'credential_groups.credentials.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listGroups: defineWorkspaceOperation({ id: 'credential_groups.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listPeople: defineWorkspaceOperation({ id: 'credential_groups.people.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: enrolls an outside person in a credential group, not a member in a workspace, so invitations.send does not name it sendInvite: defineWorkspaceOperation({ id: 'credential_groups.invites.send', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: mints an enrollment link for an outside person, not a workspace invitation, so invitations.send does not name it createInviteLink: defineWorkspaceOperation({ id: 'credential_groups.invites.link.create', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section startSlackConfiguration: defineWorkspaceOperation({ id: 'credential_groups.slack_configuration.start', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section completeSlackConfiguration: defineWorkspaceOperation({ id: 'credential_groups.slack_configuration.complete', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), } as const diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts index 65a3b7a9133..ea01ec344ed 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -31,6 +31,7 @@ const memberOperation = defineCredentialOperation( minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'integrations.manage', }), 'member' ) @@ -40,6 +41,7 @@ const adminOperation = defineCredentialOperation( minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'integrations.manage', }), 'admin' ) diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts index 95fd6e08712..77010182ee9 100644 --- a/apps/sim/lib/credentials/application/authorized-user-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -1,9 +1,12 @@ import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import { getUserOrganization } from '@/lib/billing/organizations/membership' import type { OperationUseCase } from '@/lib/core/application' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { CredentialUserOperation } from '@/lib/credentials/application/operations' +import { refuseCapability } from '@/lib/permission-groups/capabilities' +import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' export interface CredentialUserAuditEntry { workspaceId: string | null @@ -65,6 +68,38 @@ function recordCredentialUserAudit( } } +/** + * Refuses when the group governing the acting user withholds the operation's + * capability. + * + * permission-group-enforced: integrations.manage — these operations have no + * workspace, so `authorizeWorkspaceOperation` never sees them and the capability + * is applied here instead, at the one place every current-user credential + * operation passes through. + * + * The user's own OAuth connections belong to no workspace, so this resolves the + * organization's default group — the same resolution personal API keys and + * invitations use for an organization-level action. A no-op when the user is in + * no organization or no group governs them, which is the personal-workspace and + * non-enterprise case. + * + * Runs after the session-principal check above, never before: the principal kind + * is this operation's whole access story, and answering the capability question + * first would tell a caller who is not a session about the organization's + * configuration. + */ +async function assertCurrentUserCapability( + userId: string, + operation: CredentialUserOperation +): Promise { + if (operation.capability === 'none') return + const membership = await getUserOrganization(userId) + if (!membership?.organizationId) return + if (await isOrganizationCapabilityWithheld(membership.organizationId, operation.capability)) { + refuseCapability(operation.capability) + } +} + /** Defines a current-user credential operation that cannot borrow workspace identity. */ export function defineAuthorizedCredentialUserUseCase< const O extends CredentialUserOperation, @@ -77,6 +112,7 @@ export function defineAuthorizedCredentialUserUseCase< if (principal.kind !== 'session') { throw new OrchestrationError('forbidden', 'Session authentication required') } + await assertCurrentUserCapability(principal.userId, definition.operation) try { const result = await definition.execute({ principal, input, request }) recordCredentialUserAudit( diff --git a/apps/sim/lib/credentials/application/capability-gate.test.ts b/apps/sim/lib/credentials/application/capability-gate.test.ts new file mode 100644 index 00000000000..247a3ae1260 --- /dev/null +++ b/apps/sim/lib/credentials/application/capability-gate.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + * + * The current-user credential operations govern listing and disconnecting a + * user's OAuth connections. They are minted by `defineCredentialUserOperation`, + * which does not call `defineWorkspaceOperation`, so `authorizeWorkspaceOperation` + * never sees them and they shipped with no capability at all — a member whose + * group revokes Integrations could still enumerate and disconnect every + * connection. These pin the gate through the real routes, so the refusal the + * caller actually receives is what is asserted. + * + * They have no workspace, so the gate resolves the organization's default group + * — the same resolution personal API keys use for an organization-level action. + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetUserOrganization, + mockGetOrgPermissionConfig, + mockListOAuthConnectionsForUser, + mockListConnectedAccountsForUser, + mockDisconnectOAuthAccounts, +} = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockListOAuthConnectionsForUser: vi.fn(), + mockListConnectedAccountsForUser: vi.fn(), + mockDisconnectOAuthAccounts: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: vi.fn(), + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/oauth-accounts', () => ({ + listOAuthConnectionsForUser: mockListOAuthConnectionsForUser, + listConnectedAccountsForUser: mockListConnectedAccountsForUser, + disconnectOAuthAccounts: mockDisconnectOAuthAccounts, + OAuthDisconnectPartialFailureError: class OAuthDisconnectPartialFailureError extends Error { + credentials: unknown[] = [] + }, +})) + +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { GET as listConnectedAccounts } from '@/app/api/auth/accounts/route' +import { GET as listConnections } from '@/app/api/auth/oauth/connections/route' +import { POST as disconnect } from '@/app/api/auth/oauth/disconnect/route' + +const USER_ID = 'user-1' +const ORGANIZATION_ID = 'org-1' + +const mockGetSession = authMockFns.mockGetSession + +function callListConnections() { + return listConnections(createMockRequest('GET'), { params: Promise.resolve({}) }) +} + +function callListConnectedAccounts() { + return listConnectedAccounts( + createMockRequest('GET', undefined, {}, 'http://localhost/api/auth/accounts'), + { params: Promise.resolve({}) } + ) +} + +function callDisconnect() { + return disconnect(createMockRequest('POST', { provider: 'google' }), { + params: Promise.resolve({}), + }) +} + +const INTEGRATIONS_REFUSAL = capabilityRefusal('integrations.manage') + +describe('integrations.manage gate on the current-user credential operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: USER_ID }, + session: { id: 'session-1' }, + }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: ORGANIZATION_ID, + role: 'member', + memberId: 'member-1', + }) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockListOAuthConnectionsForUser.mockResolvedValue([]) + mockListConnectedAccountsForUser.mockResolvedValue([]) + mockDisconnectOAuthAccounts.mockResolvedValue({ + credentials: [], + provider: 'google', + providerId: undefined, + }) + }) + + describe('when the group withholds Integrations', () => { + beforeEach(() => { + mockGetOrgPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + }) + }) + + it('refuses to enumerate the OAuth connections, and never reads them', async () => { + const response = await callListConnections() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: INTEGRATIONS_REFUSAL }) + expect(mockListOAuthConnectionsForUser).not.toHaveBeenCalled() + }) + + it('refuses to list the connected accounts, and never reads them', async () => { + const response = await callListConnectedAccounts() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: INTEGRATIONS_REFUSAL }) + expect(mockListConnectedAccountsForUser).not.toHaveBeenCalled() + }) + + it('refuses the disconnect, and never deletes a credential', async () => { + const response = await callDisconnect() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: INTEGRATIONS_REFUSAL }) + expect(mockDisconnectOAuthAccounts).not.toHaveBeenCalled() + }) + + /** + * Concealment: authentication still runs first, so an unauthenticated + * caller is told to authenticate rather than told how someone else's + * organization is configured. + */ + it('still answers an unauthenticated caller with 401, not the capability', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await callListConnections() + + expect(response.status).toBe(401) + expect(mockGetOrgPermissionConfig).not.toHaveBeenCalled() + }) + }) + + describe('when no group governs the caller', () => { + it('lists the OAuth connections', async () => { + const response = await callListConnections() + + expect(response.status).toBe(200) + expect(mockListOAuthConnectionsForUser).toHaveBeenCalledWith(USER_ID) + }) + + it('disconnects', async () => { + const response = await callDisconnect() + + expect(response.status).toBe(200) + expect(mockDisconnectOAuthAccounts).toHaveBeenCalledTimes(1) + }) + + /** + * The personal-workspace case: a user in no organization has no group to + * resolve, so the gate is a no-op and never asks. + */ + it('does not even resolve a group for a user in no organization', async () => { + mockGetUserOrganization.mockResolvedValue(null) + + const response = await callListConnections() + + expect(response.status).toBe(200) + expect(mockGetOrgPermissionConfig).not.toHaveBeenCalled() + }) + }) + + describe('when a group governs the caller but permits Integrations', () => { + it('lets the disconnect through', async () => { + mockGetOrgPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideSecretsTab: true, + }) + + const response = await callDisconnect() + + expect(response.status).toBe(200) + expect(mockDisconnectOAuthAccounts).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index de2a9cd71c9..fc6cef25692 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -7,6 +7,11 @@ const mocks = vi.hoisted(() => ({ listCatalog: vi.fn(), getWorkspaceCredential: vi.fn(), getCredentialActorContext: vi.fn(), + assertWorkspaceCapability: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/capability-assertions', () => ({ + assertWorkspaceCapability: mocks.assertWorkspaceCapability, })) vi.mock('@/lib/credentials/application/provider-catalog', () => ({ @@ -85,6 +90,28 @@ describe('resolveCredentialConnectionTarget', () => { mocks.listCatalog.mockResolvedValue([salesforceProvider]) mocks.getWorkspaceCredential.mockResolvedValue(credential) mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + mocks.assertWorkspaceCapability.mockResolvedValue(undefined) + }) + + /** + * `disablePersonalCredentials` leaves members "only workspace-shared ones", so + * it withholds connecting an account and not re-authorizing a credential the + * workspace already holds. Declaring it on the operation refused both. + */ + it('asserts the personal-credential capability only when connecting an account', async () => { + await resolveCredentialConnectionTarget({ principal, context, providerId: 'salesforce' }) + + expect(mocks.assertWorkspaceCapability).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'credentials.personal', + null + ) + + mocks.assertWorkspaceCapability.mockClear() + await resolveCredentialConnectionTarget({ principal, context, credentialId: 'credential-1' }) + + expect(mocks.assertWorkspaceCapability).not.toHaveBeenCalled() }) it('accepts an exact authorization option for a new connection', async () => { diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 6c98ac540ce..c30d685d38a 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' @@ -10,6 +11,7 @@ import { } from '@/lib/credentials/application/provider-catalog' import { getWorkspaceCredential } from '@/lib/credentials/queries' import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ResolvedCredentialConnectionTarget { @@ -40,6 +42,28 @@ export async function resolveCredentialConnectionTarget(params: { const catalog = await listCredentialProviderCatalog(principal, context) if (providerId) { + /** + * permission-group-enforced: credentials.personal — scope is the request's + * target, not a property of the operation, exactly as it is for + * `credentials.create`. + * + * Only this branch connects a personal account. Reconnecting re-authorizes a + * credential the workspace already holds, which is the very thing + * `disablePersonalCredentials` leaves members ("leaving only workspace-shared + * ones"), so gating the reconnect on it would withhold the credentials that + * setting mandates. The operations declare `integrations.manage`, which + * governs both branches; this narrower one is asserted where the act + * actually is personal. + */ + const governedUserId = capabilityGovernedPrincipalUserId(principal) + if (governedUserId) { + await assertWorkspaceCapability( + governedUserId, + context.workspaceId, + 'credentials.personal', + context.workspaceOrganizationId + ) + } return { provider: requireAvailableOAuthCredentialProvider(catalog, providerId), providerId, diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index c89fc68718c..1f095959a97 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -31,6 +31,7 @@ vi.mock('@/lib/credentials/connect-draft', () => ({ vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl, + SITE_URL: 'http://localhost:3000', })) import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index 83e98bee4f7..c90980159b9 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -1,7 +1,12 @@ /** * @vitest-environment node */ -import { auditMock, auditMockFns } from '@sim/testing' +import { + auditMock, + auditMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -11,8 +16,11 @@ const mocks = vi.hoisted(() => ({ getCredentialById: vi.fn(), getActor: vi.fn(), updateRecord: vi.fn(), + createRecord: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, @@ -32,17 +40,22 @@ vi.mock('@/lib/credentials/access', () => ({ })) vi.mock('@/lib/credentials/orchestration', () => ({ updateCredentialRecord: mocks.updateRecord, - createCredentialRecord: vi.fn(), + createCredentialRecord: mocks.createRecord, isProviderOutageCode: () => false, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/credentials/oauth', () => ({ syncWorkspaceOAuthCredentialsForUser: vi.fn() })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: vi.fn() })) +import { PermissionGroupCapabilityError } from '@/lib/core/application' import { CredentialProviderOperationError, + createWorkspaceCredential, updateWorkspaceCredentialUseCase, } from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const WORKSPACE_ID = 'workspace-1' const OTHER_WORKSPACE_ID = 'workspace-2' @@ -299,3 +312,104 @@ describe('updateWorkspaceCredentialUseCase', () => { expect(error.code).toBe('validation') }) }) + +describe('personal-credential capability', () => { + const ORGANIZATION_ID = 'organization-1' + const governedWorkspace = { ...workspace, workspaceOrganizationId: ORGANIZATION_ID } + + function createdCredential(type: 'env_personal' | 'env_workspace') { + return { ...credential, type, envKey: 'OPENAI_API_KEY', encryptedServiceAccountKey: null } + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(governedWorkspace) + mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disablePersonalCredentials: true, + }) + }) + + /** + * A connection operation takes a target, not a scope: the same operation + * connects an account and re-authorizes a workspace-shared credential. + * `credentials.personal` belongs to the first branch only and is asserted + * there; the operation carries the capability that governs both. Pinned + * because declaring the narrower one here compiles just as well, and it + * refused the shared credentials that setting exists to mandate. + */ + it.each(['createConnection', 'prepareConnection', 'launchConnection'] as const)( + 'declares the capability on %s that governs both of its targets', + (operationName) => { + expect(credentialOperations[operationName].capability).toBe('integrations.manage') + } + ) + + it('refuses a personal environment secret before it reaches the manager', async () => { + await expect( + createWorkspaceCredential.execute({ + principal: sessionPrincipal, + input: { + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'My OpenAI key', + envKey: 'OPENAI_API_KEY', + }, + }) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + + expect(mocks.createRecord).not.toHaveBeenCalled() + }) + + /** + * Scope is the request's `type`, so the same operation must still serve the + * workspace-shared secret the organization is steering members toward. + */ + it('still creates a workspace-shared secret under the same restriction', async () => { + const created = createdCredential('env_workspace') + mocks.createRecord.mockResolvedValue({ success: true, created: true, credential: created }) + mocks.getActor.mockResolvedValue({ + credential: created, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await createWorkspaceCredential.execute({ + principal: sessionPrincipal, + input: { + workspaceId: WORKSPACE_ID, + type: 'env_workspace', + displayName: 'Shared OpenAI key', + envKey: 'OPENAI_API_KEY', + }, + }) + + expect(result.credential).toEqual(created) + }) + + it('creates the personal secret when no group withholds it', async () => { + resolveGroupConfigMock.mockResolvedValue(null) + const created = createdCredential('env_personal') + mocks.createRecord.mockResolvedValue({ success: true, created: true, credential: created }) + mocks.getActor.mockResolvedValue({ + credential: created, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await createWorkspaceCredential.execute({ + principal: sessionPrincipal, + input: { + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'My OpenAI key', + envKey: 'OPENAI_API_KEY', + }, + }) + + expect(result.credential).toEqual(created) + }) +}) diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index c28bb5302a0..e0ba3090979 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -29,6 +29,7 @@ import { } from '@/lib/credentials/queries' import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -166,6 +167,16 @@ export interface CreateWorkspaceCredentialResult { auditMetadata: Record } +/** + * The credential types that belong to one person rather than to the workspace: + * a personal environment secret, and an OAuth grant bound to the connecting + * user's own linked account. `env_workspace`, `service_account` and + * `managed_oauth` are workspace-shared and stay available. + */ +const PERSONAL_SCOPE_CREDENTIAL_TYPES: ReadonlySet = new Set( + ['env_personal', 'oauth'] +) + export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.create, resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { @@ -174,8 +185,23 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ return context }, authorizationOptions: {}, - async execute({ principal, input }): Promise { + async execute({ principal, input, context }): Promise { const userId = requirePrincipalSubjectUserId(principal) + /** + * permission-group-enforced: credentials.personal — scope is the request's + * `type`, not a property of the operation: the same `credentials.create` + * makes a personal secret and a workspace-shared one. Declaring the + * capability on the operation would refuse both, so it is asserted here + * against the type actually being created. + */ + if (PERSONAL_SCOPE_CREDENTIAL_TYPES.has(input.type)) { + await assertWorkspaceCapability( + userId, + context.workspaceId, + 'credentials.personal', + context.workspaceOrganizationId + ) + } const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) if (!result.success) throwCredentialMutationFailure(result) if (!result.credential) throw new Error('Credential creation succeeded without a credential') diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts index 561ae6a130d..58a2262ce60 100644 --- a/apps/sim/lib/credentials/application/credential-members.ts +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -15,6 +15,7 @@ import { removeCredentialMember, upsertCredentialMember, } from '@/lib/credentials/members' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { captureServerEvent } from '@/lib/posthog/server' interface CredentialMemberResourceInput { @@ -124,10 +125,46 @@ export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ }, }) +/** + * Every credential names a workspace (`credential.workspace_id` is NOT NULL), so + * the rows this user-global listing returns are workspace resources reached + * without naming a workspace. The endpoint's own gate resolves the caller's + * organization default group, which is right for the *act* — it belongs to the + * person, not to any one workspace — but it cannot answer per row. + * + * So each row is projected against the group governing **this same user** in the + * workspace holding that credential. That is the person's own group, not a + * bystander's: `credentials.list` withholds exactly these rows from them in that + * workspace under `integrations.manage`, and a listing that names no workspace + * must not be the way back to what the workspace-scoped listing hides. + * + * Only the projection. Leaving a membership stays ungoverned by the workspace + * group on purpose: it revokes the caller's own access and grants nothing, so + * gating it would strand a member inside a credential share they can no longer + * see — the same reasoning that keeps pausing a knowledge connector available + * after its type leaves the allowlist. + * + * The capability is read off the operation rather than spelled out here, so the + * projection follows the declaration if it is ever renamed. + */ export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ operation: credentialUserOperations.listMemberships, async execute({ principal }) { - return { memberships: await listCredentialMembershipsForUser(principal.userId) } + const memberships = await listCredentialMembershipsForUser(principal.userId) + const capability = credentialUserOperations.listMemberships.capability + if (capability === 'none') return { memberships } + const workspaceIds = [...new Set(memberships.map((membership) => membership.workspaceId))] + const withheld = new Set() + await Promise.all( + workspaceIds.map(async (workspaceId) => { + if (await isWorkspaceCapabilityWithheld(principal.userId, workspaceId, capability)) { + withheld.add(workspaceId) + } + }) + ) + return { + memberships: memberships.filter((membership) => !withheld.has(membership.workspaceId)), + } }, }) diff --git a/apps/sim/lib/credentials/application/membership-projection.test.ts b/apps/sim/lib/credentials/application/membership-projection.test.ts new file mode 100644 index 00000000000..9f92845b680 --- /dev/null +++ b/apps/sim/lib/credentials/application/membership-projection.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + * + * `GET /api/credentials/memberships` names no workspace, so its own gate reads + * the caller's organization default group. Every credential it returns does name + * one (`credential.workspace_id` is NOT NULL), and `credentials.list` withholds + * those same rows inside the workspace under `integrations.manage`. These pin + * that the user-global listing is not the way back to what the workspace-scoped + * listing hides — projected against this user's own group in each workspace, + * never a bystander's — and that leaving a membership stays available. + */ +import { authMockFns, createMockRequest, permissionGroupScopeMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserOrganization, mockGetOrgPermissionConfig, mockList, mockLeave } = vi.hoisted( + () => ({ + mockGetUserOrganization: vi.fn(), + mockGetOrgPermissionConfig: vi.fn(), + mockList: vi.fn(), + mockLeave: vi.fn(), + }) +) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: vi.fn(), + getUserPermissionConfigForOrganization: mockGetOrgPermissionConfig, + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/credentials/members', () => ({ + leaveCredentialMembership: mockLeave, + listCredentialMembers: vi.fn(), + listCredentialMembershipsForUser: mockList, + removeCredentialMember: vi.fn(), + upsertCredentialMember: vi.fn(), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { DELETE, GET } from '@/app/api/credentials/memberships/route' + +const USER_ID = 'user-1' +const GOVERNED_WORKSPACE = 'workspace-governed' +const OPEN_WORKSPACE = 'workspace-open' + +const mockGetSession = authMockFns.mockGetSession +const mockResolveConfig = permissionGroupScopeMock.resolvePermissionGroupConfig + +function membership(id: string, workspaceId: string) { + return { + membershipId: `membership-${id}`, + credentialId: id, + workspaceId, + type: 'oauth' as const, + displayName: id, + providerId: 'google', + role: 'member' as const, + status: 'active' as const, + joinedAt: null, + } +} + +function callList() { + return GET( + createMockRequest('GET', undefined, {}, 'http://localhost/api/credentials/memberships'), + { params: Promise.resolve({}) } + ) +} + +function callLeave(credentialId: string) { + return DELETE( + createMockRequest( + 'DELETE', + undefined, + {}, + `http://localhost/api/credentials/memberships?credentialId=${credentialId}` + ), + { params: Promise.resolve({}) } + ) +} + +describe('credential membership listing under a workspace group', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveConfig.mockReset() + mockGetSession.mockResolvedValue({ user: { id: USER_ID }, session: { id: 'session-1' } }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetOrgPermissionConfig.mockResolvedValue(null) + mockList.mockResolvedValue([ + membership('cred-governed', GOVERNED_WORKSPACE), + membership('cred-open', OPEN_WORKSPACE), + ]) + mockLeave.mockResolvedValue(undefined) + mockResolveConfig.mockImplementation(async (_userId: string, workspaceId: string) => + workspaceId === GOVERNED_WORKSPACE + ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideIntegrationsTab: true } + : null + ) + }) + + it('drops the rows whose workspace withholds Integrations from this user', async () => { + const response = await callList() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.memberships.map((row: { credentialId: string }) => row.credentialId)).toEqual([ + 'cred-open', + ]) + }) + + it('resolves the group as the caller themself, in each credential’s workspace', async () => { + await callList() + + expect(mockResolveConfig).toHaveBeenCalledWith(USER_ID, GOVERNED_WORKSPACE, undefined) + expect(mockResolveConfig).toHaveBeenCalledWith(USER_ID, OPEN_WORKSPACE, undefined) + }) + + it('asks once per workspace, not once per credential', async () => { + mockList.mockResolvedValue([ + membership('cred-a', GOVERNED_WORKSPACE), + membership('cred-b', GOVERNED_WORKSPACE), + membership('cred-c', OPEN_WORKSPACE), + ]) + + await callList() + + expect(mockResolveConfig).toHaveBeenCalledTimes(2) + }) + + it('returns every row when no group governs the caller anywhere', async () => { + mockResolveConfig.mockResolvedValue(null) + + const response = await callList() + + const body = await response.json() + expect(body.memberships).toHaveLength(2) + }) + + /** + * Leaving revokes the caller's own access and grants nothing, so a workspace + * that hides the module must not strand them inside the share. + */ + it('still lets the member leave a credential in the withholding workspace', async () => { + const response = await callLeave('cred-governed') + + expect(response.status).toBe(200) + expect(mockLeave).toHaveBeenCalledWith({ userId: USER_ID, credentialId: 'cred-governed' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 77737952366..975e3b14668 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -44,6 +44,7 @@ describe('credential operations', () => { minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['workspace_api_key'], + capability: 'integrations.manage', }) expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4753c3136a8..4bda143e590 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,5 +1,6 @@ -import type { ApplicationOperation } from '@/lib/core/application' +import type { ApplicationOperation, OperationDeclarableCapability } from '@/lib/core/application' import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' export type CredentialRole = 'member' | 'admin' @@ -34,30 +35,46 @@ export const credentialOperations = { id: 'credentials.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), listProviders: defineWorkspaceOperation({ id: 'credentials.providers.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), + /** + * `integrations.manage`, like every other credential operation — these three + * take a *target*, not a scope: `providerId` connects a personal account, + * `credentialId` re-authorizes a credential the workspace already holds. Only + * the first is what `disablePersonalCredentials` withholds, so the narrower + * `credentials.personal` is asserted on that branch inside + * `resolveCredentialConnectionTarget` rather than declared here. Declaring it + * here withheld the reconnect too — refusing the workspace-shared credentials + * that same setting exists to mandate — and, because an operation declares one + * capability, let a group that hid the whole Integrations module still connect. + */ createConnection: defineWorkspaceOperation({ id: 'credentials.connections.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key'], }), prepareConnection: defineWorkspaceOperation({ id: 'credentials.connections.prepare', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -65,6 +82,7 @@ export const credentialOperations = { id: 'credentials.service_accounts.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session', 'personal_api_key'], }), read: defineCredentialOperation( @@ -72,6 +90,7 @@ export const credentialOperations = { id: 'credentials.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), 'member' @@ -80,6 +99,7 @@ export const credentialOperations = { id: 'credentials.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), update: defineCredentialOperation( @@ -87,6 +107,7 @@ export const credentialOperations = { id: 'credentials.update', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', ...HUMAN_AND_COPILOT_PRINCIPALS, }), 'admin' @@ -96,6 +117,7 @@ export const credentialOperations = { id: 'credentials.delete', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', ...HUMAN_AND_COPILOT_PRINCIPALS, }), 'admin' @@ -104,6 +126,7 @@ export const credentialOperations = { id: 'credentials.delete_many', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -111,12 +134,14 @@ export const credentialOperations = { id: 'credentials.drafts.save', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), listMembers: defineWorkspaceOperation({ id: 'credentials.members.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), upsertMember: defineCredentialOperation( @@ -124,6 +149,7 @@ export const credentialOperations = { id: 'credentials.members.upsert', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), 'admin' @@ -133,6 +159,7 @@ export const credentialOperations = { id: 'credentials.members.remove', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), 'admin' @@ -141,12 +168,14 @@ export const credentialOperations = { id: 'credentials.connections.launch', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'integrations.manage', principalKinds: ['delegated'], delegatedServices: ['executor'], resourcePolicy: { @@ -156,22 +185,81 @@ export const credentialOperations = { }), } as const +/** + * A credential operation whose resource is the acting user's own account rather + * than a workspace, so it carries no role and no workspace-key policy. + * + * It still carries a `capability`, and required rather than optional for the + * same reason `defineWorkspaceOperation` requires one: an absent field cannot be + * told apart from an unreviewed one. Listing and disconnecting a user's OAuth + * connections is exactly what `hideIntegrationsTab` claims to revoke, and these + * operations shipped with no capability at all — invisibly, because this factory + * does not call `defineWorkspaceOperation` and so is read by neither that + * builder's definition-time guard nor `check:permission-group-enforcement`, + * which parses `defineWorkspaceOperation` call sites out of the source text. + */ export interface CredentialUserOperation extends ApplicationOperation { readonly principalKinds: readonly ['session'] + readonly capability: OperationDeclarableCapability | 'none' } function defineCredentialUserOperation( - id: Id + id: Id, + capability: OperationDeclarableCapability | 'none' ): CredentialUserOperation { if (!id.trim()) throw new Error('Credential user operation ID must not be empty') - return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) + if (capability === undefined) { + throw new Error( + `Credential user operation ${id} declares no capability; name one, or 'none' with a reason` + ) + } + if (capability !== 'none') { + const rule = CAPABILITY_RULES[capability] + if (!rule) + throw new Error(`Credential user operation ${id} names unknown capability ${capability}`) + /** + * A parameterized rule reads a value only the request carries, which + * `defineAuthorizedCredentialUserUseCase` never sees; declared here it would + * read as enforced while nothing applied it. + */ + if (rule.kind !== 'static') { + throw new Error( + `Credential user operation ${id} declares parameterized capability ${capability}; assert it from the use case instead` + ) + } + } + return Object.freeze({ id, capability, principalKinds: Object.freeze(['session'] as const) }) } +/** + * All five take `integrations.manage`, matching every other credential + * operation that is not personal-scope by construction — `credentials.list`, + * `credentials.connections.list`, `credentials.members.list` and the rest above. + * Not `credentials.personal`: that one is reserved for the OAuth *connect* flow, + * whose credential is personal by construction, and an organization that + * revokes the Integrations module means members cannot see or remove a + * connection either. + */ export const credentialUserOperations = { - listMemberships: defineCredentialUserOperation('credentials.memberships.list'), - leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), - listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), - listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), - disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), + listMemberships: defineCredentialUserOperation( + 'credentials.memberships.list', + 'integrations.manage' + ), + leaveMembership: defineCredentialUserOperation( + 'credentials.memberships.leave', + 'integrations.manage' + ), + listOAuthConnections: defineCredentialUserOperation( + 'credentials.oauth_connections.list', + 'integrations.manage' + ), + listConnectedAccounts: defineCredentialUserOperation( + 'credentials.accounts.list', + 'integrations.manage' + ), + disconnectOAuth: defineCredentialUserOperation( + 'credentials.oauth_connections.disconnect', + 'integrations.manage' + ), } as const diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index f485a80465d..73fc59914fc 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -20,20 +20,38 @@ vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.getUserPermissionConfig, })) -vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ - intersectIntegrationAllowlists: ( +/** + * The real helpers canonicalize each side through the generated successor map; + * this stub keeps the intersection semantics without the map, because the ids + * used here are fixtures rather than real block types. + */ +vi.mock('@/lib/permission-groups/integration-allowlist', () => { + const intersect = ( permissionGroup: readonly string[] | null, deployment: readonly string[] | null ) => { if (!permissionGroup) return deployment if (!deployment) return permissionGroup return permissionGroup.filter((type) => deployment.includes(type)) - }, -})) + } + return { + intersectIntegrationAllowlists: intersect, + intersectAccessControlAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + const result = intersect(permissionGroup, deployment) + return result === null ? null : new Set(result) + }, + resolveAccessControlBlockType: (blockType: string) => blockType, + toAccessControlAllowlist: (allowlist: readonly string[] | null) => + allowlist ? new Set(allowlist) : null, + } +}) vi.mock('@/lib/integrations/credential-visibility.server', () => ({ createIntegrationCredentialVisibility: mocks.createVisibility, @@ -92,9 +110,15 @@ describe('listCredentialProviderCatalog', () => { beforeEach(() => { vi.clearAllMocks() mocks.getAllOAuthServices.mockReturnValue(services) - mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + /** + * The permission group is the NARROWER half on purpose. With the deployment + * allowlist narrower, the intersection is the same set whether or not the + * group is read at all, and every assertion below passes against a catalog + * that never consulted it — which is what this fixture used to look like. + */ + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce', 'trello']) mocks.getUserPermissionConfig.mockResolvedValue({ - allowedIntegrations: ['salesforce', 'trello'], + allowedIntegrations: ['salesforce'], }) mocks.getBlockVisibility.mockResolvedValue({ revealed: new Set(), @@ -186,8 +210,13 @@ describe('listCredentialProviderCatalog', () => { ) expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + /** + * The deployment allowlist alone, not the personal caller's narrower group: + * a workspace API key has no user and therefore no group, and borrowing the + * key creator's would hide Trello from every caller of a shared credential. + */ expect(mocks.createVisibility).toHaveBeenCalledWith( - expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce', 'trello']) }) ) }) diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts index fbeace41633..1de8b666ec4 100644 --- a/apps/sim/lib/custom-tools/application/operations.ts +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -9,29 +9,39 @@ const HUMAN_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +/** + * Every operation declares `custom_tools.use`, reads included. A custom tool is + * a user-authored function an agent calls; a group that withholds them has no + * use for the definitions either, and gating only execution would leave the + * authoring surface open to a member who can never run what it produces. + */ export const customToolOperations = { list: defineWorkspaceOperation({ id: 'custom_tools.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'custom_tools.list_available', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'custom_tools.use', ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'custom_tools.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), readAvailableByIdOrTitle: defineWorkspaceOperation({ id: 'custom_tools.read_available_by_id_or_title', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'custom_tools.use', principalKinds: ['delegated'], delegatedServices: ['copilot', 'executor'], }), @@ -39,36 +49,42 @@ export const customToolOperations = { id: 'custom_tools.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), save: defineWorkspaceOperation({ id: 'custom_tools.save', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'custom_tools.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), updateAvailable: defineWorkspaceOperation({ id: 'custom_tools.update_available', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'custom_tools.use', ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'custom_tools.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'custom_tools.use', ...ALL_PRINCIPAL_POLICY, }), deleteAvailable: defineWorkspaceOperation({ id: 'custom_tools.delete_available', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'custom_tools.use', ...HUMAN_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/data-drains/access.ts b/apps/sim/lib/data-drains/access.ts index 5a1db375444..8399011a73a 100644 --- a/apps/sim/lib/data-drains/access.ts +++ b/apps/sim/lib/data-drains/access.ts @@ -24,7 +24,13 @@ export type DrainAccessResult = /** * Auth + membership + role + enterprise-plan gate shared by every data-drain * route. Owner/admin role is required for reads as well as writes since drain - * configs expose customer bucket names and webhook URLs. On Sim Cloud the + * configs expose customer bucket names and webhook URLs. + * + * Deliberately above member permission groups, like the audit-log surface: + * the reader of a drained record is the organization, not a member, so the + * per-member log projections (`hideCostInfo`, `hideTraceSpans`) do not apply + * to what a drain serializes, and no group capability gates configuring one — + * the owner/admin requirement is the whole access story. On Sim Cloud the * gate is the Enterprise plan; on self-hosted it's `DATA_DRAINS_ENABLED`, * which 404s when unset so a newer image doesn't silently expose drains. */ diff --git a/apps/sim/lib/function-execution/application/operations.ts b/apps/sim/lib/function-execution/application/operations.ts index ba37ad117e5..66231142760 100644 --- a/apps/sim/lib/function-execution/application/operations.ts +++ b/apps/sim/lib/function-execution/application/operations.ts @@ -1,10 +1,12 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const functionExecutionOperations = { + // permission-group-exempt: running a Function block is the workflow executing its own code; no group key names code execution, and a gate here would fail runs the group permits execute: defineWorkspaceOperation({ id: 'function-executions.execute', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['executor', 'copilot'], }), diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts index f7d0a6b993d..3e239092b15 100644 --- a/apps/sim/lib/integrations/principal-scope.server.ts +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -1,7 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { intersectAccessControlAllowlists } from '@/lib/permission-groups/integration-allowlist' /** * The workspace integration gate, shared by every catalog that projects @@ -15,7 +15,7 @@ import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-ch * first time either changed, and the two endpoints describe the same * integrations. * - * Server-only: `getUserPermissionConfig` reads the database. + * Server-only: `resolvePermissionGroupConfig` reads the database. */ /** @@ -41,16 +41,21 @@ export function principalUserId(principal: Principal): string | undefined { * The intersection of the caller's permission-group allowlist with the * deployment's `ALLOWED_INTEGRATIONS`. A principal with no user contributes no * permission-group half, leaving the deployment allowlist alone. + * + * Each half is successor-resolved *before* the intersection, so a group naming + * `slack_v2` and a deployment naming `slack` still meet. Callers resolve the + * type they test the same way. */ export async function allowedIntegrationTypes( principal: Principal, workspaceId: string ): Promise | null> { const userId = principalUserId(principal) - const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null - const integrations = intersectIntegrationAllowlists( + const permissionConfig = userId + ? await resolvePermissionGroupConfig(userId, workspaceId, undefined) + : null + return intersectAccessControlAllowlists( permissionConfig?.allowedIntegrations ?? null, getAllowedIntegrationsFromEnv() ) - return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null } diff --git a/apps/sim/lib/internal/enrichment/execute-tool.ts b/apps/sim/lib/internal/enrichment/execute-tool.ts index 0d353357566..232dab84bd7 100644 --- a/apps/sim/lib/internal/enrichment/execute-tool.ts +++ b/apps/sim/lib/internal/enrichment/execute-tool.ts @@ -41,6 +41,7 @@ export const executeEnrichmentTool: InternalToolOperationHandler = async (reques return executeEnrichment(parsed.data, { workspaceId: request.context.workspaceId, + userId: request.context.userId, signal: request.signal, resolvedSecretTraceRegistry: request.context.resolvedSecretTraceRegistry, }) diff --git a/apps/sim/lib/internal/enrichment/operations.ts b/apps/sim/lib/internal/enrichment/operations.ts index ee9f9a1f97d..173d497e744 100644 --- a/apps/sim/lib/internal/enrichment/operations.ts +++ b/apps/sim/lib/internal/enrichment/operations.ts @@ -8,6 +8,8 @@ const logger = createLogger('EnrichmentOperations') export interface EnrichmentOperationContext { workspaceId: string + /** The acting user, so the per-tool permission gate applies to the provider call. */ + userId: string signal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } @@ -24,6 +26,7 @@ export async function executeEnrichment( const { result, cost, error, provider } = await runEnrichment(enrichment, input.inputs, { workspaceId: context.workspaceId, + userId: context.userId, signal: context.signal, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, }) diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index ec014758f1c..0d28507befd 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -91,6 +91,7 @@ vi.mock('@sim/audit', () => auditMock) import { acceptInvitation, rejectInvitation, + resolveInvitationAdmissionOrganizationId, revokeInvitationAsAdmin, updateInvitation, } from '@/lib/invitations/core' @@ -2181,3 +2182,128 @@ describe('locked invitation mutations', () => { expect(dbChainMockFns.set).not.toHaveBeenCalled() }) }) + +/** + * What an invitation ADMITS TO, which is not its `kind`. The send-capability + * gates read this so they cannot let an invitation carry a member into an + * organization whose group withholds invitations, and so they cannot demand an + * organization's permission for an invitation that joins nobody to it. + */ +describe('resolveInvitationAdmissionOrganizationId', () => { + const invitation = { + id: 'invitation-1', + kind: 'workspace' as const, + email: 'invitee@example.com', + organizationId: 'organization-1', + membershipIntent: 'internal' as const, + inviterId: 'inviter-1', + role: 'member', + status: 'pending' as const, + token: 'token-1', + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + grants: [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'read' as const, + workspaceName: 'Workspace', + }, + ], + organizationName: null, + inviterName: null, + inviterEmail: null, + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetUserOrganization.mockResolvedValue(null) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-1', + billedAccountUserId: 'owner-1', + }) + }) + + it('names the organization a granted workspace belongs to, for a workspace invitation', async () => { + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBe('organization-1') + }) + + it('names nobody when the granted workspace belongs to no organization', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: null, + billedAccountUserId: 'owner-1', + }) + + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBeNull() + }) + + /** + * The workspace moved after the invite went out. Acceptance escalates into the + * new organization only when the inviter currently holds admin standing there, + * so the gate has to ask the same question of the same organization. + */ + it('follows a moved workspace into its live organization when the inviter may escalate', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-2', + billedAccountUserId: 'owner-1', + }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'organization-2', + role: 'admin', + }) + + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBe('organization-2') + }) + + it('names nobody when the escalation acceptance would refuse is the only join on offer', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-2', + billedAccountUserId: 'owner-1', + }) + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'organization-2', + role: 'member', + }) + + expect(await resolveInvitationAdmissionOrganizationId(invitation)).toBeNull() + }) + + /** + * An organization invitation joins its STAMPED organization whatever its + * granted workspaces do — the join target is never re-derived from a workspace + * whose organization can change after send. + */ + it('keeps an organization invitation on its stamped organization', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + organizationId: 'organization-2', + billedAccountUserId: 'owner-1', + }) + + expect( + await resolveInvitationAdmissionOrganizationId({ ...invitation, kind: 'organization' }) + ).toBe('organization-1') + }) + + it('names nobody for an external invitation, which creates no membership', async () => { + expect( + await resolveInvitationAdmissionOrganizationId({ + ...invitation, + membershipIntent: 'external', + }) + ).toBeNull() + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + }) + + it('falls back to the stamped organization for an invitation with no grants', async () => { + expect(await resolveInvitationAdmissionOrganizationId({ ...invitation, grants: [] })).toBe( + 'organization-1' + ) + }) +}) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 40b2026a0ca..f0cb61d32c2 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -250,6 +250,27 @@ export function isInvitationExpired(inv: Pick return new Date() > new Date(inv.expiresAt) } +/** + * The organization acceptance will land the invitee's membership in, before the + * gates that can downgrade the join to external. + * + * Workspace-kind invitations take the granted workspace's LIVE organization — + * the workspace is what was shared, and its organization can change after the + * invite goes out. Organization-kind invitations take their STAMPED one, which + * a granted workspace's move must never redirect. Acceptance, the accept-screen + * preview, and the resend gate all read the target here so none of them can + * disagree about which organization an invitation admits to. + */ +function invitationJoinTargetOrganizationId( + inv: Pick, + primaryWorkspace: Pick | null +): string | null { + if (inv.kind === 'workspace' && inv.grants.length > 0 && primaryWorkspace) { + return primaryWorkspace.organizationId + } + return inv.organizationId +} + /** * A workspace invitation only escalates into an EXISTING organization when * that organization matches what was stamped at send time — a workspace that @@ -281,6 +302,40 @@ async function stampedOrganizationAllowsEscalation( ) } +/** + * The organization ACCEPTANCE of this invitation would admit the invitee to, or + * `null` when acceptance creates no membership anywhere. + * + * Read by the send-capability gates, which have to key on what an invitation + * ADMITS TO rather than on its `kind`: a workspace-kind invitation whose granted + * workspace belongs to an organization joins the invitee to that organization + * exactly as an organization-kind one does ({@link acceptLockedInvitation} + * creates the member row from this same target), so gating those on their grants + * alone would let a workspace group that permits invitations carry a member into + * an organization whose default group withholds them. + * + * Mirrors acceptance's own decision, one clause at a time: an external + * membership intent creates no member row, and an escalation the stamped + * organization does not allow is downgraded to external before one is created. + * Both are read through the predicates acceptance uses, so a change there + * reaches this gate too. The reads are unlocked — a race resolves at accept + * time, where the locks are. + */ +export async function resolveInvitationAdmissionOrganizationId( + inv: InvitationWithGrants, + executor?: DbOrTx +): Promise { + if (inv.membershipIntent === 'external') return null + const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId + const primaryWorkspace = primaryGrantWorkspaceId + ? await getWorkspaceWithOwner(primaryGrantWorkspaceId, executor ? { executor } : undefined) + : null + const organizationId = invitationJoinTargetOrganizationId(inv, primaryWorkspace) + if (!organizationId) return null + if (!(await stampedOrganizationAllowsEscalation(inv, organizationId, executor ?? db))) return null + return organizationId +} + /** * True when a member-role organization invitation still has at least one * granted workspace inside the organization it was stamped with. All grants @@ -362,18 +417,12 @@ export async function getInvitationJoinPreview( workspaceIdsToMove: [], }) - let workspaceOrganizationId = inv.organizationId - let billedAccountUserId: string | null = null const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId - if (primaryGrantWorkspaceId) { - const primaryWorkspace = await getWorkspaceWithOwner(primaryGrantWorkspaceId) - if (primaryWorkspace) { - billedAccountUserId = primaryWorkspace.billedAccountUserId - if (inv.kind === 'workspace') { - workspaceOrganizationId = primaryWorkspace.organizationId - } - } - } + const primaryWorkspace = primaryGrantWorkspaceId + ? await getWorkspaceWithOwner(primaryGrantWorkspaceId) + : null + const billedAccountUserId = primaryWorkspace?.billedAccountUserId ?? null + const workspaceOrganizationId = invitationJoinTargetOrganizationId(inv, primaryWorkspace) /** * Personal-workspace invites only produce an organization through billing's @@ -858,11 +907,10 @@ async function acceptLockedInvitation( */ const primaryGrant = inv.grants[0] let billingOwnerUserId = inv.inviterId - let workspaceOrganizationId = inv.organizationId if (primaryGrant && lockPlan.primaryWorkspace && inv.kind === 'workspace') { billingOwnerUserId = lockPlan.primaryWorkspace.billedAccountUserId - workspaceOrganizationId = lockPlan.primaryWorkspace.organizationId } + const workspaceOrganizationId = invitationJoinTargetOrganizationId(inv, lockPlan.primaryWorkspace) if ( shouldJoinOrganization && diff --git a/apps/sim/lib/invitations/workspace-invitations.test.ts b/apps/sim/lib/invitations/workspace-invitations.test.ts index 6cebbf6ae47..afe9de882cc 100644 --- a/apps/sim/lib/invitations/workspace-invitations.test.ts +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -112,7 +112,13 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateInvitationsAllowed: vi.fn(), })) -import { createWorkspaceInvitation } from '@/lib/invitations/workspace-invitations' +import { + createWorkspaceInvitation, + prepareWorkspaceInvitationContext, +} from '@/lib/invitations/workspace-invitations' +import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' +import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' function queueWhereResponses(responses: unknown[][]) { const queue = [...responses] @@ -682,3 +688,56 @@ describe('createWorkspaceInvitation', () => { ).rejects.toThrow('invitation changed concurrently') }) }) + +/** + * The capability runs after the role check, never before it. Refusing on + * `invitations.send` first would answer a non-admin with a distinct `403` + * naming an organization setting, which tells a bystander in the same + * organization how another workspace's permission group is configured. + */ +describe('prepareWorkspaceInvitationContext refusal ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.mocked(validateInvitationsAllowed).mockResolvedValue(undefined) + vi.mocked(getWorkspaceInvitePolicy).mockResolvedValue({ + allowed: true, + reason: null, + requiresSeat: false, + organizationId: 'org-1', + upgradeRequired: false, + } as unknown as Awaited>) + }) + + it('refuses a non-admin on the role before consulting the permission group', async () => { + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false) + vi.mocked(validateInvitationsAllowed).mockRejectedValue( + new Error('Sending invitations is not available under your permission group') + ) + + await expect( + prepareWorkspaceInvitationContext({ + workspaceIds: ['ws-1'], + inviterId: 'outsider-1', + inviterName: 'Outsider', + }) + ).rejects.toThrow('You need admin permissions to invite users') + expect(validateInvitationsAllowed).not.toHaveBeenCalled() + }) + + it('still refuses an admin whose permission group withholds invitations', async () => { + vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true) + vi.mocked(validateInvitationsAllowed).mockRejectedValue( + new Error('Sending invitations is not available under your permission group') + ) + + await expect( + prepareWorkspaceInvitationContext({ + workspaceIds: ['ws-1'], + inviterId: 'user-1', + inviterName: 'Owner', + }) + ).rejects.toThrow('Sending invitations is not available under your permission group') + expect(validateInvitationsAllowed).toHaveBeenCalledWith('user-1', 'ws-1') + }) +}) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 03e19257890..c96c3934fbe 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -206,8 +206,6 @@ export async function prepareWorkspaceInvitationContext({ const targets: WorkspaceInvitationTarget[] = [] for (const workspaceId of uniqueWorkspaceIds) { - await validateInvitationsAllowed(inviterId, workspaceId) - const isAdmin = await hasWorkspaceAdminAccess(inviterId, workspaceId) if (!isAdmin) { throw new WorkspaceInvitationError({ @@ -216,6 +214,16 @@ export async function prepareWorkspaceInvitationContext({ }) } + /** + * permission-group-enforced: invitations.send — after the admin check, not + * before it. The refusal names an organization setting, so answering it to + * someone with no admin reach into `workspaceId` would tell a bystander in + * the same organization how another workspace's group is configured. The + * role check is also the cheaper of the two and names the remedy the caller + * can actually act on. + */ + await validateInvitationsAllowed(inviterId, workspaceId) + const workspaceDetails = await getWorkspaceWithOwner(workspaceId) if (!workspaceDetails) { throw new WorkspaceInvitationError({ message: 'Workspace not found', status: 404 }) diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 3c2abeac045..599f7f42ba3 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ refreshToken: vi.fn(), validateConnectorConfig: vi.fn(), recordAudit: vi.fn(), + getUserPermissionConfig: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -67,6 +68,10 @@ vi.mock('@/lib/oauth/credential-service', () => ({ refreshAccessTokenIfNeeded: mocks.refreshToken, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { confluence: { @@ -84,6 +89,8 @@ import { updateKnowledgeConnector, updateKnowledgeConnectorDocuments, } from '@/lib/knowledge/application/connectors' +import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const crossWorkspaceContext = { workspaceId: 'workspace-b', @@ -141,6 +148,7 @@ describe('knowledge connector application use cases', () => { mocks.refreshToken.mockResolvedValue('access-token') mocks.validateConnectorConfig.mockResolvedValue({ valid: true }) mocks.resolveBilling.mockResolvedValue(BILLING) + mocks.getUserPermissionConfig.mockResolvedValue(null) }) afterAll(resetDbChainMock) @@ -661,4 +669,166 @@ describe('knowledge connector application use cases', () => { ) } ) + + describe('connector allow-list', () => { + const sameWorkspaceContext = { + ...crossWorkspaceContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + } + + const createInput = { + knowledgeBaseId: 'knowledge-a', + assertedWorkspaceId: 'workspace-a', + connectorType: 'confluence', + credentialId: 'credential-1', + sourceConfig: {}, + syncIntervalMinutes: 1440, + resolveBillingAttribution: mocks.resolveBilling, + } + + beforeEach(() => { + mocks.resolveKnowledgeBase.mockResolvedValue(sameWorkspaceContext) + }) + + function allowOnly(connectorTypes: string[] | null) { + mocks.getUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedKnowledgeConnectors: connectorTypes, + }) + } + + it('refuses a connector the group withholds, before the connector is created', async () => { + allowOnly(['google_drive']) + + await expect( + createKnowledgeConnector.execute({ principal: delegatedPrincipal, input: createInput }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: capabilityRefusal('knowledge.connectors'), + }) + + expect(mocks.createConnector).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it.each([ + ['the group names it', ['confluence', 'google_drive']], + ['the group restricts nothing', null], + ])('permits a connector when %s', async (_case, allowed) => { + allowOnly(allowed as string[] | null) + mocks.createConnector.mockResolvedValueOnce({ + success: true, + connector: { id: 'connector-a', connectorType: 'confluence', syncIntervalMinutes: 1440 }, + }) + + const result = await createKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: createInput, + }) + + expect(result.connector.id).toBe('connector-a') + expect(mocks.createConnector).toHaveBeenCalledTimes(1) + }) + + it('leaves an ungoverned caller unaffected', async () => { + mocks.getUserPermissionConfig.mockResolvedValue(null) + mocks.createConnector.mockResolvedValueOnce({ + success: true, + connector: { id: 'connector-a', connectorType: 'confluence', syncIntervalMinutes: 1440 }, + }) + + await createKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: createInput, + }) + + expect(mocks.createConnector).toHaveBeenCalledTimes(1) + }) + + /** + * A manual sync re-runs the pull, so an admin who has since removed the + * source from the allowlist has withdrawn it. The type comes off the + * persisted connector, which is the only place the request names it. + */ + describe('manual sync', () => { + const syncInput = { + knowledgeBaseId: 'knowledge-a', + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + resolveBillingAttribution: mocks.resolveBilling, + } + + beforeEach(() => { + mocks.resolveConnector.mockResolvedValue({ + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + }) + }) + + it('refuses a sync of a connector whose type the group no longer names', async () => { + allowOnly(['google_drive']) + + await expect( + syncKnowledgeConnector.execute({ principal: delegatedPrincipal, input: syncInput }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: capabilityRefusal('knowledge.connectors'), + }) + + expect(mocks.syncConnector).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('permits the sync while the group still names the persisted type', async () => { + allowOnly(['confluence']) + mocks.syncConnector.mockResolvedValueOnce({ success: true }) + + await syncKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: syncInput, + }) + + expect(mocks.syncConnector).toHaveBeenCalledTimes(1) + }) + + /** + * Pausing and deleting stay reachable: the point is to stop the member + * re-running the pull, never to strand the connector. + */ + it('still lets the same caller pause and delete the withheld connector', async () => { + allowOnly(['google_drive']) + mocks.updateConnector.mockResolvedValueOnce({ + success: true, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + }) + mocks.deleteConnector.mockResolvedValueOnce({ + success: true, + documentsDeleted: 0, + documentsKept: 1, + }) + + await updateKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + updates: { status: 'paused' }, + resolveBillingAttribution: mocks.resolveBilling, + }, + }) + await deleteKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { connectorId: 'connector-b', assertedWorkspaceId: 'workspace-a' }, + }) + + expect(mocks.updateConnector).toHaveBeenCalledTimes(1) + expect(mocks.deleteConnector).toHaveBeenCalledTimes(1) + }) + }) + }) }) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index d659aece16b..5e704149b80 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { document, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' import { and, asc, count, desc, eq, inArray, isNull } from 'drizzle-orm' @@ -41,6 +42,8 @@ import type { KnowledgeOrchestrationResult, } from '@/lib/knowledge/orchestration/shared' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' +import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' interface KnowledgeConnectorApplicationInput { assertedWorkspaceId?: string @@ -102,6 +105,43 @@ export interface UpdateKnowledgeConnectorDocumentsInput extends ReadKnowledgeCon documentIds: string[] } +const CONNECTOR_ALLOWLIST_RULE = CAPABILITY_RULES['knowledge.connectors'] + +/** + * Refuses a connector the caller's permission group has not sanctioned. + * + * A connector pulls a whole external corpus into the workspace, so which source + * a member may attach is a per-request decision — the authorization funnel + * applies an operation's capability knowing only the principal, the workspace + * and the operation, and never sees `connectorType`. Hence the assertion here, + * ahead of the write, rather than a `capability` on `knowledge.connectors.create`. + * + * No-op when no permission group governs the caller, which is what keeps + * non-enterprise and ungoverned organizations unaffected. + * + * A permission group is a membership of users, so an actorless caller — a + * schedule, or a webhook with no external subject — resolves no group and + * passes through, exactly as the authorization funnel treats one. Requiring a + * subject here would turn every scheduled connector sync into a 500 rather than + * a refusal anyone could act on. + * + * Refused through {@link refuseCapability} so the sentence reads exactly like + * every other capability refusal. The error it throws is a + * `ForbiddenOperationError` carrying this rule's own detail code, so the status + * and error contract are the ones this already raised. + */ +async function assertConnectorTypeAllowed( + userId: string | undefined, + workspaceId: string, + connectorType: string +): Promise { + if (!userId) return + const config = await resolvePermissionGroupConfig(userId, workspaceId, undefined) + if (!config || !CONNECTOR_ALLOWLIST_RULE.deniedBy(config, connectorType)) return + + refuseCapability('knowledge.connectors') +} + function requireSuccessfulOutcome( outcome: KnowledgeOrchestrationResult, fallback: string @@ -293,6 +333,12 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ const requestId = generateRequestId() const workspaceId = requireConnectorWorkspaceId(context) const actingUserId = resolveKnowledgeAttributedUserId(principal, context) + // permission-group-enforced: knowledge.connectors — needs the request's connector id, which the funnel never sees + await assertConnectorTypeAllowed( + resolvePrincipalSubjectUserId(principal), + workspaceId, + input.connectorType + ) const outcome = await performCreateKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorType: input.connectorType, @@ -337,6 +383,13 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ }), }) +/** + * Deliberately not gated by `knowledge.connectors`: an update may change the + * source config, sync interval or status, never the connector type. The + * sanctioned-source decision was made when the connector was created, and + * re-asserting it here would strand an existing connector — including the + * ability to pause it — the moment an admin narrowed the allowlist. + */ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.updateConnector, resolveContext: ({ input }: { input: UpdateKnowledgeConnectorInput }) => @@ -440,12 +493,33 @@ export const deleteKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ }), }) +/** + * Gated by `knowledge.connectors` on the *persisted* type, unlike + * {@link updateKnowledgeConnector}: a manual sync is a fresh act by a person + * pulling the external corpus in again, so an admin who has since removed the + * source from the allowlist has withdrawn it. Pausing and deleting stay + * available for the reason recorded on the update use case — nothing here + * strands a connector, it only stops a member re-running the pull by hand. + * + * Only the manual path passes through this use case. The scheduled continuation + * of an existing connector runs `executeSync` from the sync engine directly + * (`background/knowledge-connector-sync.ts`) and is untouched, matching the + * webhook precedent: passive continuation keeps running, a person re-initiating + * it is gated. An actorless caller resolves no group and passes through, as + * {@link assertConnectorTypeAllowed} documents. + */ export const syncKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.syncConnector, resolveContext: ({ input }: { input: SyncKnowledgeConnectorInput }) => resolveActiveKnowledgeConnectorContext(input), async execute({ principal, input, context, request }) { const workspaceId = requireConnectorWorkspaceId(context) + // permission-group-enforced: knowledge.connectors — needs the persisted connector type, which the funnel never sees + await assertConnectorTypeAllowed( + resolvePrincipalSubjectUserId(principal), + workspaceId, + context.connector.connectorType + ) const outcome = await performSyncKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorId: context.connectorId, diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 803a4223c3b..9180ee81346 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -156,4 +156,43 @@ describe('knowledge operation registry', () => { expect(knowledgeOperations.search.delegatedServices).toEqual(['copilot', 'executor']) expect(knowledgeOperations.uploadComplete.delegatedServices).toBeUndefined() }) + + it('withholds knowledge base creation separately from using existing ones', () => { + expect(knowledgeOperations.create.capability).toBe('knowledge.create') + for (const operation of [ + knowledgeOperations.list, + knowledgeOperations.read, + knowledgeOperations.search, + knowledgeOperations.update, + knowledgeOperations.delete, + knowledgeOperations.createFolder, + knowledgeOperations.createTag, + knowledgeOperations.createConnector, + ]) { + expect(operation.capability).toBe('knowledge.use') + } + }) + + it('withholds every path that carries caller-supplied document bytes', () => { + for (const operation of [ + knowledgeOperations.uploadDocument, + knowledgeOperations.uploadCreate, + knowledgeOperations.uploadParts, + knowledgeOperations.uploadComplete, + knowledgeOperations.uploadCancel, + ]) { + expect(operation.capability).toBe('knowledge.upload') + } + }) + + it('leaves the connector sync path on the shared knowledge capability', () => { + /** A connector's documents are the sanctioned source, so an upload ban must not reach them. */ + for (const operation of [ + knowledgeOperations.syncConnector, + knowledgeOperations.updateConnectorDocuments, + knowledgeOperations.addWorkspaceFiles, + ]) { + expect(operation.capability).toBe('knowledge.use') + } + }) }) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index a4ffeb7567b..ddd8cdc6fe8 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -1,4 +1,5 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' +import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability, defineWorkspaceOperation } from '@/lib/core/application' const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -42,30 +43,40 @@ export const knowledgeOperations = { id: 'knowledge.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'knowledge.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), + /** + * The only operation that brings a knowledge base into existence, so it is the + * only one `knowledge.create` governs — a group may be allowed to query, + * populate and organize the bases it already has without opening new ones. + */ create: defineWorkspaceOperation({ id: 'knowledge.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.create', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'knowledge.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'knowledge.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), /** @@ -79,162 +90,195 @@ export const knowledgeOperations = { id: 'knowledge.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), bulkMoveItems: defineWorkspaceOperation({ id: 'knowledge.bulk_move_items', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), bulkDeleteItems: defineWorkspaceOperation({ id: 'knowledge.bulk_delete_items', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), bulkDelete: defineWorkspaceOperation({ id: 'knowledge.bulk_delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_POLICY, }), renameByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.rename', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), moveByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.move', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), manageVfsFolders: defineWorkspaceOperation({ id: 'knowledge.vfs.folders.manage', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), deleteByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...COPILOT_PRINCIPAL_POLICY, }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'knowledge.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), createFolder: defineWorkspaceOperation({ id: 'knowledge.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), relocateFolder: defineWorkspaceOperation({ id: 'knowledge.folders.relocate', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), deleteFolder: defineWorkspaceOperation({ id: 'knowledge.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', principalKinds: HTTP_PRINCIPAL_KINDS, }), listDocuments: defineWorkspaceOperation({ id: 'knowledge.documents.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), readDocument: defineWorkspaceOperation({ id: 'knowledge.documents.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), + /** + * The single-request upload path: the caller hands over file bytes, so the + * document's provenance is whatever the caller chose. `knowledge.upload` is + * what an organization withholds to admit documents only from the connectors + * it sanctioned. + */ uploadDocument: defineWorkspaceOperation({ id: 'knowledge.documents.upload', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.upload', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), addWorkspaceFiles: defineWorkspaceOperation({ id: 'knowledge.documents.add_workspace_files', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteDocument: defineWorkspaceOperation({ id: 'knowledge.documents.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), bulkDeleteDocuments: defineWorkspaceOperation({ id: 'knowledge.documents.bulk_delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateDocument: defineWorkspaceOperation({ id: 'knowledge.documents.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), bulkDocuments: defineWorkspaceOperation({ id: 'knowledge.documents.bulk', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), listChunks: defineWorkspaceOperation({ id: 'knowledge.chunks.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), readChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), createChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), updateChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), deleteChunk: defineWorkspaceOperation({ id: 'knowledge.chunks.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), bulkChunks: defineWorkspaceOperation({ id: 'knowledge.chunks.bulk', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), /** @@ -247,42 +291,49 @@ export const knowledgeOperations = { id: 'knowledge.tags.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'knowledge.use', ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), createTag: defineWorkspaceOperation({ id: 'knowledge.tags.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateTag: defineWorkspaceOperation({ id: 'knowledge.tags.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteTag: defineWorkspaceOperation({ id: 'knowledge.tags.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), readTagUsage: defineWorkspaceOperation({ id: 'knowledge.tags.read_usage', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), readDetailedTagUsage: defineWorkspaceOperation({ id: 'knowledge.tags.read_detailed_usage', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), readNextTagSlot: defineWorkspaceOperation({ id: 'knowledge.tags.read_next_slot', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), /** @@ -296,6 +347,7 @@ export const knowledgeOperations = { id: 'knowledge.tags.bulk_save', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), /** Removal over that same vocabulary — unused definitions, or all of them. */ @@ -303,88 +355,127 @@ export const knowledgeOperations = { id: 'knowledge.tags.cleanup', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), listConnectors: defineWorkspaceOperation({ id: 'knowledge.connectors.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), readConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), createConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), syncConnector: defineWorkspaceOperation({ id: 'knowledge.connectors.sync', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, }), listConnectorDocuments: defineWorkspaceOperation({ id: 'knowledge.connectors.documents.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), updateConnectorDocuments: defineWorkspaceOperation({ id: 'knowledge.connectors.documents.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'knowledge.use', ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), + /** + * The four session operations are one upload, split across requests only + * because a large file cannot arrive in one. They carry the same capability + * for that reason — including cancel, which would otherwise be the one open + * door into a surface the group was denied. + */ uploadCreate: defineWorkspaceOperation({ id: 'knowledge.documents.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadParts: defineWorkspaceOperation({ id: 'knowledge.documents.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadComplete: defineWorkspaceOperation({ id: 'knowledge.documents.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), uploadCancel: defineWorkspaceOperation({ id: 'knowledge.documents.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'knowledge.upload', principalKinds: HTTP_PRINCIPAL_KINDS, }), } as const +/** + * The session-scoped entry points, which resolve a knowledge base first and then + * hand authorization to the workspace-scoped `knowledgeOperations` sibling that + * matches. The capability rides on that sibling, so each of these declares + * `'none'` — but declares it, rather than being minted from a bare object + * literal as they were, which is the form that kept them out of + * `check:permission-group-enforcement` entirely. + */ +function defineKnowledgeSessionOperation( + operation: ApplicationOperation +): ApplicationOperation { + assertOperationCapability(operation) + return Object.freeze(operation) +} + export const knowledgeSessionOperations = { - list: Object.freeze({ id: 'knowledge.session.list' as const }), - read: Object.freeze({ id: 'knowledge.session.read' as const }), - update: Object.freeze({ id: 'knowledge.session.update' as const }), - delete: Object.freeze({ id: 'knowledge.session.delete' as const }), - restore: Object.freeze({ id: 'knowledge.session.restore' as const }), + // permission-group-exempt: delegates to knowledgeOperations.list, which carries knowledge.use + list: defineKnowledgeSessionOperation({ id: 'knowledge.session.list', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.read, which carries knowledge.use + read: defineKnowledgeSessionOperation({ id: 'knowledge.session.read', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.update, which carries knowledge.use + update: defineKnowledgeSessionOperation({ id: 'knowledge.session.update', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.delete, which carries knowledge.use + delete: defineKnowledgeSessionOperation({ id: 'knowledge.session.delete', capability: 'none' }), + // permission-group-exempt: delegates to knowledgeOperations.restore, which carries knowledge.use + restore: defineKnowledgeSessionOperation({ id: 'knowledge.session.restore', capability: 'none' }), } as const export type KnowledgeOperation = (typeof knowledgeOperations)[keyof typeof knowledgeOperations] diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts index dd86463580d..7b7f9406bff 100644 --- a/apps/sim/lib/logs/application/get-public-log.ts +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -1,3 +1,4 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { CostLedger } from '@/lib/api/contracts/logs' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -8,6 +9,11 @@ import { logDelegationAuthorization } from '@/lib/logs/application/authorization import { logOperations } from '@/lib/logs/application/operations' import { buildCostLedger } from '@/lib/logs/cost-ledger' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { + logProjectionSubjectUserId, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { getPublicWorkflowLog, getPublicWorkflowLogScope } from '@/lib/logs/public-queries' import { sanitizeExecutionSnapshotState } from '@/lib/logs/snapshot-sanitizer' import { @@ -89,6 +95,27 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: logDelegationAuthorization(), execute: async ({ principal, context }): Promise => { + /** + * Attribution and the projection subject in one value; a workspace API key + * represents no user and therefore reads the run whole. See + * `list-public-logs.ts` for why the key's creator is never substituted. + */ + const viewerUserId = resolvePrincipalSubjectUserId(principal) + + /** + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + * + * The same projection the list and the internal detail path apply. Without + * it this route published the whole trace and the itemized ledger to a + * member whose group withholds both everywhere else. + */ + const projection = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) + const log = await getPublicWorkflowLog( { column: 'executionId', value: context.executionId }, context.workspaceId @@ -110,7 +137,7 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ workspaceId: context.workspaceId, workflowId: log.workflowId, executionId: log.executionId, - userId: principal.kind === 'personal_api_key' ? principal.userId : undefined, + userId: viewerUserId, } ) /** @@ -121,16 +148,25 @@ export const getPublicLog = defineAuthorizedWorkspaceUseCase({ * before resolving one legitimately has none — so null is an answer here, * not a missing join. */ - const costLedger = await buildCostLedger(log.executionId) + /** + * The ledger is the itemization of the very total `costTotal` reports, so a + * group withholding spend has to lose both — blanking the total alone would + * leave the caller able to sum the lines. + */ + const costLedger = projection.hideCostInfo ? null : await buildCostLedger(log.executionId) return { - log: { ...log, workflowState: sanitizeExecutionSnapshotState(log.workflowState) }, + log: { + ...log, + costTotal: projection.hideCostInfo ? null : log.costTotal, + workflowState: sanitizeExecutionSnapshotState(log.workflowState), + }, costLedger, workflowFolderPath: publicLogFolderPath( folderIndex.pathById, log.workflowFolderId, log.workflowName !== null ), - executionData, + executionData: projectExecutionData(executionData, projection) as Record, } }, }) diff --git a/apps/sim/lib/logs/application/list-logs.test.ts b/apps/sim/lib/logs/application/list-logs.test.ts new file mode 100644 index 00000000000..2e534ab544b --- /dev/null +++ b/apps/sim/lib/logs/application/list-logs.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ + +import type { Principal } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readLogs: vi.fn(), + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@/lib/logs/list-logs', () => ({ + readLogs: mocks.readLogs, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (held: string | null, required: string) => + held === 'admin' || held === required || (held === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { listLogsUseCase } from '@/lib/logs/application/list-logs' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' + +const WORKSPACE_ID = 'workspace-1' +const SESSION: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } +const INPUT = { workspaceId: WORKSPACE_ID, limit: 100, sortBy: 'date', sortOrder: 'desc' } as never + +describe('listLogsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.readLogs.mockResolvedValue({ data: [], nextCursor: null }) + resolveGroupConfigMock.mockResolvedValue(null) + }) + + /** + * A cost withheld on the detail but still printed on the list withholds + * nothing, so the same key has to reach both queries. + */ + it('tells the list query to withhold spend when the group does', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await listLogsUseCase.execute({ principal: SESSION, input: INPUT }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: true })) + }) + + it('leaves spend in place when no group withholds it', async () => { + await listLogsUseCase.execute({ principal: SESSION, input: INPUT }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) + }) + + /** + * Blanking the field is not enough on its own: `cost > X` answered faithfully + * is a bisection oracle over the very number that was withheld, and the sort + * leaks the same ranking more slowly. + */ + it.each([ + ['a cost sort', { sortBy: 'cost' }], + ['a cost filter', { costOperator: '>', costValue: 0.5 }], + ['an equality cost filter', { costOperator: '=', costValue: 0 }], + ])('refuses %s when the group withholds spend', async (_label, overrides) => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await expect( + listLogsUseCase.execute({ + principal: SESSION, + input: { ...(INPUT as object), ...overrides } as never, + }) + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + expect(mocks.readLogs).not.toHaveBeenCalled() + }) + + it('answers the same cost query when no group withholds spend', async () => { + await listLogsUseCase.execute({ + principal: SESSION, + input: { ...(INPUT as object), sortBy: 'cost', costOperator: '>', costValue: 0.5 } as never, + }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ sortBy: 'cost' })) + }) + + /** A duration filter names nothing the group withholds, so it still answers. */ + it('leaves a duration filter alone under a spend-withholding group', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await listLogsUseCase.execute({ + principal: SESSION, + input: { ...(INPUT as object), durationOperator: '>', durationValue: 100 } as never, + }) + + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: true })) + }) + + /** + * An actorless run has no user, so there is no group to resolve — it reads its + * own workspace's logs whole rather than being handed a stand-in viewer. + */ + it('does not resolve a group for a principal with no subject', async () => { + await listLogsUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + } as Principal, + input: INPUT, + }) + + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) + }) + + /** + * An executor delegation names the person who triggered the run, but carries + * their role and none of their capabilities — the exemption the authorization + * funnel already applied on the way in. Projecting on them here would be a + * second, contrary decision about the same principal, and a cost-sorted read + * would not merely lose a column: `assertLogCostQueryAllowed` would refuse the + * run's own listing outright. + */ + it('reads whole for a run delegated by a member whose group withholds spend', async () => { + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await listLogsUseCase.execute({ + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:logs', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + } as Principal, + input: { ...(INPUT as object), sortBy: 'cost' } as never, + }) + + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + expect(mocks.readLogs).toHaveBeenCalledWith(expect.objectContaining({ hideCostInfo: false })) + }) +}) diff --git a/apps/sim/lib/logs/application/list-logs.ts b/apps/sim/lib/logs/application/list-logs.ts index 9fed67ced18..f4294009bd5 100644 --- a/apps/sim/lib/logs/application/list-logs.ts +++ b/apps/sim/lib/logs/application/list-logs.ts @@ -7,6 +7,11 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { type ListLogsParams, readLogs } from '@/lib/logs/list-logs' +import { + assertLogCostQueryAllowed, + logProjectionSubjectUserId, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ @@ -14,8 +19,31 @@ const authorizedListLogsUseCase = defineAuthorizedWorkspaceUseCase({ resolveContext: ({ input }: { input: ListLogsParams }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), authorizationOptions: logDelegationAuthorization(), - async execute({ input, context }) { - return readLogs({ ...input, workspaceId: context.workspaceId }) + async execute({ principal, input, context }) { + /** + * permission-group-enforced: logs.cost — the list carries the same run + * total the detail does, so withholding it only on the detail would hide + * nothing. A projection rather than a refusal, for the reason given in + * `read-log-detail.ts`, resolved through the shared helper every other log + * surface reads so the subject and the rule cannot drift apart here. + */ + const { hideCostInfo } = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) + /** + * The list's own `sortBy=cost` and `costOperator`/`costValue` select on the + * very figure the row above blanks, so they have to be refused rather than + * answered — see {@link assertLogCostQueryAllowed}. + */ + assertLogCostQueryAllowed(input, { hideCostInfo }) + + return readLogs({ + ...input, + workspaceId: context.workspaceId, + hideCostInfo, + }) }, }) diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts index 729f015c6c9..a5020c61538 100644 --- a/apps/sim/lib/logs/application/list-public-logs.ts +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -1,3 +1,4 @@ +import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -6,6 +7,13 @@ import { logDelegationAuthorization } from '@/lib/logs/application/authorization import { logOperations } from '@/lib/logs/application/operations' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' import { resolveLogFolderScope } from '@/lib/logs/folder-scope' +import { + assertLogCostQueryAllowed, + type LogFieldProjection, + logProjectionSubjectUserId, + projectExecutionData, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import type { LogFilters } from '@/lib/logs/public-filters' import { type PublicLogListRow, @@ -41,6 +49,20 @@ export interface ListPublicLogsResult { includeTraceSpans: boolean } +/** + * The row with its spend blanked when the viewer's group withholds it. + * + * Blanked on the row rather than in the presenter so a surface that reads + * `costTotal` or a job run's `cost` directly cannot report a figure the group + * withholds by forgetting to ask. The two branches spell the same column + * differently because the two tables do: a workflow run stores a `numeric` + * total, a job run a jsonb document. + */ +function projectRowSpend(log: PublicLogListRow, projection: LogFieldProjection): PublicLogListRow { + if (!projection.hideCostInfo) return log + return log.kind === 'job' ? { ...log, cost: null } : { ...log, costTotal: null } +} + export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ operation: logOperations.list, resolveContext: async ({ input }: { input: ListPublicLogsInput }) => { @@ -50,11 +72,58 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: logDelegationAuthorization(), execute: async ({ principal, input, context }): Promise => { + /** + * Attribution and the projection subject in one value: a workspace API key + * authorizes as the workspace and represents no user, so it resolves to + * `undefined` and reads the page whole. Substituting the key's creator would + * apply a bystander's group to every caller of a shared credential. + */ + const viewerUserId = resolvePrincipalSubjectUserId(principal) + + /** + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + * + * A projection rather than a refusal, for the reason + * {@link resolveLogFieldProjection} gives — and applied here, in the use + * case, rather than in the v2 presenter, so the withholding cannot be lost + * by a second surface reading the same list. + */ + const projection = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) + + /** + * Refused after the workspace role check above and before the read below: + * `minCost`/`maxCost` bisect the very total the rows blank, and + * `sortBy=cost` leaks the same figure as a ranking — see + * {@link assertLogCostQueryAllowed}. + */ + assertLogCostQueryAllowed( + { + sortBy: input.sortBy, + minCost: input.filters.minCost, + maxCost: input.filters.maxCost, + }, + projection + ) + const folderScope = input.folderPaths ? await resolveLogFolderScope(context.workspaceId, input.folderPaths) : undefined - const needsMaterialization = input.includeFinalOutput || input.includeTraceSpans + /** + * A group withholding execution detail turns both render flags off below, + * so every materialized payload would be projected and then dropped + * unread. Skipped here instead: materialization is an object-store read per + * row plus a secret projection over the whole trace, and paying for a page + * of them to discard the result is the most expensive way to withhold + * something. + */ + const needsMaterialization = + (input.includeFinalOutput || input.includeTraceSpans) && !projection.hideTraceSpans const { data, nextCursorKeys } = await readPublicLogPage({ filters: { ...input.filters, workspaceId: context.workspaceId }, limit: input.limit, @@ -66,7 +135,6 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ cursorKeys: input.cursorKeys, }) - const userId = principal.kind === 'personal_api_key' ? principal.userId : undefined /** * Job runs carry no materializable execution data on this surface: their * `execution_data` is a job envelope rather than a workflow trace, and @@ -76,28 +144,40 @@ export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ */ const items = needsMaterialization ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { - if (log.kind !== 'workflow' || !log.executionData) return { log } + const projectedLog = projectRowSpend(log, projection) + if (log.kind !== 'workflow' || !log.executionData) return { log: projectedLog } + const materialized = await materializeExecutionDataForDisplay( + log.executionData as Record, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + userId: viewerUserId, + } + ) return { - log, - executionData: await materializeExecutionDataForDisplay( - log.executionData as Record, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - userId, - } - ), + log: projectedLog, + executionData: projectExecutionData(materialized, projection) as Record< + string, + unknown + >, } }) - : data.map((log) => ({ log })) + : data.map((log) => ({ log: projectRowSpend(log, projection) })) + /** + * The render flags are narrowed rather than left for the presenter to + * re-check. `projectExecutionData` deletes the withheld payloads, but the + * presenter reads `executionData.traceSpans ?? []`, so a deleted array would + * come back as an empty one — present, and indistinguishable from a run + * whose spans aged out. Turning the flag off omits the field instead. + */ return { items, nextCursorKeys, includeFullDetails: input.includeFullDetails, - includeFinalOutput: input.includeFinalOutput, - includeTraceSpans: input.includeTraceSpans, + includeFinalOutput: input.includeFinalOutput && !projection.hideTraceSpans, + includeTraceSpans: input.includeTraceSpans && !projection.hideTraceSpans, } }, }) diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts index 2d84652e988..e0b760eb506 100644 --- a/apps/sim/lib/logs/application/operations.ts +++ b/apps/sim/lib/logs/application/operations.ts @@ -7,28 +7,36 @@ const LOG_READER_PRINCIPAL_POLICY = { } as const export const logOperations = { + // permission-group-exempt: reading the log list is governed by workspace role; the group withholds fields inside a run — trace spans and cost — not the fact that it ran list: defineWorkspaceOperation({ id: 'logs.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...LOG_READER_PRINCIPAL_POLICY, }), + // permission-group-exempt: aggregate run counts carry no execution payload, so there is nothing here for a group to withhold readStats: defineWorkspaceOperation({ id: 'logs.read_stats', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + // permission-group-exempt: as with the list, the group projects trace spans and cost out of the response rather than refusing the read readDetail: defineWorkspaceOperation({ id: 'logs.read_detail', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...LOG_READER_PRINCIPAL_POLICY, }), + // permission-group-exempt: the executor reading its own run's snapshot mid-flight; refusing it would fail runs the group permits readExecutionSnapshot: defineWorkspaceOperation({ id: 'logs.read_execution_snapshot', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session', 'delegated'], delegatedServices: ['executor'], }), diff --git a/apps/sim/lib/logs/application/public-log-projection.test.ts b/apps/sim/lib/logs/application/public-log-projection.test.ts new file mode 100644 index 00000000000..6532246d979 --- /dev/null +++ b/apps/sim/lib/logs/application/public-log-projection.test.ts @@ -0,0 +1,423 @@ +/** + * @vitest-environment node + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS, not gates — a group + * withholds those fields from the response rather than refusing the read, which + * is why `logOperations.list` and `logOperations.readDetail` correctly declare + * `capability: 'none'`. + * + * `/api/v2/logs` and `/api/v2/logs/{runId}` applied none of it: an enterprise + * member whose group hides spend or execution detail read both in full through + * a personal API key, while the same person was withheld them on the internal + * and v1 surfaces. These run the real use cases against the real + * `resolveLogFieldProjection` — the same helper `readLogDetail` and the v1 + * routes resolve their flags through — so they fail if this surface stops + * projecting. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getLogScope: vi.fn(), + getLog: vi.fn(), + listLogs: vi.fn(), + loadFolders: vi.fn(), + materialize: vi.fn(), + buildCostLedger: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/logs/public-queries', () => ({ + getPublicWorkflowLogScope: mocks.getLogScope, + getPublicWorkflowLog: mocks.getLog, + readPublicLogPage: mocks.listLogs, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolders, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplay: mocks.materialize, +})) + +vi.mock('@/lib/logs/cost-ledger', () => ({ + buildCostLedger: mocks.buildCostLedger, +})) + +vi.mock('@/lib/logs/snapshot-sanitizer', () => ({ + sanitizeExecutionSnapshotState: (state: unknown) => state, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getPublicLog } from '@/lib/logs/application/get-public-log' +import { listPublicLogs } from '@/lib/logs/application/list-public-logs' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const WORKSPACE_ID = 'workspace-1' + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const EXECUTION_DATA = { + /** + * The run-level roll-up every completed run carries. `models` is the + * per-model dollar breakdown, so a projection that blanks the total and + * leaves this published the finer figure it was hiding. + */ + tokens: { input: 60, output: 30, total: 90 }, + models: { 'gpt-4': { input: 0.4, output: 0.35, total: 0.75 } }, + finalOutput: { answer: 'a customer address' }, + workflowInput: { question: 'who?' }, + blockInput: { prompt: 'who?' }, + blockExecutions: [{ blockId: 'b1', cost: { total: 0.2 }, tokens: { total: 90 } }], + traceSpans: [ + { + id: 's1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 120 }, + children: [{ id: 's2', name: 'tool', cost: { total: 0.1 } }], + }, + ], +} + +const COST_LEDGER = { total: 0.75, items: [{ model: 'gpt-4', cost: 0.75 }] } + +const workflowLog = { + kind: 'workflow' as const, + id: 'log-1', + executionId: 'run-1', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + workflowName: 'Support triage', + workflowFolderId: 'folder-1', + workflowUserId: 'owner-1', + workflowOwnerEmail: 'owner@example.com', + workflowState: { blocks: {} }, + costTotal: '0.75', + executionData: { pointer: true }, +} + +const jobLog = { + kind: 'job' as const, + executionId: 'job-1', + cost: { total: 0.4 }, + executionData: { pointer: true }, +} + +/** A person governed by a group; the group's own keys decide what is withheld. */ +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-9', + keyId: 'key-9', +} + +/** A workspace key has no user and therefore no group. */ +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} + +function governedBy(overrides: Partial) { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...overrides, + }) +} + +function listInput(overrides: Record = {}) { + return { + workspaceId: WORKSPACE_ID, + filters: {}, + sortBy: 'startedAt' as const, + sortOrder: 'desc' as const, + cursorKeys: undefined, + limit: 50, + includeFullDetails: true, + includeFinalOutput: true, + includeTraceSpans: true, + includeJobRuns: false, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getLogScope.mockResolvedValue({ + executionId: 'run-1', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + }) + mocks.getLog.mockResolvedValue(workflowLog) + mocks.listLogs.mockResolvedValue({ data: [workflowLog], nextCursorKeys: null }) + mocks.loadFolders.mockResolvedValue({ + idByPath: new Map([['/agents', 'folder-1']]), + pathById: new Map([['folder-1', '/agents']]), + }) + mocks.materialize.mockImplementation(async () => structuredClone(EXECUTION_DATA)) + mocks.buildCostLedger.mockResolvedValue(structuredClone(COST_LEDGER)) +}) + +describe('listPublicLogs field projection', () => { + it('blanks the run cost on the row when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + + expect((result.items[0].log as { costTotal: string | null }).costTotal).toBeNull() + }) + + it('blanks a job run cost too, which the presenter reads from another column', async () => { + governedBy({ hideCostInfo: true }) + mocks.listLogs.mockResolvedValueOnce({ data: [jobLog], nextCursorKeys: null }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput({ includeJobRuns: true }), + }) + + expect((result.items[0].log as { cost: unknown }).cost).toBeNull() + }) + + it('strips spend from the spans it still returns when only cost is hidden', async () => { + governedBy({ hideCostInfo: true }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + const [span] = result.items[0].executionData?.traceSpans as Array> + + expect(result.items[0].executionData).not.toHaveProperty('models') + expect(span.name).toBe('agent') + expect(span).not.toHaveProperty('cost') + expect(span).not.toHaveProperty('tokens') + expect((span.children as Array>)[0]).not.toHaveProperty('cost') + }) + + /** + * The flags are what the presenter renders from. Deleting the payloads alone + * is not enough: it reads `executionData.traceSpans ?? []`, so a deleted + * array would come back as an empty one — present, and indistinguishable from + * a run whose spans aged out. + */ + it('withholds the execution payloads and turns off their render flags', async () => { + governedBy({ hideTraceSpans: true }) + + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + + expect(result.includeTraceSpans).toBe(false) + expect(result.includeFinalOutput).toBe(false) + expect(result.items[0].executionData).toBeUndefined() + }) + + /** + * The page is withheld whole, so the object-store read and the secret + * projection behind every payload buy nothing. Asserted on the read itself, + * not only on `materializeExecutionDataForDisplay`, because the column is + * what the work hangs off. + */ + it('materializes nothing when the group withholds execution detail', async () => { + governedBy({ hideTraceSpans: true }) + + await listPublicLogs.execute({ principal: personalPrincipal, input: listInput() }) + + expect(mocks.materialize).not.toHaveBeenCalled() + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: false }) + ) + }) + + it('still materializes for a group that withholds only spend', async () => { + governedBy({ hideCostInfo: true }) + + await listPublicLogs.execute({ principal: personalPrincipal, input: listInput() }) + + expect(mocks.materialize).toHaveBeenCalledTimes(1) + }) + + it('withholds nothing from a caller no group governs', async () => { + const result = await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput(), + }) + + expect((result.items[0].log as { costTotal: string | null }).costTotal).toBe('0.75') + expect(result.includeTraceSpans).toBe(true) + expect(result.items[0].executionData?.traceSpans).toHaveLength(1) + expect(result.items[0].executionData?.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + }) + + /** + * A workspace API key authorizes as the workspace and represents no user, so + * there is no group to apply. Substituting the key's creator would govern + * every caller of a shared credential by a bystander's group. + */ + it('withholds nothing from a workspace API key and never resolves a group', async () => { + governedBy({ hideTraceSpans: true, hideCostInfo: true }) + + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: listInput(), + }) + + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + expect((result.items[0].log as { costTotal: string | null }).costTotal).toBe('0.75') + expect(result.includeTraceSpans).toBe(true) + expect(result.items[0].executionData?.traceSpans).toHaveLength(1) + }) +}) + +/** + * Withholding the figure is not enough on its own: `minCost`/`maxCost` bisect + * it, and `sortBy=cost` reads it as a ranking. Refused rather than dropped — + * dropping the clause answers a question nobody asked. + */ +describe('listPublicLogs cost-selective queries', () => { + it.each([ + ['a cost sort', { sortBy: 'cost' as const }], + ['a minCost filter', { filters: { minCost: 0.5 } }], + ['a maxCost filter', { filters: { maxCost: 0.5 } }], + ])('refuses %s for a group that withholds spend', async (_label, overrides) => { + governedBy({ hideCostInfo: true }) + + await expect( + listPublicLogs.execute({ principal: personalPrincipal, input: listInput(overrides) }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + message: "Execution cost is not available under your organization's permission group", + }) + + expect(mocks.listLogs).not.toHaveBeenCalled() + }) + + it('answers a cost sort for a group that withholds nothing', async () => { + await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput({ sortBy: 'cost' as const }), + }) + + expect(mocks.listLogs).toHaveBeenCalledWith(expect.objectContaining({ sortBy: 'cost' })) + }) + + it('answers a cost filter for a workspace API key', async () => { + governedBy({ hideCostInfo: true }) + + await listPublicLogs.execute({ + principal: workspacePrincipal, + input: listInput({ filters: { minCost: 0.5 } }), + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ filters: expect.objectContaining({ minCost: 0.5 }) }) + ) + }) + + it('leaves a non-spend filter alone for a group that withholds spend', async () => { + governedBy({ hideCostInfo: true }) + + await listPublicLogs.execute({ + principal: personalPrincipal, + input: listInput({ filters: { minDurationMs: 100 } }), + }) + + expect(mocks.listLogs).toHaveBeenCalled() + }) +}) + +describe('getPublicLog field projection', () => { + it('withholds the run total and the itemized ledger when the group hides cost', async () => { + governedBy({ hideCostInfo: true }) + + const result = await getPublicLog.execute({ + principal: personalPrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.log.costTotal).toBeNull() + expect(result.costLedger).toBeNull() + expect( + (result.executionData.traceSpans as Array>)[0] + ).not.toHaveProperty('cost') + expect( + (result.executionData.blockExecutions as Array>)[0] + ).not.toHaveProperty('tokens') + expect(result.executionData).not.toHaveProperty('tokens') + expect(result.executionData).not.toHaveProperty('models') + }) + + it('withholds the execution payloads when the group hides trace spans', async () => { + governedBy({ hideTraceSpans: true }) + + const result = await getPublicLog.execute({ + principal: personalPrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.executionData).not.toHaveProperty('traceSpans') + expect(result.executionData).not.toHaveProperty('finalOutput') + expect(result.executionData).not.toHaveProperty('workflowInput') + expect(result.executionData).not.toHaveProperty('blockExecutions') + }) + + it('withholds nothing from a caller no group governs', async () => { + const result = await getPublicLog.execute({ + principal: personalPrincipal, + input: { runId: 'run-1' }, + }) + + expect(result.log.costTotal).toBe('0.75') + expect(result.costLedger).toEqual(COST_LEDGER) + expect(result.executionData.finalOutput).toEqual(EXECUTION_DATA.finalOutput) + expect(result.executionData.models).toEqual(EXECUTION_DATA.models) + }) + + it('withholds nothing from a workspace API key', async () => { + governedBy({ hideTraceSpans: true, hideCostInfo: true }) + + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + expect(result.log.costTotal).toBe('0.75') + expect(result.costLedger).toEqual(COST_LEDGER) + expect(result.executionData.traceSpans).toHaveLength(1) + }) +}) diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.test.ts b/apps/sim/lib/logs/application/read-execution-snapshot.test.ts new file mode 100644 index 00000000000..30df45ff087 --- /dev/null +++ b/apps/sim/lib/logs/application/read-execution-snapshot.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + * + * `logs.cost` is a PROJECTION, not a gate — `logOperations.readExecutionSnapshot` + * correctly declares `capability: 'none'`, and the run stays readable while its + * spend does not. + * + * The snapshot read applied none of it, on either of its two doors: the internal + * `/api/logs/execution/{executionId}` route and the `logs_get_execution` Copilot + * tool both presented `executionMetadata.cost` verbatim, so a member whose group + * hides spend read the run total here after being withheld it everywhere else. + * Projecting in the use case is what makes both doors inherit it, which is why + * these exercise the use case against the real `resolveLogFieldProjection`. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + select: vi.fn(), + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + materialize: vi.fn(), + hydrateChildTraces: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: {}, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === 'write' || actual === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mocks.materialize, +})) + +vi.mock('@/lib/logs/execution/hydrate-child-traces', () => ({ + hydrateChildTraces: mocks.hydrateChildTraces, +})) + +import { readExecutionSnapshotUseCase } from '@/lib/logs/application/read-execution-snapshot' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const WORKSPACE_ID = 'workspace-1' + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const workflowRecord = { + id: 'log-1', + workflowId: 'workflow-1', + workspaceId: WORKSPACE_ID, + executionId: 'run-1', + stateSnapshotId: 'snapshot-1', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + costTotal: '0.75', + executionData: null, +} + +const jobRecord = { + id: 'job-log-1', + workspaceId: WORKSPACE_ID, + executionId: 'job-1', + trigger: 'schedule', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + cost: { total: 0.75, input: 0.5, output: 0.25 }, +} + +/** + * Answers `db.select(...)` calls in order. The snapshot read walks the workflow + * log, then (only when that missed) the job log, then the state snapshot. + */ +function queueSelects(...results: unknown[][]): void { + for (const rows of results) { + mocks.select.mockReturnValueOnce({ + from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }), + }) + } +} + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +/** + * `logs.read_execution_snapshot` denies a workspace API key outright, so the + * subjectless caller that actually reaches this read is the executor delegation — + * which carries a workspace role but no capabilities, and must read whole. + */ +const executorPrincipal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:logs', + issuedAt: new Date(Date.now() - 60_000), + expiresAt: new Date(Date.now() + 60 * 60_000), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + currentWorkflow: { mode: 'deployment' as const }, + /** Never the projection subject: it is compatibility policy, not the caller. */ + compatibilityActor: { kind: 'legacy_execution_user' as const, userId: 'user-1' }, + }, +} + +function read(actor: typeof principal | typeof executorPrincipal, executionId: string) { + return readExecutionSnapshotUseCase.execute({ + principal: actor, + input: { executionId }, + }) +} + +describe('readExecutionSnapshot spend projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.materialize.mockResolvedValue(null) + }) + + it('reads the run total whole for a member no group governs', async () => { + queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + + const result = await read(principal, 'run-1') + + expect(result.executionMetadata.cost).toEqual({ total: 0.75 }) + }) + + it('withholds the run total from a member whose group hides spend', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + + const result = await read(principal, 'run-1') + + expect(result.executionMetadata.cost).toBeNull() + expect(result.executionMetadata.trigger).toBe('api') + expect(result.workflowState).toEqual({ blocks: {} }) + }) + + /** A job run spells its spend as a jsonb document; the same rule covers it. */ + it("withholds a job run's spend document from a member whose group hides spend", async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueSelects([], [jobRecord]) + + const result = await read(principal, 'job-1') + + expect(result.executionMetadata.cost).toBeNull() + }) + + /** + * A workspace API key authorizes as the workspace and represents no user, so + * it resolves to no subject — the key's creator is never substituted. + */ + it('reads whole and resolves no group for an executor delegation', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + + const result = await read(executorPrincipal, 'run-1') + + expect(result.executionMetadata.cost).toEqual({ total: 0.75 }) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.ts b/apps/sim/lib/logs/application/read-execution-snapshot.ts index c44f803852a..15d45119c63 100644 --- a/apps/sim/lib/logs/application/read-execution-snapshot.ts +++ b/apps/sim/lib/logs/application/read-execution-snapshot.ts @@ -12,6 +12,11 @@ import { import { logOperations } from '@/lib/logs/application/operations' import { hydrateChildTraces } from '@/lib/logs/execution/hydrate-child-traces' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { + logProjectionSubjectUserId, + projectCostTotal, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { type ActiveWorkspaceApplicationContext, @@ -133,6 +138,27 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( async execute({ principal, input, context }): Promise { input.signal?.throwIfAborted() const record = context.record + + /** + * A projection rather than a refusal, resolved through the shared helper the + * log-detail and v1 paths read — see {@link resolveLogFieldProjection}. Applied + * here in the use case so both doors onto this read inherit it: the internal + * snapshot route and the `logs_get_execution` Copilot tool. + * + * `cost` is the only field on the withheld list this resource carries. The + * snapshot's other payloads are the workflow's own definition — its state + * snapshot and any child-workflow snapshots — which neither capability + * withholds, and the execution data is read only to collect child snapshot + * ids; no trace span, block execution, input or final output is returned. + * + * permission-group-enforced: logs.cost + */ + const projection = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) + if (record.kind === 'job') { return { executionId: record.executionId, @@ -144,7 +170,7 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString(), totalDurationMs: record.totalDurationMs, - cost: record.cost || null, + cost: projection.hideCostInfo ? null : record.cost || null, }, } } @@ -208,7 +234,7 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( startedAt: record.startedAt.toISOString(), endedAt: record.endedAt?.toISOString(), totalDurationMs: record.totalDurationMs, - cost: record.costTotal != null ? { total: Number(record.costTotal) } : null, + cost: projectCostTotal(record.costTotal, projection), }, } }, diff --git a/apps/sim/lib/logs/application/read-log-detail.test.ts b/apps/sim/lib/logs/application/read-log-detail.test.ts index ee291213706..8960be8fd09 100644 --- a/apps/sim/lib/logs/application/read-log-detail.test.ts +++ b/apps/sim/lib/logs/application/read-log-detail.test.ts @@ -4,7 +4,12 @@ import type { Principal } from '@sim/auth/principal' import { workflowExecutionLogs } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -13,6 +18,8 @@ const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@/lib/logs/fetch-log-detail', () => ({ readLogDetail: mocks.readLogDetail, })) @@ -21,6 +28,8 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: (held: string | null, required: string) => held === 'admin' || held === required || (held === 'write' && required === 'read'), @@ -93,6 +102,7 @@ describe('readLogDetailUseCase', () => { }) mocks.readLogDetail.mockResolvedValue({ id: 'log-1', executionId: EXECUTION_ID }) mocks.resolvePermission.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(null) }) afterAll(resetDbChainMock) @@ -123,4 +133,57 @@ describe('readLogDetailUseCase', () => { expect.objectContaining({ viewerUserId: 'user-1' }) ) }) + + /** + * A projection, not a refusal: the loader is still asked for the log, just + * told to leave the spend out of it. + */ + it('tells the loader to withhold spend when the group does', async () => { + queueLogRow() + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await readLogDetailUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, + }) + + expect(mocks.readLogDetail).toHaveBeenCalledWith( + expect.objectContaining({ hideCostInfo: true }) + ) + }) + + /** + * The same person's group, reached through the run they triggered rather than + * through their own session. The delegation carries their role and none of + * their capabilities — `authorizeWorkspaceOperation` already passed it + * ungated — so projecting on it would withhold from a run on a group the + * funnel declined to apply. Attribution still names them. + */ + it('leaves spend in place for a run delegated by that same person', async () => { + queueLogRow() + resolveGroupConfigMock.mockResolvedValue({ hideCostInfo: true }) + + await readLogDetailUseCase.execute({ + principal: HUMAN_PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, + }) + + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + expect(mocks.readLogDetail).toHaveBeenCalledWith( + expect.objectContaining({ viewerUserId: 'user-1', hideCostInfo: false }) + ) + }) + + it('leaves spend in place when no group withholds it', async () => { + queueLogRow() + + await readLogDetailUseCase.execute({ + principal: HUMAN_PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, lookupColumn: 'executionId', lookupValue: EXECUTION_ID }, + }) + + expect(mocks.readLogDetail).toHaveBeenCalledWith( + expect.objectContaining({ hideCostInfo: false }) + ) + }) }) diff --git a/apps/sim/lib/logs/application/read-log-detail.ts b/apps/sim/lib/logs/application/read-log-detail.ts index 326a3047ee1..8c2c0c0137f 100644 --- a/apps/sim/lib/logs/application/read-log-detail.ts +++ b/apps/sim/lib/logs/application/read-log-detail.ts @@ -11,6 +11,7 @@ import { } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' import { readLogDetail } from '@/lib/logs/fetch-log-detail' +import { logProjectionSubjectUserId, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { type ActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, @@ -79,12 +80,26 @@ const authorizedReadLogDetailUseCase = defineAuthorizedWorkspaceUseCase({ input.signal?.throwIfAborted() // Attribution, not authorization: an actorless run (a schedule, or a webhook // with no external subject) reads its own workspace's logs with no user to name. + const viewerUserId = resolvePrincipalSubjectUserId(principal) + + /** + * A projection rather than a refusal: the log stays readable, its execution + * payloads and its spend do not. Resolved through the shared helper, which + * the v1 public API reads too — see {@link resolveLogFieldProjection}. + */ + const projection = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) + const detail = await readLogDetail({ - viewerUserId: resolvePrincipalSubjectUserId(principal), + viewerUserId, workspaceId: context.workspaceId, lookupColumn: input.lookupColumn, lookupValue: input.lookupValue, signal: input.signal, + ...projection, }) input.signal?.throwIfAborted() if (!detail) throw new OrchestrationError('not_found', 'Not found') diff --git a/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts b/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts index 0af90346a48..58a0de0555c 100644 --- a/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts +++ b/apps/sim/lib/logs/execution/hydrate-child-traces.test.ts @@ -32,7 +32,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionDataForDisplay: mockMaterialize, - stripSpanCosts: (spans: unknown) => { + stripJoinedChildTraceSpend: (spans: unknown) => { if (!Array.isArray(spans)) return for (const span of spans) { if (span && typeof span === 'object') { diff --git a/apps/sim/lib/logs/execution/hydrate-child-traces.ts b/apps/sim/lib/logs/execution/hydrate-child-traces.ts index cb4281eb92e..b47f7adbf69 100644 --- a/apps/sim/lib/logs/execution/hydrate-child-traces.ts +++ b/apps/sim/lib/logs/execution/hydrate-child-traces.ts @@ -6,7 +6,7 @@ import { inArray } from 'drizzle-orm' import { flattenWorkflowChildren } from '@/lib/logs/execution/trace-spans/span-factory' import { materializeExecutionDataForDisplay, - stripSpanCosts, + stripJoinedChildTraceSpend, } from '@/lib/logs/execution/trace-store' import type { TraceSpan } from '@/lib/logs/types' @@ -261,10 +261,11 @@ export async function hydrateChildTraces( // The same flattening the in-process workflow-in-workflow path uses, so a // cross-workspace child nests identically to a local one. const children = flattenWorkflowChildren(childSpans) - // The child's spend is billed to the SOURCE workspace and was never rolled - // into this run's total, so leaving per-span cost here would make the - // waterfall's numbers contradict the run cost shown above it. - stripSpanCosts(children) + // The child's spend is billed to the SOURCE workspace and was never + // rolled into this run's total, so leaving any of it here would make the + // waterfall's numbers contradict the run cost shown above it. Tokens go + // with the dollars — see {@link stripJoinedChildTraceSpend}. + stripJoinedChildTraceSpend(children) span.children = children span.childTraceAccess = 'granted' diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index c73e65241a4..299cbfda3fa 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -25,14 +25,18 @@ vi.mock('@/lib/execution/payloads/store', () => ({ })) import { + copyTraceSpansWithoutCosts, externalizeExecutionData, materializeExecutionData, materializeExecutionDataForDisplayWithBlockOutputs, projectExecutionDataForDisplay, RESOLVED_SECRET_PROVENANCE_KEY, SECRET_PROJECTION_VERSION, + stripJoinedChildTraceSpend, + stripSpanCosts, TRACE_STORE_REF_KEY, } from '@/lib/logs/execution/trace-store' +import type { TraceSpan } from '@/lib/logs/types' const CONTEXT = { workspaceId: 'workspace-1', @@ -772,3 +776,171 @@ describe('stored provenance display reporting', () => { ) }) }) + +/** + * The two strips are not the same removal, and the difference is whether the + * result is written. + * + * `stripJoinedChildTraceSpend` is what stands between a joined cross-workspace + * child run and the parent's reader: the child's spend is billed to the SOURCE + * workspace and was never rolled into this run's total, so anything it leaves + * behind is spend the reader was never meant to see — and it never persists. + * `stripSpanCosts` runs inside `backfill-trace-spans.ts`, which stores what it + * returns, so anything IT clears is gone for every authorized reader of that run + * forever. Only the dollars belong in that set. + */ +function spanWithSpend() { + return [ + { + id: 'span-1', + name: 'agent', + cost: { total: 0.5 }, + tokens: { total: 900 }, + providerTiming: { + duration: 5, + segments: [ + { type: 'model', name: 'gpt-4', tokens: { total: 900 }, cost: { total: 0.5 } }, + { type: 'tool', name: 'search' }, + ], + }, + children: [ + { + id: 'span-2', + name: 'model', + cost: { total: 0.2 }, + tokens: { total: 400 }, + providerTiming: { segments: [{ type: 'model', tokens: { total: 400 } }] }, + }, + ], + }, + ] +} + +describe('stripJoinedChildTraceSpend', () => { + it('clears the span roll-up and the provider-timing segments that itemize it', () => { + const spans = spanWithSpend() + + stripJoinedChildTraceSpend(spans) + + expect(spans[0].cost).toBeUndefined() + expect(spans[0].tokens).toBeUndefined() + const [modelSegment, toolSegment] = spans[0].providerTiming.segments as Array< + Record + > + expect(modelSegment.tokens).toBeUndefined() + expect(modelSegment.cost).toBeUndefined() + // Structure and identity are what the waterfall renders; only spend goes. + expect(modelSegment).toMatchObject({ type: 'model', name: 'gpt-4' }) + expect(toolSegment).toMatchObject({ type: 'tool', name: 'search' }) + }) + + it('reaches the segments of nested children too', () => { + const spans = spanWithSpend() + + stripJoinedChildTraceSpend(spans) + + const child = spans[0].children[0] + expect(child.cost).toBeUndefined() + expect(child.tokens).toBeUndefined() + expect( + (child.providerTiming.segments as Array>)[0].tokens + ).toBeUndefined() + }) + + it('leaves a span with no provider timing alone', () => { + const spans = [{ id: 'span-1', name: 'api', cost: { total: 0.1 } }] + + expect(() => stripJoinedChildTraceSpend(spans)).not.toThrow() + expect(spans[0]).toMatchObject({ id: 'span-1', name: 'api' }) + }) +}) + +describe('stripSpanCosts', () => { + it('clears cost at both levels and through children', () => { + const spans = spanWithSpend() + + stripSpanCosts(spans) + + expect(spans[0].cost).toBeUndefined() + expect(spans[0].children[0].cost).toBeUndefined() + expect( + (spans[0].providerTiming.segments as Array>)[0].cost + ).toBeUndefined() + }) + + /** + * The migration stores what this returns. A legacy run's token counts are + * ordinary trace detail its authorized readers have always had, and the ledger + * — not the span — is where dollars live, so erasing them buys nothing and + * cannot be undone. + */ + it('keeps the token counts the migration is about to persist', () => { + const spans = spanWithSpend() + + stripSpanCosts(spans) + + expect(spans[0].tokens).toEqual({ total: 900 }) + expect(spans[0].children[0].tokens).toEqual({ total: 400 }) + const segments = spans[0].providerTiming.segments as Array> + expect(segments[0].tokens).toEqual({ total: 900 }) + expect( + (spans[0].children[0].providerTiming.segments as Array>)[0].tokens + ).toEqual({ total: 400 }) + }) +}) + +/** + * The COMPLETION write. `stripSpanCosts` only ever ran over legacy rows the + * backfill touched; every normal run went through this copy, which used to drop + * the span's own `cost` and leave the same dollars itemized underneath it in + * `providerTiming.segments`. Both writers now share one removal rule, so the + * two cannot answer differently about what a persisted span may carry. + */ +describe('copyTraceSpansWithoutCosts', () => { + it('clears the segment dollars the completion write used to persist', () => { + const spans = spanWithSpend() as unknown as TraceSpan[] + + const persisted = copyTraceSpansWithoutCosts(spans) + + const [span] = persisted as Array> + expect(span.cost).toBeUndefined() + expect(span.providerTiming.segments[0].cost).toBeUndefined() + expect(span.children[0].cost).toBeUndefined() + expect(span.children[0].providerTiming.segments[0].cost).toBeUndefined() + }) + + it('keeps the token counts and the segment identity a trace is read for', () => { + const spans = spanWithSpend() as unknown as TraceSpan[] + + const [span] = copyTraceSpansWithoutCosts(spans) as unknown as Array> + + expect(span.tokens).toEqual({ total: 900 }) + expect(span.children[0].tokens).toEqual({ total: 400 }) + expect(span.providerTiming.segments[0]).toMatchObject({ + type: 'model', + name: 'gpt-4', + tokens: { total: 900 }, + }) + expect(span.providerTiming.duration).toBe(5) + }) + + /** + * The strip runs in place, so the copy has to reach every node it writes to. + * Sharing the `providerTiming` with the caller would blank the segments of the + * spans the rest of the run still holds in memory. + */ + it('leaves the caller’s in-memory spans untouched', () => { + const spans = spanWithSpend() as unknown as TraceSpan[] + + copyTraceSpansWithoutCosts(spans) + + const [span] = spans as unknown as Array> + expect(span.cost).toEqual({ total: 0.5 }) + expect(span.providerTiming.segments[0].cost).toEqual({ total: 0.5 }) + expect(span.children[0].cost).toEqual({ total: 0.2 }) + }) + + it('returns undefined for no spans', () => { + expect(copyTraceSpansWithoutCosts(undefined)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index ff229f87345..4851910dab4 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -100,27 +100,127 @@ function workflowIdFromStorageKey(key: string | undefined): string | undefined { } /** - * Recursively removes `cost` from trace spans before persistence. Cost lives in - * exactly one place — the usage_log ledger — so persisted spans carry only - * structure, timing, and tokens (KTD7). Must run AFTER `calculateCostSummary` - * has consumed span costs in memory. + * Recursively removes spend from trace spans, in place. + * + * `tokens` is optional because the two callers withhold different things. + * Persistence withholds only dollars — cost lives in exactly one place, the + * usage_log ledger (KTD7), so a stored span carries structure, timing and + * tokens. A joined cross-workspace child run withholds the whole amount: its + * token counts are the same spend in another unit, recoverable by anyone who + * knows the model's rate. + * + * Must run AFTER `calculateCostSummary` has consumed span costs in memory. */ -export function stripSpanCosts(spans: unknown): void { +function stripSpanSpendFields(spans: unknown, options: { tokens: boolean }): void { if (!Array.isArray(spans)) return for (const span of spans) { if (!span || typeof span !== 'object') continue - const record = span as { cost?: unknown; children?: unknown } + const record = span as { + cost?: unknown + tokens?: unknown + children?: unknown + providerTiming?: unknown + } + if ('cost' in record) record.cost = undefined + if (options.tokens && 'tokens' in record) record.tokens = undefined + stripProviderTimingSegmentSpend(record.providerTiming, options) + if (Array.isArray(record.children)) stripSpanSpendFields(record.children, options) + } +} + +/** + * Removes per-span `cost` before persistence, leaving tokens in place. + * + * The one strip that WRITES: `backfill-trace-spans.ts` runs it over a legacy + * row's spans and stores the result, so anything it clears is gone for every + * authorized reader of that run, forever. Only cost belongs in that set — the + * ledger owns the dollars, and the spans have never been the place they live. + */ +export function stripSpanCosts(spans: unknown): void { + stripSpanSpendFields(spans, { tokens: false }) +} + +/** + * Removes cost AND token counts from a joined child run's spans, in memory. + * + * The child's spend is billed to the SOURCE workspace and was never rolled into + * the parent run's total, so leaving any of it would publish spend the reader + * was never meant to see and make the waterfall contradict the run cost above + * it. A read-time projection only: these spans are hydrated onto a response and + * never written back. + */ +export function stripJoinedChildTraceSpend(spans: unknown): void { + stripSpanSpendFields(spans, { tokens: true }) +} + +/** + * The same removal one level down, in `providerTiming.segments`. + * + * A `ProviderTimingSegment` carries its own `tokens` and `cost` — the per-model + * iteration breakdown behind the span's roll-up — so clearing the span alone + * left the whole figure itemized underneath it, which is strictly more than the + * span published in the first place. + */ +function stripProviderTimingSegmentSpend( + providerTiming: unknown, + options: { tokens: boolean } +): void { + if (!providerTiming || typeof providerTiming !== 'object') return + const segments = (providerTiming as { segments?: unknown }).segments + if (!Array.isArray(segments)) return + for (const segment of segments) { + if (!segment || typeof segment !== 'object') continue + const record = segment as { cost?: unknown; tokens?: unknown } if ('cost' in record) record.cost = undefined - if (Array.isArray(record.children)) stripSpanCosts(record.children) + if (options.tokens && 'tokens' in record) record.tokens = undefined } } -/** Creates a persistence-owned span tree with per-span cost fields removed. */ +/** + * Copies exactly the nodes {@link stripSpanSpendFields} writes to — each span, + * its children, its `providerTiming`, and that timing's segments — and shares + * every other value with the caller's tree. Enough isolation for the strip to + * run in place without reaching the in-memory spans the rest of the run still + * holds, and no deep clone of the payloads hanging off a span. + */ +function copySpanTreeForStrip(spans: TraceSpan[]): TraceSpan[] { + return spans.map((span) => { + const copy: TraceSpan = { ...span } + if (Array.isArray(copy.children)) copy.children = copySpanTreeForStrip(copy.children) + if (copy.providerTiming && typeof copy.providerTiming === 'object') { + const { segments } = copy.providerTiming + copy.providerTiming = { + ...copy.providerTiming, + ...(Array.isArray(segments) + ? { + segments: segments.map((segment) => + segment && typeof segment === 'object' ? { ...segment } : segment + ), + } + : {}), + } + } + return copy + }) +} + +/** + * Creates a persistence-owned span tree with spend removed, for the COMPLETION + * write. + * + * Runs the same {@link stripSpanCosts} the legacy backfill does, over a copy — + * one removal rule for both writers, which is the point: this used to drop the + * span's own `cost` and nothing else, so every completed run persisted the + * itemized dollars underneath it in `providerTiming.segments`, which the backfill + * had already learned to clear. Tokens survive, on both paths: the ledger owns + * the dollars, and a span's token counts are trace detail the reader is entitled + * to. + */ export function copyTraceSpansWithoutCosts(spans?: TraceSpan[]): TraceSpan[] | undefined { - return spans?.map(({ cost: _cost, children, ...span }) => ({ - ...span, - ...(children ? { children: copyTraceSpansWithoutCosts(children) } : {}), - })) + if (!spans) return undefined + const copy = copySpanTreeForStrip(spans) + stripSpanCosts(copy) + return copy } /** diff --git a/apps/sim/lib/logs/fetch-log-detail.test.ts b/apps/sim/lib/logs/fetch-log-detail.test.ts index 326032257bc..c4303867f52 100644 --- a/apps/sim/lib/logs/fetch-log-detail.test.ts +++ b/apps/sim/lib/logs/fetch-log-detail.test.ts @@ -23,8 +23,110 @@ vi.mock('@/lib/logs/execution-origin', () => ({ workflowExecutionOriginSql: () => ({ as: () => ({}) }), })) +import { workflowLogDetailSchema } from '@/lib/api/contracts/logs' import { readLogDetail } from '@/lib/logs/fetch-log-detail' +function queueWorkflowLogRow(overrides: Record = {}): void { + queueTableRows(workflowExecutionLogs, [ + { + id: 'log-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: 'info', + status: 'completed', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: {}, + costTotal: '1.25', + files: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + workflowName: 'Workflow', + workflowDescription: null, + workflowFolderId: null, + workflowUserId: 'user-1', + workflowWorkspaceId: 'workspace-1', + workflowCreatedAt: new Date('2026-01-01T00:00:00.000Z'), + workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + pausedStatus: null, + pausedTotalPauseCount: 0, + pausedResumedCount: 0, + executionOrigin: null, + ...overrides, + }, + ]) +} + +const SPEND_BEARING_EXECUTION_DATA = { + /** + * The run-level roll-up `buildCompletedExecutionData` writes on every + * completed run. `models` is the per-model dollar breakdown itself, so it is + * finer-grained than the total the projection blanks. + */ + tokens: { input: 500, output: 400, total: 900 }, + models: { + 'gpt-4': { input: 0.4, output: 0.35, total: 0.75, tokens: { total: 900 } }, + }, + cost: { total: 0.75 }, + traceSpans: [ + { + id: 'span-1', + name: 'Agent 1', + type: 'agent', + duration: 5, + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:00.005Z', + cost: { total: 0.75 }, + tokens: { total: 900 }, + /** A span's own itemization, one level below its roll-up. */ + providerTiming: { + duration: 5, + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:00.005Z', + segments: [ + { + type: 'model', + name: 'gpt-4', + startTime: 0, + endTime: 5, + duration: 5, + tokens: { total: 900 }, + cost: { total: 0.75 }, + }, + ], + }, + children: [ + { + id: 'span-2', + name: 'Model', + type: 'model', + cost: { total: 0.5 }, + tokens: { total: 400 }, + }, + ], + }, + ], + blockExecutions: [ + { + id: 'block-exec-1', + blockId: 'block-1', + blockName: 'Agent 1', + blockType: 'agent', + startedAt: '2026-01-01T00:00:00.000Z', + endedAt: '2026-01-01T00:00:00.005Z', + durationMs: 5, + status: 'success', + inputData: {}, + outputData: {}, + cost: { total: 0.75 }, + }, + ], +} + describe('readLogDetail', () => { beforeEach(() => { vi.clearAllMocks() @@ -154,4 +256,73 @@ describe('readLogDetail', () => { viewerUserId: undefined, }) }) + + describe("when the viewer's permission group withholds cost", () => { + beforeEach(() => { + queueTableRows(usageLog, []) + mocks.materializeExecutionData.mockResolvedValue( + structuredClone(SPEND_BEARING_EXECUTION_DATA) + ) + }) + + it('still returns a log the contract accepts, with every spend figure gone', async () => { + queueWorkflowLogRow() + + const result = await readLogDetail({ + viewerUserId: 'user-1', + workspaceId: 'workspace-1', + lookupColumn: 'id', + lookupValue: 'log-1', + hideCostInfo: true, + }) + + // The projection must stay inside the wire contract: a withheld log is + // still a log, and a client parsing the response cannot be made to fail. + expect(() => workflowLogDetailSchema.parse(result)).not.toThrow() + + expect(result?.cost).toBeNull() + expect(result).not.toHaveProperty('costLedger') + + const [span] = result?.executionData.traceSpans ?? [] + expect(span).not.toHaveProperty('cost') + expect(span).not.toHaveProperty('tokens') + // Nested spans carry their own figures; summing children would otherwise + // reconstruct exactly the total that was withheld. + expect(span?.children?.[0]).not.toHaveProperty('cost') + expect(result?.executionData.blockExecutions?.[0]).not.toHaveProperty('cost') + + // The run's own roll-up. `models` is the per-model dollar breakdown, so + // leaving it published the finest figure of all next to a blanked total. + expect(result?.executionData).not.toHaveProperty('tokens') + expect(result?.executionData).not.toHaveProperty('models') + expect(result?.executionData).not.toHaveProperty('cost') + + // Provider-timing segments itemize the span's own roll-up, so stripping + // the span alone leaves the amount recoverable one level down. + const [segment] = (span as { providerTiming?: { segments?: unknown[] } })?.providerTiming + ?.segments as Array> + expect(segment).toMatchObject({ name: 'gpt-4' }) + expect(segment).not.toHaveProperty('cost') + expect(segment).not.toHaveProperty('tokens') + + // Everything the restriction does not cover is untouched. + expect(result).toMatchObject({ id: 'log-1', status: 'completed' }) + expect(span).toMatchObject({ id: 'span-1', name: 'Agent 1' }) + }) + + it('reports the run total when the group does not withhold it', async () => { + queueWorkflowLogRow() + + const result = await readLogDetail({ + viewerUserId: 'user-1', + workspaceId: 'workspace-1', + lookupColumn: 'id', + lookupValue: 'log-1', + }) + + expect(result?.cost).toEqual({ total: 1.25 }) + expect(result?.executionData.traceSpans?.[0]).toHaveProperty('cost') + expect(result?.executionData).toHaveProperty('models') + }) + }) }) diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 32a13e208b5..d54b6182b13 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -40,6 +40,113 @@ interface FetchLogDetailArgs { lookupColumn: LookupColumn lookupValue: string signal?: AbortSignal + /** + * Whether the viewer's permission group withholds execution detail. Applied + * here rather than in the client, because the payloads it covers — trace + * spans, block inputs and outputs, the final output — are the customer data + * the restriction exists to withhold, and a hidden tab withholds nothing from + * a caller reading the route directly. + */ + hideTraceSpans?: boolean + /** + * Whether the viewer's permission group withholds spend. Applied here for the + * same reason as {@link FetchLogDetailArgs.hideTraceSpans}: the run total, the + * itemized ledger and the per-block and per-span costs are the figures the + * restriction exists to withhold, and a hidden column withholds nothing from a + * caller reading the route directly. + * + * Required for the same reason as {@link FetchLogDetailArgs.hideTraceSpans}. + */ + hideCostInfo: boolean +} + +/** + * Strips the execution payloads a permission group withholds. + * + * Deletes rather than relies on the schema: `executionDataDetailSchema` is a + * passthrough, so a field left in place would survive response validation. + * Applied before child traces are hydrated, so a withheld view does not pay for + * a cross-workspace join whose result it discards. + */ +export function withheldExecutionData( + executionData: Record +): Record { + const { + traceSpans: _traceSpans, + blockExecutions: _blockExecutions, + finalOutput: _finalOutput, + workflowInput: _workflowInput, + blockInput: _blockInput, + ...retained + } = executionData + return retained +} + +/** + * A `providerTiming` with the per-iteration spend stripped from its segments. + * + * A segment carries its own `cost` and `tokens` — the itemization behind the + * span's roll-up — so removing the span's fields alone leaves the finer + * breakdown in place, which withholds nothing. + */ +function withoutSegmentSpend(providerTiming: unknown): unknown { + if (!providerTiming || typeof providerTiming !== 'object' || Array.isArray(providerTiming)) { + return providerTiming + } + const record = providerTiming as Record + if (!Array.isArray(record.segments)) return providerTiming + return { + ...record, + segments: record.segments.map((segment) => { + if (!segment || typeof segment !== 'object' || Array.isArray(segment)) return segment + const { cost: _cost, tokens: _tokens, ...retained } = segment as Record + return retained + }), + } +} + +/** A span or block execution with the spend fields stripped from it. */ +function withoutSpend(entry: unknown): unknown { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry + const { + cost: _cost, + tokens: _tokens, + children, + providerTiming, + ...rest + } = entry as Record + const retained = + providerTiming === undefined + ? rest + : { ...rest, providerTiming: withoutSegmentSpend(providerTiming) } + return Array.isArray(children) ? { ...retained, children: children.map(withoutSpend) } : retained +} + +/** + * Strips spend from the execution payloads a permission group withholds. + * + * Reaches into trace spans and block executions rather than only blanking the + * run total: both carry their own `cost` and `tokens`, and a viewer who can sum + * the spans has not been withheld anything. Deletes rather than relies on the + * schema, because `executionDataDetailSchema` is a passthrough and a span's own + * shape is a `catchall`, so a field left in place would survive validation. + * + * The run's own roll-up goes first. `buildCompletedExecutionData` writes + * `tokens` and `models` at the root of every completed run, and `models` is the + * per-model dollar breakdown itself — leaving it while stripping the spans + * published the finest-grained figure of all next to a blanked total. `cost` is + * dropped with them for the runs old enough to carry it inline. + */ +export function withheldSpendData(executionData: Record): Record { + const { tokens: _tokens, models: _models, cost: _cost, ...retained } = executionData + const projected: Record = { ...retained } + if (Array.isArray(projected.traceSpans)) { + projected.traceSpans = projected.traceSpans.map(withoutSpend) + } + if (Array.isArray(projected.blockExecutions)) { + projected.blockExecutions = projected.blockExecutions.map(withoutSpend) + } + return projected } /** @@ -56,6 +163,8 @@ export async function readLogDetail({ lookupColumn, lookupValue, signal, + hideTraceSpans, + hideCostInfo, }: FetchLogDetailArgs): Promise { signal?.throwIfAborted() const workflowMatch: SQL = @@ -126,13 +235,13 @@ export async function readLogDetail({ // Cost is sourced exclusively from the usage_log ledger (itemized breakdown) // and its cost_total projection (run total). The cost jsonb is never read. - const costLedger = await buildCostLedger(log.executionId) + const costLedger = hideCostInfo ? null : await buildCostLedger(log.executionId) signal?.throwIfAborted() const totalDollars = costLedger?.total ?? (log.costTotal != null ? Number(log.costTotal) : null) // Trace spans / heavy execution data may live in object storage; resolve the // pointer here (no-op for inline / pre-externalization rows). - const executionData = await materializeExecutionDataForDisplay( + const materialized = await materializeExecutionDataForDisplay( log.executionData as Record | null, { workspaceId, @@ -141,6 +250,8 @@ export async function readLogDetail({ userId: viewerUserId, } ) + const withheldPayloads = hideTraceSpans ? withheldExecutionData(materialized) : materialized + const executionData = hideCostInfo ? withheldSpendData(withheldPayloads) : withheldPayloads signal?.throwIfAborted() // A custom block's child ran in another workspace and kept its spans on its @@ -180,8 +291,8 @@ export async function readLogDetail({ createdAt: log.startedAt.toISOString(), workflow: workflowSummary, jobTitle: null, - cost: totalDollars != null ? { total: totalDollars } : null, - costLedger, + cost: hideCostInfo || totalDollars == null ? null : { total: totalDollars }, + ...(hideCostInfo ? {} : { costLedger }), pauseSummary: { status: log.pausedStatus ?? null, total: totalPauseCount, @@ -226,7 +337,7 @@ export async function readLogDetail({ const jobLog = jobRows[0] if (!jobLog) return null - const execData = await materializeExecutionDataForDisplay( + const materializedJobData = await materializeExecutionDataForDisplay( jobLog.executionData as Record | null, { workspaceId, @@ -235,6 +346,10 @@ export async function readLogDetail({ userId: viewerUserId, } ) + const withheldJobPayloads = hideTraceSpans + ? withheldExecutionData(materializedJobData) + : materializedJobData + const execData = hideCostInfo ? withheldSpendData(withheldJobPayloads) : withheldJobPayloads signal?.throwIfAborted() return workflowLogDetailSchema.parse({ id: jobLog.id, @@ -251,7 +366,7 @@ export async function readLogDetail({ createdAt: jobLog.startedAt.toISOString(), workflow: null, jobTitle: ((execData.trigger as Record | undefined)?.source as string) ?? null, - cost: jobCostTotal(jobLog.cost), + cost: hideCostInfo ? null : jobCostTotal(jobLog.cost), pauseSummary: { status: null, total: 0, resumed: 0 }, hasPendingPause: false, executionData: { diff --git a/apps/sim/lib/logs/list-logs.test.ts b/apps/sim/lib/logs/list-logs.test.ts index 75da730b2f0..3ab31ecc96e 100644 --- a/apps/sim/lib/logs/list-logs.test.ts +++ b/apps/sim/lib/logs/list-logs.test.ts @@ -183,3 +183,31 @@ describe('readLogs', () => { expect(result.data[0].workflowId).toBe('wf-1') }) }) + +describe('readLogs cost projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The list carries the same run total the detail does, so a group that + * withholds spend on one and not the other has withheld nothing. + */ + it('blanks the run total on workflow and job summaries alike', async () => { + queueTableRows(workflowExecutionLogs, [workflowRow()]) + queueTableRows(jobExecutionLogs, [jobRow()]) + + const result = await readLogs(baseParams({ hideCostInfo: true })) + + expect(result.data).toHaveLength(2) + for (const summary of result.data) { + expect(summary.cost).toBeNull() + } + // Nothing else about the row is withheld. + expect(result.data.find((row) => row.id === 'log-1')).toMatchObject({ + executionId: 'exec-1', + duration: '1000ms', + }) + }) +}) diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index 81a5d5d2dcc..2fd1baa5acb 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -39,18 +39,35 @@ import { encodeLogSortCursor, } from '@/lib/logs/sort-cursor' +/** What a caller asks for: the contract's query, plus cancellation. */ export type ListLogsParams = z.output & { signal?: AbortSignal } +/** + * What the query actually runs with — the request, plus the viewer's spend + * projection. + * + * `hideCostInfo` is required and lives on this type rather than on + * {@link ListLogsParams} for two reasons that pull the same way. It is resolved + * by the application use case and never read off the query, so a client cannot + * ask for a row it is not entitled to; and being required rather than defaulted + * to `false`, a caller of this read that forgets it fails to compile instead of + * quietly disclosing every run's cost. + */ +export type ReadLogsParams = ListLogsParams & { + hideCostInfo: boolean +} + type SortBy = 'date' | 'duration' | 'cost' | 'status' type SortOrder = 'asc' | 'desc' /** * Canonical logs list query after workspace authorization. */ -export async function readLogs(params: ListLogsParams): Promise { +export async function readLogs(params: ReadLogsParams): Promise { params.signal?.throwIfAborted() + const { hideCostInfo } = params const sortBy = params.sortBy as SortBy const sortOrder = params.sortOrder as SortOrder const cursor = params.cursor ? decodeLogSortCursor(params.cursor) : null @@ -61,7 +78,7 @@ export async function readLogs(params: ListLogsParams): Promise = (() => { switch (sortBy) { @@ -350,7 +367,7 @@ export async function readLogs(params: ListLogsParams): Promise { + if (!viewerUserId) return NO_LOG_FIELD_PROJECTION + + const config = await resolvePermissionGroupConfig(viewerUserId, workspaceId, organizationId) + return { + hideTraceSpans: capabilityDeniedBy('logs.trace_spans', config), + hideCostInfo: capabilityDeniedBy('logs.cost', config), + } +} + +/** + * Applies {@link LogFieldProjection} to a materialized execution payload. + * + * Both halves DELETE the withheld fields rather than leaving them for response + * validation to drop, because the log contracts are passthrough (and a span's + * own shape is a `catchall`), so a field left in place would survive the schema. + */ +export function projectExecutionData | null | undefined>( + executionData: T, + projection: LogFieldProjection +): T | Record { + if (!executionData) return executionData + const withoutPayloads = projection.hideTraceSpans + ? withheldExecutionData(executionData) + : executionData + return projection.hideCostInfo ? withheldSpendData(withoutPayloads) : withoutPayloads +} + +/** The run's cost total, or `null` when the group withholds spend. */ +export function projectCostTotal( + costTotal: unknown, + projection: LogFieldProjection +): { total: number } | null { + if (projection.hideCostInfo || costTotal == null) return null + return { total: Number(costTotal) } +} + +/** + * The spend-selecting halves of a log query, in the spellings the surfaces use. + * + * The first-party list spells its filter as an operator plus a value; the public + * adapters spell theirs as a `minCost`/`maxCost` pair. Both are read here so the + * rule lives once — a second copy is how one of them stops refusing. + */ +export interface LogCostQuerySurface { + sortBy?: string | null + costOperator?: string | null + costValue?: number | null + minCost?: number | null + maxCost?: number | null +} + +/** Whether the query orders or selects rows by run spend. */ +export function logQuerySelectsCost(query: LogCostQuerySurface): boolean { + if (query.sortBy === 'cost') return true + if (query.costOperator && query.costValue != null) return true + return query.minCost != null || query.maxCost != null +} + +/** + * Refuses a cost-ordered or cost-filtered query from a viewer whose group + * withholds spend. + * + * Withholding the *field* is not enough on its own: `cost > X` answered + * faithfully is an oracle, and a caller who can repeat it recovers every run's + * cost by bisection — with `includeTotal` they do not even have to read the + * rows. Ordering leaks the same thing more slowly, as a ranking. + * + * Refused rather than silently ignored. Dropping the clause would answer a + * question nobody asked — a list of every run under a `cost > 5` chip, in an + * order the caller did not request — and a wrong answer presented as the right + * one is worse than a refusal. The refusal discloses nothing new either: the + * workspace role check has already passed by the time this runs, so the caller + * is a member being told about their own group, not an outsider being handed an + * organization-configuration oracle. + * + * `logs.trace_spans` needs no counterpart. Nothing the trace projection + * withholds — `traceSpans`, `blockExecutions`, `finalOutput`, `workflowInput`, + * `blockInput` — is filterable or sortable on any log surface: `search` matches + * the execution id alone, and every sort key is a scalar column. + * + * permission-group-enforced: logs.cost + */ +export function assertLogCostQueryAllowed( + query: LogCostQuerySurface, + projection: Pick +): void { + if (!projection.hideCostInfo) return + if (!logQuerySelectsCost(query)) return + refuseCapability('logs.cost') +} diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 75218e8f402..f145601905f 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -1,8 +1,26 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' describe('MCP server operation registry', () => { it('requires a human subject for tool discovery', () => { @@ -103,3 +121,118 @@ describe('MCP server operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) }) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** + * Every operation's capability, by name. + * + * Pinned as a whole map rather than derived from the registry, because the + * refusal tests below select their subjects with `operation.capability === x` — + * a filter over the very field under test. Dropping the capability from one + * operation would simply remove it from that filter and leave those tests green + * (`mcp_servers.create`, which registers a server and stores its credentials + * against the workspace, was verified to do exactly that). This map fails + * instead. + */ +const EXPECTED_CAPABILITIES: Record = { + list: 'mcp_tools.use', + read: 'mcp_tools.use', + create: 'mcp_tools.use', + register: 'mcp_tools.use', + update: 'mcp_tools.use', + reconfigure: 'mcp_tools.use', + delete: 'mcp_tools.use', + discoverTools: 'mcp_tools.use', + executeTool: 'mcp_tools.use', + listWorkflowDeployments: 'deploy.mcp', + readWorkflowDeploymentServer: 'deploy.mcp', + listWorkflowDeploymentTools: 'deploy.mcp', + createWorkflowDeploymentServer: 'deploy.mcp', + updateWorkflowDeploymentServer: 'deploy.mcp', + deleteWorkflowDeploymentServer: 'deploy.mcp', + deployWorkflowTool: 'deploy.mcp', + undeployWorkflowTool: 'deploy.mcp', +} + +describe('MCP operation capability declarations', () => { + it('declares a capability on every operation, by name', () => { + const declared = Object.fromEntries( + Object.entries(mcpServerOperations).map(([key, operation]) => [key, operation.capability]) + ) + + expect(declared).toEqual(EXPECTED_CAPABILITIES) + }) +}) + +/** `tools.execute` admits only the executor delegation, so a session cannot stand in for it. */ +function sessionReachable(capability: string) { + return Object.values(mcpServerOperations).filter( + (operation) => + operation.capability === capability && operation.principalKinds.includes('session') + ) +} + +/** + * The declaration is only half the gate; these prove the funnel actually + * refuses, so a capability could not be renamed into one nothing reads. + */ +describe('MCP operations under a withholding permission group', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('refuses every mcp_servers operation when the group blocks MCP tools', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableMcpTools: true, + }) + + const registryOperations = sessionReachable('mcp_tools.use') + expect(registryOperations.length).toBeGreaterThan(0) + + for (const operation of registryOperations) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('refuses every workflow-deployment operation when the group hides MCP deployment', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const deploymentOperations = sessionReachable('deploy.mcp') + expect(deploymentOperations.length).toBeGreaterThan(0) + + for (const operation of deploymentOperations) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('allows the same operations when the group withholds neither', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + for (const operation of [ + ...sessionReachable('mcp_tools.use'), + ...sessionReachable('deploy.mcp'), + ]) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index dc4ed4d2335..e5cba22f155 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -17,23 +17,40 @@ const EXECUTION_PRINCIPAL_POLICY = { delegatedServices: ['executor'], } as const +/** + * Two capabilities, because the family covers two different things. + * + * `mcp_servers.*` is the workspace's registry of external MCP servers — the + * connections an agent calls tools through — so every one of them declares + * `mcp_tools.use`. Gating only `tools.execute` would leave a group that blocks + * MCP tools able to keep registering servers and storing their credentials + * against the workspace, which is the accumulation the key exists to stop. + * + * `mcp_servers.workflow_deployments.*` is the opposite direction: publishing a + * workflow *as* an MCP server, which is what `hideDeployMcp` names. Reads carry + * `deploy.mcp` alongside the writes, so a group that withholds the deployment + * surface does not still answer with what is published on it. + */ export const mcpServerOperations = { list: defineWorkspaceOperation({ id: 'mcp_servers.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), discoverTools: defineWorkspaceOperation({ id: 'mcp_servers.tools.discover', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'mcp_tools.use', ...DISCOVERY_PRINCIPAL_POLICY, }), executeTool: defineWorkspaceOperation({ id: 'mcp_servers.tools.execute', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'mcp_tools.use', ...EXECUTION_PRINCIPAL_POLICY, }), /** @@ -56,6 +73,7 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), /** @@ -70,18 +88,21 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.read_server', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), listWorkflowDeploymentTools: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.list_tools', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), createWorkflowDeploymentServer: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.create_server', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), /** @@ -101,60 +122,70 @@ export const mcpServerOperations = { id: 'mcp_servers.workflow_deployments.update_server', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), deleteWorkflowDeploymentServer: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.delete_server', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), deployWorkflowTool: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.deploy_tool', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), undeployWorkflowTool: defineWorkspaceOperation({ id: 'mcp_servers.workflow_deployments.undeploy_tool', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.mcp', ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'mcp_servers.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), register: defineWorkspaceOperation({ id: 'mcp_servers.register', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'mcp_servers.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), reconfigure: defineWorkspaceOperation({ id: 'mcp_servers.reconfigure', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'mcp_servers.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/mcp/middleware.test.ts b/apps/sim/lib/mcp/middleware.test.ts new file mode 100644 index 00000000000..6f1a15efecc --- /dev/null +++ b/apps/sim/lib/mcp/middleware.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + * + * The gate lives on the middleware, so this is where it is proved. Thirteen raw + * MCP management routes sit behind `withMcpAuth` and only the workflow-server + * create handler ever grew a capability check of its own; asserting per route + * would have reproduced exactly that, so these assertions are about the wrapper + * every one of them shares. + */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + permissions: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', async () => { + const AuthType = { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' } as const + return { + AuthType, + checkSessionOrInternalAuth: mocks.auth, + capabilityGovernedAuthUserId: (auth: { + userId?: string + authType?: string + apiKeyType?: string + }) => { + if (!auth?.userId) return null + if (auth.authType === AuthType.SESSION) return auth.userId + return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' + ? auth.userId + : null + }, + } +}) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mocks.permissions, +})) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import { withMcpAuth } from '@/lib/mcp/middleware' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +const handler = vi.fn(async () => Response.json({ ok: true }) as never) + +function request() { + return new Request('http://localhost:3000/api/mcp/anything?workspaceId=workspace-1', { + method: 'POST', + }) as NextRequest +} + +function call(capability: 'deploy.mcp' | 'mcp_tools.use' | 'none') { + return withMcpAuth('write', capability)(handler)(request(), { + params: Promise.resolve({}), + }) +} + +describe('withMcpAuth permission-group gate', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.auth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mocks.permissions.mockResolvedValue('admin') + resolveGroupConfigMock.mockResolvedValue(null) + }) + + it('refuses a session caller whose group withholds the declared capability', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('deploy.mcp') + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + error: "MCP server deployment is not available under your organization's permission group", + }) + expect(handler).not.toHaveBeenCalled() + }) + + /** + * The two capabilities are separate keys, so a group withholding one must not + * refuse a route declaring the other — that would make the gate a blanket MCP + * switch rather than the two doors `mcpServerOperations` describes. + */ + it('admits a route whose declared capability the group does not withhold', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('mcp_tools.use') + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + }) + + it('admits a caller no permission group governs', async () => { + const response = await call('deploy.mcp') + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + }) + + /** + * The executor exemption. An internal JWT's `userId` is the subject the + * executor embedded, so resolving it would hand the run actor's grants to a + * credential that bears no person — the same rule + * `capabilityGovernedAuthUserId` states for every other surface. + */ + it('passes a non-user-bearing internal JWT ungated', async () => { + mocks.auth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('deploy.mcp') + + expect(response.status).toBe(200) + expect(handler).toHaveBeenCalled() + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + it('resolves no group at all for a route declaring no capability', async () => { + const response = await call('none') + + expect(response.status).toBe(200) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) + + /** + * A capability refusal handed to a non-member would confirm the workspace + * exists and name which modules the organization withholds; the role failure + * conceals both, so it has to come first. + */ + it('answers the role failure, not the capability refusal, for a non-member', async () => { + mocks.permissions.mockResolvedValue(null) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployMcp: true, + }) + + const response = await call('deploy.mcp') + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + error: 'Insufficient permissions', + }) + expect(resolveGroupConfigMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mcp/middleware.ts b/apps/sim/lib/mcp/middleware.ts index 6987f57e960..88be4b04c39 100644 --- a/apps/sim/lib/mcp/middleware.ts +++ b/apps/sim/lib/mcp/middleware.ts @@ -2,7 +2,12 @@ import { createLogger } from '@sim/logger' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import type { NextRequest, NextResponse } from 'next/server' -import { type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { + type AuthTypeValue, + capabilityGovernedAuthUserId, + checkSessionOrInternalAuth, + type AuthResult as HybridAuthResult, +} from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { assertContentLengthWithinLimit, @@ -10,6 +15,11 @@ import { readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { createMcpErrorResponse } from '@/lib/mcp/utils' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('McpAuthMiddleware') @@ -18,6 +28,52 @@ const parsedBodies = new WeakMap() export type McpPermissionLevel = 'read' | 'write' | 'admin' +/** + * The permission-group capability an MCP management route requires, or `'none'` + * when no group governs it. + * + * Required at every call site, and `'none'` spelled out rather than omitted, + * for the same reason `capability` is required on `defineWorkspaceOperation` + * and on `V1RouteCapability` in the v1 middleware: an absent declaration cannot be told apart + * from an unreviewed one. That is exactly how this surface came to gate + * `deploy.mcp` on one of its thirteen routes — the create handler grew an + * inline check and its twelve siblings, including the one that flips a server + * public, silently did not. + * + * Each route's value is the one its `/api/v2` twin already declares in + * `mcpServerOperations`; this surface does not get a mapping of its own. + */ +export type McpRouteCapability = StaticPermissionGroupCapability | 'none' + +/** + * The permission-group gate for an MCP management route. + * + * Only a user-bearing credential carries capabilities. + * `checkSessionOrInternalAuth` rejects `x-api-key` outright, so the two kinds + * that reach here are a browser session and the executor's internal JWT — and + * the JWT's `userId` is the subject the executor embedded, a value that must not + * hand the run's actor's grants to a caller the executor exemption deliberately + * passes ungated. {@link capabilityGovernedAuthUserId} is the one place that + * distinction is read, so this cannot drift from the funnel's own rule. + * + * Never called before the role check. A capability refusal handed to a + * non-member would confirm the workspace exists and disclose which modules the + * organization withholds; the role failure conceals both. + */ +async function capabilityRefusalResponse( + auth: HybridAuthResult, + workspaceId: string, + capability: McpRouteCapability +): Promise { + if (capability === 'none') return null + const userId = capabilityGovernedAuthUserId(auth) + if (!userId) return null + if (!(await isWorkspaceCapabilityWithheld(userId, workspaceId, capability))) return null + + logger.warn('MCP request blocked by permission group', { workspaceId, userId, capability }) + return createMcpErrorResponse(null, capabilityRefusal(capability), 403) +} + export interface McpAuthContext { userId: string userName?: string | null @@ -118,7 +174,8 @@ export function mcpBodyReadErrorResponse( */ async function validateMcpAuth( request: NextRequest, - permissionLevel: McpPermissionLevel + permissionLevel: McpPermissionLevel, + capability: McpRouteCapability ): Promise { const requestId = generateRequestId() @@ -194,6 +251,11 @@ async function validateMcpAuth( } } + const capabilityFailure = await capabilityRefusalResponse(auth, workspaceId, capability) + if (capabilityFailure) { + return { success: false, errorResponse: capabilityFailure } + } + return { success: true, context: { @@ -246,18 +308,20 @@ function getPermissionErrorMessage(permissionLevel: McpPermissionLevel): string * Higher-order function that wraps MCP route handlers with authentication middleware * * @param permissionLevel - Required permission level ('read', 'write', or 'admin') + * @param capability - The permission-group capability the route requires, or + * `'none'` with a reason. See {@link McpRouteCapability}. * @returns Middleware wrapper function - * */ export function withMcpAuth>( - permissionLevel: McpPermissionLevel = 'read' + permissionLevel: McpPermissionLevel, + capability: McpRouteCapability ) { return function middleware(handler: McpRouteHandler) { return async function wrappedHandler( request: NextRequest, routeContext: { params: Promise } ): Promise { - const authResult = await validateMcpAuth(request, permissionLevel) + const authResult = await validateMcpAuth(request, permissionLevel, capability) if (!authResult.success) { return (authResult as AuthFailure).errorResponse diff --git a/apps/sim/lib/memory/application/operations.ts b/apps/sim/lib/memory/application/operations.ts index 253df541c3d..3a99abef426 100644 --- a/apps/sim/lib/memory/application/operations.ts +++ b/apps/sim/lib/memory/application/operations.ts @@ -5,11 +5,18 @@ const MEMORY_EXECUTOR_PRINCIPAL_POLICY = { delegatedServices: ['executor'], } as const +/** + * Memory is the executor's own store: an Agent block writes and reads it inside + * a run the workspace already authorized, and no permission-group key names it. + * A gate here would fail runs the group permits rather than withhold a + * capability from a member, so all four operations declare `'none'`. + */ function readOperation(id: Id) { return defineWorkspaceOperation({ id, minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...MEMORY_EXECUTOR_PRINCIPAL_POLICY, }) } @@ -19,13 +26,18 @@ function writeOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...MEMORY_EXECUTOR_PRINCIPAL_POLICY, }) } export const memoryOperations = { + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows list: readOperation('memory.list'), + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows read: readOperation('memory.read'), + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows append: writeOperation('memory.append'), + // permission-group-exempt: the executor's own per-run store; no group key names it, and refusing would fail runs the group allows delete: writeOperation('memory.delete'), } as const diff --git a/apps/sim/lib/permission-groups/block-access.test.ts b/apps/sim/lib/permission-groups/block-access.test.ts new file mode 100644 index 00000000000..88514b10888 --- /dev/null +++ b/apps/sim/lib/permission-groups/block-access.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + isAccessControlAllowlistRow, + isBlockTypeAccessControlExempt, +} from '@/lib/permission-groups/block-access' +import { getBlock } from '@/blocks/registry' + +const mockGetBlock = getBlock as unknown as ReturnType + +interface FakeBlock { + hideFromToolbar?: boolean + sunset?: { status: 'legacy' | 'deprecated'; replacedBy?: string } +} + +/** + * Only `hideFromToolbar` is read from here: the successor half of the decision + * comes from the generated map, so every id used below is a real one whose real + * successor the assertion depends on. + */ +function registry(blocks: Record) { + mockGetBlock.mockImplementation((type: string) => blocks[type]) +} + +describe('isBlockTypeAccessControlExempt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('exempts the universal entry point', () => { + registry({}) + + expect(isBlockTypeAccessControlExempt('start_trigger')).toBe(true) + }) + + /** + * The bypass this closes: a legacy block is fully functional, so an allowlist + * naming only the current version used to be satisfied by the retired one. + */ + it('does not exempt a superseded block, which is judged as its successor', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + expect(isBlockTypeAccessControlExempt('slack')).toBe(false) + }) + + /** + * A retired block with no successor has no row in the editor and nothing to + * be permitted as, so denying it would break older workflows an admin could + * not have rescued. + */ + it('exempts a retired block with no successor', () => { + registry({ thinking: { hideFromToolbar: true } }) + + expect(isBlockTypeAccessControlExempt('thinking')).toBe(true) + }) + + it('does not exempt a current block', () => { + registry({ slack_v2: {} }) + + expect(isBlockTypeAccessControlExempt('slack_v2')).toBe(false) + }) + + /** + * The editor never offers `start_trigger` as an allowlist row, so a retired + * entry point judged as its successor would be refused by every active + * allowlist — breaking every saved workflow that still carries one. + */ + it('exempts a retired entry point, whose successor is the universal one', () => { + registry({ + starter: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'start_trigger' } }, + manual_trigger: { + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'start_trigger' }, + }, + start_trigger: {}, + }) + + expect(isBlockTypeAccessControlExempt('starter')).toBe(true) + expect(isBlockTypeAccessControlExempt('manual_trigger')).toBe(true) + }) +}) + +describe('isAccessControlAllowlistRow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * The bug this closes: the editor renders only visible blocks but used to + * materialize an allowlist from every non-exempt one. Unchecking `slack_v2` + * on a previously-unrestricted group therefore wrote `slack` into the stored + * list, and the runtime resolves `slack` to `slack_v2` — re-allowing exactly + * the integration the admin had just denied. + */ + it('is not a row for a superseded block, which has no row of its own', () => { + registry({ + slack: { hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'slack_v2' } }, + slack_v2: {}, + }) + + expect(isAccessControlAllowlistRow('slack')).toBe(false) + expect(isBlockTypeAccessControlExempt('slack')).toBe(false) + }) + + it('is a row for a current block', () => { + registry({ slack_v2: {} }) + + expect(isAccessControlAllowlistRow('slack_v2')).toBe(true) + }) + + /** Exempt block types are decided by no row at all. */ + it('is not a row for an exempt block', () => { + registry({ thinking: { hideFromToolbar: true }, start_trigger: {} }) + + expect(isAccessControlAllowlistRow('thinking')).toBe(false) + expect(isAccessControlAllowlistRow('start_trigger')).toBe(false) + }) +}) diff --git a/apps/sim/lib/permission-groups/block-access.ts b/apps/sim/lib/permission-groups/block-access.ts index 884f952665a..e18a2068ae1 100644 --- a/apps/sim/lib/permission-groups/block-access.ts +++ b/apps/sim/lib/permission-groups/block-access.ts @@ -1,22 +1,66 @@ +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getBlock } from '@/blocks/registry' +/** + * The universal workflow entry point. Every retired entry point resolves to it, + * and it is never an allowlist row, so both it and anything that resolves to it + * are exempt. + */ +const UNIVERSAL_ENTRY_POINT = 'start_trigger' + /** * Block types that bypass permission-group access control entirely. * - * Two kinds of blocks are exempt: - * - `start_trigger`: the universal workflow entry point. A workflow must always - * be startable regardless of the configured integration allowlist. - * - Legacy blocks (`hideFromToolbar: true`): superseded integration versions and - * deprecated blocks. They never appear in the toolbar or the Access Control - * admin list, so admins cannot allowlist them — yet they may still live inside - * older workflows. Exempting them keeps those workflows runnable instead of - * silently blocking blocks the admin had no way to permit. + * Three kinds are exempt: + * - `start_trigger`: the universal workflow entry point. A workflow must be + * startable whatever the integration allowlist says. + * - A retired block with no successor. It is hidden from the toolbar and from + * the Access Control editor, so an admin has no row to permit it on and + * nothing to permit it *as*; denying it would silently break the older + * workflows still carrying it. + * - A retired entry point — `starter`, `manual_trigger`, `api_trigger`, + * `chat_trigger` — whose successor is `start_trigger`. It is judged as the + * universal entry point, and the universal entry point is exempt, so it must + * be too. The editor never offers `start_trigger` as an allowlist row, so + * without this every active allowlist refuses every workflow still carrying + * an old starter block. * - * This is the single source of truth shared by both the runtime enforcement - * paths and the Access Control admin UI so the "hidden from the list" set and - * the "skipped by enforcement" set never drift apart. + * A *superseded* block is deliberately not exempt. Legacy `slack` talks to + * Slack exactly as `slack_v2` does, so exempting it let an allowlist naming + * `slack_v2` be satisfied by `slack` — reachable through workflow import, the + * API, or a Copilot-built workflow, and invisible to the admin who configured + * the allowlist. It is judged as its successor instead; see + * {@link resolveAccessControlBlockType}. + * + * Shared by the runtime enforcement paths and the Access Control editor, so the + * set that is hidden and the set that is skipped cannot drift apart. */ export function isBlockTypeAccessControlExempt(blockType: string): boolean { - if (blockType === 'start_trigger') return true - return getBlock(blockType)?.hideFromToolbar === true + if (blockType === UNIVERSAL_ENTRY_POINT) return true + const block = getBlock(blockType) + if (block?.hideFromToolbar !== true) return false + const successor = resolveAccessControlBlockType(blockType) + return successor === blockType || successor === UNIVERSAL_ENTRY_POINT +} + +/** + * Whether `blockType` is a row in the Access Control editor's allowlist + * universe — the set the editor materializes an allowlist from and compares + * against to collapse one back to `null`. + * + * Narrower than {@link isBlockTypeAccessControlExempt} on purpose. A superseded + * block must stay non-exempt at runtime (legacy `slack` reaches Slack and is + * judged as `slack_v2`), but it must not be an editor row: the editor renders + * only visible blocks, so an admin narrowing a previously-unrestricted + * allowlist by unchecking `slack_v2` would still write the hidden `slack` into + * it — and canonical resolution then reads that entry as `slack_v2` and allows + * the very integration the admin just denied. + * + * Viewer-independent, like the exemption: it reads the pure registry and the + * generated successor map, never the visibility projection, so a preview block + * gated for the acting admin stays in the universe and keeps its stored grant. + */ +export function isAccessControlAllowlistRow(blockType: string): boolean { + if (isBlockTypeAccessControlExempt(blockType)) return false + return resolveAccessControlBlockType(blockType) === blockType } diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts new file mode 100644 index 00000000000..f7a2a702ad9 --- /dev/null +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -0,0 +1,51 @@ +/** + * Generated by `bun run generate:block-successors` from the block registry. + * Do not edit this file directly. + * + * Maps a retired block type to the *terminal* type an access-control decision + * about it is made against — `sunset.replacedBy`, followed transitively. It + * exists as a generated projection because `lib/permission-groups/` may not + * import `blocks/`; see `scripts/generate-block-successors.ts`. + */ +export const BLOCK_ACCESS_SUCCESSORS: Record = { + api_trigger: 'start_trigger', + chat_trigger: 'start_trigger', + confluence: 'confluence_v2', + cursor: 'cursor_v2', + extend: 'extend_v2', + file: 'file_v5', + file_v2: 'file_v5', + file_v3: 'file_v5', + file_v4: 'file_v5', + fireflies: 'fireflies_v2', + github: 'github_v2', + gmail: 'gmail_v2', + google_calendar: 'google_calendar_v2', + google_sheets: 'google_sheets_v2', + google_slides: 'google_slides_v2', + grain: 'grain_v2', + image_generator: 'image_generator_v2', + input_trigger: 'start_trigger', + intercom: 'intercom_v2', + kalshi: 'kalshi_v2', + linear: 'linear_v2', + logs: 'logs_v2', + manual_trigger: 'start_trigger', + microsoft_excel: 'microsoft_excel_v2', + mistral_parse: 'mistral_parse_v3', + mistral_parse_v2: 'mistral_parse_v3', + notion: 'notion_v2', + openai: 'embeddings', + pulse: 'pulse_v2', + reducto: 'reducto_v2', + router: 'router_v2', + sharepoint: 'sharepoint_v2', + slack: 'slack_v2', + starter: 'start_trigger', + stt: 'stt_v2', + table: 'table_v2', + textract: 'textract_v2', + video_generator: 'video_generator_v3', + video_generator_v2: 'video_generator_v3', + workflow: 'workflow_input', +} diff --git a/apps/sim/lib/permission-groups/capabilities.test.ts b/apps/sim/lib/permission-groups/capabilities.test.ts new file mode 100644 index 00000000000..30e19dc4cf0 --- /dev/null +++ b/apps/sim/lib/permission-groups/capabilities.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, +} from '@/lib/permission-groups/fields' + +function configWith(overrides: Partial): PermissionGroupConfig { + return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...overrides } +} + +describe('knowledge capability rules', () => { + const create = CAPABILITY_RULES['knowledge.create'] + const upload = CAPABILITY_RULES['knowledge.upload'] + const connectors = CAPABILITY_RULES['knowledge.connectors'] + + it('permits creation and upload under the unrestricted config', () => { + expect(create.deniedBy(DEFAULT_PERMISSION_GROUP_CONFIG)).toBe(false) + expect(upload.deniedBy(DEFAULT_PERMISSION_GROUP_CONFIG)).toBe(false) + }) + + it('withholds creation and upload from their own keys', () => { + expect(create.deniedBy(configWith({ disableKnowledgeBaseCreation: true }))).toBe(true) + expect(upload.deniedBy(configWith({ disableKnowledgeBaseFileUpload: true }))).toBe(true) + }) + + it('subsumes the module-wide key, since an operation declares only one capability', () => { + const hidden = configWith({ hideKnowledgeBaseTab: true }) + expect(create.deniedBy(hidden)).toBe(true) + expect(upload.deniedBy(hidden)).toBe(true) + }) + + it('reads the connector allow-list as a named set, with null meaning unrestricted', () => { + expect(connectors.deniedBy(DEFAULT_PERMISSION_GROUP_CONFIG, 'confluence')).toBe(false) + + const narrowed = configWith({ allowedKnowledgeConnectors: ['google_drive'] }) + expect(connectors.deniedBy(narrowed, 'google_drive')).toBe(false) + expect(connectors.deniedBy(narrowed, 'confluence')).toBe(true) + }) + + it('withholds every connector when the allow-list is emptied rather than cleared', () => { + const emptied = configWith({ allowedKnowledgeConnectors: [] }) + expect(connectors.deniedBy(emptied, 'google_drive')).toBe(true) + }) +}) diff --git a/apps/sim/lib/permission-groups/capabilities.ts b/apps/sim/lib/permission-groups/capabilities.ts new file mode 100644 index 00000000000..c6e67edae20 --- /dev/null +++ b/apps/sim/lib/permission-groups/capabilities.ts @@ -0,0 +1,444 @@ +import type { ForbiddenDetailCode } from '@/lib/core/application/forbidden' +import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' +import type { + PermissionGroupConfig, + PermissionGroupConfigKey, +} from '@/lib/permission-groups/fields' + +/** + * Every capability a permission group can withhold. + * + * Domain-shaped, because the declaration site is an operation + * (`tables.rows.create`) while the config is surface-shaped (`hideTablesTab`). + * {@link CAPABILITY_RULES} is the only place the two vocabularies meet — without + * it every domain's operations module would restate a config key, and the + * mapping would drift the first time one was renamed. + * + * A closed union rather than a predicate on the operation: operations are + * frozen policy data, and a closure cannot be logged, compared, or read by + * `check:permission-group-enforcement`. Fifty table operations naming one + * capability is fifty identical strings, not fifty identical functions. + */ +export const CAPABILITY_IDS = [ + 'knowledge.use', + 'tables.use', + 'files.use', + 'inbox.use', + 'copilot.use', + 'secrets.manage', + 'api_keys.manage', + 'integrations.manage', + 'deploy.api', + 'deploy.mcp', + 'deploy.chat', + 'deploy.chat.auth_mode', + 'file_share.publish', + 'file_share.auth_mode', + 'public_api.use', + 'invitations.send', + 'mcp_tools.use', + 'custom_tools.use', + 'skills.use', + 'logs.trace_spans', + 'personal_api_key.use', + 'logs.export', + 'logs.cost', + 'knowledge.create', + 'knowledge.upload', + 'knowledge.connectors', + 'tables.create', + 'tables.export', + 'files.bulk_download', + 'credentials.personal', + 'workspace.create', + 'organization.member_directory', + 'cli.use', + 'triggers.webhook', + 'copilot.tool_auto_approval', +] as const + +export type PermissionGroupCapability = (typeof CAPABILITY_IDS)[number] + +interface CapabilityRuleBase { + /** The config keys this rule reads, so the audit can prove a key is enforced. */ + readonly configKeys: readonly PermissionGroupConfigKey[] + readonly detailCode: ForbiddenDetailCode + /** + * The subject of the shared refusal sentence — ` is not available + * under your organization's permission group` — so it is written as a + * singular noun or gerund phrase that agrees with the verb. + */ + readonly describe: string +} + +/** + * Decidable from the config alone, so the authorization funnel can apply it + * knowing only the principal, the workspace, and the operation. + */ +export interface StaticCapabilityRule extends CapabilityRuleBase { + readonly kind: 'static' + deniedBy(config: PermissionGroupConfig): boolean +} + +/** + * Needs a value only the request carries. The funnel never sees request input, + * and widening the authorization context to carry it would reach every use case + * for the sake of two keys, so these are asserted from inside `execute` and held + * to account by the audit's annotation rule instead. They are not valid as an + * operation's declared capability. + */ +export interface ParameterizedCapabilityRule extends CapabilityRuleBase { + readonly kind: 'parameterized' + deniedBy(config: PermissionGroupConfig, parameter: string): boolean +} + +export type CapabilityRule = StaticCapabilityRule | ParameterizedCapabilityRule + +/** + * The one sentence every capability refusal uses, wherever it is raised. + * + * Shared so the funnel, a raw route gating inline, and a parameterized rule + * asserted from a use case cannot word the same refusal three ways. Each rule's + * `describe` is written to read as this sentence's subject. + */ +export function capabilityRefusal(capability: PermissionGroupCapability): string { + return `${CAPABILITY_RULES[capability].describe} is not available under your organization's permission group` +} + +/** + * Throws {@link capabilityRefusal} as the error the surfaces project. + * + * Accepts any capability, static or parameterized, because a parameterized one + * is refused from a call site rather than by the funnel and still has to read + * identically. + */ +export function refuseCapability(capability: PermissionGroupCapability): never { + throw new PermissionGroupCapabilityError( + capability, + CAPABILITY_RULES[capability].detailCode, + capabilityRefusal(capability) + ) +} + +/** An allowlist denies a member when it is set and does not name it; `null` names everything. */ +function allowlistDenies(allowed: readonly string[] | null, member: string): boolean { + return allowed !== null && !allowed.includes(member) +} + +/** + * What each capability means in terms of the stored config. + * + * `satisfies` rather than an annotation, so adding a capability still fails to + * compile until it is given a rule — the same completeness gate + * `FORBIDDEN_DETAIL_CODE_DESCRIPTIONS` uses — while each entry keeps its own + * `kind`. Annotating would widen every entry to `CapabilityRule`, and + * {@link StaticPermissionGroupCapability} would then resolve to `never`, + * silently rejecting every capability an operation tried to declare. + */ +export const CAPABILITY_RULES = { + 'knowledge.use': { + kind: 'static', + configKeys: ['hideKnowledgeBaseTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The Knowledge Base module', + deniedBy: (config) => config.hideKnowledgeBaseTab, + }, + 'tables.use': { + kind: 'static', + configKeys: ['hideTablesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The Tables module', + deniedBy: (config) => config.hideTablesTab, + }, + 'files.use': { + kind: 'static', + configKeys: ['hideFilesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The Files module', + deniedBy: (config) => config.hideFilesTab, + }, + 'inbox.use': { + kind: 'static', + configKeys: ['hideInboxTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The inbox', + deniedBy: (config) => config.hideInboxTab, + }, + 'copilot.use': { + kind: 'static', + configKeys: ['hideCopilot'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Chat', + deniedBy: (config) => config.hideCopilot, + }, + 'secrets.manage': { + kind: 'static', + configKeys: ['hideSecretsTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Managing secrets', + deniedBy: (config) => config.hideSecretsTab, + }, + 'api_keys.manage': { + kind: 'static', + configKeys: ['hideApiKeysTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Managing API keys', + deniedBy: (config) => config.hideApiKeysTab, + }, + 'integrations.manage': { + kind: 'static', + configKeys: ['hideIntegrationsTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Managing integrations', + deniedBy: (config) => config.hideIntegrationsTab, + }, + 'deploy.api': { + kind: 'static', + configKeys: ['hideDeployApi'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'API deployment', + deniedBy: (config) => config.hideDeployApi, + }, + 'deploy.mcp': { + kind: 'static', + configKeys: ['hideDeployMcp'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'MCP server deployment', + deniedBy: (config) => config.hideDeployMcp, + }, + 'deploy.chat': { + kind: 'static', + configKeys: ['hideDeployChatbot'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Chat deployment', + deniedBy: (config) => config.hideDeployChatbot, + }, + 'deploy.chat.auth_mode': { + kind: 'parameterized', + configKeys: ['allowedChatDeployAuthTypes'], + detailCode: 'CHAT_AUTH_MODE_NOT_PERMITTED', + describe: 'This chat authentication mode', + deniedBy: (config, mode) => allowlistDenies(config.allowedChatDeployAuthTypes, mode), + }, + 'file_share.publish': { + kind: 'static', + configKeys: ['disablePublicFileSharing'], + detailCode: 'PUBLIC_SHARING_NOT_ALLOWED', + describe: 'Public file sharing', + deniedBy: (config) => config.disablePublicFileSharing, + }, + 'file_share.auth_mode': { + kind: 'parameterized', + configKeys: ['allowedFileShareAuthTypes'], + detailCode: 'PUBLIC_SHARING_NOT_ALLOWED', + describe: 'This file-share authentication mode', + deniedBy: (config, mode) => allowlistDenies(config.allowedFileShareAuthTypes, mode), + }, + 'public_api.use': { + kind: 'static', + configKeys: ['disablePublicApi'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Public API access', + deniedBy: (config) => config.disablePublicApi, + }, + 'invitations.send': { + kind: 'static', + configKeys: ['disableInvitations'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Sending invitations', + deniedBy: (config) => config.disableInvitations, + }, + 'mcp_tools.use': { + kind: 'static', + configKeys: ['disableMcpTools'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Calling MCP tools', + deniedBy: (config) => config.disableMcpTools, + }, + 'custom_tools.use': { + kind: 'static', + configKeys: ['disableCustomTools'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Calling custom tools', + deniedBy: (config) => config.disableCustomTools, + }, + 'skills.use': { + kind: 'static', + configKeys: ['disableSkills'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Loading skills', + deniedBy: (config) => config.disableSkills, + }, + 'logs.trace_spans': { + kind: 'static', + configKeys: ['hideTraceSpans'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The per-block execution trace', + deniedBy: (config) => config.hideTraceSpans, + }, + /** + * Not declarable on an operation: it refuses a *principal kind* rather than a + * capability of the resource, so it applies to every operation a personal key + * could reach. Asserted in the authorization funnel's personal-key branch. + */ + 'personal_api_key.use': { + kind: 'static', + configKeys: ['disablePersonalApiKeys'], + detailCode: 'PERSONAL_API_KEYS_DISABLED', + describe: 'Using a personal API key', + deniedBy: (config) => config.disablePersonalApiKeys, + }, + 'logs.export': { + kind: 'static', + configKeys: ['disableLogExport'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Exporting execution logs', + deniedBy: (config) => config.disableLogExport, + }, + 'logs.cost': { + kind: 'static', + configKeys: ['hideCostInfo'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Execution cost', + deniedBy: (config) => config.hideCostInfo, + }, + /** + * Also reads `hideKnowledgeBaseTab`, because an operation declares exactly one + * capability: moving knowledge-base creation off `knowledge.use` would + * otherwise let a group that withheld the whole module still create one + * through the API. The narrower capability has to subsume the broader. + */ + 'knowledge.create': { + kind: 'static', + configKeys: ['disableKnowledgeBaseCreation', 'hideKnowledgeBaseTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating a knowledge base', + deniedBy: (config) => config.disableKnowledgeBaseCreation || config.hideKnowledgeBaseTab, + }, + /** Subsumes `knowledge.use` for the same reason as `knowledge.create`. */ + 'knowledge.upload': { + kind: 'static', + configKeys: ['disableKnowledgeBaseFileUpload', 'hideKnowledgeBaseTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Uploading documents to a knowledge base', + deniedBy: (config) => config.disableKnowledgeBaseFileUpload || config.hideKnowledgeBaseTab, + }, + /** + * Parameterized on the connector id, because the decision is which source a + * member may sync — a connector pulls a whole external corpus into the + * workspace, and an organization that sanctions Drive rarely sanctions every + * one of the other sixty. + */ + 'knowledge.connectors': { + kind: 'parameterized', + configKeys: ['allowedKnowledgeConnectors'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'This knowledge base connector', + deniedBy: (config, connectorType) => + allowlistDenies(config.allowedKnowledgeConnectors, connectorType), + }, + /** + * Also reads `hideTablesTab`, for the reason `knowledge.create` does: an + * operation declares exactly one capability, so the narrower one replacing the + * broader one on `tables.create` would otherwise let a group that withholds + * the whole module still create tables through the API. + */ + 'tables.create': { + kind: 'static', + configKeys: ['disableTableCreation', 'hideTablesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating a table', + deniedBy: (config) => config.disableTableCreation || config.hideTablesTab, + }, + /** Subsumes `tables.use` for the same reason as `tables.create`. */ + 'tables.export': { + kind: 'static', + configKeys: ['disableTableExport', 'hideTablesTab'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Exporting a table', + deniedBy: (config) => config.disableTableExport || config.hideTablesTab, + }, + 'files.bulk_download': { + kind: 'static', + configKeys: ['disableBulkFileDownload'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Downloading files in bulk', + deniedBy: (config) => config.disableBulkFileDownload, + }, + 'credentials.personal': { + kind: 'static', + configKeys: ['disablePersonalCredentials'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Connecting personal credentials', + deniedBy: (config) => config.disablePersonalCredentials, + }, + 'workspace.create': { + kind: 'static', + configKeys: ['disableWorkspaceCreation'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating a workspace', + deniedBy: (config) => config.disableWorkspaceCreation, + }, + 'organization.member_directory': { + kind: 'static', + configKeys: ['hideOrgMemberDirectory'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'The organization member directory', + deniedBy: (config) => config.hideOrgMemberDirectory, + }, + 'cli.use': { + kind: 'static', + configKeys: ['disableCliAccess'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'CLI access', + deniedBy: (config) => config.disableCliAccess, + }, + 'triggers.webhook': { + kind: 'static', + configKeys: ['disableWebhookTriggers'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Creating a webhook trigger', + deniedBy: (config) => config.disableWebhookTriggers, + }, + 'copilot.tool_auto_approval': { + kind: 'static', + configKeys: ['disableToolAutoApproval'], + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + describe: 'Silencing a tool confirmation', + deniedBy: (config) => config.disableToolAutoApproval, + }, +} satisfies { readonly [K in PermissionGroupCapability]: CapabilityRule } + +/** + * The capabilities an operation may declare — the static ones. A parameterized + * rule needs a request value the funnel cannot see, so naming one on an + * operation would silently never fire. + */ +export type StaticPermissionGroupCapability = { + [K in PermissionGroupCapability]: (typeof CAPABILITY_RULES)[K] extends StaticCapabilityRule + ? K + : never +}[PermissionGroupCapability] + +/** + * Proof that the static/parameterized split resolves. + * + * `StaticPermissionGroupCapability` reads each rule's own `kind`, so annotating + * {@link CAPABILITY_RULES} instead of using `satisfies` would widen every entry + * and collapse this type to `never` — at which point no operation could declare + * any capability and every gate would silently never fire. Nothing at runtime + * would look wrong, so it is asserted here. + * + * The aliases below are deliberately unexported: the constraint on + * {@link Assert} is checked where the alias is declared, so an export bought + * nothing but the appearance of a consumer that never existed. They are unused + * on purpose, and deleting one deletes the proof. + */ +type Assert = T + +type AssertsStaticCapabilityResolves = Assert< + 'tables.use' extends StaticPermissionGroupCapability ? true : false +> +type AssertsParameterizedCapabilityIsExcluded = Assert< + 'deploy.chat.auth_mode' extends StaticPermissionGroupCapability ? false : true +> diff --git a/apps/sim/lib/permission-groups/capability-assertions.ts b/apps/sim/lib/permission-groups/capability-assertions.ts new file mode 100644 index 00000000000..9d5c51f5506 --- /dev/null +++ b/apps/sim/lib/permission-groups/capability-assertions.ts @@ -0,0 +1,92 @@ +import { + CAPABILITY_RULES, + refuseCapability, + type StaticPermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' + +/** + * Re-exported so a caller that gates inline reaches the refusal sentence and the + * assertions through one module; {@link CAPABILITY_RULES} remains its only + * definition. + */ +export { capabilityRefusal } from '@/lib/permission-groups/capabilities' + +/** + * The one way to ask whether a permission group withholds a capability. + * + * The authorization funnel decides from the operation alone, which is right when + * the capability describes the whole operation. Three cases fall outside it: a + * decision that depends on request input (one download is a single file, the + * next a folder tree), a raw route that predates the operation boundary, and an + * organization-level action with no workspace. All of them come through here, so + * the decision always reads {@link CAPABILITY_RULES} rather than a config key + * spelled out at a call site — where a renamed key would silently stop denying + * anything, and the refusal wording would drift from the funnel's. + */ +export function capabilityDeniedBy( + capability: StaticPermissionGroupCapability, + config: PermissionGroupConfig | null +): boolean { + if (!config) return false + const rule = CAPABILITY_RULES[capability] + return rule.kind === 'static' && rule.deniedBy(config) +} + +/** + * Throws when `userId`'s group in `workspaceId` withholds `capability`. + * + * A no-op when no group governs the user, so a personal workspace or a + * non-enterprise organization is unaffected. Pass `organizationId` when the + * caller has already loaded the workspace; omitting it costs one lookup, and + * both forms share the same per-request memo either way. + */ +export async function assertWorkspaceCapability( + userId: string, + workspaceId: string, + capability: StaticPermissionGroupCapability, + organizationId?: string | null +): Promise { + const config = await resolvePermissionGroupConfig(userId, workspaceId, organizationId) + if (capabilityDeniedBy(capability, config)) refuseCapability(capability) +} + +/** + * Whether the capability is withheld, without throwing. + * + * For a caller that must answer rather than refuse — a raw handler rendering its + * own response shape, or a policy that reports a structured decision. + */ +export async function isWorkspaceCapabilityWithheld( + userId: string, + workspaceId: string, + capability: StaticPermissionGroupCapability, + organizationId?: string | null +): Promise { + return capabilityDeniedBy( + capability, + await resolvePermissionGroupConfig(userId, workspaceId, organizationId) + ) +} + +/** + * The organization-scoped counterpart of {@link isWorkspaceCapabilityWithheld}. + * + * Outside the per-request memo on purpose. That memo is keyed by user and + * workspace, and this decision is keyed by organization alone, so sharing it + * would need a second key vocabulary in the store. No request asks an + * organization-scoped capability twice — every call site gates one + * organization-level act — so the memo would never be hit. Key it if that + * changes. + */ +export async function isOrganizationCapabilityWithheld( + organizationId: string, + capability: StaticPermissionGroupCapability +): Promise { + return capabilityDeniedBy( + capability, + await getUserPermissionConfigForOrganization(organizationId) + ) +} diff --git a/apps/sim/lib/permission-groups/capability-error.ts b/apps/sim/lib/permission-groups/capability-error.ts new file mode 100644 index 00000000000..a470a7592b6 --- /dev/null +++ b/apps/sim/lib/permission-groups/capability-error.ts @@ -0,0 +1,26 @@ +import { type ForbiddenDetailCode, ForbiddenOperationError } from '@/lib/core/application/forbidden' +import type { PermissionGroupCapability } from '@/lib/permission-groups/capabilities' + +/** + * The caller's permission group withholds a capability the request needs. + * + * Carries the capability so a log line or an audit entry can name it; the + * message names it for the caller. The detail code comes from the capability's + * own rule rather than being fixed here — the closed code set is closed over + * remedies, so the handful of capabilities with a remedy of their own (a chat + * auth mode, public sharing, personal API keys) carry a code of their own and + * the rest share the generic one. + * + * Lives here rather than beside the authorization funnel so the assertion + * helpers can throw it without importing the funnel, which imports them. + */ +export class PermissionGroupCapabilityError extends ForbiddenOperationError { + constructor( + readonly capability: PermissionGroupCapability, + detailCode: ForbiddenDetailCode, + message: string + ) { + super(detailCode, message) + this.name = 'PermissionGroupCapabilityError' + } +} diff --git a/apps/sim/lib/permission-groups/capability-response.ts b/apps/sim/lib/permission-groups/capability-response.ts new file mode 100644 index 00000000000..a1d28471aaa --- /dev/null +++ b/apps/sim/lib/permission-groups/capability-response.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { + CAPABILITY_RULES, + capabilityRefusal, + type PermissionGroupCapability, +} from '@/lib/permission-groups/capabilities' + +/** + * The 403 a raw route returns when a permission group withholds a capability, + * as opposed to the caller's role being too low. + * + * One builder so the sentence and the detail code cannot drift between the + * routes that gate through a shared access check, the ones that assert inline, + * and the ones that catch {@link PermissionGroupCapabilityError} and render its + * capability. The detail code is read off the rule rather than spelled out at + * the call site — four capabilities carry a more specific one, and a literal + * would report them as the generic block. + * + * The v1 public API renders its own `{ error: { code, message } }` envelope and + * is deliberately not converged here; see `resolveCapabilityRefusal` in + * `app/api/v1/middleware.ts`. + */ +export function capabilityRefusalResponse(capability: PermissionGroupCapability): NextResponse { + return NextResponse.json( + { + error: capabilityRefusal(capability), + details: { code: CAPABILITY_RULES[capability].detailCode }, + }, + { status: 403 } + ) +} diff --git a/apps/sim/lib/permission-groups/config-scope.server.test.ts b/apps/sim/lib/permission-groups/config-scope.server.test.ts new file mode 100644 index 00000000000..7f4f473e885 --- /dev/null +++ b/apps/sim/lib/permission-groups/config-scope.server.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserPermissionConfig, mockResolveVerifiedContext } = vi.hoisted(() => ({ + mockGetUserPermissionConfig: vi.fn(), + mockResolveVerifiedContext: vi.fn(), +})) + +vi.mock('react', () => ({ cache: (fn: F) => fn })) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, + resolveVerifiedUserAccessControlContext: mockResolveVerifiedContext, +})) + +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' + +const CONFIG = { hideTablesTab: true } + +describe('resolvePermissionGroupConfig scope memo', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserPermissionConfig.mockResolvedValue(CONFIG) + mockResolveVerifiedContext.mockResolvedValue({ config: CONFIG }) + }) + + /** + * The key omits `organizationId` because a caller may only pass the + * organization of the workspace it names, so the two arms resolve the same + * group. Adding it to the key would split the cache and query twice. + */ + it('shares one query between the looked-up and the already-loaded form', async () => { + const [first, second] = await withPermissionGroupScope(() => + Promise.all([ + resolvePermissionGroupConfig('user-1', 'workspace-1', undefined), + resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1'), + ]) + ) + + expect(first).toBe(second) + expect( + mockGetUserPermissionConfig.mock.calls.length + mockResolveVerifiedContext.mock.calls.length + ).toBe(1) + }) + + it('resolves a different user or workspace separately', async () => { + await withPermissionGroupScope(async () => { + await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1') + await resolvePermissionGroupConfig('user-2', 'workspace-1', 'org-1') + await resolvePermissionGroupConfig('user-1', 'workspace-2', 'org-1') + }) + + expect(mockResolveVerifiedContext).toHaveBeenCalledTimes(3) + }) + + it('still answers outside a scope, without memoizing', async () => { + await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1') + await resolvePermissionGroupConfig('user-1', 'workspace-1', 'org-1') + + expect(mockResolveVerifiedContext).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/permission-groups/config-scope.server.ts b/apps/sim/lib/permission-groups/config-scope.server.ts new file mode 100644 index 00000000000..42d480c46c7 --- /dev/null +++ b/apps/sim/lib/permission-groups/config-scope.server.ts @@ -0,0 +1,68 @@ +import { cache } from 'react' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import type { PermissionGroupScopeKey } from '@/lib/permission-groups/request-scope.server' +import { getPermissionGroupConfigStore } from '@/lib/permission-groups/request-scope.server' +import { + getUserPermissionConfig, + resolveVerifiedUserAccessControlContext, +} from '@/lib/permission-groups/resolve.server' + +/** + * Memoized for a React server request, so an RSC render that resolves the same + * viewer twice still makes one query. Both paths delegate here, so the scope and + * the React cache can never disagree. + */ +const resolveCached = cache( + async ( + userId: string, + workspaceId: string, + organizationId: string | null | undefined + ): Promise => + organizationId === undefined + ? await getUserPermissionConfig(userId, workspaceId) + : (await resolveVerifiedUserAccessControlContext(userId, workspaceId, organizationId)).config +) + +/** + * The permission-group config governing `userId` in `workspaceId`, resolved at + * most once per scope. + * + * Caches the promise rather than the value, so concurrent callers share one + * query instead of racing to start several. Caches `null` too — "no group + * governs this user" is the common answer and the one least worth re-asking. + * + * Outside a scope this degrades to the React memo, and outside a request to a + * direct call: slower, never wrong. + * + * Pass `undefined` for `organizationId` when the caller has not already loaded + * the workspace — a raw route, typically. The resolver looks it up, and both + * forms share this memo, so a request that mixes them still queries once. + * + * `organizationId` is deliberately NOT part of the key, and adding it would + * split the cache and double the queries for no gain. Both arms end in + * `resolveUserAccessControlContextForOrganization(userId, workspaceId, org)`; + * they differ only in where `org` came from, and a caller may only pass the + * organization of the very workspace it names — it is a value it loaded off + * that workspace, not an independent argument. So `organizationId` is a + * function of `workspaceId`, and the key already carries it. A caller that + * passed some *other* organization would be resolving the wrong group with or + * without this memo, and would fail open (no group in that organization targets + * this workspace, so nothing restricts); that is a call-site invariant, which + * is why the parameter is documented as "already loaded" rather than free. + */ +export function resolvePermissionGroupConfig( + userId: string, + workspaceId: string, + organizationId: string | null | undefined +): Promise { + const store = getPermissionGroupConfigStore() + if (!store) return resolveCached(userId, workspaceId, organizationId) + + const key: PermissionGroupScopeKey = `${userId}:${workspaceId}` + const existing = store.get(key) + if (existing) return existing + + const pending = resolveCached(userId, workspaceId, organizationId) + store.set(key, pending) + return pending +} diff --git a/apps/sim/lib/permission-groups/constraints.ts b/apps/sim/lib/permission-groups/constraints.ts new file mode 100644 index 00000000000..f87bde2ec06 --- /dev/null +++ b/apps/sim/lib/permission-groups/constraints.ts @@ -0,0 +1,8 @@ +export const PERMISSION_GROUP_CONSTRAINTS = { + organizationName: 'permission_group_organization_name_unique', + organizationDefault: 'permission_group_organization_default_unique', +} as const + +export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { + groupUser: 'permission_group_member_group_user_unique', +} as const diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts index c7496830cd3..45211533c16 100644 --- a/apps/sim/lib/permission-groups/features.test.ts +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -4,12 +4,13 @@ import { describe, expect, it } from 'vitest' import { getActivePermissionGroupRestrictions, + isFeatureInertForGroup, PLATFORM_FEATURES, } from '@/lib/permission-groups/features' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, -} from '@/lib/permission-groups/types' +} from '@/lib/permission-groups/fields' describe('getActivePermissionGroupRestrictions', () => { it('returns no restrictions for an absent or unrestricted config', () => { @@ -95,3 +96,80 @@ describe('getActivePermissionGroupRestrictions', () => { } ) }) + +/** + * The editor renders every boolean key on every group, so a key whose + * capability is read from the organization's *default* group is a checkbox that + * does nothing on any other group. `scope` is what lets the editor say so, and + * this pins the membership of each class: a new key that reads the default + * group has to be added here deliberately, rather than shipping as a silently + * inert checkbox. + * + * `workspace-or-organization` is the honest third answer. `api_keys.manage`, + * `cli.use`, `integrations.manage`, `invitations.send` and + * `personal_api_key.use` each have a workspace-scoped path that reads the group + * being edited *and* an account-level path that falls back to the default + * group, so they are neither inert nor purely local — marking them + * `organization` would tell an admin their workspace restriction does not apply + * when it does. + */ +describe('platform feature scope', () => { + function keysWithScope(scope: string): string[] { + return PLATFORM_FEATURES.filter((feature) => feature.scope === scope) + .map((feature) => feature.configKey) + .sort() + } + + it('reads exactly two keys from the organization default group alone', () => { + expect(keysWithScope('organization')).toEqual([ + 'disableWorkspaceCreation', + 'hideOrgMemberDirectory', + ]) + }) + + it('reads exactly five keys from both a workspace group and the default group', () => { + expect(keysWithScope('workspace-or-organization')).toEqual([ + 'disableCliAccess', + 'disableInvitations', + 'disablePersonalApiKeys', + 'hideApiKeysTab', + 'hideIntegrationsTab', + ]) + }) + + it('gives every feature a scope', () => { + for (const feature of PLATFORM_FEATURES) { + expect( + ['workspace', 'organization', 'workspace-or-organization'], + `${feature.configKey} declares no known scope` + ).toContain(feature.scope) + } + }) +}) + +describe('isFeatureInertForGroup', () => { + function feature(configKey: string) { + const found = PLATFORM_FEATURES.find((f) => f.configKey === configKey) + if (!found) throw new Error(`No platform feature for ${configKey}`) + return found + } + + it('makes an organization-scoped key inert on a non-default group', () => { + expect(isFeatureInertForGroup(feature('hideOrgMemberDirectory'), false)).toBe(true) + expect(isFeatureInertForGroup(feature('disableWorkspaceCreation'), false)).toBe(true) + }) + + it('leaves an organization-scoped key editable on the default group', () => { + expect(isFeatureInertForGroup(feature('hideOrgMemberDirectory'), true)).toBe(false) + }) + + /** + * The dual-scope keys have a workspace path that reads the group being + * edited, so making them inert would withhold a restriction that does apply. + */ + it('never makes a workspace or dual-scope key inert', () => { + expect(isFeatureInertForGroup(feature('hideApiKeysTab'), false)).toBe(false) + expect(isFeatureInertForGroup(feature('disableCliAccess'), false)).toBe(false) + expect(isFeatureInertForGroup(feature('hideTraceSpans'), false)).toBe(false) + }) +}) diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts index 78aa4f79545..642921fa3ca 100644 --- a/apps/sim/lib/permission-groups/features.ts +++ b/apps/sim/lib/permission-groups/features.ts @@ -1,6 +1,11 @@ -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { + PERMISSION_GROUP_FIELDS, + type PermissionGroupCapabilityScope, + type PermissionGroupConfig, + type PermissionGroupConfigKey, +} from '@/lib/permission-groups/fields' -type BooleanPermissionGroupConfigKey = { +export type BooleanPermissionGroupConfigKey = { [Key in keyof PermissionGroupConfig]: PermissionGroupConfig[Key] extends boolean ? Key : never }[keyof PermissionGroupConfig] @@ -10,6 +15,33 @@ export interface PermissionGroupPlatformFeature { category: string configKey: BooleanPermissionGroupConfigKey hint: string + /** See {@link PermissionGroupCapabilityScope}. */ + scope: PermissionGroupCapabilityScope +} + +/** + * The note a group editor shows beside an organization-scoped row. + * + * One sentence, in one place, so the editor and any other surface that has to + * explain the row say the same thing. + */ +export const ORGANIZATION_SCOPED_FEATURE_NOTE = + "Read from the organization's default group, so it applies organization-wide no matter which group sets it." + +/** + * Whether a group editor can decide this key at all. + * + * An `organization`-scoped key is read from the organization's default group, so + * on any other group the checkbox writes a value nothing will ever read. The + * editor renders those rows inert and every bulk action skips them, both from + * this one predicate — a second copy is how the row and the "Select All" it sits + * under would come to disagree. + */ +export function isFeatureInertForGroup( + feature: PermissionGroupPlatformFeature, + groupIsDefault: boolean +): boolean { + return feature.scope === 'organization' && !groupIsDefault } export interface ActivePermissionGroupRestriction { @@ -17,151 +49,49 @@ export interface ActivePermissionGroupRestriction { description: string } -/** Render order for the platform-feature category sections; unlisted ones follow. */ +/** + * Render order for the platform-feature category sections; unlisted ones follow. + * + * Named after what a group withholds rather than after the surface the key once + * hid, for the reason `PlatformFeatureMeta.hint` in `fields.ts` gives at length. + */ export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ - 'Sidebar', - 'Deploy Tabs', - 'Chat', - 'Collaboration', - 'Workflow Panel', + 'Modules', + 'Knowledge Base', + 'Tables', + 'Files', + 'Deployment', 'Tools', - 'Features', - 'Settings Tabs', 'Logs', - 'Files', + 'Collaboration', + 'Credentials & Access', ] as const -/** User-facing descriptions shared by the Access Control editor and live permission context. */ -export const PLATFORM_FEATURES = [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab', - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab', - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot', - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab', - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab', - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab', - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab', - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi', - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp', - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools', - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools', - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills', - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans', - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations', - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab', - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi', - hint: 'Disable public API access to deployed workflows.', - }, - { - id: 'hide-deploy-chatbot', - label: 'Deployment', - category: 'Chat', - configKey: 'hideDeployChatbot', - hint: 'Hide the chat deployment option.', - }, - { - id: 'disable-public-file-sharing', - label: 'Public Sharing', - category: 'Files', - configKey: 'disablePublicFileSharing', - hint: 'Disable public file-share links.', - }, -] as const satisfies readonly PermissionGroupPlatformFeature[] +const FIELD_ENTRIES = Object.entries(PERMISSION_GROUP_FIELDS) as Array< + [PermissionGroupConfigKey, (typeof PERMISSION_GROUP_FIELDS)[PermissionGroupConfigKey]] +> -/** Returns only restrictions that actively constrain the current user. */ +/** + * The boolean toggles the Access Control editor renders, in registry order. + * + * Derived rather than listed, so a boolean key cannot reach the config without + * reaching the editor — an unrendered key is one an admin can neither set nor + * see, which is how a restriction ends up applying with nothing to explain it. + */ +export const PLATFORM_FEATURES: readonly PermissionGroupPlatformFeature[] = FIELD_ENTRIES.flatMap( + ([key, field]) => + field.kind === 'boolean-restriction' + ? [{ ...field.feature, configKey: key as BooleanPermissionGroupConfigKey }] + : [] +) + +/** + * Returns only restrictions that actively constrain the current user. + * + * Two passes, allowlists and denylists before booleans, because the resulting + * prose is what the Copilot context and the group roster read: reordering it + * would rewrite text that surfaces to users for no reason. + */ export function getActivePermissionGroupRestrictions( config: PermissionGroupConfig | null ): ActivePermissionGroupRestriction[] { @@ -169,53 +99,16 @@ export function getActivePermissionGroupRestrictions( const restrictions: ActivePermissionGroupRestriction[] = [] - if (config.allowedIntegrations !== null) { - restrictions.push({ - key: 'allowedIntegrations', - description: - config.allowedIntegrations.length > 0 - ? 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.' - : 'No non-exempt integrations or blocks are allowed.', - }) - } - if (config.allowedModelProviders !== null) { - restrictions.push({ - key: 'allowedModelProviders', - description: - config.allowedModelProviders.length > 0 - ? 'Model providers are limited to effectiveConfig.allowedModelProviders.' - : 'No model providers are allowed.', - }) - } - if (config.deniedModels.length > 0) { - restrictions.push({ - key: 'deniedModels', - description: 'Models listed in effectiveConfig.deniedModels are blocked.', - }) - } - if (config.deniedTools.length > 0) { - restrictions.push({ - key: 'deniedTools', - description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', - }) - } - if (config.allowedFileShareAuthTypes !== null) { - restrictions.push({ - key: 'allowedFileShareAuthTypes', - description: - config.allowedFileShareAuthTypes.length > 0 - ? 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.' - : 'No public file-share authentication modes are allowed.', - }) - } - if (config.allowedChatDeployAuthTypes !== null) { - restrictions.push({ - key: 'allowedChatDeployAuthTypes', - description: - config.allowedChatDeployAuthTypes.length > 0 - ? 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.' - : 'No chat deployment authentication modes are allowed.', - }) + for (const [key, field] of FIELD_ENTRIES) { + const value = config[key] + if (field.kind === 'allowlist' && Array.isArray(value)) { + restrictions.push({ + key, + description: value.length > 0 ? field.phrasing.limited : field.phrasing.empty, + }) + } else if (field.kind === 'denylist' && Array.isArray(value) && value.length > 0) { + restrictions.push({ key, description: field.phrasing }) + } } for (const feature of PLATFORM_FEATURES) { diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts new file mode 100644 index 00000000000..e5ae2c82d0d --- /dev/null +++ b/apps/sim/lib/permission-groups/fields.test.ts @@ -0,0 +1,352 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { permissionGroupFullConfigSchema } from '@/lib/api/contracts/permission-groups' +import { PLATFORM_FEATURES } from '@/lib/permission-groups/features' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, + parsePermissionGroupConfig, + permissionGroupConfigSchema, +} from '@/lib/permission-groups/fields' + +/** + * The coercion corpus, pinned against the hand-written parser before it is + * replaced by a derived one. + * + * Every row states what a stored `jsonb` value coerces to today. A derived + * implementation has to reproduce this table exactly, so any row that changes + * in a later diff is a deliberate semantic decision someone has to defend + * rather than a silent regression. + */ +interface CoercionFixture { + name: string + input: unknown + expected: PermissionGroupConfig +} + +const fixtures: readonly CoercionFixture[] = [ + { name: 'null', input: null, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'undefined', input: undefined, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + /** + * `typeof [] === 'object'`, so an array-valued column falls through the + * object guard and coerces to defaults rather than throwing. A derived + * parser built on `z.object()` throws here unless it guards `Array.isArray`. + */ + { name: 'an array (jsonb [])', input: [], expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'a string', input: 'nope', expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'a number', input: 7, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { name: 'an empty object', input: {}, expected: DEFAULT_PERMISSION_GROUP_CONFIG }, + { + name: 'unknown keys, which are dropped', + input: { bogus: 1, hideCopilot: true }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true }, + }, + { + name: 'a boolean given a string', + input: { hideCopilot: 'yes' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a boolean given null', + input: { hideTablesTab: null }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a boolean given false explicitly', + input: { hideFilesTab: false }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a denylist with mixed members, keeping the strings', + input: { deniedTools: ['slack_canvas', 42, null, { a: 1 }] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedTools: ['slack_canvas'] }, + }, + { + name: 'a denylist given an object', + input: { deniedModels: {} }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'a denylist given a string', + input: { deniedModels: 'gpt-4o' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'auth types with an invalid member, keeping the valid ones', + input: { allowedFileShareAuthTypes: ['sso', 'bogus', 'password'] }, + expected: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedFileShareAuthTypes: ['sso', 'password'], + }, + }, + { + name: 'auth types given a bare string', + input: { allowedChatDeployAuthTypes: 'sso' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'auth types emptied, which denies every mode', + input: { allowedChatDeployAuthTypes: [] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedChatDeployAuthTypes: [] }, + }, + /** + * An emptied allowlist denies everything while `null` allows everything, so + * the two must never collapse into one another. + */ + { + name: 'an emptied allowlist, which denies every integration', + input: { allowedIntegrations: [] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] }, + }, + { + name: 'an explicitly null allowlist', + input: { allowedModelProviders: null }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + { + name: 'an allowlist given a string', + input: { allowedIntegrations: 'slack' }, + expected: DEFAULT_PERMISSION_GROUP_CONFIG, + }, + /** + * The allowlists used to be the only keys that skipped element validation, so + * a `string[]`-typed field could hold a number and the read schema then + * refused the config it produced. Filtering keeps the members that parse and + * fails closed. + */ + { + name: 'an allowlist with a non-string member, keeping the strings', + input: { allowedIntegrations: ['slack', 42] }, + expected: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['slack'] }, + }, + { + name: 'a fully populated config', + input: { + allowedIntegrations: ['slack_v2'], + allowedModelProviders: ['anthropic'], + deniedModels: ['gpt-4o'], + deniedTools: ['slack_canvas'], + hideTraceSpans: true, + hideKnowledgeBaseTab: true, + hideTablesTab: true, + hideCopilot: true, + hideIntegrationsTab: true, + hideSecretsTab: true, + hideApiKeysTab: true, + hideInboxTab: true, + hideFilesTab: true, + disableMcpTools: true, + disableCustomTools: true, + disableSkills: true, + disableInvitations: true, + disablePublicApi: true, + disablePublicFileSharing: true, + allowedFileShareAuthTypes: ['sso'], + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + allowedChatDeployAuthTypes: ['password'], + disablePersonalApiKeys: true, + disableLogExport: true, + hideCostInfo: true, + disableKnowledgeBaseCreation: true, + disableKnowledgeBaseFileUpload: true, + allowedKnowledgeConnectors: ['google_drive'], + disableTableCreation: true, + disableTableExport: true, + disableBulkFileDownload: true, + disablePersonalCredentials: true, + disableWorkspaceCreation: true, + hideOrgMemberDirectory: true, + disableCliAccess: true, + disableWebhookTriggers: true, + disableToolAutoApproval: true, + }, + expected: { + allowedIntegrations: ['slack_v2'], + allowedModelProviders: ['anthropic'], + deniedModels: ['gpt-4o'], + deniedTools: ['slack_canvas'], + hideTraceSpans: true, + hideKnowledgeBaseTab: true, + hideTablesTab: true, + hideCopilot: true, + hideIntegrationsTab: true, + hideSecretsTab: true, + hideApiKeysTab: true, + hideInboxTab: true, + hideFilesTab: true, + disableMcpTools: true, + disableCustomTools: true, + disableSkills: true, + disableInvitations: true, + disablePublicApi: true, + disablePublicFileSharing: true, + allowedFileShareAuthTypes: ['sso'], + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + allowedChatDeployAuthTypes: ['password'], + disablePersonalApiKeys: true, + disableLogExport: true, + hideCostInfo: true, + disableKnowledgeBaseCreation: true, + disableKnowledgeBaseFileUpload: true, + allowedKnowledgeConnectors: ['google_drive'], + disableTableCreation: true, + disableTableExport: true, + disableBulkFileDownload: true, + disablePersonalCredentials: true, + disableWorkspaceCreation: true, + hideOrgMemberDirectory: true, + disableCliAccess: true, + disableWebhookTriggers: true, + disableToolAutoApproval: true, + }, + }, +] + +describe('parsePermissionGroupConfig', () => { + it.each(fixtures)('coerces $name', ({ input, expected }) => { + expect(parsePermissionGroupConfig(input)).toEqual(expected) + }) + + it.each(fixtures)('emits every key in wire order for $name', ({ input }) => { + expect(Object.keys(parsePermissionGroupConfig(input))).toEqual( + Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + ) + }) + + it.each(fixtures)('produces a config the read schema accepts for $name', ({ input }) => { + const parsed = structuredClone(parsePermissionGroupConfig(input)) + expect(permissionGroupFullConfigSchema.safeParse(parsed).success).toBe(true) + }) + + /** + * The allowlists used to skip element validation, so a corrupted row coerced + * to a value `permissionGroupFullConfigSchema` then refused — the route + * reading it failed response validation instead of returning a usable + * allowlist. Filtering is fail-closed: the members that parse survive, and a + * corrupt one narrows the allowlist rather than voiding it. + */ + it('narrows a corrupted allowlist instead of voiding it', () => { + const parsed = parsePermissionGroupConfig({ allowedIntegrations: ['slack', 42] }) + expect(parsed.allowedIntegrations).toEqual(['slack']) + expect(permissionGroupFullConfigSchema.safeParse(structuredClone(parsed)).success).toBe(true) + }) + + it('is idempotent', () => { + for (const { input } of fixtures) { + const once = parsePermissionGroupConfig(input) + expect(parsePermissionGroupConfig(structuredClone(once))).toEqual(once) + } + }) +}) + +/** + * A deterministic generator, seeded so a failure reproduces from the printed + * seed alone. A fixed corpus pins the cases we thought of; this covers the + * shapes we did not, and asserts only invariants so it stays meaningful after + * the parser is reimplemented. + */ +function createRandom(seed: number): () => number { + let state = seed + return () => { + state = (state * 1664525 + 1013904223) % 0x100000000 + return state / 0x100000000 + } +} + +const MALFORMED_VALUES: readonly unknown[] = [ + undefined, + null, + true, + false, + 0, + 1, + 'sso', + '', + [], + ['slack'], + ['slack', 42], + ['sso', 'bogus'], + [null], + [{}], + {}, + { nested: true }, + Number.NaN, +] + +describe('parsePermissionGroupConfig invariants', () => { + const configKeys = Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + + it('holds over randomly malformed configs', () => { + const seed = 0x5eed + const random = createRandom(seed) + + for (let iteration = 0; iteration < 2000; iteration++) { + const input: Record = {} + for (const key of configKeys) { + if (random() < 0.35) continue + input[key] = MALFORMED_VALUES[Math.floor(random() * MALFORMED_VALUES.length)] + } + + const parsed = parsePermissionGroupConfig(input) + const context = `seed ${seed}, iteration ${iteration}, input ${JSON.stringify(input)}` + + expect(Object.keys(parsed), context).toEqual(configKeys) + expect(parsePermissionGroupConfig(structuredClone(parsed)), context).toEqual(parsed) + expect( + permissionGroupFullConfigSchema.safeParse(structuredClone(parsed)).success, + context + ).toBe(true) + } + }) +}) + +describe('permission group config key coverage', () => { + it('declares the same keys in the write schema, the defaults, and the read schema', () => { + expect(Object.keys(permissionGroupConfigSchema.shape)).toEqual( + Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + ) + expect(Object.keys(permissionGroupFullConfigSchema.shape)).toEqual( + Object.keys(DEFAULT_PERMISSION_GROUP_CONFIG) + ) + }) + + it('registers every boolean config key as a platform feature', () => { + const booleanKeys = Object.entries(DEFAULT_PERMISSION_GROUP_CONFIG) + .filter(([, value]) => typeof value === 'boolean') + .map(([key]) => key) + + expect([...PLATFORM_FEATURES.map((feature) => feature.configKey)].sort()).toEqual( + [...booleanKeys].sort() + ) + }) + + /** + * Each key gates an act that names no workspace, so each is read from the + * organization's default group only — a group scoped to specific workspaces + * cannot deny an account-level login, a workspace that does not exist yet, or + * a roster read that belongs to the organization rather than to any one + * workspace. The editor still offers the checkbox on such a group, so the + * hint is the only place an admin learns where it applies; all three shipped + * saying nothing, and a hint that omits it is a checkbox that silently + * enforces nothing wherever an admin is most likely to tick it. + */ + it.each(['disableWorkspaceCreation', 'disableCliAccess', 'hideOrgMemberDirectory'] as const)( + "tells an admin that %s is read from the organization's default group", + (configKey) => { + const feature = PLATFORM_FEATURES.find((entry) => entry.configKey === configKey) + + expect(feature?.hint).toContain("organization's default group") + } + ) + + it('gives every platform feature a unique id', () => { + const ids = PLATFORM_FEATURES.map((feature) => feature.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts new file mode 100644 index 00000000000..79b2d92e6fa --- /dev/null +++ b/apps/sim/lib/permission-groups/fields.ts @@ -0,0 +1,595 @@ +import { z } from 'zod' + +/** + * Auth modes a public file share or a chat deployment can use; admins may + * restrict the allowed subset. The two surfaces share the same four modes. + */ +export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as const + +const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES) + +/** + * Which mechanism refuses a request when this key is set. + * + * - `capability`: an operation declares a capability whose rule reads the key, + * so the authorization funnel refuses before the use case runs. + * - `executor`: the key is read per block, tool or model at execution time by + * `assertPermissionsAllowed`. It governs what a run may *do*, which no + * operation-level gate can express. + * - `ui-only`: the key hides a surface without withholding it, so a caller that + * skips the UI still reaches the API. + * + * Declared rather than inferred, because `ui-only` is the value an admin is + * most likely to mistake for a control — twelve keys shipped that way. + * + * Not derived from the capability rules, which also name config keys, and not + * worth collapsing into them: `capabilities.ts` imports this module for + * `PermissionGroupConfigKey`, so the dependency runs one way only, and the two + * are different facts anyway — a field is storage plus admin UI, a rule is a + * decision — related many-to-many (`knowledge.create` reads two keys; + * `hideKnowledgeBaseTab` is read by three rules). This value is the single fact + * they share, and `check:permission-group-enforcement` is what keeps it honest + * in both directions. + */ +type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' + +/** + * Which group a key's capability is actually read from when it is enforced. + * + * - `workspace`: resolved from the group governing the caller in the workspace + * the request names, so the group being edited is the group that applies. + * - `organization`: resolved from the organization's *default* group, because + * the act names no workspace (creating one, reading the member directory). + * Setting it on any other group changes nothing at all. + * - `workspace-or-organization`: both, on different paths — a workspace-scoped + * act reads this group, and the same capability's account-level path + * (minting a key, an organization-wide invitation) falls back to the default + * group. + * + * Declared because the editor renders every key on every group, and two of them + * are read from one group no matter which is open. That was disclosed only in a + * hint an admin has to hover, so the checkbox looked like it did something on + * the group in front of them; `scope` is what lets the editor say so in the row + * itself. Required rather than optional so the next organization-scoped key + * ships marked instead of inheriting the majority answer by omission. + */ +export type PermissionGroupCapabilityScope = + | 'workspace' + | 'organization' + | 'workspace-or-organization' + +/** The admin-editor descriptor for a boolean key, rendered from the registry. */ +interface PlatformFeatureMeta { + readonly id: string + readonly label: string + readonly category: string + /** See {@link PermissionGroupCapabilityScope}. */ + readonly scope: PermissionGroupCapabilityScope + /** + * What the key withholds, in one or two short sentences. Read twice — as the + * editor's hint and as the prose `getActivePermissionGroupRestrictions` + * reports for an active restriction — so it states the restriction rather + * than what remains permitted, which reads backwards in the second place. + * + * It must describe the *access* withheld, never a surface hidden. Every key + * with `enforcement: 'capability'` refuses at the API, so a hint that says + * "hide from the sidebar" tells an admin they are tidying a nav bar when they + * are revoking a module. Twelve keys shipped worded that way while they were + * genuinely cosmetic; the wording outlived the behavior. + */ + readonly hint: string +} + +/** Prose for an allowlist, which reads differently narrowed than emptied. */ +interface AllowlistPhrasing { + readonly limited: string + readonly empty: string +} + +/** + * Coerces an untrusted value into an array of `item`, element by element. + * + * Deliberately not `z.array(item).catch(fallback)`: `.catch` is whole-value + * tolerant, so one bad member would discard every good one. For an allowlist + * that is also a fail-open change, because the fallback is `null` and `null` + * means unrestricted — a partially corrupt allowlist would stop restricting + * anything. Filtering keeps the surviving members and fails closed. + */ +function tolerantArray[] | null>( + item: TItem, + fallback: TFallback +): z.ZodType[] | TFallback> { + return z.unknown().transform((raw) => { + if (!Array.isArray(raw)) return fallback + return raw.flatMap((entry) => { + const parsed = item.safeParse(entry) + return parsed.success ? [parsed.data as z.infer] : [] + }) + }) +} + +interface BooleanRestrictionField { + readonly kind: 'boolean-restriction' + readonly writeSchema: z.ZodOptional + readonly readSchema: z.ZodBoolean + readonly tolerantSchema: z.ZodType + readonly default: boolean + readonly enforcement: PermissionGroupEnforcement + readonly feature: PlatformFeatureMeta +} + +interface AllowlistField { + readonly kind: 'allowlist' + readonly writeSchema: z.ZodOptional>> + readonly readSchema: z.ZodNullable> + readonly tolerantSchema: z.ZodType[] | null> + readonly default: null + readonly enforcement: PermissionGroupEnforcement + readonly phrasing: AllowlistPhrasing +} + +interface DenylistField { + readonly kind: 'denylist' + readonly writeSchema: z.ZodOptional> + readonly readSchema: z.ZodDefault> + readonly tolerantSchema: z.ZodType[]> + readonly default: never[] + readonly enforcement: PermissionGroupEnforcement + readonly phrasing: string +} + +/** + * The structural shape every entry satisfies. Deliberately loose in its schema + * members: a concrete `AllowlistField` is not reliably assignable to + * `AllowlistField` through zod's internals, and widening the registry + * to this type would erase the per-key output types the config depends on. It + * exists for `satisfies`, never as an annotation. + */ +type PermissionGroupField = { + readonly kind: 'boolean-restriction' | 'allowlist' | 'denylist' + readonly writeSchema: z.ZodType + readonly readSchema: z.ZodType + readonly tolerantSchema: z.ZodType + readonly default: unknown + readonly enforcement: PermissionGroupEnforcement +} + +function booleanRestriction( + enforcement: PermissionGroupEnforcement, + feature: PlatformFeatureMeta +): BooleanRestrictionField { + const schema = z.boolean() + return { + kind: 'boolean-restriction', + writeSchema: schema.optional(), + readSchema: schema, + tolerantSchema: schema.catch(false), + default: false, + enforcement, + feature, + } +} + +function allowlist( + item: TItem, + enforcement: PermissionGroupEnforcement, + phrasing: AllowlistPhrasing +): AllowlistField { + const schema = z.array(item).nullable() + return { + kind: 'allowlist', + writeSchema: schema.optional(), + readSchema: schema, + tolerantSchema: tolerantArray(item, null), + default: null, + enforcement, + phrasing, + } +} + +function denylist( + item: TItem, + enforcement: PermissionGroupEnforcement, + phrasing: string +): DenylistField { + const schema = z.array(item) + return { + kind: 'denylist', + writeSchema: schema.optional(), + readSchema: schema.default([]), + tolerantSchema: tolerantArray(item, [] as never[]), + default: [], + enforcement, + phrasing, + } +} + +/** + * Every permission-group config key, in wire order. + * + * Declaration order here is the key order of `PermissionGroupConfig`, of both + * zod schemas, and of every config JSON that crosses the API boundary. The + * group editor's dirty check compares stringified configs, so reordering + * entries is a breaking change. + * + * Adding a key here adds it to the write schema, the read schema, the type, the + * defaults, the tolerant parser and — for a boolean — the admin editor. It does + * not add enforcement: `enforcement` names the mechanism that refuses, and + * `check:permission-group-enforcement` refuses a key that claims one it does + * not have. + */ +export const PERMISSION_GROUP_FIELDS = { + allowedIntegrations: allowlist(z.string(), 'executor', { + limited: 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.', + empty: 'No non-exempt integrations or blocks are allowed.', + }), + allowedModelProviders: allowlist(z.string(), 'executor', { + limited: 'Model providers are limited to effectiveConfig.allowedModelProviders.', + empty: 'No model providers are allowed.', + }), + deniedModels: denylist( + z.string(), + 'executor', + 'Models listed in effectiveConfig.deniedModels are blocked.' + ), + deniedTools: denylist( + z.string(), + 'executor', + 'Integration tools listed in effectiveConfig.deniedTools are blocked.' + ), + hideTraceSpans: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + hint: 'Withhold per-block trace spans from logs and from the API.', + }), + hideKnowledgeBaseTab: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Knowledge Base', + hint: 'Revoke the Knowledge Base module. Members cannot open, search, or query any knowledge base.', + }), + hideTablesTab: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-tables', + label: 'Tables', + category: 'Tables', + hint: 'Revoke the Tables module. Members cannot read or write any table.', + }), + hideCopilot: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-copilot', + label: 'Chat', + category: 'Modules', + hint: 'Revoke Chat. Members cannot ask Sim to build or edit anything.', + }), + hideIntegrationsTab: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'hide-integrations', + label: 'Integrations', + category: 'Credentials & Access', + hint: 'Revoke integration connections. Members cannot view, add, or remove an OAuth connection.', + }), + hideSecretsTab: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-secrets', + label: 'Secrets', + category: 'Credentials & Access', + hint: 'Revoke secrets. Members cannot read, add, or change a workspace environment variable.', + }), + hideApiKeysTab: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'hide-api-keys', + label: 'API Keys', + category: 'Credentials & Access', + hint: 'Revoke workspace API keys. Members cannot list, create, or revoke one.', + }), + hideInboxTab: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Modules', + hint: 'Revoke the Sim Mailer inbox. Members cannot read or send mail.', + }), + hideFilesTab: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-files', + label: 'Files', + category: 'Files', + hint: 'Revoke the Files module. Members cannot list, upload, or download workspace files.', + }), + disableMcpTools: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + hint: 'Block agents from calling MCP tools.', + }), + disableCustomTools: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + hint: 'Block agents from calling user-defined custom tools.', + }), + disableSkills: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + hint: 'Block agents from loading skills.', + }), + disableInvitations: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + hint: 'Prevent inviting anyone to a workspace or to the organization.', + }), + disablePublicApi: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-public-api', + label: 'Public API', + category: 'Deployment', + hint: 'Revoke public API access. Calls to a deployed workflow are refused.', + }), + disablePublicFileSharing: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + hint: 'Revoke public file sharing. Members cannot create a share link.', + }), + allowedFileShareAuthTypes: allowlist(shareAuthType, 'capability', { + limited: + 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.', + empty: 'No public file-share authentication modes are allowed.', + }), + hideDeployApi: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-deploy-api', + label: 'API Deployment', + category: 'Deployment', + hint: 'Prevent deploying a workflow as an API endpoint.', + }), + hideDeployMcp: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-deploy-mcp', + label: 'MCP Server', + category: 'Deployment', + hint: 'Prevent exposing a workflow as an MCP server.', + }), + hideDeployChatbot: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-deploy-chatbot', + label: 'Chat Deployment', + category: 'Deployment', + hint: 'Prevent publishing a workflow as a chat.', + }), + allowedChatDeployAuthTypes: allowlist(shareAuthType, 'capability', { + limited: + 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', + empty: 'No chat deployment authentication modes are allowed.', + }), + /** + * Appended rather than grouped with the other restrictions: declaration order + * is the wire order, and moving an existing key would read as an unsaved + * change in every open group editor. + */ + disablePersonalApiKeys: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'disable-personal-api-keys', + label: 'Personal API Keys', + category: 'Credentials & Access', + hint: 'Prevent members from using a personal API key against this workspace.', + }), + disableLogExport: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-log-export', + label: 'Log Export', + category: 'Logs', + hint: 'Prevent downloading execution logs as a CSV.', + }), + hideCostInfo: booleanRestriction('capability', { + scope: 'workspace', + id: 'hide-cost-info', + label: 'Execution Cost', + category: 'Logs', + hint: 'Withhold execution cost. Logs and member exports omit cost and token spend; organization-level data drains, configurable by org admins only, are not projected.', + }), + disableKnowledgeBaseCreation: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-knowledge-base-creation', + label: 'Knowledge Base Creation', + category: 'Knowledge Base', + hint: 'Prevent creating knowledge bases, leaving existing ones queryable.', + }), + disableKnowledgeBaseFileUpload: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-knowledge-base-upload', + label: 'Knowledge Base Uploads', + category: 'Knowledge Base', + hint: 'Prevent uploading local documents, leaving sanctioned connectors as the only source.', + }), + allowedKnowledgeConnectors: allowlist(z.string(), 'capability', { + limited: 'Knowledge base connectors are limited to effectiveConfig.allowedKnowledgeConnectors.', + empty: 'No knowledge base connectors are allowed.', + }), + disableTableCreation: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-table-creation', + label: 'Table Creation', + category: 'Tables', + hint: 'Prevent creating tables, leaving existing ones usable.', + }), + disableTableExport: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-table-export', + label: 'Table Export', + category: 'Tables', + hint: 'Prevent downloading a whole table as CSV or JSON.', + }), + disableBulkFileDownload: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-bulk-file-download', + label: 'Bulk Download', + category: 'Files', + hint: 'Prevent downloading folders as an archive.', + }), + disablePersonalCredentials: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-personal-credentials', + label: 'Personal Credentials', + category: 'Credentials & Access', + hint: 'Prevent connecting personal credentials, leaving only workspace-shared ones.', + }), + disableWorkspaceCreation: booleanRestriction('capability', { + scope: 'organization', + id: 'disable-workspace-creation', + label: 'Workspace Creation', + category: 'Collaboration', + hint: "Prevent creating new workspaces, which no existing group would govern. Read from the organization's default group, because creating a workspace names none.", + }), + hideOrgMemberDirectory: booleanRestriction('capability', { + scope: 'organization', + id: 'hide-org-member-directory', + label: 'Member Directory', + category: 'Collaboration', + hint: "Withhold the member directory. Members cannot see the names or email addresses of other members. Read from the organization's default group, because the directory belongs to the organization and names no workspace.", + }), + disableCliAccess: booleanRestriction('capability', { + scope: 'workspace-or-organization', + id: 'disable-cli-access', + label: 'CLI Access', + category: 'Credentials & Access', + hint: "Prevent approving a CLI login, which mints a key for the public API. A login naming one of this group's workspaces is refused; an account-level login names none, so it is read from the organization's default group.", + }), + disableWebhookTriggers: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-webhook-triggers', + label: 'Webhook Triggers', + category: 'Deployment', + hint: 'Prevent making a workflow reachable from an inbound webhook.', + }), + disableToolAutoApproval: booleanRestriction('capability', { + scope: 'workspace', + id: 'disable-tool-auto-approval', + label: 'Tool Auto-Approval', + category: 'Tools', + hint: 'Prevent silencing a tool confirmation, so every call is confirmed again.', + }), +} satisfies Record + +export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS +export type PermissionGroupConfigKey = keyof PermissionGroupFields + +type DerivedPermissionGroupConfig = { + [K in PermissionGroupConfigKey]: z.infer +} + +/** + * The effective permission-group configuration. + * + * Declared as an interface over the derived shape so it keeps a stable name in + * errors and hovers rather than expanding to a mapped type at every use site. + */ +export interface PermissionGroupConfig extends DerivedPermissionGroupConfig {} + +/** The per-field properties that each project into one whole-config shape. */ +type FieldProjection = 'writeSchema' | 'readSchema' | 'tolerantSchema' | 'default' + +/** + * Collects one property from every field, preserving declaration order. + * + * `Object.fromEntries` widens to an index signature, so the result is asserted + * back to the mapped type. The assertion is safe by construction — the value at + * each key is that key's own entry read at a fixed property — but TypeScript + * cannot follow that correspondence through a union of field kinds, so it is + * stated once here rather than at each of the four shapes. + */ +function collectFieldProperty

( + property: P +): { [K in PermissionGroupConfigKey]: PermissionGroupFields[K][P] } { + const entries = Object.entries(PERMISSION_GROUP_FIELDS).map(([key, field]) => [ + key, + field[property], + ]) + return Object.fromEntries(entries) as { + [K in PermissionGroupConfigKey]: PermissionGroupFields[K][P] + } +} + +/** The PATCH shape: every key optional, so a partial config is a legal write. */ +export const permissionGroupWriteShape = collectFieldProperty('writeSchema') + +/** + * The config a create or update body may carry: every key optional, so a caller + * patches only what it means to change. The route merges the result over the + * group's stored config, which is what heals a row written before a key + * existed. + */ +export const permissionGroupConfigSchema = z.object(permissionGroupWriteShape) + +/** The wire shape: every key present, denylists defaulted. */ +export const permissionGroupReadShape = collectFieldProperty('readSchema') + +const tolerantConfigSchema = z.object(collectFieldProperty('tolerantSchema')) + +/** + * The unrestricted config: allowlists `null` (everything permitted), denylists + * empty, restrictions off. Assigned rather than asserted, so each field's + * declared default has to actually satisfy that key's config type. + */ +export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = + collectFieldProperty('default') + +/** + * Coerces an untrusted stored config into a complete, well-typed one. + * + * Never throws: every field is total, and the guard covers the shapes a `jsonb` + * column can hold that `z.object()` refuses. `Array.isArray` is load-bearing — + * `typeof [] === 'object'`, so an array-valued column would otherwise reach + * `.parse` and throw where it used to coerce to defaults. + */ +export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return DEFAULT_PERMISSION_GROUP_CONFIG + } + return tolerantConfigSchema.parse(config) +} + +/** + * Compile-time proof that deriving the config from the registry did not widen + * it. Every consumer reads these keys expecting a precise type, and a zod + * generic that degraded to `unknown` would be invisible at runtime — the values + * would still be right, so no test would fail, while every call site quietly + * lost its narrowing. Declared here rather than in a `.test.ts` because + * type-check excludes test files. + */ +type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false + +/** + * Fails to compile unless `T` is exactly `true`, which is what makes the aliases + * below load-bearing. They are deliberately unexported: the constraint is + * checked where the alias is declared, so an export bought nothing but the + * appearance of a consumer that never existed. Nothing may import them; they + * are unused on purpose, and deleting one deletes the proof. + */ +type Assert = T + +type AssertsAllowlistStaysPrecise = Assert< + Exact +> +type AssertsDenylistStaysPrecise = Assert> +type AssertsRestrictionStaysPrecise = Assert> +type AssertsAuthTypesStayPrecise = Assert< + Exact< + PermissionGroupConfig['allowedFileShareAuthTypes'], + (typeof FILE_SHARE_AUTH_TYPES)[number][] | null + > +> +type AssertsParserReturnsTheConfig = Assert< + Exact, PermissionGroupConfig> +> diff --git a/apps/sim/lib/permission-groups/integration-allowlist.test.ts b/apps/sim/lib/permission-groups/integration-allowlist.test.ts index 314bdf9c86e..fd183581ac3 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.test.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.test.ts @@ -1,16 +1,145 @@ +/** + * @vitest-environment node + * + * These helpers read the generated successor map rather than the block + * registry, so every id below is a real one and the assertions are about the + * repository's actual lifecycle facts: `slack` was replaced by `slack_v2`, + * `notion` by `notion_v2`, `file` by `file_v5`. + */ import { describe, expect, it } from 'vitest' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { + intersectAccessControlAllowlists, + intersectIntegrationAllowlists, + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' + +describe('resolveAccessControlBlockType', () => { + it('judges a superseded block as its successor', () => { + expect(resolveAccessControlBlockType('slack')).toBe('slack_v2') + }) + + /** The map is flattened, so a chain costs one lookup and never a partial hop. */ + it('answers the terminal version of a chain, not an intermediate one', () => { + expect(resolveAccessControlBlockType('file')).toBe('file_v5') + expect(resolveAccessControlBlockType('file_v3')).toBe('file_v5') + }) + + it('leaves a current block alone', () => { + expect(resolveAccessControlBlockType('slack_v2')).toBe('slack_v2') + }) + + /** + * A retired block with no successor keeps its own identity, which is the only + * id an admin can permit it under. + */ + it('keeps its own identity when nothing replaced it', () => { + expect(resolveAccessControlBlockType('thinking')).toBe('thinking') + }) + + it('accepts the dashed spelling the registry also normalizes', () => { + expect(resolveAccessControlBlockType('google-sheets')).toBe('google_sheets_v2') + }) + + /** + * `allowedIntegrations` is admin-supplied jsonb and `ALLOWED_INTEGRATIONS` is + * hand-written, so an arbitrary string reaches the successor map. An + * inherited key must stay an ordinary unresolved id rather than answering + * with `Object.prototype`'s function. + */ + it('leaves an object-prototype key alone', () => { + expect(resolveAccessControlBlockType('constructor')).toBe('constructor') + expect(resolveAccessControlBlockType('toString')).toBe('toString') + expect(resolveAccessControlBlockType('__proto__')).toBe('__proto__') + }) +}) + +describe('toAccessControlAllowlist', () => { + it('keeps an unrestricted allowlist unrestricted', () => { + expect(toAccessControlAllowlist(null)).toBeNull() + }) + + /** + * `ALLOWED_INTEGRATIONS` is written by hand against whatever ids its author + * knows, so a deployment that permitted `slack` must not refuse `slack_v2`. + */ + it('judges a policy entry naming a retired id as its successor', () => { + const allowlist = toAccessControlAllowlist(['Slack']) + + expect(allowlist?.has('slack_v2')).toBe(true) + expect(allowlist?.has('slack')).toBe(false) + }) + + it('denies everything for an empty allowlist', () => { + expect(toAccessControlAllowlist([])?.size).toBe(0) + }) + + /** + * A prototype key used to resolve to an inherited function and throw on + * `.toLowerCase()`, turning one configured string into a 500 on every + * enforcement path that read the group. + */ + it('indexes an object-prototype entry as an ordinary block type', () => { + const allowlist = toAccessControlAllowlist(['constructor', 'slack']) + + expect(allowlist?.has('constructor')).toBe(true) + expect(allowlist?.has('slack_v2')).toBe(true) + }) +}) + +describe('intersectAccessControlAllowlists', () => { + /** + * The two policies are written independently — `ALLOWED_INTEGRATIONS` by hand + * against whatever ids its author knew, the group through an editor that only + * offers current ones — so they routinely name the same integration by + * different vintages. Intersecting before resolving leaves those disjoint, + * which refuses an integration both policies allow. + */ + it('intersects a retired id against its successor', () => { + expect([...(intersectAccessControlAllowlists(['slack'], ['slack_v2']) ?? [])]).toEqual([ + 'slack_v2', + ]) + expect([...(intersectAccessControlAllowlists(['slack_v2'], ['slack']) ?? [])]).toEqual([ + 'slack_v2', + ]) + }) + + it('keeps either side null as unrestricted', () => { + expect([...(intersectAccessControlAllowlists(null, ['notion']) ?? [])]).toEqual(['notion_v2']) + expect([...(intersectAccessControlAllowlists(['notion'], null) ?? [])]).toEqual(['notion_v2']) + expect(intersectAccessControlAllowlists(null, null)).toBeNull() + }) + + it('keeps an empty policy denying everything', () => { + expect(intersectAccessControlAllowlists([], ['notion'])?.size).toBe(0) + }) + + it('drops an integration only one policy names', () => { + expect([...(intersectAccessControlAllowlists(['notion', 'gmail'], ['gmail']) ?? [])]).toEqual([ + 'gmail_v2', + ]) + }) +}) describe('intersectIntegrationAllowlists', () => { it('uses the configured list when the other policy is unrestricted', () => { - expect(intersectIntegrationAllowlists(null, ['Slack'])).toEqual(['slack']) - expect(intersectIntegrationAllowlists(['Notion'], null)).toEqual(['notion']) + expect(intersectIntegrationAllowlists(null, ['Slack'])).toEqual(['slack_v2']) + expect(intersectIntegrationAllowlists(['Notion'], null)).toEqual(['notion_v2']) expect(intersectIntegrationAllowlists(null, null)).toBeNull() }) + /** + * The list form must canonicalize identically to the set form, or the config + * the catalogs carry and the gate the block path applies would disagree about + * the same two policies. + */ + it('keeps a mixed-vintage integration both policies allow', () => { + expect(intersectIntegrationAllowlists(['slack'], ['slack_v2'])).toEqual(['slack_v2']) + }) + it('keeps only integrations allowed by both policies', () => { expect(intersectIntegrationAllowlists(['Slack', 'Notion'], ['notion', 'gmail'])).toEqual([ - 'notion', + 'notion_v2', ]) }) diff --git a/apps/sim/lib/permission-groups/integration-allowlist.ts b/apps/sim/lib/permission-groups/integration-allowlist.ts index dd38b4ce07d..8f1789c48ef 100644 --- a/apps/sim/lib/permission-groups/integration-allowlist.ts +++ b/apps/sim/lib/permission-groups/integration-allowlist.ts @@ -1,29 +1,100 @@ +import { BLOCK_ACCESS_SUCCESSORS } from '@/lib/permission-groups/block-successors.generated' + /** - * Intersects integration allowlists from independent policy layers. - * `null` means unrestricted, while an empty array denies every integration. + * The block type an allowlist decision about `blockType` is really made against. + * + * A superseded version resolves to the successor its `sunset.replacedBy` names, + * transitively, so allowing or denying an integration covers every version of + * it. Without this an admin would have to know each retired id and deny it + * individually — and could not, since the editor only offers the current ones. + * + * A retired block with no successor keeps its own identity and appears in the + * editor under it, which is the only way an admin can decide about it at all. + * + * Reads the generated projection of the registry rather than the registry + * itself: this module sits under the authorization funnel, which + * `scripts/check-application-graph.ts` forbids from importing `blocks/`. + * `check:block-successors` fails the build when the projection drifts. */ -export function intersectIntegrationAllowlists( - first: readonly string[] | null, - second: readonly string[] | null -): string[] | null { - const normalizedFirst = first?.map((integration) => integration.toLowerCase()) ?? null - const normalizedSecond = second?.map((integration) => integration.toLowerCase()) ?? null - - if (normalizedFirst === null) return normalizedSecond - if (normalizedSecond === null) return normalizedFirst +export function resolveAccessControlBlockType(blockType: string): string { + return ownSuccessor(blockType) ?? ownSuccessor(blockType.replace(/-/g, '_')) ?? blockType +} - const secondSet = new Set(normalizedSecond) - return normalizedFirst.filter((integration) => secondSet.has(integration)) +/** + * Reads the successor map by its own keys only. + * + * The generated map is an object literal with an intact prototype, so a bare + * bracket lookup answers `constructor`, `toString`, `valueOf` and friends with + * an inherited function. The ids reaching here come from admin-supplied jsonb + * (`allowedIntegrations`) and from `ALLOWED_INTEGRATIONS`, so a group naming + * `constructor` made {@link toAccessControlAllowlist} call `.toLowerCase()` on + * a function and throw — an unclassified 500 on every enforcement path that + * read that group. `getBlock` guards the registry the same way for the same + * reason. + */ +function ownSuccessor(blockType: string): string | undefined { + return Object.hasOwn(BLOCK_ACCESS_SUCCESSORS, blockType) + ? BLOCK_ACCESS_SUCCESSORS[blockType] + : undefined } /** - * The lowercased block types an allowlist permits, indexed for membership tests. - * `null` stays `null` — unrestricted, not "nothing allowed". + * The allowlist, indexed for membership tests against the block type an + * allowlist decision is made against. `null` stays `null` — unrestricted, not + * "nothing allowed". + * + * Both sides have to be normalized or they compare different vocabularies. A + * policy list can name a retired id: `ALLOWED_INTEGRATIONS` is written by hand + * against whatever ids the author knows, so `ALLOWED_INTEGRATIONS=slack` is the + * expected way to permit Slack. The checked type is always successor-resolved, + * so without normalizing the policy the deployment that permitted `slack` would + * refuse every `slack_v2` block in it. */ -export function toAllowedIntegrationTypes( +export function toAccessControlAllowlist( allowedIntegrations: readonly string[] | null ): ReadonlySet | null { return allowedIntegrations - ? new Set(allowedIntegrations.map((integration) => integration.toLowerCase())) + ? new Set( + allowedIntegrations.map((integration) => + resolveAccessControlBlockType(integration.toLowerCase()).toLowerCase() + ) + ) : null } + +/** + * Intersects two independent integration policies in the *resolved* vocabulary. + * + * Each side is canonicalized before the intersection, not after. A policy list + * can name a retired id while the other names its successor — + * `ALLOWED_INTEGRATIONS=slack` against a group naming `slack_v2` — and folding + * only case leaves those two ids disjoint, intersecting to nothing and hiding + * an integration both policies allow. `null` stays unrestricted on either side. + */ +export function intersectAccessControlAllowlists( + first: readonly string[] | null, + second: readonly string[] | null +): ReadonlySet | null { + const resolvedFirst = toAccessControlAllowlist(first) + const resolvedSecond = toAccessControlAllowlist(second) + if (resolvedFirst === null) return resolvedSecond + if (resolvedSecond === null) return resolvedFirst + return new Set([...resolvedFirst].filter((type) => resolvedSecond.has(type))) +} + +/** + * Intersects integration allowlists from independent policy layers, as a list. + * `null` means unrestricted, while an empty array denies every integration. + * + * The list form of {@link intersectAccessControlAllowlists}, for the callers + * that carry the effective policy on a `PermissionGroupConfig`. It canonicalizes + * for the same reason and the result is in the same resolved vocabulary, so + * callers must successor-resolve the type they check against it. + */ +export function intersectIntegrationAllowlists( + first: readonly string[] | null, + second: readonly string[] | null +): string[] | null { + const intersection = intersectAccessControlAllowlists(first, second) + return intersection === null ? null : [...intersection] +} diff --git a/apps/sim/lib/permission-groups/model-access.ts b/apps/sim/lib/permission-groups/model-access.ts index b677f96f15b..b78f98fd4e2 100644 --- a/apps/sim/lib/permission-groups/model-access.ts +++ b/apps/sim/lib/permission-groups/model-access.ts @@ -1,4 +1,4 @@ -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { findProviderFromModel } from '@/providers/utils' /** Decides whether the caller's permission group allows a concrete model id. */ diff --git a/apps/sim/lib/permission-groups/queries.ts b/apps/sim/lib/permission-groups/queries.ts index 06bc3e96eb9..f61ef57e394 100644 --- a/apps/sim/lib/permission-groups/queries.ts +++ b/apps/sim/lib/permission-groups/queries.ts @@ -6,8 +6,11 @@ import { workspace, } from '@sim/db/schema' import { asc, count, desc, eq, inArray } from 'drizzle-orm' -import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { + type ActivePermissionGroupRestriction, + getActivePermissionGroupRestrictions, +} from '@/lib/permission-groups/features' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' /** A workspace reference (id + display name). */ export interface OrgWorkspaceRef { @@ -38,7 +41,7 @@ export interface PermissionGroupRosterEntry { isDefault: boolean memberCount: number workspaces: OrgWorkspaceRef[] - activeRestrictions: Array<{ key: string; description: string }> + activeRestrictions: ActivePermissionGroupRestriction[] } /** diff --git a/apps/sim/lib/permission-groups/request-scope.server.ts b/apps/sim/lib/permission-groups/request-scope.server.ts new file mode 100644 index 00000000000..c972449dd5f --- /dev/null +++ b/apps/sim/lib/permission-groups/request-scope.server.ts @@ -0,0 +1,67 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' + +/** + * A resolution key, `userId:workspaceId`. `organizationId` is deliberately not + * part of it — see `resolvePermissionGroupConfig` for why. + * + * Named for the scope rather than the config, so it cannot be mistaken for + * `PermissionGroupConfigKey` in `fields.ts`, which is a config *field* name. + */ +export type PermissionGroupScopeKey = `${string}:${string}` + +/** + * The per-scope memo: a resolution key to the in-flight resolution for it. + * + * Holds the promise rather than the resolved value so N concurrent + * authorizations share one query instead of racing to start several. + */ +export type PermissionGroupConfigStore = Map< + PermissionGroupScopeKey, + Promise +> + +interface Storage { + getStore(): T | undefined + run(store: T, fn: () => R): R +} + +/** + * AsyncLocalStorage is only available in Node.js. Parts of this graph reach the + * Edge runtime, so fall back to a no-op that simply runs the callback — the + * resolver then degrades to its React `cache()` memo, which is slower but never + * wrong. + */ +let storage: Storage + +if (typeof globalThis.process !== 'undefined' && globalThis.process.versions?.node) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AsyncLocalStorage } = require('node:async_hooks') as typeof import('node:async_hooks') + storage = new AsyncLocalStorage() +} else { + storage = { + getStore: () => undefined, + run: (_store: PermissionGroupConfigStore, fn: () => R) => fn(), + } +} + +/** + * Establishes one permission-group memo for everything a request or job does. + * + * A request that authorizes several operations — a bulk mutation, a route that + * runs two use cases — would otherwise resolve the same group once per + * operation. Nesting this inside the request context means every route handler + * gets it without threading a parameter through every operation. + * + * This module is deliberately free of runtime imports. `withRouteHandler` wraps + * every route in the app, so anything reachable from here is loaded by every + * route and every route test; the resolver that fills the store lives in + * `config-scope.server.ts`, which only the gate call sites import. + */ +export function withPermissionGroupScope(run: () => R): R { + return storage.run(new Map(), run) +} + +/** The memo for the current scope, or undefined when running outside one. */ +export function getPermissionGroupConfigStore(): PermissionGroupConfigStore | undefined { + return storage.getStore() +} diff --git a/apps/sim/lib/permission-groups/resolve.server.test.ts b/apps/sim/lib/permission-groups/resolve.server.test.ts new file mode 100644 index 00000000000..12e4176cb14 --- /dev/null +++ b/apps/sim/lib/permission-groups/resolve.server.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsOrganizationOnEnterprisePlan, mockGetWorkspaceWithOwner } = vi.hoisted(() => ({ + mockIsOrganizationOnEnterprisePlan: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: mockGetWorkspaceWithOwner, +})) + +import { + getUserPermissionConfig, + getUserPermissionConfigForOrganization, + resolveVerifiedUserAccessControlContext, +} from '@/lib/permission-groups/resolve.server' + +const ORGANIZATION_ID = 'org-1' +const USER_ID = 'user-1' +const WORKSPACE_ID = 'workspace-1' + +/** + * Stands in for the entitlement resolver's two regimes: it answers `false` for + * the lenient default — which is exactly what a swallowed billing outage looks + * like — and rejects only for a caller that asked to throw. A resolution path + * that drops the `'throw'` argument therefore reads the outage as "not + * entitled" and these tests go red. + */ +function entitlementReadFails(): void { + mockIsOrganizationOnEnterprisePlan.mockImplementation( + async (_organizationId: string, onError?: string) => { + if (onError === 'throw') throw new Error('billing database unavailable') + return false + } + ) +} + +describe('permission-group resolution under a failed entitlement read', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isHosted: true, isAccessControlEnabled: true }) + mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: ORGANIZATION_ID }) + }) + + afterAll(resetEnvFlagsMock) + + /** + * `config: null` is not a stricter answer — it means every capability allowed + * and every allowlist off. Resolving it from a billing-read failure would + * turn the whole regime off for the request, so the failure has to surface. + */ + it('rejects rather than resolving an unrestricted context for a verified workspace', async () => { + entitlementReadFails() + + await expect( + resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID) + ).rejects.toThrow('billing database unavailable') + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(ORGANIZATION_ID, 'throw') + }) + + it('rejects rather than resolving a null config from the workspace-lookup path', async () => { + entitlementReadFails() + + await expect(getUserPermissionConfig(USER_ID, WORKSPACE_ID)).rejects.toThrow( + 'billing database unavailable' + ) + }) + + it('rejects rather than resolving a null config for the organization-addressed path', async () => { + entitlementReadFails() + + await expect(getUserPermissionConfigForOrganization(ORGANIZATION_ID)).rejects.toThrow( + 'billing database unavailable' + ) + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith(ORGANIZATION_ID, 'throw') + }) + + /** + * The fail-closed policy must not turn a genuine plan lapse into an error: + * an organization that simply is not on the plan still resolves to an + * inactive context. + */ + it('still resolves an inactive context when the organization is genuinely unentitled', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + + await expect( + resolveVerifiedUserAccessControlContext(USER_ID, WORKSPACE_ID, ORGANIZATION_ID) + ).resolves.toEqual({ + organizationId: ORGANIZATION_ID, + entitled: false, + permissionGroup: null, + config: null, + }) + await expect(getUserPermissionConfigForOrganization(ORGANIZATION_ID)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts new file mode 100644 index 00000000000..ec5beb74d0e --- /dev/null +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -0,0 +1,296 @@ +/** + * Resolves the permission group governing a user, and its config. + * + * Lives here rather than in `ee/access-control` because the authorization + * funnel reads it: `capability-assertions.ts` and `config-scope.server.ts` sit + * under `@/lib/core/application`, which ~24 domain `operations.ts` modules + * import. `ee/access-control/utils/permission-check.ts` also holds the model, + * block and tool gates, and those reach the provider registry, the block + * registry and the billing barrel — a graph no authorization decision should + * load. Splitting resolution out is what keeps the funnel light; + * `scripts/check-application-graph.ts` fails the build if the edge returns. + * + * `permission-check.ts` re-exports these, so the surfaces that read every + * validator from one module are unaffected. + */ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' +import { and, asc, eq, sql } from 'drizzle-orm' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { + getAllowedIntegrationsFromEnv, + isAccessControlEnabled, + isHosted, +} from '@/lib/core/config/env-flags' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/fields' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' + +/** + * Merges the env allowlist into a permission config. + * + * Returns null only when neither layer restricts anything. Otherwise the group's + * own allowlist is intersected with the env one by + * {@link intersectIntegrationAllowlists}, which canonicalizes both sides — case + * *and* successor — before intersecting. Both matter here: a stored config + * reaches this function straight off the wire, where the contract permits any + * casing, and the two layers are written independently, so one can name a + * retired id (`ALLOWED_INTEGRATIONS=slack`) while the other names its successor + * (`slack_v2`). Intersecting those textually yields the empty allowlist, which + * refuses an integration both layers allow. The result is therefore in the + * resolved vocabulary, and callers must judge a block type through + * `resolveAccessControlBlockType` before testing membership. + */ +export function mergeEnvAllowlist( + config: PermissionGroupConfig | null +): PermissionGroupConfig | null { + const envAllowlist = getAllowedIntegrationsFromEnv() + if (config === null && envAllowlist === null) return null + + const base = config ?? DEFAULT_PERMISSION_GROUP_CONFIG + return { + ...base, + allowedIntegrations: intersectIntegrationAllowlists(base.allowedIntegrations, envAllowlist), + } +} + +/** + * 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 + resolution: 'explicit-member' | 'all-members' | 'default' + config: PermissionGroupConfig +} + +export interface UserAccessControlContext { + organizationId: string | null + entitled: boolean + permissionGroup: { + id: string + name: string + resolution: ResolvedPermissionGroup['resolution'] + } | null + config: PermissionGroupConfig | null +} + +function inactiveUserAccessControlContext(organizationId: string | null): UserAccessControlContext { + return { + organizationId, + entitled: false, + permissionGroup: null, + config: mergeEnvAllowlist(null), + } +} + +/** The organization's single default group (`isDefault`), or `null`. */ +async function resolveDefaultGroup( + organizationId: string +): Promise { + 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, + resolution: 'default', + 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 { + const rows = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + config: permissionGroup.config, + isMember: sql`exists ( + select 1 from ${permissionGroupMember} + where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} + and ${permissionGroupMember.userId} = ${userId} + )`, + hasMembers: sql`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 explicitMemberGroup = rows.find((row) => row.isMember) + const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers) + + if (winner) { + return { + permissionGroupId: winner.id, + groupName: winner.name, + resolution: explicitMemberGroup ? 'explicit-member' : 'all-members', + 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. + */ +async function resolveUserAccessControlContextForOrganization( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + if (!organizationId) return inactiveUserAccessControlContext(null) + + /** + * `'throw'` because an unentitled organization resolves to `config: null`, + * and `null` is not a smaller permission set — it is *no* permission group at + * all: every capability allowed, every allowlist off. Under the lenient + * default a single subscription-read failure would be indistinguishable from + * a genuine plan lapse and would turn the whole regime off for the request. + * Throwing surfaces the outage as an error instead. + */ + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw') + if (!isEnterprise) { + return inactiveUserAccessControlContext(organizationId) + } + + const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId) + return { + organizationId, + entitled: true, + permissionGroup: resolved + ? { + id: resolved.permissionGroupId, + name: resolved.groupName, + resolution: resolved.resolution, + } + : null, + config: mergeEnvAllowlist(resolved?.config ?? null), + } +} + +/** + * Resolves Access Control from an organization ID obtained from an already + * access-checked workspace. This function does not independently authorize the + * user for the workspace; callers must establish that boundary first. + */ +export async function resolveVerifiedUserAccessControlContext( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) + } + return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) +} + +/** + * The unverified counterpart of {@link resolveVerifiedUserAccessControlContext}: + * it loads the workspace itself to learn the owning organization. + * + * For the callers that have not already access-checked the workspace — a raw + * route, typically. Everything else holds the organization id already and + * should pass it, rather than paying for a second lookup of a value it has. + */ +export async function getUserPermissionConfig( + userId: string, + workspaceId: string +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return mergeEnvAllowlist(null) + } + + const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + const context = await resolveUserAccessControlContextForOrganization( + userId, + workspaceId, + workspace?.organizationId ?? null + ) + return context.config +} + +/** + * 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 { + if (!isHosted && !isAccessControlEnabled) { + return mergeEnvAllowlist(null) + } + + /** `'throw'` for the same reason as in {@link resolveUserAccessControlContextForOrganization}. */ + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw') + if (!isEnterprise) { + return mergeEnvAllowlist(null) + } + + const resolved = await resolveDefaultGroup(organizationId) + return mergeEnvAllowlist(resolved?.config ?? null) +} diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts deleted file mode 100644 index 15bdb9773f9..00000000000 --- a/apps/sim/lib/permission-groups/types.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { z } from 'zod' -import type { ShareAuthType } from '@/lib/api/contracts/public-shares' - -/** - * Auth modes a public file share or a chat deployment can use; admins may - * restrict the allowed subset. The two surfaces share the same four modes. - */ -export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as const - -export const PERMISSION_GROUP_CONSTRAINTS = { - organizationName: 'permission_group_organization_name_unique', - organizationDefault: 'permission_group_organization_default_unique', -} as const - -export const PERMISSION_GROUP_MEMBER_CONSTRAINTS = { - groupUser: 'permission_group_member_group_user_unique', -} as const - -export const permissionGroupConfigSchema = z.object({ - allowedIntegrations: z.array(z.string()).nullable().optional(), - allowedModelProviders: z.array(z.string()).nullable().optional(), - deniedModels: z.array(z.string()).optional(), - deniedTools: z.array(z.string()).optional(), - hideTraceSpans: z.boolean().optional(), - hideKnowledgeBaseTab: z.boolean().optional(), - hideTablesTab: z.boolean().optional(), - hideCopilot: z.boolean().optional(), - hideIntegrationsTab: z.boolean().optional(), - hideSecretsTab: z.boolean().optional(), - hideApiKeysTab: z.boolean().optional(), - hideInboxTab: z.boolean().optional(), - hideFilesTab: z.boolean().optional(), - disableMcpTools: z.boolean().optional(), - disableCustomTools: z.boolean().optional(), - disableSkills: z.boolean().optional(), - disableInvitations: z.boolean().optional(), - disablePublicApi: z.boolean().optional(), - disablePublicFileSharing: z.boolean().optional(), - allowedFileShareAuthTypes: z.array(z.enum(FILE_SHARE_AUTH_TYPES)).nullable().optional(), - hideDeployApi: z.boolean().optional(), - hideDeployMcp: z.boolean().optional(), - hideDeployChatbot: z.boolean().optional(), - allowedChatDeployAuthTypes: z.array(z.enum(FILE_SHARE_AUTH_TYPES)).nullable().optional(), -}) - -export interface PermissionGroupConfig { - allowedIntegrations: string[] | null - allowedModelProviders: string[] | null - /** - * Fully-qualified model IDs (e.g. `ollama/llama3`, `gpt-4o`) blocked for this - * group, checked after `allowedModelProviders`. Empty means nothing is blocked. - */ - deniedModels: string[] - /** - * Snake_case tool IDs (e.g. `slack_canvas`) blocked for this group, checked - * after the block-level `allowedIntegrations` gate. Lets an admin allow an - * integration but deny specific operations within it. Empty means nothing is - * blocked. - */ - deniedTools: string[] - hideTraceSpans: boolean - hideKnowledgeBaseTab: boolean - hideTablesTab: boolean - hideCopilot: boolean - hideIntegrationsTab: boolean - hideSecretsTab: boolean - hideApiKeysTab: boolean - hideInboxTab: boolean - hideFilesTab: boolean - disableMcpTools: boolean - disableCustomTools: boolean - disableSkills: boolean - disableInvitations: boolean - disablePublicApi: boolean - disablePublicFileSharing: boolean - /** Allowed public-file-share auth modes; `null` means all are allowed. */ - allowedFileShareAuthTypes: ShareAuthType[] | null - hideDeployApi: boolean - hideDeployMcp: boolean - hideDeployChatbot: boolean - /** Allowed chat-deployment auth modes; `null` means all are allowed. */ - allowedChatDeployAuthTypes: ShareAuthType[] | null -} - -export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { - allowedIntegrations: null, - allowedModelProviders: null, - deniedModels: [], - deniedTools: [], - hideTraceSpans: false, - hideKnowledgeBaseTab: false, - hideTablesTab: false, - hideCopilot: false, - hideIntegrationsTab: false, - hideSecretsTab: false, - hideApiKeysTab: false, - hideInboxTab: false, - hideFilesTab: false, - disableMcpTools: false, - disableCustomTools: false, - disableSkills: false, - disableInvitations: false, - disablePublicApi: false, - disablePublicFileSharing: false, - allowedFileShareAuthTypes: null, - hideDeployApi: false, - hideDeployMcp: false, - hideDeployChatbot: false, - allowedChatDeployAuthTypes: null, -} - -export function parsePermissionGroupConfig(config: unknown): PermissionGroupConfig { - if (!config || typeof config !== 'object') { - return DEFAULT_PERMISSION_GROUP_CONFIG - } - - const c = config as Record - - return { - allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null, - allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null, - deniedModels: Array.isArray(c.deniedModels) - ? c.deniedModels.filter((m): m is string => typeof m === 'string') - : [], - deniedTools: Array.isArray(c.deniedTools) - ? c.deniedTools.filter((t): t is string => typeof t === 'string') - : [], - hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false, - hideKnowledgeBaseTab: - typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false, - hideTablesTab: typeof c.hideTablesTab === 'boolean' ? c.hideTablesTab : false, - hideCopilot: typeof c.hideCopilot === 'boolean' ? c.hideCopilot : false, - hideIntegrationsTab: typeof c.hideIntegrationsTab === 'boolean' ? c.hideIntegrationsTab : false, - hideSecretsTab: typeof c.hideSecretsTab === 'boolean' ? c.hideSecretsTab : false, - hideApiKeysTab: typeof c.hideApiKeysTab === 'boolean' ? c.hideApiKeysTab : false, - hideInboxTab: typeof c.hideInboxTab === 'boolean' ? c.hideInboxTab : false, - hideFilesTab: typeof c.hideFilesTab === 'boolean' ? c.hideFilesTab : false, - disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false, - disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false, - disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false, - disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false, - disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false, - disablePublicFileSharing: - typeof c.disablePublicFileSharing === 'boolean' ? c.disablePublicFileSharing : false, - allowedFileShareAuthTypes: Array.isArray(c.allowedFileShareAuthTypes) - ? c.allowedFileShareAuthTypes.filter((t): t is ShareAuthType => - (FILE_SHARE_AUTH_TYPES as readonly string[]).includes(t as string) - ) - : null, - hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false, - hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false, - hideDeployChatbot: typeof c.hideDeployChatbot === 'boolean' ? c.hideDeployChatbot : false, - allowedChatDeployAuthTypes: Array.isArray(c.allowedChatDeployAuthTypes) - ? c.allowedChatDeployAuthTypes.filter((t): t is ShareAuthType => - (FILE_SHARE_AUTH_TYPES as readonly string[]).includes(t as string) - ) - : null, - } -} diff --git a/apps/sim/lib/permission-groups/user-scope.server.ts b/apps/sim/lib/permission-groups/user-scope.server.ts new file mode 100644 index 00000000000..e160ec1076a --- /dev/null +++ b/apps/sim/lib/permission-groups/user-scope.server.ts @@ -0,0 +1,37 @@ +import { getUserOrganization } from '@/lib/billing/organizations/membership' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { + isOrganizationCapabilityWithheld, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' + +/** + * Whether the group governing `userId` withholds `capability`, for an action + * that may or may not name a workspace. + * + * A workspace-scoped action is governed by the group targeting that workspace. + * A user-global one — a personal API key, a CLI login with no workspace — falls + * back to the organization's default group rather than going ungoverned, which + * would leave the narrower scope as the unguarded one. + * + * Shared so that fallback cannot drift between the surfaces that mint the same + * credential: `/api/users/me/api-keys`, `/api/cli/auth/approve`. It restates no + * capability of its own — each caller names the one it enforces, and carries + * the `permission-group-enforced:` annotation for it. + * + * Not in `capability-assertions.ts` on purpose: reading the caller's + * organization membership reaches the billing graph, and that module is a + * guarded root of `check:application-graph` precisely so the authorization + * funnel never loads it. + */ +export async function isCapabilityWithheldForUser( + userId: string, + capability: StaticPermissionGroupCapability, + workspaceId?: string +): Promise { + if (workspaceId) return isWorkspaceCapabilityWithheld(userId, workspaceId, capability) + + const membership = await getUserOrganization(userId) + if (!membership?.organizationId) return false + return isOrganizationCapabilityWithheld(membership.organizationId, capability) +} diff --git a/apps/sim/lib/platform-context/application/operations.ts b/apps/sim/lib/platform-context/application/operations.ts index 36c650a064f..80658e1acc9 100644 --- a/apps/sim/lib/platform-context/application/operations.ts +++ b/apps/sim/lib/platform-context/application/operations.ts @@ -5,17 +5,28 @@ const LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +/** + * What Sim reads about itself before it can answer at all: the workspace's plan, + * its seat and usage state, and whether the organization is on the enterprise + * tier. No permission-group key names them, and a member whose group withheld + * them would get an agent that cannot tell them why anything is unavailable — + * withholding the description of a restriction is not the same as applying one. + */ export const platformContextOperations = { + // permission-group-exempt: the plan and usage state every answer is framed against; withholding it blanks the agent rather than restricting it readAccountBilling: defineWorkspaceOperation({ id: 'platform_context.account_billing.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, }), + // permission-group-exempt: reports which enterprise features the organization has, the frame the restrictions themselves are described in readEnterpriseContext: defineWorkspaceOperation({ id: 'platform_context.enterprise.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts index 64319782d72..44976130c67 100644 --- a/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts +++ b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts @@ -34,11 +34,11 @@ vi.mock('@/lib/workspaces/host-context', () => ({ getWorkspaceHostContextForViewer: mocks.getWorkspaceHostContextForViewer, })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, })) -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' diff --git a/apps/sim/lib/platform-context/application/read-enterprise-context.ts b/apps/sim/lib/platform-context/application/read-enterprise-context.ts index dab02b17e5d..cbca10f2ca6 100644 --- a/apps/sim/lib/platform-context/application/read-enterprise-context.ts +++ b/apps/sim/lib/platform-context/application/read-enterprise-context.ts @@ -2,6 +2,7 @@ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' @@ -46,9 +47,9 @@ export const readEnterpriseContext = defineAuthorizedWorkspaceUseCase({ const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') const allDeploymentSurfacesHidden = - accessControl.config?.hideDeployApi === true && - accessControl.config.hideDeployMcp === true && - accessControl.config.hideDeployChatbot === true + capabilityDeniedBy('deploy.api', accessControl.config) && + capabilityDeniedBy('deploy.mcp', accessControl.config) && + capabilityDeniedBy('deploy.chat', accessControl.config) return { workspace: { diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts index 00f408fd84e..b629a1014dd 100644 --- a/apps/sim/lib/secrets/application/operations.ts +++ b/apps/sim/lib/secrets/application/operations.ts @@ -7,18 +7,21 @@ export const secretOperations = { id: 'secrets.list', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), set: defineWorkspaceOperation({ id: 'secrets.set', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), delete: defineWorkspaceOperation({ id: 'secrets.delete', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), /** @@ -29,6 +32,7 @@ export const secretOperations = { id: 'secrets.usage', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), /** @@ -40,6 +44,7 @@ export const secretOperations = { id: 'secrets.references', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'secrets.manage', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), } as const diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index 2259c03021c..992e8732c2e 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -61,7 +62,13 @@ vi.mock('@/lib/selectors/server/sanitize', () => ({ sanitizeSelectorResult: mocks.sanitize, })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +const mockResolvePermissionGroupConfig = + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + import { selectorScopeSchema } from '@/lib/api/contracts/selectors/execute' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { executeSelector } from '@/lib/selectors/application/execute-selector' import { getSelectorManifestEntry } from '@/lib/selectors/manifest' import { @@ -69,6 +76,7 @@ import { SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } const scope = { kind: 'workspace' as const, workspaceId: 'workspace-1' } @@ -130,6 +138,7 @@ describe('executeSelector', () => { mocks.events.push('sanitization') return result }) + mockResolvePermissionGroupConfig.mockResolvedValue(null) }) it('authorizes canonical scope before references, credentials, and provider execution', async () => { @@ -148,6 +157,209 @@ describe('executeSelector', () => { ]) }) + /** + * The picker is a use of the integration, not a neutral list: it reaches the + * provider's API with the caller's credential. The authorization funnel never + * sees which integration a selector key stands for, so the allowlist decision + * is asserted from the use case instead. + */ + it('refuses a selector whose integration the permission group excludes', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + + expect(mocks.events).toEqual([ + 'canonical-scope', + 'workspace-authorization', + 'reference-resolution', + 'credential-authorization', + ]) + }) + + it('executes a selector whose integration the permission group names', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['gmail_v2'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** + * `serviceIds` names which credentials a selector accepts, not which resource + * it reads. `google.drive` accepts a Drive, Docs, Sheets or Forms connection + * because all four carry Drive scope, but it only ever calls the Drive API. + * Judging the accepted set let a group that permits `google_sheets_v2` and + * excludes `google_drive` read Drive through it. + */ + it('refuses a multi-service selector whose own resource is excluded', async () => { + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1', providerId: 'google-sheets' } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-docs', 'google-sheets', 'google-forms'], + resourceServiceId: 'google-drive', + }, + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['google_sheets_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + /** + * The same selector, with its own resource permitted. The credential is a + * Sheets one and `google_sheets_v2` is *not* allowed, which is deliberate: + * the credential narrows nothing, because the API the selector reaches is the + * only thing the allowlist has an opinion about. + */ + it('allows a multi-service selector whose own resource is permitted', async () => { + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1', providerId: 'google-sheets' } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-docs', 'google-sheets', 'google-forms'], + resourceServiceId: 'google-drive', + }, + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['google_drive'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** The SharePoint/Excel pair reads SharePoint, whatever credential opened it. */ + it('refuses a sharepoint selector when only the excel half is allowed', async () => { + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1', providerId: 'microsoft-excel' } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['sharepoint', 'microsoft-excel'], + resourceServiceId: 'sharepoint', + }, + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['microsoft_excel_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + /** + * The hole this closes: a selector authenticated from raw context fields + * (CloudWatch's AWS keys, IMAP's host and password) carries no credential + * policy, so the gate used to resolve it to an empty service list and return + * without checking — reaching the third party with the caller's keys under an + * allowlist that never named it. + */ + it('refuses a raw-context selector whose declared integration is excluded', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + integrationBlockTypes: ['cloudwatch'], + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + it('executes a raw-context selector whose declared integration is permitted', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + integrationBlockTypes: ['cloudwatch'], + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['cloudwatch'], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** + * An API-key integration owns no OAuth catalog entry, so its service id maps + * to no block type. The declaration is what gives the allowlist something to + * judge, and it must win over the catalog. + */ + it('refuses an api-key selector whose declared integration is excluded', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['snowflake'] }, + integrationBlockTypes: ['snowflake'], + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack_v2'], + }) + + await expect(execute()).rejects.toBeInstanceOf(IntegrationNotAllowedError) + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + /** + * A selector with no integration identity is not an integration: an internal + * selector declares no credential policy at all, so an allowlist that names + * nothing still leaves workspace files and knowledge bases pickable. + */ + it('passes through a selector that carries no credential policy', async () => { + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + execute: mocks.executeAttachment, + }) + mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: [], + }) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + + /** No group governs the caller, so nothing narrows the allowlist. */ + it('executes when no permission group governs the caller', async () => { + mockResolvePermissionGroupConfig.mockResolvedValue(null) + + await expect(execute()).resolves.toMatchObject({ kind: 'list' }) + expect(mocks.executeAttachment).toHaveBeenCalledTimes(1) + }) + it('prepares non-fixed destinations after credential authorization and before provider execution', async () => { const prepare = vi.fn(async () => { mocks.events.push('destination-preparation') diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 82b25dacbbb..75a109feb0a 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -18,12 +18,17 @@ import { SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' +import { + assertSelectorIntegrationAllowed, + selectorIntegrationBlockTypes, +} from '@/lib/selectors/server/integration-access' import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' import { resolveSelectorReferences } from '@/lib/selectors/server/references' import { getServerSelectorAttachment } from '@/lib/selectors/server/registry' import { sanitizeSelectorResult } from '@/lib/selectors/server/sanitize' import type { ResolvedSelectorReference } from '@/lib/selectors/server/types' import type { SelectorExecutionResult, SelectorRequest } from '@/lib/selectors/types' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' const logger = createLogger('ExecuteSelector') @@ -157,6 +162,26 @@ async function executeAuthorizedSelector(args: { }) : undefined + /** + * Enforces the permission group's `allowedIntegrations` decision, which the + * funnel cannot apply because it never sees which integration a selector + * reaches. Not a `permission-group-enforced:` annotation because that names + * a capability, and this key's enforcement mechanism is `executor`, not + * `capability`. + * + * Judged against the selector's own resource — the API it calls — not the + * set of credentials it accepts, and not the bound credential's provider. + * A selector the OAuth catalog cannot identify (raw-context credentials, an + * API-key integration) declares its block types instead of resolving to + * none and passing untested. Placed before the provider call so a denied + * integration is never reached. + */ + await assertSelectorIntegrationAllowed({ + principal: args.principal, + workspaceId: args.context.workspaceId, + blockTypes: selectorIntegrationBlockTypes(attachment), + }) + const credentialAccess = credential?.access let credentialUseRecorded = false const recordCredentialUse = @@ -240,7 +265,10 @@ async function executeAuthorizedSelector(args: { if ( error instanceof SelectorContextUnavailableError || error instanceof SelectorConnectionUnavailableError || - error instanceof SelectorOptionsUnavailableError + error instanceof SelectorOptionsUnavailableError || + // A refusal, not a provider failure: it reaches the caller as its own 403 + // rather than being folded into "Options unavailable". + error instanceof IntegrationNotAllowedError ) { throw error } diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index 72f00f08a3b..2e91b477da8 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -1,10 +1,12 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const selectorOperations = { + // permission-group-exempt: no static capability names selector browsing — credential access is authorized per credential, and per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a selector reaches. That decision is enforced from the use case by assertSelectorIntegrationAllowed, against the selector's own resource, ahead of the provider call. execute: defineWorkspaceOperation({ id: 'selectors.execute', minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'none', }), } as const diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index 4263ab3734d..1595c5c09b5 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -59,6 +59,35 @@ describe('selector manifest', () => { ]) }) + /** + * `serviceIds` names which credentials a selector accepts; the integration + * allowlist has to judge which resource it *reaches*, and for a shared + * provider API those differ. A multi-service declaration that named no + * resource fell back to "any accepted service is allowed", which let a group + * permitting `google_sheets_v2` read Drive through `google.drive`. + */ + it('makes every multi-service selector name the resource it reaches', () => { + for (const [key, attachment] of Object.entries(serverSelectorRegistry)) { + const credential = attachment.credential + if (!credential || credential.serviceIds.length < 2) continue + + expect(credential.resourceServiceId, `${key} declares no resourceServiceId`).toBeDefined() + expect(credential.serviceIds).toContain(credential.resourceServiceId) + } + }) + + it('pins the resource each shared-provider selector reaches', () => { + expect(serverSelectorRegistry['google.drive'].credential?.resourceServiceId).toBe( + 'google-drive' + ) + expect(serverSelectorRegistry['onedrive.folders'].credential?.resourceServiceId).toBe( + 'onedrive' + ) + expect(serverSelectorRegistry['sharepoint.sites'].credential?.resourceServiceId).toBe( + 'sharepoint' + ) + }) + it('requires executable preparation for every non-fixed destination', () => { const preparedDestinations = Object.values(serverSelectorRegistry).filter( (attachment) => attachment.destination !== 'fixed' diff --git a/apps/sim/lib/selectors/server/integration-access.test.ts b/apps/sim/lib/selectors/server/integration-access.test.ts new file mode 100644 index 00000000000..82aa9da94d2 --- /dev/null +++ b/apps/sim/lib/selectors/server/integration-access.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { selectorManifest } from '@/lib/selectors/manifest' +import { selectorIntegrationBlockTypes } from '@/lib/selectors/server/integration-access' +import { serverSelectorRegistry } from '@/lib/selectors/server/registry' + +describe('selectorIntegrationBlockTypes', () => { + /** + * The gate passes a selector with no integration identity, so an identity it + * cannot derive is a silent hole: `POST /api/selectors/execute` reaches the + * third party with the caller's credentials and the group's + * `allowedIntegrations` never gets a say. Every selector the manifest calls + * `provider-server` must therefore resolve to at least one block type, either + * through the OAuth credential catalog or by declaring one. + */ + it('gives every provider selector an integration identity', () => { + const ungated = Object.entries(serverSelectorRegistry) + .filter(([key]) => selectorManifest[key as keyof typeof selectorManifest]) + .filter( + ([key, attachment]) => + selectorManifest[key as keyof typeof selectorManifest].classification === + 'provider-server' && selectorIntegrationBlockTypes(attachment).length === 0 + ) + .map(([key]) => key) + + expect(ungated).toEqual([]) + }) + + /** + * The other half of the same rule: an internal selector reads Sim's own + * workspace data, so it is not an integration and nothing gates it. + */ + it('gives an internal selector no integration identity', () => { + const internal = Object.entries(serverSelectorRegistry).filter( + ([key]) => + selectorManifest[key as keyof typeof selectorManifest]?.classification === 'internal-server' + ) + + expect(internal.length).toBeGreaterThan(0) + for (const [key, attachment] of internal) { + expect([key, selectorIntegrationBlockTypes(attachment)]).toEqual([key, []]) + } + }) + + /** A declaration wins over the catalog, which is what an API-key selector needs. */ + it('prefers a declared block type over the credential catalog', () => { + expect( + selectorIntegrationBlockTypes({ + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['gmail'] }, + integrationBlockTypes: ['snowflake'], + }) + ).toEqual(['snowflake']) + }) + + it('derives the block type from the credential resource when none is declared', () => { + expect( + selectorIntegrationBlockTypes({ + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-sheets'], + resourceServiceId: 'google-drive', + }, + }) + ).toContain('google_drive') + }) +}) diff --git a/apps/sim/lib/selectors/server/integration-access.ts b/apps/sim/lib/selectors/server/integration-access.ts new file mode 100644 index 00000000000..a1fc5a513cb --- /dev/null +++ b/apps/sim/lib/selectors/server/integration-access.ts @@ -0,0 +1,106 @@ +import type { Principal } from '@sim/auth/principal' +import { getIntegrationTypesForOAuthServiceId } from '@sim/deployment-config/integration-availability' +import { createLogger } from '@sim/logger' +import { allowedIntegrationTypes } from '@/lib/integrations/principal-scope.server' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' +import type { + SelectorCredentialPolicy, + ServerSelectorAttachment, +} from '@/lib/selectors/server/types' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('SelectorIntegrationAccess') + +/** + * The OAuth service a selector execution actually reaches — its own resource + * rather than the set of credentials it accepts. See + * {@link SelectorCredentialPolicy} for why those two differ and why the bound + * credential's provider id is never consulted. + */ +function selectorResourceServiceIds(policy: SelectorCredentialPolicy): readonly string[] { + return policy.resourceServiceId ? [policy.resourceServiceId] : policy.serviceIds +} + +/** + * The block types an allowlist decision about this selector is made against. + * + * Two independent sources, because the OAuth credential catalog cannot identify + * every selector that reaches a third-party API; the declared + * `integrationBlockTypes` cover the shapes it misses and win over the catalog + * when both are present. See + * {@link ServerSelectorAttachment.integrationBlockTypes} for which shapes those + * are. + * + * An empty result means "no integration identity", which is a pass. That is + * reserved for the internal selectors — workspace files, knowledge bases, + * tables — which read only Sim's own data. `integration-access.test.ts` keeps + * every provider selector out of it: "gives every provider selector an + * integration identity" walks the whole manifest and fails on the first + * provider-backed selector this function answers with an empty list. + */ +export function selectorIntegrationBlockTypes( + attachment: Pick +): readonly string[] { + if (attachment.integrationBlockTypes?.length) return attachment.integrationBlockTypes + if (!attachment.credential) return [] + return selectorResourceServiceIds(attachment.credential).flatMap((serviceId) => + getIntegrationTypesForOAuthServiceId(serviceId) + ) +} + +/** + * Refuses a selector execution whose integration the caller's permission group + * does not permit. + * + * `POST /api/selectors/execute` reaches a provider's API with the caller's + * credential, so it is a use of the integration and not merely a picker. The + * authorization funnel cannot apply the rule: `allowedIntegrations` is a + * parameterized decision about *which* integration, and the funnel knows only + * the principal, the workspace and the operation. Hence the assertion here, + * ahead of the provider call, exactly as `knowledge.connectors` is asserted + * ahead of the connector write. + * + * The decision is the one the block-access path makes. `allowedIntegrationTypes` + * is the shared gate — it intersects the caller's permission group with the + * deployment's `ALLOWED_INTEGRATIONS`, contributes no group half for a principal + * that stands for no person, and canonicalizes each half through + * `resolveAccessControlBlockType` *before* intersecting, so a group naming + * `slack_v2` and a deployment naming `slack` still meet. The checked side is + * successor-resolved the same way, so a group naming `slack` and a selector + * bound to `slack_v2` match. + * + * A `null` allowlist, a caller no group governs, and a selector with no + * integration identity all pass through; see {@link selectorIntegrationBlockTypes} + * for why the last of those is reserved for the internal selectors. + * + * One service can still map to several block types — the `google-drive` entry + * authenticates both `google_drive` and `google_slides_v2` — and any of them + * satisfies the check. That is the catalog's own shared-service convention and + * not a widening: both block types hold the same Drive scope on the same + * credential, so permitting either already grants the access. + */ +export async function assertSelectorIntegrationAllowed(input: { + principal: Principal + workspaceId: string + blockTypes: readonly string[] +}): Promise { + const blockTypes = input.blockTypes + if (blockTypes.length === 0) return + + const allowlist = await allowedIntegrationTypes(input.principal, input.workspaceId) + if (allowlist === null) return + + const allowed = blockTypes.some( + (blockType) => + isBlockTypeAccessControlExempt(blockType) || + allowlist.has(resolveAccessControlBlockType(blockType).toLowerCase()) + ) + if (allowed) return + + logger.warn('Selector integration blocked by integration allowlist', { + workspaceId: input.workspaceId, + blockTypes, + }) + throw new IntegrationNotAllowedError(blockTypes[0]) +} diff --git a/apps/sim/lib/selectors/server/providers/cloudwatch.ts b/apps/sim/lib/selectors/server/providers/cloudwatch.ts index 734498c1674..ad82c168ba3 100644 --- a/apps/sim/lib/selectors/server/providers/cloudwatch.ts +++ b/apps/sim/lib/selectors/server/providers/cloudwatch.ts @@ -53,8 +53,16 @@ async function executeCloudWatchListing( } } +/** + * The integration this selector reaches. Declared rather than derived: the selector authenticates from raw AWS keys in the request context and + * carries no stored connection, so the OAuth credential catalog can identify + * nothing to gate it on. + */ +const integrationBlockTypes = ['cloudwatch'] as const + export const cloudWatchSelectorAttachments = { 'cloudwatch.logGroups': { + integrationBlockTypes, destination: 'fixed', async execute(args) { const listingCredentials = credentials(args.context) @@ -94,6 +102,7 @@ export const cloudWatchSelectorAttachments = { }, }, 'cloudwatch.logStreams': { + integrationBlockTypes, destination: 'fixed', async execute(args) { const listingCredentials = credentials(args.context) diff --git a/apps/sim/lib/selectors/server/providers/google.ts b/apps/sim/lib/selectors/server/providers/google.ts index da478ecba83..418df995295 100644 --- a/apps/sim/lib/selectors/server/providers/google.ts +++ b/apps/sim/lib/selectors/server/providers/google.ts @@ -422,7 +422,12 @@ export const googleSelectorAttachments = { execute: executeCalendars, }, 'google.drive': { - credential: storedCredential(['google-drive', 'google-docs', 'google-sheets', 'google-forms']), + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-drive', 'google-docs', 'google-sheets', 'google-forms'], + resourceServiceId: 'google-drive', + }, destination: 'fixed', execute: executeDrive, }, diff --git a/apps/sim/lib/selectors/server/providers/harmonic.ts b/apps/sim/lib/selectors/server/providers/harmonic.ts index 5621237c3fc..52f4f04eeed 100644 --- a/apps/sim/lib/selectors/server/providers/harmonic.ts +++ b/apps/sim/lib/selectors/server/providers/harmonic.ts @@ -175,9 +175,16 @@ async function executeSavedSearches(args: ExecuteServerSelectorArgs) { ) } +/** + * The integration this selector reaches. Declared rather than derived: Harmonic is an API-key integration with no entry in the deployment OAuth + * catalog, so its service id maps to no block type. + */ +const integrationBlockTypes = ['harmonic'] as const + export const harmonicSelectorAttachments = { 'harmonic.savedSearches': { credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['harmonic'] }, + integrationBlockTypes, destination: 'fixed', execute: executeSavedSearches, }, diff --git a/apps/sim/lib/selectors/server/providers/imap.ts b/apps/sim/lib/selectors/server/providers/imap.ts index c6557a7c09e..984172f9c5a 100644 --- a/apps/sim/lib/selectors/server/providers/imap.ts +++ b/apps/sim/lib/selectors/server/providers/imap.ts @@ -19,8 +19,16 @@ function throwPublicImapError(error: unknown): never { throw new SelectorConnectionUnavailableError() } +/** + * The integration this selector reaches. Declared rather than derived: the selector opens an IMAP connection from raw host and password fields in + * the request context and carries no stored connection, so the OAuth + * credential catalog can identify nothing to gate it on. + */ +const integrationBlockTypes = ['imap'] as const + export const imapSelectorAttachments = { 'imap.mailboxes': definePreparedSelectorAttachment({ + integrationBlockTypes, destination: { kind: 'user-controlled', async prepare(args) { diff --git a/apps/sim/lib/selectors/server/providers/managed-agent.ts b/apps/sim/lib/selectors/server/providers/managed-agent.ts index 4598b6be86f..6400bc0bc75 100644 --- a/apps/sim/lib/selectors/server/providers/managed-agent.ts +++ b/apps/sim/lib/selectors/server/providers/managed-agent.ts @@ -97,27 +97,39 @@ const credential = { serviceIds: ['claude-platform'], } as const +/** + * The integration this selector reaches. Declared rather than derived: The managed-agent platform is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type and the allowlist would have nothing to + * judge it on. + */ +const integrationBlockTypes = ['managed_agent'] as const + export const managedAgentSelectorAttachments = { 'managedAgent.agents': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'agents'), }, 'managedAgent.environments': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'environments'), }, 'managedAgent.vaults': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'vaults'), }, 'managedAgent.memoryStores': { credential, + integrationBlockTypes, destination: 'fixed', auditCredentialUse: true, execute: (args) => executeResource(args, 'memory-stores'), diff --git a/apps/sim/lib/selectors/server/providers/microsoft.ts b/apps/sim/lib/selectors/server/providers/microsoft.ts index 228a85d6e5e..b2984dde819 100644 --- a/apps/sim/lib/selectors/server/providers/microsoft.ts +++ b/apps/sim/lib/selectors/server/providers/microsoft.ts @@ -604,6 +604,7 @@ const oneDriveFolderCredential: SelectorCredentialPolicy = { kind: 'stored', field: 'oauthCredential', serviceIds: ['onedrive', 'microsoft-word'], + resourceServiceId: 'onedrive', } const excelCredential = microsoftCredential('microsoft-excel') const wordCredential = microsoftCredential('microsoft-word') diff --git a/apps/sim/lib/selectors/server/providers/netsuite.ts b/apps/sim/lib/selectors/server/providers/netsuite.ts index 968d8d15595..1991c6d7396 100644 --- a/apps/sim/lib/selectors/server/providers/netsuite.ts +++ b/apps/sim/lib/selectors/server/providers/netsuite.ts @@ -238,14 +238,24 @@ const credential = { serviceIds: ['netsuite'], } as const +/** + * The integration this selector reaches. Declared rather than derived: NetSuite is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type and the allowlist would have nothing to + * judge it on. + */ +const integrationBlockTypes = ['netsuite'] as const + export const netsuiteSelectorAttachments = { 'netsuite.recordTypes': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareNetSuiteDestination }, execute: executeNetSuite, }), 'netsuite.asyncTasks': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareNetSuiteDestination }, execute: executeNetSuite, }), diff --git a/apps/sim/lib/selectors/server/providers/sharepoint.ts b/apps/sim/lib/selectors/server/providers/sharepoint.ts index 1c1e720bb12..0c289d7a3d0 100644 --- a/apps/sim/lib/selectors/server/providers/sharepoint.ts +++ b/apps/sim/lib/selectors/server/providers/sharepoint.ts @@ -32,6 +32,7 @@ const siteCredential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['sharepoint', 'microsoft-excel'], + resourceServiceId: 'sharepoint', } as const async function graphToken(args: ExecuteServerSelectorArgs): Promise { diff --git a/apps/sim/lib/selectors/server/providers/snowflake.ts b/apps/sim/lib/selectors/server/providers/snowflake.ts index cb19767e9c2..fc51f3015a6 100644 --- a/apps/sim/lib/selectors/server/providers/snowflake.ts +++ b/apps/sim/lib/selectors/server/providers/snowflake.ts @@ -164,39 +164,54 @@ const credential = { serviceIds: ['snowflake'], } as const +/** + * The integration this selector reaches. Declared rather than derived: Snowflake is an + * API-key integration with no entry in the deployment OAuth catalog, so its + * service id maps to no block type and the allowlist would have nothing to + * judge it on. + */ +const integrationBlockTypes = ['snowflake'] as const + export const snowflakeSelectorAttachments = { 'snowflake.databases': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.schemas': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.tables': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.warehouses': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.roles': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.fileFormats': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), 'snowflake.procedures': definePreparedSelectorAttachment({ credential, + integrationBlockTypes, destination: { kind: 'credential-bound', prepare: prepareSnowflakeDestination }, execute: executeSnowflake, }), diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index ee43e62876c..d5b2bdcfec1 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -14,17 +14,33 @@ export type SelectorDestinationPolicy = 'fixed' | 'credential-bound' | 'user-con export type SelectorProtectedValueKind = 'secret' | 'reference' +/** + * The service whose API a selector actually reaches. + * + * `serviceIds` names which *credentials* a selector accepts, which is not the + * same question as which *resource* it reads. `google.drive` accepts a Drive, + * Docs, Sheets or Forms connection because all four carry Drive scope, but it + * only ever calls the Drive API. Judging the integration allowlist against the + * accepted set let a group that permits `google_sheets_v2` and excludes + * `google_drive` read Drive through it. + * + * Required whenever `serviceIds` names more than one service, and must be one + * of them; `lib/selectors/manifest.test.ts` pins both. A single-service + * declaration is its own resource and omits it. + */ export type SelectorCredentialPolicy = | { kind: 'stored' field: 'oauthCredential' serviceIds: readonly string[] + resourceServiceId?: string } | { kind: 'stored-or-fixed-token' field: 'oauthCredential' serviceIds: readonly string[] tokenPrefixes: readonly string[] + resourceServiceId?: string } export interface AuthorizedSelectorCredential { @@ -82,6 +98,21 @@ export interface PreparedSelectorDestination { export interface ServerSelectorAttachment { credential?: SelectorCredentialPolicy + /** + * The block type(s) whose integration this selector's API belongs to, for a + * selector the OAuth credential catalog cannot identify. + * + * The integration gate normally derives the block type from the credential + * policy's service ids. Two shapes defeat that: a selector authenticated from + * raw context fields rather than a stored connection (CloudWatch's AWS keys, + * IMAP's host and password) declares no policy at all, and an API-key + * integration (Snowflake, NetSuite, Harmonic) owns no OAuth catalog entry, so + * its service id maps to nothing. Both still reach a third-party API with the + * caller's credentials, so both must name their integration here. Internal + * selectors — the ones reading only Sim's own workspace data — name none, and + * that is what leaves them ungated. + */ + integrationBlockTypes?: readonly string[] destination: 'fixed' | PreparedSelectorDestination auditCredentialUse?: boolean execute( @@ -125,6 +156,7 @@ export function detailSelectorResult(item: SafeSelectorOption | null): SelectorE export function definePreparedSelectorAttachment(input: { credential?: SelectorCredentialPolicy + integrationBlockTypes?: readonly string[] destination: { kind: Exclude prepare(args: ExecuteServerSelectorArgs): Promise @@ -137,6 +169,7 @@ export function definePreparedSelectorAttachment(input: { }): ServerSelectorAttachment { return { ...(input.credential ? { credential: input.credential } : {}), + ...(input.integrationBlockTypes ? { integrationBlockTypes: input.integrationBlockTypes } : {}), destination: { kind: input.destination.kind, prepare: input.destination.prepare, diff --git a/apps/sim/lib/skills/application/operations.test.ts b/apps/sim/lib/skills/application/operations.test.ts index 5669aadb134..a9152130d80 100644 --- a/apps/sim/lib/skills/application/operations.test.ts +++ b/apps/sim/lib/skills/application/operations.test.ts @@ -2,7 +2,25 @@ * @vitest-environment node */ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' -import { describe, expect, it } from 'vitest' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), +})) + +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +import type { WorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceOperation, PermissionGroupCapabilityError } from '@/lib/core/application' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { skillOperations } from '@/lib/skills/application/operations' /** @@ -100,3 +118,46 @@ describe('skill operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) }) + +const sessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, +} + +/** + * The declaration is only half the gate. These call the funnel so a capability + * cannot be declared on the operations and then read by nothing. + */ +describe('skill operations under a group that blocks skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('refuses authoring and editor grants, not only loading', async () => { + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableSkills: true, + }) + + for (const operation of Object.values(skillOperations)) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).rejects.toBeInstanceOf(PermissionGroupCapabilityError) + } + }) + + it('allows them all when the group withholds nothing', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + for (const operation of Object.values(skillOperations)) { + await expect( + authorizeWorkspaceOperation(sessionPrincipal, operation as WorkspaceOperation, context), + operation.id + ).resolves.toBeUndefined() + } + }) +}) diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index 32ed520ccad..06cb71eb7fd 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -36,35 +36,47 @@ const HUMAN_HTTP_SKILL_EDITOR_POLICY = { * act. Denying it keeps the whole lifecycle under one authorization model. * Pinned in `operations.test.ts`. */ +/** + * Every operation declares `skills.use`. The key reads "block agents from + * loading skills", and the skills a group's members author are exactly the ones + * their agents would load — so authoring, sharing, and editor grants are gated + * with execution rather than left as a side door that fills the workspace with + * skills the group may not run. + */ export const skillOperations = { list: defineWorkspaceOperation({ id: 'skills.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'skills.use', ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'skills.list_available', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'skills.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'skills.use', ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'skills.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'skills.update', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), /** @@ -81,30 +93,35 @@ export const skillOperations = { id: 'skills.upsert', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'skills.delete', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_PRINCIPAL_POLICY, }), listEditors: defineWorkspaceOperation({ id: 'skills.editors.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'skills.use', ...HTTP_SKILL_EDITOR_READ_POLICY, }), grantEditor: defineWorkspaceOperation({ id: 'skills.editors.grant', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_HTTP_SKILL_EDITOR_POLICY, }), revokeEditor: defineWorkspaceOperation({ id: 'skills.editors.revoke', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'skills.use', ...HUMAN_HTTP_SKILL_EDITOR_POLICY, }), } as const diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.ts b/apps/sim/lib/table/application/copilot-bulk-rows.ts index 76c55db7350..0c29ed56b06 100644 --- a/apps/sim/lib/table/application/copilot-bulk-rows.ts +++ b/apps/sim/lib/table/application/copilot-bulk-rows.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' @@ -255,6 +256,7 @@ export const copilotUpdateRowsByFilter = defineAuthorizedTableUseCase({ actorUserId: resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance: createExactEmptyTableRowSecretProvenance(idData), }, requestId() diff --git a/apps/sim/lib/table/application/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts index 736ecd7c0c2..10d48cbf25c 100644 --- a/apps/sim/lib/table/application/exports.test.ts +++ b/apps/sim/lib/table/application/exports.test.ts @@ -3,17 +3,21 @@ */ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ cancel: vi.fn(), + resolveWorkspaceContext: vi.fn(), create: vi.fn(), getTable: vi.fn(), require: vi.fn(), resolveContext: vi.fn(), })) +const resolveGroupConfigMock = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig + vi.mock('@sim/audit', () => ({ AuditAction: { TABLE_EXPORTED: 'table.exported' }, AuditResourceType: { TABLE: 'table' }, @@ -23,15 +27,11 @@ vi.mock('@sim/platform-authz/workspace', () => ({ permissionSatisfies: () => true, resolveEffectiveWorkspacePermission: vi.fn(), })) +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) vi.mock('@/lib/table', () => ({ getTableById: mocks.getTable })) vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mocks.resolveContext, - resolveTableWorkspaceContext: vi.fn(async (workspaceId: string) => ({ - workspaceId, - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-owner-1', - })), + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) vi.mock('@/lib/table/orchestration/export-resource', () => ({ cancelTableExportResource: mocks.cancel, @@ -43,6 +43,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ generatePresignedDownloadUrl: vi.fn(), })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { cancelTableExportUseCase, createTableExportUseCase, @@ -110,6 +111,13 @@ describe('table export application use cases', () => { mocks.create.mockResolvedValue(record) mocks.require.mockResolvedValue(record) mocks.cancel.mockResolvedValue({ ...record, status: 'canceled' }) + resolveGroupConfigMock.mockResolvedValue(null) + mocks.resolveWorkspaceContext.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })) }) it('returns domain records for create and read operations', async () => { @@ -164,4 +172,61 @@ describe('table export application use cases', () => { expect(mocks.create).not.toHaveBeenCalled() }) + + describe('permission-group capability', () => { + const member = { kind: 'session' as const, userId: 'user-1' } + const governedContext = { + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + } + + beforeEach(() => { + mocks.resolveContext.mockResolvedValue(governedContext) + mocks.resolveWorkspaceContext.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })) + resolveGroupConfigMock.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableExport: true, + }) + }) + + it('refuses to generate an export when the group withholds tables.export', async () => { + await expect( + createTableExportUseCase.execute({ + principal: member, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).rejects.toMatchObject({ capability: 'tables.export' }) + + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('still allows cancelling an export, which stops extraction rather than performing it', async () => { + await expect( + cancelTableExportUseCase.execute({ + principal: member, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ export: { status: 'canceled' } }) + }) + + it('generates an export when the group withholds nothing', async () => { + resolveGroupConfigMock.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) + + await expect( + createTableExportUseCase.execute({ + principal: member, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).resolves.toEqual({ export: record }) + }) + }) }) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 59f3135caa7..db29e6c125f 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -290,6 +290,29 @@ describe('workflow and enrichment Table application commands', () => { expect(mocks.signal).toHaveBeenCalledWith(table.id) }) + /** + * Adding an output backfills it from saved runs, and a backfilled cell can + * satisfy a downstream group's deps and start it. That cascade is gated on + * the acting person, which is not the billing attribution beside it. + */ + it('names the acting person, not the billing actor, as the backfill cascade subject', async () => { + await addWorkflowTableGroupOutput.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + blockId: 'block-2', + path: 'score', + }, + }) + + expect(mocks.addOutput).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: 'user-1' }), + 'request-1' + ) + }) + it('persists disabled auto-run on a newly created workflow group', async () => { const result = await createWorkflowTableGroup.execute({ principal, @@ -333,6 +356,7 @@ describe('workflow and enrichment Table application commands', () => { isManualRun: false, requestId: 'request-1', triggeredByUserId: 'user-1', + capabilityGovernedUserId: 'user-1', }) }) @@ -1063,6 +1087,9 @@ describe('workflow and enrichment Table application commands', () => { expect(mocks.addOutput).toHaveBeenCalledWith( expect.objectContaining({ + // A copilot delegation stays governed, so the backfill's downstream + // cells run under the delegating person rather than ungated. + capabilityGovernedUserId: 'user-1', resolvedOutput: expect.objectContaining({ workflowId: 'workflow-1', columnType: 'number', diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 485c960b78e..bbbb1750f8e 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' @@ -182,6 +183,11 @@ function dispatchGroupAutoRun(params: { workspaceId: string groupId: string actorUserId: string + /** + * The gate's subject, which is not the meter's `actorUserId`; `null` means no + * acting person. See {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. + */ + capabilityGovernedUserId: string | null label: string }): void { runDetached(params.label, async () => { @@ -193,6 +199,7 @@ function dispatchGroupAutoRun(params: { isManualRun: false, requestId: generateRequestId(), triggeredByUserId: params.actorUserId, + capabilityGovernedUserId: params.capabilityGovernedUserId, }) logger.info('Started table group auto-run', { tableId: params.tableId, @@ -274,6 +281,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const groupId = input.group.id ?? generateId() /** * The public surface lets an `enrichment` group omit `workflowId`, so the @@ -302,10 +310,16 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, actorUserId, + capabilityGovernedUserId, }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId), actorUserId } + return { + table, + group: groupFromTable(table, groupId), + actorUserId, + capabilityGovernedUserId, + } }, projectAudit({ result }) { return { @@ -325,6 +339,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-group-create-auto-run', }) } @@ -411,6 +426,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ outputs, } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const table = await addWorkflowGroup( { tableId: context.tableId, @@ -420,10 +436,16 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, actorUserId, + capabilityGovernedUserId, }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId), actorUserId } + return { + table, + group: groupFromTable(table, groupId), + actorUserId, + capabilityGovernedUserId, + } }, projectAudit({ result }) { return { @@ -443,6 +465,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-workflow-group-create-auto-run', }) } @@ -550,6 +573,7 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const table = await addWorkflowGroup( { tableId: context.tableId, @@ -559,10 +583,16 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, actorUserId, + capabilityGovernedUserId, }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId), actorUserId } + return { + table, + group: groupFromTable(table, groupId), + actorUserId, + capabilityGovernedUserId, + } }, projectAudit({ result }) { return { @@ -586,6 +616,7 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-enrichment-group-create-auto-run', }) } @@ -596,7 +627,11 @@ export interface UpdateTableGroupInput extends TableGroupInput, Omit< UpdateWorkflowGroupData, - 'tableId' | 'workspaceId' | 'actorUserId' | 'suppressAutoRunDispatch' + | 'tableId' + | 'workspaceId' + | 'actorUserId' + | 'capabilityGovernedUserId' + | 'suppressAutoRunDispatch' > {} export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ @@ -735,6 +770,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ } } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const hasMappingUpdates = Boolean(input.mappingUpdates && input.mappingUpdates.length > 0) if (hasMappingUpdates && !resolvedWorkflow) { throw new Error('Workflow metadata is required for workflow group mapping updates') @@ -764,6 +800,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: input.groupId, actorUserId, + capabilityGovernedUserId, suppressAutoRunDispatch: true, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), @@ -795,6 +832,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), startAutoRun: previousGroup?.autoRun === false && input.autoRun === true, actorUserId, + capabilityGovernedUserId, } }, projectAudit({ result }) { @@ -816,6 +854,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-group-update-auto-run', }) } @@ -956,12 +995,14 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ } const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) const table = await updateWorkflowGroup( { tableId: context.tableId, workspaceId: context.workspaceId, groupId: input.groupId, actorUserId, + capabilityGovernedUserId, suppressAutoRunDispatch: true, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), @@ -984,6 +1025,7 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), startAutoRun: previousGroup.autoRun === false && input.autoRun === true, actorUserId, + capabilityGovernedUserId, } }, projectAudit({ result }) { @@ -1005,6 +1047,7 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, groupId: result.group.id, actorUserId: result.actorUserId, + capabilityGovernedUserId: result.capabilityGovernedUserId, label: 'table-workflow-group-update-auto-run', }) } @@ -1104,6 +1147,7 @@ export const addWorkflowTableGroupOutput = defineAuthorizedTableUseCase({ actorUserId: resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), resolvedOutput: { workflowId: resolvedWorkflow.workflowId, columnType: columnTypeForLeaf(output.leafType), diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index c5226c24e5d..162f762fe2f 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -22,6 +22,11 @@ const mocks = vi.hoisted(() => ({ startUploadedImport: vi.fn(), tableImportBodyFromUpload: vi.fn(), resourceFromUpload: vi.fn(), + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, })) vi.mock('@sim/platform-authz/workspace', () => ({ @@ -71,6 +76,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mocks.getWorkspaceFile, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { cancelTableImportUseCase, completeTableImportUseCase, @@ -167,6 +173,7 @@ describe('table import application use cases', () => { mocks.createResource.mockResolvedValue({ record, upload: null }) mocks.getWorkspaceFile.mockResolvedValue(workspaceFile) mocks.resourceFromUpload.mockReturnValue(record) + mocks.getUserPermissionConfig.mockResolvedValue(null) }) it('creates an import through the domain resource boundary without presenting a v2 DTO', async () => { @@ -456,4 +463,152 @@ describe('table import application use cases', () => { expect(mocks.resolveTableContext).not.toHaveBeenCalled() expect(mocks.createResource).not.toHaveBeenCalled() }) + + describe('permission-group capability', () => { + beforeEach(() => { + mocks.getUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableTableCreation: true, + }) + mocks.resolveTableContext.mockResolvedValue({ + tableId: 'table-1', + ...workspaceContext, + }) + }) + + it('refuses an import that would create a table when the group withholds tables.create', async () => { + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'new', name: 'People' }, + }, + }, + request: new Request('http://localhost:3000/api/table/imports', { method: 'POST' }), + }) + ).rejects.toMatchObject({ capability: 'tables.create' }) + + expect(mocks.createResource).not.toHaveBeenCalled() + }) + + /** + * A run carries the role of whoever triggered it but not their + * capabilities — the same exemption `authorizeWorkspaceOperation` applies. + * Keying this check on the raw subject instead would re-apply a capability + * the funnel deliberately passed, failing an executor import under a group + * that withholds table creation from the person who started the workflow. + */ + it('exempts an executor delegation carrying a subject, as the funnel does', async () => { + await expect( + createTableImportUseCase.execute({ + principal: executor, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'new', name: 'People' }, + }, + }, + request: new Request('http://localhost:3000/api/table/imports', { method: 'POST' }), + }) + ).resolves.toBeDefined() + + expect(mocks.createResource).toHaveBeenCalled() + }) + + it('exempts an executor delegation at completion too', async () => { + await expect( + completeTableImportUseCase.execute({ + principal: executor, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).resolves.toBeDefined() + + expect(mocks.startUploadedImport).toHaveBeenCalled() + }) + + it('still allows importing into an existing table, which creates nothing', async () => { + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'existing', tableId: 'table-1' }, + }, + }, + request: new Request('http://localhost:3000/api/table/imports', { method: 'POST' }), + }) + ).resolves.toEqual({ import: { record, upload: null } }) + }) + + /** + * Creation and completion are separate requests, so a group that withholds + * creation between them has to be read again at completion — otherwise the + * upload started while it was allowed still lands a table. + */ + it('refuses to complete an upload that would create a table, and never starts the import', async () => { + await expect( + completeTableImportUseCase.execute({ + principal: reader, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).rejects.toMatchObject({ capability: 'tables.create' }) + + expect(mocks.startUploadedImport).not.toHaveBeenCalled() + }) + + it('still completes an upload targeting an existing table', async () => { + mocks.tableImportBodyFromUpload.mockReturnValue({ + workspaceId: 'workspace-1', + source: record.source, + target: { type: 'existing', tableId: 'table-1' }, + }) + + await expect( + completeTableImportUseCase.execute({ + principal: reader, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).resolves.toEqual({ import: { ...record, status: 'ready' } }) + + expect(mocks.startUploadedImport).toHaveBeenCalledTimes(1) + }) + + /** + * A workspace API key has no acting person, so there is no group to read — + * the same exemption `createTableImportUseCase` makes, and the reason the + * re-check must not become a blanket refusal on the completion leg. + */ + it('still completes an upload driven by a workspace key, which has no subject', async () => { + await expect( + completeTableImportUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + ).resolves.toEqual({ import: { ...record, status: 'ready' } }) + + expect(mocks.startUploadedImport).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 0b14531c299..ba596f1dfb1 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -1,11 +1,15 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { + authorizeWorkspaceOperation, + capabilityGovernedPrincipalUserId, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { type TableAuthorizationContext, tableDelegationPolicy, @@ -168,6 +172,21 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: CreateTableImportInput }) => resolveCreateTableImportContext(input), async execute({ principal, input, context, request }): Promise { + /** + * permission-group-enforced: tables.create — an import targeting `new` + * creates a table, but one targeting `existing` only fills one, and the + * operation cannot tell them apart: the target is request input the + * authorization funnel never sees. Keyed to the governed subject, which + * names nobody for an actorless run and nobody for an executor delegation — + * the funnel exempts a run from capabilities even when it carries the + * subject of whoever triggered it. A copilot delegation stays governed. + */ + if (input.body.target.type === 'new') { + const actingUserId = capabilityGovernedPrincipalUserId(principal) + if (actingUserId) { + await assertWorkspaceCapability(actingUserId, context.workspaceId, 'tables.create') + } + } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) @@ -273,6 +292,22 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ await authorizeWorkspaceOperation(principal, tableOperations.completeImport, context, { delegation: tableDelegationPolicy, }) + /** + * permission-group-enforced: tables.create — the same assertion + * `createTableImportUseCase` makes, repeated here because the two are + * separate requests: an upload started before the group withheld + * creation would otherwise still land a table when it completed. Read + * from the claimed session so the target is the one the upload was + * created for, and keyed to the governed subject for the reason the + * create path is: an actorless run and an executor delegation are both + * ungoverned, a copilot delegation is not. + */ + if (tableImportBodyFromUpload(claimed).target.type === 'new') { + const actingUserId = capabilityGovernedPrincipalUserId(principal) + if (actingUserId) { + await assertWorkspaceCapability(actingUserId, context.workspaceId, 'tables.create') + } + } return { value: null } }, }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 209d11979a7..2f9aaa110aa 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -1,4 +1,5 @@ import { defineWorkspaceOperation } from '@/lib/core/application' +import type { OperationDeclarableCapability } from '@/lib/core/application/operation' const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -24,6 +25,7 @@ function readOperation(id: Id) { id, minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_PRINCIPAL_POLICY, }) } @@ -33,15 +35,29 @@ function writeOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_PRINCIPAL_POLICY, }) } -function toolWriteOperation(id: Id) { +/** + * Not every table operation needs the same capability — creating a table and + * exporting one are each withheld separately from ordinary table use — so the + * factories that mint more than one kind take the capability as an argument. + * + * No default, deliberately: a default would let a new operation inherit + * `tables.use` without anyone deciding it should, which is exactly the + * unreviewed omission this gate exists to prevent. + */ +function toolWriteOperation( + id: Id, + capability: OperationDeclarableCapability +) { return defineWorkspaceOperation({ id, minimumRole: 'write', workspaceApiKey: 'allow', + capability, ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, }) } @@ -51,15 +67,20 @@ function toolReadOperation(id: Id) { id, minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'tables.use', ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, }) } -function internalExecutorReadOperation(id: Id) { +function internalExecutorReadOperation( + id: Id, + capability: OperationDeclarableCapability +) { return defineWorkspaceOperation({ id, minimumRole: 'read', workspaceApiKey: 'allow', + capability, ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, }) } @@ -69,15 +90,20 @@ function internalExecutorWriteOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'tables.use', ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, }) } -function delegatedWriteOperation(id: Id) { +function delegatedWriteOperation( + id: Id, + capability: OperationDeclarableCapability +) { return defineWorkspaceOperation({ id, minimumRole: 'write', workspaceApiKey: 'deny', + capability, principalKinds: ['delegated'], delegatedServices: ['copilot'], }) @@ -86,7 +112,7 @@ function delegatedWriteOperation(id: Id) { export const tableOperations = { list: toolReadOperation('tables.list'), read: toolReadOperation('tables.read'), - create: toolWriteOperation('tables.create'), + create: toolWriteOperation('tables.create', 'tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), restore: writeOperation('tables.restore'), @@ -96,18 +122,21 @@ export const tableOperations = { id: 'tables.vfs.rename', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', ...COPILOT_PRINCIPAL_POLICY, }), moveByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.move', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', ...COPILOT_PRINCIPAL_POLICY, }), deleteByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'tables.use', ...COPILOT_PRINCIPAL_POLICY, }), listFolders: readOperation('tables.folders.list'), @@ -122,37 +151,46 @@ export const tableOperations = { queryRows: toolReadOperation('tables.rows.query'), searchRows: readOperation('tables.rows.search'), readRow: toolReadOperation('tables.rows.read'), - createRows: toolWriteOperation('tables.rows.create'), + createRows: toolWriteOperation('tables.rows.create', 'tables.use'), replaceRows: writeOperation('tables.rows.replace'), - updateRow: toolWriteOperation('tables.rows.update'), - updateRows: toolWriteOperation('tables.rows.update_many'), - deleteRow: toolWriteOperation('tables.rows.delete'), - deleteRows: toolWriteOperation('tables.rows.delete_many'), - upsertRow: toolWriteOperation('tables.rows.upsert'), + updateRow: toolWriteOperation('tables.rows.update', 'tables.use'), + updateRows: toolWriteOperation('tables.rows.update_many', 'tables.use'), + deleteRow: toolWriteOperation('tables.rows.delete', 'tables.use'), + deleteRows: toolWriteOperation('tables.rows.delete_many', 'tables.use'), + upsertRow: toolWriteOperation('tables.rows.upsert', 'tables.use'), listViews: readOperation('tables.views.list'), readView: readOperation('tables.views.read'), createView: writeOperation('tables.views.create'), updateView: writeOperation('tables.views.update'), deleteView: writeOperation('tables.views.delete'), listGroups: readOperation('tables.groups.list'), - createGroup: toolWriteOperation('tables.groups.create'), - updateGroup: toolWriteOperation('tables.groups.update'), - deleteGroup: toolWriteOperation('tables.groups.delete'), + createGroup: toolWriteOperation('tables.groups.create', 'tables.use'), + updateGroup: toolWriteOperation('tables.groups.update', 'tables.use'), + deleteGroup: toolWriteOperation('tables.groups.delete', 'tables.use'), startRun: writeOperation('tables.runs.start'), /** Reading the state of a run — including one you started — is a read. */ readRun: readOperation('tables.runs.read'), cancelRuns: writeOperation('tables.runs.cancel'), createImport: internalExecutorWriteOperation('tables.imports.create'), - createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), - importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file'), - readImport: internalExecutorReadOperation('tables.imports.read'), + createFromWorkspaceFile: delegatedWriteOperation( + 'tables.imports.create_from_workspace_file', + 'tables.create' + ), + importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file', 'tables.use'), + readImport: internalExecutorReadOperation('tables.imports.read', 'tables.use'), createImportParts: internalExecutorWriteOperation('tables.imports.create_parts'), completeImport: internalExecutorWriteOperation('tables.imports.complete'), cancelImport: internalExecutorWriteOperation('tables.imports.cancel'), - createExport: internalExecutorReadOperation('tables.exports.create'), - readExport: internalExecutorReadOperation('tables.exports.read'), - cancelExport: internalExecutorReadOperation('tables.exports.cancel'), - downloadExport: internalExecutorReadOperation('tables.exports.download'), + /** + * Only generating the file and fetching it are extraction. Reading an + * export's status carries no rows, and cancelling one stops an extraction + * rather than performing it — gating either would strand a member with an + * export they can neither watch nor stop after the group changed. + */ + createExport: internalExecutorReadOperation('tables.exports.create', 'tables.export'), + readExport: internalExecutorReadOperation('tables.exports.read', 'tables.use'), + cancelExport: internalExecutorReadOperation('tables.exports.cancel', 'tables.use'), + downloadExport: internalExecutorReadOperation('tables.exports.download', 'tables.export'), } as const export type TableOperation = (typeof tableOperations)[keyof typeof tableOperations] diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 4ca671cdc4b..4bce8ce9ad5 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -9,6 +9,7 @@ import { db } from '@sim/db' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance' @@ -788,6 +789,7 @@ export const createTableRows = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, data, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), position: input.position, afterRowId: input.afterRowId, beforeRowId: input.beforeRowId, @@ -847,6 +849,7 @@ export const createTableRows = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, rows, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), orderKeys: input.orderKeys, secretProvenance, }, @@ -1151,6 +1154,7 @@ export const updateTableRow = defineAuthorizedTableUseCase({ rowId: input.rowId, data, actorUserId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance, }, context.table, @@ -1213,6 +1217,7 @@ export const updateTableRows = defineAuthorizedTableUseCase({ data, limit: input.limit, actorUserId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance, }, requestId(input), @@ -1305,6 +1310,7 @@ export const batchUpdateTableRows = defineAuthorizedTableUseCase({ updates, workspaceId: context.workspaceId, actorUserId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenanceByRowId: Object.fromEntries( updates.flatMap((update, index) => { const stamp = secretProvenance[index] @@ -1472,6 +1478,7 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ data, conflictTarget, userId: actorUserId(principal, context.billedAccountUserId), + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), secretProvenance, }, context.table, diff --git a/apps/sim/lib/table/application/runs.test.ts b/apps/sim/lib/table/application/runs.test.ts index 93a1675e4ef..e2735125059 100644 --- a/apps/sim/lib/table/application/runs.test.ts +++ b/apps/sim/lib/table/application/runs.test.ts @@ -159,11 +159,39 @@ describe('table run application use cases', () => { mode: 'all', requestId: 'request-1', triggeredByUserId: PRINCIPAL.userId, + capabilityGovernedUserId: PRINCIPAL.userId, }) expect(result.dispatchId).toBe('dispatch-1') expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) }) + /** + * A workspace key names no human, so its run is ungoverned. The meter still + * needs someone, and attribution answers with the workspace billed account — + * a bystander whose tool denylist must not reach the run's cells. The two + * subjects are carried separately precisely so this case can differ. + */ + it('carries the billed account as the meter but nobody as the gate for a workspace key', async () => { + await startTableRun.execute({ + principal: { kind: 'workspace_api_key', workspaceId: TABLE.workspaceId, keyId: 'key-1' }, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + rowId: 'row-1', + groupId: 'group-1', + requestId: 'request-1', + }, + }) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + triggeredByUserId: 'billing-owner-1', + capabilityGovernedUserId: null, + }) + ) + }) + it('rejects missing canonical groups and rows without dispatching', async () => { await expect( startTableRun.execute({ diff --git a/apps/sim/lib/table/application/runs.ts b/apps/sim/lib/table/application/runs.ts index 7b9725f9414..6480304b46c 100644 --- a/apps/sim/lib/table/application/runs.ts +++ b/apps/sim/lib/table/application/runs.ts @@ -1,6 +1,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { DEFAULT_TABLE_PLAN_LIMITS, @@ -96,6 +97,14 @@ export const startTableRun = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: StartTableRunInput }) => resolveActiveTableContext(input), async execute({ principal, input, context }): Promise { const triggeredByUserId = actorUserId(principal, context.billedAccountUserId) + /** + * The gate's subject, which is not the meter's. `actorUserId` substitutes + * the workspace billed account when the credential names no human, so a + * workspace-API-key run would otherwise carry that bystander into the + * cells' tool denylist. Null here means no acting person and no per-tool + * gate — the same answer an executor delegation gets from the funnel. + */ + const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) if (input.kind === 'row_enrichment') { requireCanonicalGroups(context.table, [input.groupId]) const row = await getRowById(context.tableId, input.rowId, context.workspaceId) @@ -108,6 +117,7 @@ export const startTableRun = defineAuthorizedTableUseCase({ mode: 'all', requestId: requestId(input), triggeredByUserId, + capabilityGovernedUserId, }) return { table: context.table, @@ -165,6 +175,7 @@ export const startTableRun = defineAuthorizedTableUseCase({ limit: input.limit, requestId: requestId(input), triggeredByUserId, + capabilityGovernedUserId, }) return { table: context.table, diff --git a/apps/sim/lib/table/application/workspace-file-imports.ts b/apps/sim/lib/table/application/workspace-file-imports.ts index 25917059fc5..22febc596f5 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' @@ -226,6 +227,9 @@ async function batchInsertAll(params: { rows: RowData[] workspaceId: string userId: string + /** The gate's subject for enrichment the landed rows auto-fire; see + * {@link BatchInsertData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null assertNotAborted?: () => void }): Promise { let inserted = 0 @@ -238,6 +242,7 @@ async function batchInsertAll(params: { rows: batch, workspaceId: params.workspaceId, userId: params.userId, + capabilityGovernedUserId: params.capabilityGovernedUserId, secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), }, { ...params.table, rowCount: params.table.rowCount + inserted }, @@ -401,6 +406,7 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ }), workspaceId: context.workspaceId, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), assertNotAborted: input.assertNotAborted, }) const summary = summarizeRejections(rejections, cellsRejected, sourceFile) @@ -563,6 +569,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ rows, workspaceId: context.workspaceId, userId, + capabilityGovernedUserId: capabilityGovernedPrincipalUserId(principal), assertNotAborted: input.assertNotAborted, }) return { diff --git a/apps/sim/lib/table/backfill-governed-subject.test.ts b/apps/sim/lib/table/backfill-governed-subject.test.ts new file mode 100644 index 00000000000..40f03203deb --- /dev/null +++ b/apps/sim/lib/table/backfill-governed-subject.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ + +import { tableRowExecutions, userTableRows, workflowExecutionLogs } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockBatchUpdateRows, mockMaterializeExecutionData, mockGetFunctionalBlockOutput } = + vi.hoisted(() => ({ + mockBatchUpdateRows: vi.fn(), + mockMaterializeExecutionData: vi.fn(), + mockGetFunctionalBlockOutput: vi.fn(), + })) + +vi.mock('@/lib/table/rows/service', () => ({ + batchUpdateRows: mockBatchUpdateRows, +})) +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mockMaterializeExecutionData, +})) +vi.mock('@/lib/logs/execution/functional-outputs', () => ({ + getFunctionalBlockOutput: mockGetFunctionalBlockOutput, +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createTableRowSecretProvenanceFromRegistry: () => ({ complete: true, columns: {} }), +})) + +import { maybeBackfillGroupOutputs } from '@/lib/table/backfill-runner' + +const TABLE = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [] }, +} as unknown as TableDefinition + +/** Queues the four reads one inline backfill page makes, in the order it makes them. */ +function queueOnePage(): void { + queueTableRows(tableRowExecutions, [{ count: 1 }]) + queueTableRows(tableRowExecutions, [{ rowId: 'row-1', executionId: 'execution-1' }]) + queueTableRows(userTableRows, [{ id: 'row-1', data: {} }]) + queueTableRows(workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionData: {}, + }, + ]) + queueTableRows(tableRowExecutions, []) +} + +describe('backfill cascade governance', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockMaterializeExecutionData.mockResolvedValue({}) + mockGetFunctionalBlockOutput.mockReturnValue({ value: 'filled' }) + mockBatchUpdateRows.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + }) + + /** + * A backfilled cell is a dependency: `batchUpdateRows` starts every downstream + * group whose deps it just satisfied. Passing no subject there ran those + * cells with no per-tool gate, which is what `null` means on this field. + */ + it('cascades under the person who made the schema change', async () => { + queueOnePage() + + await maybeBackfillGroupOutputs({ + table: TABLE, + groupId: 'group-1', + outputs: [{ blockId: 'block-1', path: 'value', columnName: 'value' }], + overwrite: true, + requestId: 'request-1', + actorUserId: 'billed-owner', + capabilityGovernedUserId: 'member-1', + }) + + expect(mockBatchUpdateRows).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'billed-owner', + capabilityGovernedUserId: 'member-1', + }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }) + + /** + * The one payload that can omit the field is a large backfill enqueued before + * it existed and still running after the deploy. `actorUserId` is not a + * recovery: `attributedUserId` yields the workspace's billed account for a + * change made by a workspace API key, and nothing here tells that apart from + * a human — so borrowing it would apply a bystander's denylist, the exact + * substitution this field removes. Null for one deploy's worth of in-flight + * jobs is the least wrong of the available answers. + */ + it('keeps an absent subject null rather than borrowing the billing actor', async () => { + queueOnePage() + + await maybeBackfillGroupOutputs({ + table: TABLE, + groupId: 'group-1', + outputs: [{ blockId: 'block-1', path: 'value', columnName: 'value' }], + overwrite: true, + requestId: 'request-1', + actorUserId: 'billed-owner', + }) + + expect(mockBatchUpdateRows).toHaveBeenCalledWith( + expect.objectContaining({ capabilityGovernedUserId: null }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index 685bd43740b..582add45b7c 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -56,6 +56,31 @@ export interface TableBackfillPayload { overwrite: boolean /** User who triggered the schema change, for usage attribution on the row writes. */ actorUserId?: string | null + /** + * Person whose permission group gates any cell the backfill's writes cascade + * into. Separate from `actorUserId`, which is a billing attribution and names + * the workspace billed account when the schema change carried no human. Null + * when the change had no acting person. + * + * Absent only on a payload enqueued before this field existed and still + * running after the deploy that added it — a backfill over more rows than + * `BACKFILL_ASYNC_THRESHOLD_ROWS`, mid-flight at the cutover. Such a payload + * reads as null, and that is a deliberate choice between two wrong answers + * rather than the status quo: before this field, the cascaded cells gated on + * `actorUserId`, so for a session-made change the window loosens the gate for + * as long as that one job runs. + * + * Falling back to `actorUserId` would close that and open a worse one. + * `attributedUserId` yields the workspace's billed account for a change made + * by a workspace API key, and nothing on the payload distinguishes that id + * from a human actor — so the fallback would apply a bystander's denylist, + * which is the substitution this field exists to remove. Failing closed + * instead would abandon the backfill's writes entirely, turning a bounded + * governance edge into visible data loss on runs the schema change promised + * to fill. Null is the least wrong of the three, and the window is one + * deploy long. + */ + capabilityGovernedUserId?: string | null } /** @@ -136,8 +161,11 @@ async function processBackfillPage(opts: { execs: Array<{ rowId: string; executionId: string | null }> requestId: string actorUserId?: string | null + /** See {@link TableBackfillPayload.capabilityGovernedUserId}. */ + capabilityGovernedUserId?: string | null }): Promise { - const { table, outputs, overwrite, execs, requestId, actorUserId } = opts + const { table, outputs, overwrite, execs, requestId, actorUserId, capabilityGovernedUserId } = + opts const executionIdsByRow = new Map() for (const e of execs) { @@ -224,6 +252,15 @@ async function processBackfillPage(opts: { updates, workspaceId: table.workspaceId, actorUserId, + /** + * A backfill replays values already produced by earlier runs, but the + * cells it fills are dependencies: `batchUpdateRows` starts every + * downstream group whose deps just became satisfied. Those cells are + * governed by whoever made the schema change, carried separately from + * `actorUserId` — an attribution that names the workspace billed account + * when the change carried no human, whose denylist is nobody's to run. + */ + capabilityGovernedUserId: capabilityGovernedUserId ?? null, secretProvenanceByRowId, }, table, @@ -242,7 +279,8 @@ async function processBackfillPage(opts: { * passes skip already-filled cells). */ export async function runTableBackfill(payload: TableBackfillPayload): Promise { - const { jobId, tableId, groupId, outputs, overwrite, actorUserId } = payload + const { jobId, tableId, groupId, outputs, overwrite, actorUserId, capabilityGovernedUserId } = + payload const requestId = generateId().slice(0, 8) try { @@ -268,6 +306,7 @@ export async function runTableBackfill(payload: TableBackfillPayload): Promise { - const { table, groupId, outputs, overwrite, requestId, actorUserId } = opts + const { table, groupId, outputs, overwrite, requestId, actorUserId, capabilityGovernedUserId } = + opts if (outputs.length === 0) return const [{ count: completedCount }] = await db @@ -347,7 +389,15 @@ export async function maybeBackfillGroupOutputs(opts: { const execs = await selectCompletedExecPage(table.id, groupId, afterRowId, BACKFILL_PAGE_SIZE) if (execs.length === 0) break afterRowId = execs[execs.length - 1].rowId - await processBackfillPage({ table, outputs, overwrite, execs, requestId, actorUserId }) + await processBackfillPage({ + table, + outputs, + overwrite, + execs, + requestId, + actorUserId, + capabilityGovernedUserId, + }) } return } @@ -370,6 +420,7 @@ export async function maybeBackfillGroupOutputs(opts: { outputs, overwrite, actorUserId, + capabilityGovernedUserId, } if (isTriggerDevEnabled) { try { diff --git a/apps/sim/lib/table/cell-write.test.ts b/apps/sim/lib/table/cell-write.test.ts index 61fbbaee7b7..c49bf6675eb 100644 --- a/apps/sim/lib/table/cell-write.test.ts +++ b/apps/sim/lib/table/cell-write.test.ts @@ -157,6 +157,8 @@ describe('writeWorkflowGroupState', () => { workspaceId: TABLE.workspaceId, executionsPatch: { [GROUP.id]: RUNNING_STATE }, cancellationGuard: { groupId: GROUP.id, executionId: CONTEXT.executionId }, + /** A cell result carries no acting person down to the write layer. */ + capabilityGovernedUserId: null, secretProvenance, }, TABLE, diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 789d8702b56..72b5f7968bd 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -93,6 +93,12 @@ export async function writeWorkflowGroupState( executionsPatch, cancellationGuard, secretProvenance: payload.secretProvenance, + /** + * A cell result carries no acting person down to this layer — the + * write has no `actorUserId` either, so any cascade it fires is + * already actorless on both the meter and the gate. + */ + capabilityGovernedUserId: null, }, table, requestId, diff --git a/apps/sim/lib/table/dispatch-governed-subject.test.ts b/apps/sim/lib/table/dispatch-governed-subject.test.ts new file mode 100644 index 00000000000..b1d4937cef3 --- /dev/null +++ b/apps/sim/lib/table/dispatch-governed-subject.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/table/events', () => ({ + appendTableEvent: vi.fn(), +})) +vi.mock('@/lib/table/service', () => ({ + getTableById: vi.fn(), +})) + +import { insertDispatch } from '@/lib/table/dispatcher' + +const BASE = { + tableId: 'table-1', + workspaceId: 'workspace-1', + requestId: 'req-1', + mode: 'all' as const, + scope: { groupIds: ['group-1'] }, + isManualRun: true, +} + +/** The values `insertDispatch` handed to the single `db.insert(...).values(...)`. */ +function insertedRow(): Record { + expect(dbChainMockFns.values).toHaveBeenCalledTimes(1) + return dbChainMockFns.values.mock.calls[0][0] as Record +} + +describe('insertDispatch governed subject', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The bug this replaces: an optional field defaulting to `triggeredByUserId` + * meant a workspace-key auto-dispatch stored the workspace billed account as + * its gate subject — a bystander whose tool denylist would then run against + * a request nobody meant to govern. + */ + it('stores null for an actorless run even when the attribution names a user', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: null, + }) + const row = insertedRow() + expect(row.triggeredByUserId).toBe('billing-owner') + expect(row.capabilityGovernedUserId).toBeNull() + }) + + it('stores the acting person for a session-triggered run', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'user-1', + capabilityGovernedUserId: 'user-1', + }) + const row = insertedRow() + expect(row.capabilityGovernedUserId).toBe('user-1') + }) + + /** + * The two fields are independent: a delegated run can be metered to the payer + * while staying governed by the person who asked for it. + */ + it('keeps the gate subject independent of the meter subject', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: 'requesting-user', + }) + const row = insertedRow() + expect(row.triggeredByUserId).toBe('billing-owner') + expect(row.capabilityGovernedUserId).toBe('requesting-user') + }) + + /** + * A row written before the column existed reads `capability_governed_user_id` + * as NULL with `triggered_by_user_id` still set. Under the new semantics that + * shape means "actorless, ungated" — which is why the 0315 migration + * backfills the legacy subject onto non-terminal pre-migration rows rather + * than letting them fall through to it. + */ + it('never reconstructs the gate subject from the attribution', async () => { + await insertDispatch({ + ...BASE, + triggeredByUserId: 'user-1', + capabilityGovernedUserId: null, + }) + expect(insertedRow().capabilityGovernedUserId).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index c20d1d837a5..d05e905819d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -98,6 +98,10 @@ export interface DispatchRow { isManualRun: boolean /** User who triggered the run (for usage attribution); null for auto-fire. */ triggeredByUserId: string | null + /** Person whose permission group gates this run's cells; null when the run + * has no acting person. Deliberately not `triggeredByUserId` — see the + * column comment on `table_run_dispatches`. */ + capabilityGovernedUserId: string | null requestedAt: Date /** Set when the dispatch reached `complete`; null while it is still active. */ completedAt: Date | null @@ -248,6 +252,14 @@ export async function insertDispatch(input: { limit?: DispatchLimit | null isManualRun: boolean triggeredByUserId?: string | null + /** + * The person whose permission group gates this run's cells, or `null` when + * the run has no acting person (workspace key, schedule, auto-fire). + * + * Never defaulted from `triggeredByUserId`, and required with an explicit + * `null`; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. + */ + capabilityGovernedUserId: string | null }): Promise { const id = `tdsp_${generateId().replace(/-/g, '')}` await db.insert(tableRunDispatches).values({ @@ -265,6 +277,7 @@ export async function insertDispatch(input: { cursor: -1, isManualRun: input.isManualRun, triggeredByUserId: input.triggeredByUserId ?? null, + capabilityGovernedUserId: input.capabilityGovernedUserId, }) return id } @@ -349,6 +362,7 @@ export async function listActiveDispatches(tableId: string): Promise ({ ...p, dispatchId, triggeredByUserId: dispatch.triggeredByUserId ?? undefined })) + capabilityGovernedUserId: dispatch.capabilityGovernedUserId, + }).map((p) => ({ + ...p, + dispatchId, + triggeredByUserId: dispatch.triggeredByUserId ?? undefined, + })) // Cursor advances to the last position in this chunk regardless of // eligibility — otherwise a window full of skipped cells loops forever. @@ -790,6 +810,15 @@ async function stampQueuedForBatch( jobId: null, workflowId: runOpts.workflowId, error: null, + /** + * The marker outlives this dispatch's own worker: a cell task that + * finds the row's cascade lock held bails, and whoever owns the lock + * drains this marker instead. Persisting the subject is what makes + * that drain run under the person who requested THIS cell rather + * than under the owner's — a different dispatch, and often an + * actorless auto-fire with no gate at all. + */ + capabilityGovernedUserId: runOpts.capabilityGovernedUserId, }, } ) @@ -1026,6 +1055,7 @@ export async function cancelStaleDispatches( processedCount: row.processedCount, isManualRun: row.isManualRun, triggeredByUserId: row.triggeredByUserId, + capabilityGovernedUserId: row.capabilityGovernedUserId, requestedAt: row.requestedAt, completedAt: row.completedAt, cancelledAt: row.cancelledAt, @@ -1109,6 +1139,7 @@ export async function markActiveDispatchesCancelled( processedCount: row.processedCount, isManualRun: row.isManualRun, triggeredByUserId: row.triggeredByUserId, + capabilityGovernedUserId: row.capabilityGovernedUserId, requestedAt: row.requestedAt, completedAt: row.completedAt, cancelledAt: row.cancelledAt, diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 808a4108f5d..e1da840c599 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -265,7 +265,14 @@ export async function importAppendRows( table: TableDefinition, additions: { id?: string; name: string; type: string; required?: boolean; unique?: boolean }[], rows: RowData[], - ctx: { workspaceId: string; userId?: string; requestId: string } + ctx: { + workspaceId: string + userId?: string + requestId: string + /** Gate subject for cells the appended rows auto-fire — the subject the + * importing surface resolved from its principal, or `null` for none. */ + capabilityGovernedUserId: string | null + } ): Promise<{ inserted: TableRow[]; table: TableDefinition }> { // Gate capacity before opening the tx — the lookup is a separate pool read. const rowLimit = await assertRowCapacity({ @@ -294,6 +301,7 @@ export async function importAppendRows( rows: batch, workspaceId: ctx.workspaceId, userId: ctx.userId, + capabilityGovernedUserId: ctx.capabilityGovernedUserId, secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), }, working, diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts index dfac5d90e8d..1212f5795e6 100644 --- a/apps/sim/lib/table/orchestration/import.test.ts +++ b/apps/sim/lib/table/orchestration/import.test.ts @@ -91,6 +91,7 @@ function importParams(overrides: Record = {}) { mode: 'append' as const, timezone: 'UTC', requestId: 'req-1', + capabilityGovernedUserId: 'user-1' as string | null, ...overrides, } } @@ -133,6 +134,43 @@ describe('performTableCsvImport', () => { expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') }) + /** + * The rows an import lands start the table's workflow columns, and those + * cells gate their tools on the governed subject. Dropping it here would run + * the importing member's cells with no per-tool gate at all — the one thing + * `null` means on this field. + */ + it('dispatches the auto-fired cells under the importing person', async () => { + await performTableCsvImport(importParams({ capabilityGovernedUserId: 'user-9' })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'req-1', + 'user-1', + 'user-9' + ) + expect(mockImportAppendRows).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ capabilityGovernedUserId: 'user-9' }) + ) + }) + + /** An actorless import still says so explicitly rather than by omission. */ + it('carries a null subject through unchanged', async () => { + await performTableCsvImport(importParams({ capabilityGovernedUserId: null })) + + expect(mockDispatchAfterBatchInsert).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'req-1', + 'user-1', + null + ) + }) + it('reports the deleted count on a replace', async () => { const result = await performTableCsvImport(importParams({ mode: 'replace' })) @@ -344,6 +382,7 @@ describe('performCreateTableFromCsv', () => { folderId: null, timezone: 'UTC', requestId: 'req-1', + capabilityGovernedUserId: 'user-1', } } diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts index bc9c0d69a5a..5b2dc97549b 100644 --- a/apps/sim/lib/table/orchestration/import.ts +++ b/apps/sim/lib/table/orchestration/import.ts @@ -234,6 +234,14 @@ export interface PerformTableCsvImportParams { /** IANA zone used to read naive datetimes (Excel/Sheets exports carry no offset). */ timezone: string requestId?: string + /** + * The person whose permission group gates any cell this import auto-fires, + * or `null` when no person is behind it. An import lands rows, and landing + * rows starts the table's workflow and enrichment cells. Threaded from the + * surface that holds the principal — the route has already gated the same + * subject. Required; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. + */ + capabilityGovernedUserId: string | null } export interface TableCsvImportData extends ImportRejectionFields { @@ -271,8 +279,17 @@ export interface PerformTableCsvImportResult { export async function performTableCsvImport( params: PerformTableCsvImportParams ): Promise { - const { table, workspaceId, userId, fileStream, fileName, fallbackDelimiter, mode, timezone } = - params + const { + table, + workspaceId, + userId, + fileStream, + fileName, + fallbackDelimiter, + mode, + timezone, + capabilityGovernedUserId, + } = params const requestId = params.requestId ?? generateRequestId() if (table.archivedAt) return fail('Cannot import into an archived table', 'validation') @@ -367,10 +384,11 @@ export async function performTableCsvImport( workspaceId, userId, requestId, + capabilityGovernedUserId, }) // Fire trigger + scheduler AFTER the tx commits — both read through the // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, inserted, requestId, userId) + dispatchAfterBatchInsert(finalTable, inserted, requestId, userId, capabilityGovernedUserId) logger.info(`[${requestId}] Append CSV imported`, { tableId: table.id, @@ -418,6 +436,15 @@ export async function performTableCsvImport( export interface PerformCreateTableFromCsvParams { workspaceId: string userId: string + /** + * The person whose permission group gates any cell this import auto-fires, + * or `null` when no person is behind it. An import lands rows, and landing + * rows starts the table's workflow and enrichment cells. Threaded from the + * surface that holds the principal — the route has already gated the same + * subject. Required; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. + */ + capabilityGovernedUserId: string | null + /** Multipart file stream. The caller still owns destroying it. */ fileStream: Readable fileName: string @@ -461,8 +488,16 @@ export interface PerformCreateTableFromCsvResult { export async function performCreateTableFromCsv( params: PerformCreateTableFromCsvParams ): Promise { - const { workspaceId, userId, fileStream, fileName, fallbackDelimiter, folderId, timezone } = - params + const { + workspaceId, + userId, + fileStream, + fileName, + fallbackDelimiter, + folderId, + timezone, + capabilityGovernedUserId, + } = params const requestId = params.requestId ?? generateRequestId() const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) @@ -507,6 +542,7 @@ export async function performCreateTableFromCsv( rows: coerced as RowData[], workspaceId, userId, + capabilityGovernedUserId, secretProvenance: coerced.map(createExactEmptyTableRowSecretProvenance), }, // The created table's rowCount is frozen at 0; pass the running total so the diff --git a/apps/sim/lib/table/prestamp-governed-subject.test.ts b/apps/sim/lib/table/prestamp-governed-subject.test.ts new file mode 100644 index 00000000000..70bdf7e0bdb --- /dev/null +++ b/apps/sim/lib/table/prestamp-governed-subject.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + writeWorkflowGroupState: vi.fn(), + batchEnqueueAndWait: vi.fn(), +})) + +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/cell-write', () => ({ + writeWorkflowGroupState: mocks.writeWorkflowGroupState, +})) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot, + resolveBillingAttribution: async () => ({ actorUserId: 'billing-owner' }), + resolveSystemBillingAttribution: async () => ({ actorUserId: null }), +})) +vi.mock('@/lib/core/async-jobs/config', () => ({ + getJobQueue: async () => ({ batchEnqueueAndWait: mocks.batchEnqueueAndWait }), +})) + +import { dispatcherStep } from '@/lib/table/dispatcher' + +const GROUP = { id: 'group-1', workflowId: 'workflow-1', outputs: [] } + +const DISPATCH = { + id: 'tdsp_1', + tableId: 'table-1', + workspaceId: 'workspace-1', + requestId: 'req-1', + mode: 'incomplete', + scope: { groupIds: ['group-1'] }, + status: 'dispatching', + cursor: -1, + limit: null, + processedCount: 0, + isManualRun: true, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: 'requesting-member', + requestedAt: new Date('2026-08-21T15:00:00.000Z'), + completedAt: null, + cancelledAt: null, +} + +describe('the dispatcher pre-stamp', () => { + /** + * `buildEnqueueItems` resolves the cell task with a dynamic import of + * `@/background/workflow-column-execution` — the largest graph this step + * touches, and one none of this file's mocks intercept. Under a loaded + * parallel run that first resolution costs whole seconds, which is why the + * only test here needed a 20s budget to hold. Warm it once, outside any + * per-test budget, so the test measures the pre-stamp rather than a module + * load. + */ + beforeAll(async () => { + await Promise.all([ + import('@/background/workflow-column-execution'), + import('@/lib/table/workflow-columns'), + ]) + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getTableById.mockResolvedValue({ + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [GROUP] }, + }) + mocks.writeWorkflowGroupState.mockResolvedValue('wrote') + dbChainMockFns.limit + .mockResolvedValueOnce([DISPATCH]) + .mockResolvedValueOnce([{ id: 'row-1', tableId: 'table-1', position: 0, data: {} }]) + .mockResolvedValueOnce([DISPATCH]) + }) + + /** + * The marker outlives its own worker: a cell task that finds the row's + * cascade lock held bails, and the lock owner drains the marker. Without the + * subject on the stamp, that drain runs the request under the owner's + * subject — a different dispatch, often an ungated auto-fire. + */ + it('stamps the dispatch’s governed subject onto every cell it queues', async () => { + await dispatcherStep('tdsp_1') + + expect(mocks.writeWorkflowGroupState).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + executionState: expect.objectContaining({ + status: 'pending', + capabilityGovernedUserId: 'requesting-member', + }), + }) + ) + }) +}) diff --git a/apps/sim/lib/table/resume-context-governed-subject.test.ts b/apps/sim/lib/table/resume-context-governed-subject.test.ts new file mode 100644 index 00000000000..aa9f977502e --- /dev/null +++ b/apps/sim/lib/table/resume-context-governed-subject.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + findCellContextByExecutionId, + stashCellContextForResume, +} from '@/lib/table/workflow-columns' + +const CONTEXT = { + executionId: 'execution-1', + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + capabilityGovernedUserId: 'requesting-member', +} + +/** + * The pause snapshot is `paused_executions.metadata`, a jsonb document, so the + * subject rides it without a schema change. What this pins is that it is + * actually written and read back — the resume worker has no other source for + * it once the row's marker has been claimed. + */ +describe('the governed subject across a pause', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('writes the subject into the stashed cell context', async () => { + await stashCellContextForResume(CONTEXT) + + const [{ metadata }] = dbChainMockFns.set.mock.calls[0] + /** The jsonb literal the `||` merge appends, as bound to the SQL template. */ + const [, serializedPatch] = (metadata as { values: string[] }).values + expect(JSON.parse(serializedPatch).cellContext).toMatchObject({ + capabilityGovernedUserId: 'requesting-member', + }) + }) + + it('reads the stashed subject back', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { metadata: { cellContext: { ...CONTEXT, executionId: undefined } } }, + ]) + + const context = await findCellContextByExecutionId('execution-1') + + expect(context?.capabilityGovernedUserId).toBe('requesting-member') + }) + + /** A pause stashed before the subject was carried must read as ungated, not + * as `undefined` leaking into the payload the compiler now requires. */ + it('normalizes a legacy stash with no subject to null', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + metadata: { + cellContext: { + tableId: 'table-1', + tableName: 'Table', + rowId: 'row-1', + groupId: 'group-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + }, + ]) + + const context = await findCellContextByExecutionId('execution-1') + + expect(context).not.toBeNull() + expect(context?.capabilityGovernedUserId).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/rows/executions.test.ts b/apps/sim/lib/table/rows/executions.test.ts index dff9b36ae57..01ad6b1c56d 100644 --- a/apps/sim/lib/table/rows/executions.test.ts +++ b/apps/sim/lib/table/rows/executions.test.ts @@ -33,6 +33,48 @@ describe('writeExecutionsPatch guards', () => { resetDbChainMock() }) + /** + * The dispatcher's `pending` marker is drained by whichever worker owns the + * row's cascade lock, which may belong to another dispatch entirely. Storing + * the requesting subject with the marker is what lets that drain run under + * the person who asked rather than under the owner's own subject. + */ + it('persists the pre-stamp’s governed subject on both the insert and the upsert', async () => { + await writeExecutionsPatch( + dbChainMock.db as unknown as Parameters[0], + 'table-1', + 'row-1', + { + 'group-1': { + ...EXECUTION_STATE, + status: 'pending', + executionId: null, + capabilityGovernedUserId: 'requesting-member', + }, + } + ) + + const values = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(values.capabilityGovernedUserId).toBe('requesting-member') + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as { + set: Record + } + expect(conflict.set.capabilityGovernedUserId).toBe('requesting-member') + }) + + /** A write that names no subject clears it — only an unclaimed marker is read. */ + it('writes null for a state that carries no subject', async () => { + await writeExecutionsPatch( + dbChainMock.db as unknown as Parameters[0], + 'table-1', + 'row-1', + { 'group-1': EXECUTION_STATE } + ) + + const values = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(values.capabilityGovernedUserId).toBeNull() + }) + it('rejects a worker write when the atomic stale-or-cancel predicate returns no row', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index e1cae632391..3ed355573c6 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -5,6 +5,7 @@ * directly from `@/lib/table/rows/executions`. */ +import { db } from '@sim/db' import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' @@ -309,6 +310,12 @@ export async function writeExecutionsPatch( runningBlockIds: value.runningBlockIds ?? [], blockErrors: value.blockErrors ?? {}, cancelledAt: value.cancelledAt ? new Date(value.cancelledAt) : null, + /** + * Written verbatim rather than made sticky like `enrichmentDetails`: only + * an unclaimed pre-stamp is ever read for it, and a re-stamp by a + * different dispatch must not inherit the previous run's subject. + */ + capabilityGovernedUserId: value.capabilityGovernedUserId ?? null, enrichmentDetails: value.enrichmentDetails ?? null, updatedAt: new Date(), } as const @@ -346,6 +353,7 @@ export async function writeExecutionsPatch( runningBlockIds: insertValues.runningBlockIds, blockErrors: insertValues.blockErrors, cancelledAt: insertValues.cancelledAt, + capabilityGovernedUserId: insertValues.capabilityGovernedUserId, // Sticky: preserve a prior cascade breakdown when this write omits // it (e.g. the running pickup stamp) so only an explicit detail // overwrites it. Re-runs delete the row first, so this never serves @@ -374,6 +382,7 @@ export async function writeExecutionsPatch( runningBlockIds: insertValues.runningBlockIds, blockErrors: insertValues.blockErrors, cancelledAt: insertValues.cancelledAt, + capabilityGovernedUserId: insertValues.capabilityGovernedUserId, // Sticky: preserve a prior cascade breakdown when this write omits it // (e.g. the running pickup stamp) so only an explicit detail overwrites // it. Re-runs delete the row first, so this never serves stale detail. @@ -386,6 +395,87 @@ export async function writeExecutionsPatch( return 'wrote' } +/** + * The governed subject persisted with a cell's dispatcher pre-stamp. + * + * Read on the drain path only — a worker taking over a `pending` marker it did + * not stamp — so the column stays off the hot grid read (`loadExecutionsByRow`) + * and never reaches a client. Returns `null` for a marker written before the + * column existed and for a genuinely actorless request; both mean the same + * thing to the gate. + */ +export async function readStampedCapabilitySubject( + rowId: string, + groupId: string +): Promise { + const [stamped] = await db + .select({ capabilityGovernedUserId: tableRowExecutions.capabilityGovernedUserId }) + .from(tableRowExecutions) + .where(and(eq(tableRowExecutions.rowId, rowId), eq(tableRowExecutions.groupId, groupId))) + .limit(1) + return stamped?.capabilityGovernedUserId ?? null +} + +/** One cell whose unclaimed marker {@link cancelPendingMarkersForGovernedSubject} stopped. */ +export interface CancelledCellMarker { + tableId: string + rowId: string + groupId: string +} + +/** + * Terminalizes every still-unstarted cell marker stamped with `userId`, in the + * caller's transaction. + * + * Cancelling the departing account's `table_run_dispatches` rows is not enough + * on its own. A pre-stamp on `table_row_executions` is drained by whichever + * worker holds the row's cascade lock, and that worker's dispatch-cancel guard + * consults ITS OWN dispatch — so an unrelated, still-active sibling dispatch + * happily drains the deleted person's marker. The subject reference is + * `ON DELETE SET NULL`, which by then makes the marker indistinguishable from a + * legitimately actorless request: the drain runs it with no per-tool gate at + * all. Going terminal here is the same honest reading the dispatch cancel takes + * — a deleted person's runs stop rather than silently lose their gate. + * + * Scoped to `pending`/`queued` because those are the states a marker sits in + * before a worker claims it; a claimed or terminal row carries no subject to + * match anyway. The written state is the canonical cancel + * (`buildCancelledExecution`), which every drain path's `isExecCancelled` check + * already refuses to run. + * + * Returns what it stopped so the caller can announce it: this write is not the + * cancel path the UI listens to, and a collaborator watching the table would + * otherwise keep the cells on their in-flight pill until something else touched + * the row. + */ +export async function cancelPendingMarkersForGovernedSubject( + trx: DbOrTx, + userId: string +): Promise { + const now = new Date() + return trx + .update(tableRowExecutions) + .set({ + status: 'cancelled', + jobId: null, + error: 'Cancelled', + runningBlockIds: [], + cancelledAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableRowExecutions.capabilityGovernedUserId, userId), + inArray(tableRowExecutions.status, ['pending', 'queued']) + ) + ) + .returning({ + tableId: tableRowExecutions.tableId, + rowId: tableRowExecutions.rowId, + groupId: tableRowExecutions.groupId, + }) +} + /** * Strips the given workflow group ids from every row's executions on a table — * used by the column / group delete paths so stale running/queued exec records diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index f036f1efce0..ebed42b9c74 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -242,6 +242,7 @@ export async function insertRow( isManualRun: false, requestId, triggeredByUserId: data.userId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err)) return insertedRow @@ -279,7 +280,7 @@ export async function batchInsertRows( addedRows: result.length, limit: rowLimit, }) - dispatchAfterBatchInsert(table, result, requestId, data.userId) + dispatchAfterBatchInsert(table, result, requestId, data.userId, data.capabilityGovernedUserId) return result } @@ -401,7 +402,9 @@ export function dispatchAfterBatchInsert( table: TableDefinition, result: TableRow[], requestId: string, - actorUserId?: string | null + actorUserId: string | null | undefined, + /** The gate's subject for the auto-fire pass; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null ): void { void fireTableTrigger( table.id, @@ -425,6 +428,7 @@ export function dispatchAfterBatchInsert( isManualRun: false, requestId, triggeredByUserId: actorUserId, + capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) } @@ -923,6 +927,7 @@ export async function upsertRow( isManualRun: false, requestId, triggeredByUserId: data.userId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err)) return result @@ -1875,6 +1880,7 @@ export async function updateRow( groupIds: inFlightDownstreamGroups, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err) @@ -1889,6 +1895,7 @@ export async function updateRow( isManualRun: false, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err)) return updatedRow @@ -2077,7 +2084,9 @@ function dispatchBulkUpdateEffects( patch: RowData, now: Date, requestId: string, - actorUserId: BulkUpdateData['actorUserId'] + actorUserId: BulkUpdateData['actorUserId'], + /** The gate's subject for the auto-fire pass; see {@link BulkUpdateData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null ): void { const affectedRowIdSet = new Set(affectedRowIds) const affectedRows = rows.filter((row) => affectedRowIdSet.has(row.id)) @@ -2110,6 +2119,7 @@ function dispatchBulkUpdateEffects( isManualRun: false, requestId, triggeredByUserId: actorUserId, + capabilityGovernedUserId, }).catch((error) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, error) ) @@ -2241,7 +2251,8 @@ export async function updateRowsByFilter( data.data, now, requestId, - data.actorUserId + data.actorUserId, + data.capabilityGovernedUserId ) afterId = nextAfterId if (batchRows.length < TABLE_LIMITS.UPDATE_BATCH_SIZE) break @@ -2311,7 +2322,8 @@ export async function updateRowsByFilter( data.data, now, requestId, - data.actorUserId + data.actorUserId, + data.capabilityGovernedUserId ) return { @@ -2550,6 +2562,7 @@ export async function batchUpdateRows( groupIds: inFlightDownstreamGroups, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } } catch (err) { @@ -2569,6 +2582,7 @@ export async function batchUpdateRows( isManualRun: false, requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) } diff --git a/apps/sim/lib/table/run-column-governed-subject.test.ts b/apps/sim/lib/table/run-column-governed-subject.test.ts new file mode 100644 index 00000000000..86c3ca3be52 --- /dev/null +++ b/apps/sim/lib/table/run-column-governed-subject.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getTableById: vi.fn(), + insertDispatch: vi.fn(async () => 'tdsp_1'), + readDispatch: vi.fn(async () => null), + cancelDispatchById: vi.fn(), + bulkClearWorkflowGroupCells: vi.fn(async () => false), + runDispatcherToCompletion: vi.fn(), + resolveTableDispatchConcurrency: vi.fn(async () => 5), +})) + +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) +vi.mock('@/lib/table/dispatcher', () => ({ + bulkClearWorkflowGroupCells: mocks.bulkClearWorkflowGroupCells, + cancelDispatchById: mocks.cancelDispatchById, + insertDispatch: mocks.insertDispatch, + readDispatch: mocks.readDispatch, + runDispatcherToCompletion: mocks.runDispatcherToCompletion, +})) +vi.mock('@/lib/table/dispatch-concurrency', () => ({ + resolveTableDispatchConcurrency: mocks.resolveTableDispatchConcurrency, +})) + +import { runWorkflowColumn } from '@/lib/table/workflow-columns' + +const TABLE = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [{ id: 'group-1', outputs: [] }] }, +} + +const BASE = { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupIds: ['group-1'], + mode: 'new' as const, + isManualRun: false, + requestId: 'req-1', +} + +/** The dispatch row `runWorkflowColumn` asked the dispatcher to insert. */ +function inserted(): Record { + expect(mocks.insertDispatch).toHaveBeenCalledTimes(1) + return mocks.insertDispatch.mock.calls[0][0] as Record +} + +describe('runWorkflowColumn governed subject', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getTableById.mockResolvedValue(TABLE) + mocks.insertDispatch.mockResolvedValue('tdsp_1') + mocks.readDispatch.mockResolvedValue(null) + mocks.bulkClearWorkflowGroupCells.mockResolvedValue(false) + mocks.resolveTableDispatchConcurrency.mockResolvedValue(5) + }) + + /** + * The row-write auto-fire case: a workspace API key wrote the row, so the + * attribution names the workspace billed account. Forwarding that as the gate + * subject — which an optional field with a fallback did — puts a bystander's + * tool denylist on a run nobody governs. + */ + it('forwards an explicit null past a non-null attribution', async () => { + await runWorkflowColumn({ + ...BASE, + triggeredByUserId: 'billing-owner', + capabilityGovernedUserId: null, + }) + const row = inserted() + expect(row.triggeredByUserId).toBe('billing-owner') + expect(row.capabilityGovernedUserId).toBeNull() + }) + + it('forwards the acting person for a session-initiated run', async () => { + await runWorkflowColumn({ + ...BASE, + triggeredByUserId: 'user-1', + capabilityGovernedUserId: 'user-1', + }) + expect(inserted().capabilityGovernedUserId).toBe('user-1') + }) +}) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index a3a9ba68f2e..98747c75ed6 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -248,6 +248,14 @@ export interface RowExecutionMetadata { * re-runs whose `cancelledAt > dispatch.requestedAt` — a user cancel * mid-dispatch must not be overridden by `isManualRun`. */ cancelledAt?: string + /** + * Person whose permission group gates this cell's tools, written with the + * dispatcher's `pending` pre-stamp so the worker that eventually drains the + * marker runs it under the subject that requested it rather than its own. + * Persisted on `tableRowExecutions` but NOT hydrated by `loadExecutionsByRow` + * — it is read on demand, only while the marker is still unclaimed. + */ + capabilityGovernedUserId?: string | null /** * Enrichment cascade breakdown for `enrichment`-type groups, written on the * terminal cell write. Persisted on `tableRowExecutions` but NOT hydrated by @@ -708,6 +716,26 @@ export interface InsertRowData { * unstamped write. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** + * The person whose permission group gates any enrichment this write + * auto-fires; `null` when the write has no acting person (workspace API key, + * schedule, internal state patch). + * + * THE statement of the rule for every table payload that carries this field. + * It is deliberately not the attribution field beside it, which names the + * workspace billed account when the credential names no human and would run + * that bystander's tool denylist against an actorless run. Which principals + * a group governs at all is `capabilityGovernedPrincipalUserId` in + * `@/lib/core/application`; every surface resolves the subject there and + * threads it down rather than re-deriving it. + * + * Required with an explicit `null` rather than optional: the only way to get + * this wrong is to not think about it, and an optional field with a fallback + * let every producer that had not been taught the distinction silently + * inherit the attribution. Making omission a compile error is what stops the + * next producer from re-introducing that bystander substitution. + */ + capabilityGovernedUserId: string | null } export interface BatchInsertData { @@ -722,6 +750,9 @@ export interface BatchInsertData { orderKeys?: string[] /** Encrypted provenance for the values in `rows`, positionally aligned. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: Array | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null } export interface UpsertRowData { @@ -733,6 +764,9 @@ export interface UpsertRowData { conflictTarget?: string /** Encrypted provenance for the values in `data`. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null } export interface UpsertResult { @@ -781,6 +815,9 @@ export interface UpdateRowData { actorUserId?: string | null /** Encrypted provenance for the values in this partial patch. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null } export interface BulkUpdateData { @@ -791,6 +828,9 @@ export interface BulkUpdateData { actorUserId?: string | null /** Encrypted provenance for the values in this partial patch. Required; see {@link InsertRowData.secretProvenance}. */ secretProvenance: TableRowSecretProvenanceWrite | undefined + /** The person whose permission group gates any enrichment this write + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null } export interface BatchUpdateByIdData { @@ -805,6 +845,9 @@ export interface BatchUpdateByIdData { actorUserId?: string | null /** Encrypted provenance for the values in all partial patches; omitted by legacy callers. */ secretProvenanceByRowId?: Record + /** The person whose permission group gates any enrichment this write + * auto-fires. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null } export interface BulkDeleteData { @@ -942,8 +985,14 @@ export interface AddWorkflowGroupData { autoRun?: boolean /** Persist auto-run state without dispatching through the primitive. */ suppressAutoRunDispatch?: boolean - /** The member adding the group — billed/gated for the auto-run enrichment pass. */ + /** The member adding the group — billed for the auto-run enrichment pass. */ actorUserId?: string | null + /** The person whose permission group gates the auto-run pass this write can + * start; `null` when the write has no acting person (workspace key, system). + * Required with an explicit `null` — deliberately not `actorUserId`, which + * is an attribution and names the workspace billed account when the + * credential names no human. */ + capabilityGovernedUserId: string | null } /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ @@ -981,8 +1030,11 @@ export interface UpdateWorkflowGroupData { autoRun?: boolean /** Skip primitive dispatch when an authorized caller will start the run itself. */ suppressAutoRunDispatch?: boolean - /** The member updating the group — billed/gated for any triggered re-run. */ + /** The member updating the group — billed for any triggered re-run. */ actorUserId?: string | null + /** The person whose permission group gates the auto-run pass this write can + * start. Required; see {@link InsertRowData.capabilityGovernedUserId}. */ + capabilityGovernedUserId: string | null } export interface DeleteWorkflowGroupData { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 76d9bbafa50..7fd9c6a640f 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -192,6 +192,11 @@ export interface ScheduleOpts { groupIds?: string[] isManualRun?: boolean mode?: DispatchMode + /** Person whose permission group gates every cell this batch emits, or `null` + * for an actorless run. Required so a new call site cannot emit a payload + * with no gate by simply not thinking about one; see + * {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ + capabilityGovernedUserId: string | null } /** Pure eligibility filter + payload building. Shared by the auto-fire path @@ -199,15 +204,15 @@ export interface ScheduleOpts { export function buildPendingRuns( table: TableDefinition, rows: TableRow[], - opts?: ScheduleOpts + opts: ScheduleOpts ): WorkflowGroupCellPayload[] { const allGroups = table.schema.workflowGroups ?? [] if (allGroups.length === 0) return [] if (rows.length === 0) return [] - const groupIdFilter = opts?.groupIds + const groupIdFilter = opts.groupIds ? new Set(opts.groupIds) - : opts?.groupId + : opts.groupId ? new Set([opts.groupId]) : null const groups = groupIdFilter ? allGroups.filter((g) => groupIdFilter.has(g.id)) : allGroups @@ -221,8 +226,8 @@ export function buildPendingRuns( for (const row of orderedRows) { for (const group of groups) { const reason = classifyEligibility(group, row, { - isManualRun: opts?.isManualRun, - mode: opts?.mode, + isManualRun: opts.isManualRun, + mode: opts.mode, }) reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1 if (reason !== 'eligible' && reason !== 'manual-bypass') continue @@ -235,6 +240,7 @@ export function buildPendingRuns( ...(group.enrichmentId ? { enrichmentId: group.enrichmentId } : {}), workspaceId: table.workspaceId, executionId: generateId(), + capabilityGovernedUserId: opts.capabilityGovernedUserId, }) } } @@ -449,6 +455,13 @@ export interface WorkflowGroupCellPayload { * auto-fire (row writes, CSV import) → billing falls back to the workspace * billed account. */ triggeredByUserId?: string + /** Person whose permission group gates this cell's tools. Null/absent means + * no acting person, so no per-tool gate applies. Not `triggeredByUserId`; + * see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. + * Required like every sibling in `@/lib/table/types`: an omitted key and a + * deliberate `null` both read as "ungated", so the compiler is what makes a + * caller state which one it means. */ + capabilityGovernedUserId: string | null } export type QueuedWorkflowGroupCellPayload = Omit< @@ -730,6 +743,8 @@ export async function cancelWorkflowGroupRuns( secretProvenance: undefined, workspaceId: table.workspaceId, executionsPatch: mutation.executionsPatch, + /** A cancellation stamp writes no cell values and fires no enrichment. */ + capabilityGovernedUserId: null, }, table, `wfgrp-cancel-${mutation.rowId}` @@ -865,6 +880,10 @@ export async function runWorkflowColumn(opts: { * callers (row writes, CSV import) → falls back to the workspace billed * account at billing time. */ triggeredByUserId?: string | null + /** Person whose permission group gates the run's cells; `null` when the run + * has no acting person (workspace key, schedule, auto-fire). Required, and + * never defaulted from `triggeredByUserId`; see {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ + capabilityGovernedUserId: string | null }): Promise<{ dispatchId: string | null; shouldSignalRowsChanged: boolean }> { const { tableId, @@ -877,6 +896,7 @@ export async function runWorkflowColumn(opts: { excludeRowIds, limit, triggeredByUserId, + capabilityGovernedUserId, } = opts const isManualRun = opts.isManualRun ?? true // Empty `rowIds` array means "scope explicitly empty" — auto-fire callers @@ -945,6 +965,7 @@ export async function runWorkflowColumn(opts: { limit, isManualRun, triggeredByUserId, + capabilityGovernedUserId, }) try { @@ -1083,10 +1104,24 @@ export interface CellResumeContext { groupId: string workspaceId: string workflowId: string + /** + * Person whose permission group gates the tools of everything this cell's + * run still has to do. Required, because a pause is the one boundary where + * the subject would otherwise be reconstructed from scratch: the resumed + * cascade is driven by the resume worker, whose payload carries no dispatch + * and no row marker to re-read it from. `null` is the actorless run — no + * per-tool gate — and has to be written, not inferred from an absent key. + * + * Lives in `paused_executions.metadata`, a jsonb document, so carrying it + * needs no schema change: a pause row written before this field existed + * reads back `undefined`, which the resume worker normalizes to `null`. + */ + capabilityGovernedUserId: string | null } interface PausedMetadataPatch { - cellContext?: CellResumeContext + /** Read back from jsonb, so a pause written before a field existed lacks it. */ + cellContext?: Partial & Omit [key: string]: unknown } @@ -1132,7 +1167,13 @@ export async function findCellContextByExecutionId( .where(eq(pausedExecutions.executionId, executionId)) .limit(1) const meta = row?.metadata as PausedMetadataPatch | null - return meta?.cellContext ?? null + const stored = meta?.cellContext + if (!stored) return null + return { + ...stored, + /** A pause stashed before the subject was carried is an ungated resume. */ + capabilityGovernedUserId: stored.capabilityGovernedUserId ?? null, + } } catch (err) { logger.error(`Failed to read cell context for executionId=${executionId}:`, err) return null diff --git a/apps/sim/lib/table/workflow-groups/service.test.ts b/apps/sim/lib/table/workflow-groups/service.test.ts index 179736c2b55..d36ec47276d 100644 --- a/apps/sim/lib/table/workflow-groups/service.test.ts +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -161,6 +161,7 @@ describe('workflow group TTL availability', () => { groupId: 'group-1', blockId: 'block-1', path: 'expiresAt', + capabilityGovernedUserId: null, resolvedOutput: { workflowId: 'workflow-1', columnType: 'ttl', order: [] }, }, 'request-1' diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 056f10dd316..0796cb81b91 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -246,6 +246,7 @@ export async function addWorkflowGroup( groupIds: [data.group.id], requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) } @@ -570,6 +571,7 @@ export async function updateWorkflowGroup( overwrite: false, requestId, actorUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.warn( @@ -588,6 +590,7 @@ export async function updateWorkflowGroup( overwrite: true, requestId, actorUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.warn( @@ -609,6 +612,7 @@ export async function updateWorkflowGroup( groupIds: [data.groupId], requestId, triggeredByUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) ) @@ -634,8 +638,13 @@ export async function addWorkflowGroupOutput( path: string /** Optional override; defaults to a slug derived from `path`. */ columnName?: string - /** The member adding the output — billed/gated for any backfill-triggered re-run. */ + /** The member adding the output — the billing attribution for the backfill's + * row writes. Not the gate: see `capabilityGovernedUserId`. */ actorUserId?: string | null + /** Person whose permission group gates any cell the backfill's writes + * cascade into; `null` when the change has no acting person. Required; see + * {@link InsertRowData.capabilityGovernedUserId} in `@/lib/table/types`. */ + capabilityGovernedUserId: string | null resolvedOutput: { workflowId: string columnType: ColumnDefinition['type'] @@ -867,6 +876,7 @@ export async function addWorkflowGroupOutput( overwrite: false, requestId, actorUserId: data.actorUserId, + capabilityGovernedUserId: data.capabilityGovernedUserId, }) } catch (err) { logger.warn( diff --git a/apps/sim/lib/users/account-deletion-cancel-announcement.test.ts b/apps/sim/lib/users/account-deletion-cancel-announcement.test.ts new file mode 100644 index 00000000000..f2498b1180d --- /dev/null +++ b/apps/sim/lib/users/account-deletion-cancel-announcement.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + isSoleOwnerOfPaidOrganization: vi.fn(), + getPersonalSubscription: vi.fn(), + isUsingCloudStorage: vi.fn(), + appendTableEvent: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + isSoleOwnerOfPaidOrganization: mocks.isSoleOwnerOfPaidOrganization, +})) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mocks.getPersonalSubscription, +})) +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mocks.isUsingCloudStorage, + StorageService: { deleteFiles: vi.fn(async () => ({ failed: [] })) }, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + reassignBilledAccountForUser: vi.fn(async () => ({ unresolved: [] })), + reassignOwnedWorkspacesForUser: vi.fn(async () => ({ unresolved: [] })), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mocks.appendTableEvent })) + +import { deleteUserAccount } from '@/lib/users/account-deletion' + +const DISPATCH_ROWS = [ + { + id: 'tdsp_1', + tableId: 'table-1', + scope: { groupIds: ['group-1'] }, + cursor: 4, + mode: 'all', + isManualRun: true, + }, +] +const MARKER_ROWS = [{ tableId: 'table-1', rowId: 'row-1', groupId: 'group-2' }] + +describe('announcing the work a deleted account’s cancels stopped', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.isSoleOwnerOfPaidOrganization.mockResolvedValue({ isSoleOwner: false, name: null }) + mocks.getPersonalSubscription.mockResolvedValue(null) + mocks.isUsingCloudStorage.mockReturnValue(false) + mocks.appendTableEvent.mockResolvedValue(null) + // The two cancels are the only `.returning()` reads this teardown makes: no + // workspace is doomed, so the workspace-delete block never runs. + dbChainMockFns.returning.mockResolvedValueOnce(DISPATCH_ROWS).mockResolvedValueOnce(MARKER_ROWS) + }) + + /** + * These writes bypass the ordinary cancel path, which is what publishes the + * terminal events. Without them a collaborator in a surviving workspace keeps + * watching a dispatch that will never advance, and cells stay on their + * in-flight pill until something unrelated touches the row. + */ + it('publishes the same terminal dispatch and cell events a Stop would', async () => { + await deleteUserAccount('user-1') + + expect(mocks.appendTableEvent).toHaveBeenCalledWith({ + kind: 'dispatch', + tableId: 'table-1', + dispatchId: 'tdsp_1', + status: 'cancelled', + scope: { groupIds: ['group-1'] }, + cursor: 4, + mode: 'all', + isManualRun: true, + }) + expect(mocks.appendTableEvent).toHaveBeenCalledWith({ + kind: 'cell', + tableId: 'table-1', + rowId: 'row-1', + groupId: 'group-2', + status: 'cancelled', + executionId: null, + jobId: null, + error: 'Cancelled', + }) + }) + + /** + * The event log is not transactional, so an event published inside the + * transaction would announce a cancellation a rollback then undoes. + */ + it('publishes nothing when the teardown is rolled back', async () => { + dbChainMockFns.transaction.mockImplementationOnce(async () => { + throw new Error('rolled back') + }) + + await expect(deleteUserAccount('user-1')).rejects.toThrow('rolled back') + expect(mocks.appendTableEvent).not.toHaveBeenCalled() + }) + + /** + * A dispatcher that read its status as active a moment ago can still stamp a + * marker. Its insert's foreign key needs a `FOR KEY SHARE` on the departing + * user's row, which this `FOR UPDATE` conflicts with — so every concurrent + * stamp either commits before the marker cancel can miss it, or blocks until + * the `user` delete has landed and is refused outright. Without it a stamp + * landing between the cancel and the delete is nulled by `ON DELETE SET NULL` + * and drained by a sibling worker as actorless. + */ + it('locks the departing user’s row before cancelling anything it governs', async () => { + await deleteUserAccount('user-1') + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + const lockedAt = Math.min(...dbChainMockFns.for.mock.invocationCallOrder) + const cancelledAt = Math.min( + ...dbChainMockFns.update.mock.calls + .map((call, index) => ({ call, index })) + .filter(({ call }) => call[0] === schemaMock.tableRunDispatches) + .map(({ index }) => dbChainMockFns.update.mock.invocationCallOrder[index]) + ) + expect(lockedAt).toBeLessThan(cancelledAt) + }) +}) diff --git a/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts b/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts new file mode 100644 index 00000000000..ecd9fb429e5 --- /dev/null +++ b/apps/sim/lib/users/account-deletion-dispatch-cancel.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsSoleOwnerOfPaidOrganization, mockGetPersonalSubscription, mockIsUsingCloudStorage } = + vi.hoisted(() => ({ + mockIsSoleOwnerOfPaidOrganization: vi.fn(), + mockGetPersonalSubscription: vi.fn(), + mockIsUsingCloudStorage: vi.fn(), + })) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + isSoleOwnerOfPaidOrganization: mockIsSoleOwnerOfPaidOrganization, +})) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, +})) +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mockIsUsingCloudStorage, + StorageService: { deleteFiles: vi.fn(async () => ({ failed: [] })) }, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + reassignBilledAccountForUser: vi.fn(async () => ({ unresolved: [] })), + reassignOwnedWorkspacesForUser: vi.fn(async () => ({ unresolved: [] })), +})) + +import { deleteUserAccount } from '@/lib/users/account-deletion' + +describe('deleteUserAccount and the governed-subject foreign key', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSoleOwnerOfPaidOrganization.mockResolvedValue({ isSoleOwner: false, name: null }) + mockGetPersonalSubscription.mockResolvedValue(null) + mockIsUsingCloudStorage.mockReturnValue(false) + }) + + /** + * `capability_governed_user_id` is `ON DELETE SET NULL`, and a subject the + * database erased reads exactly like a run that never had one — so a dispatch + * that outlived its governor would keep executing its remaining windows with + * no per-tool gate at all. The in-process dispatcher has no time ceiling, so + * that is not a short window. Going terminal first is what keeps the nulled + * row unreachable. + */ + it('cancels the account’s still-queued dispatches before deleting the user row', async () => { + await deleteUserAccount('user-1') + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.tableRunDispatches) + const cancelled = dbChainMockFns.set.mock.calls.find( + ([patch]) => (patch as { status?: string }).status === 'cancelled' + ) + expect(cancelled).toBeDefined() + expect((cancelled?.[0] as { cancelledAt?: Date }).cancelledAt).toBeInstanceOf(Date) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.user) + }) + + /** + * The subject, not the attribution: `triggered_by_user_id` names the workspace + * billed account when the run's credential named no human, so cancelling on it + * would stop a workspace-key run this account never governed and leave the + * account's own actorless-looking rows alive. + */ + it('cancels on the governed subject and only the still-active statuses', async () => { + await deleteUserAccount('user-1') + + const filter = dbChainMockFns.where.mock.calls + .map(([condition]) => condition) + .find((condition) => + hasMockCondition( + condition, + (node) => node.left === schemaMock.tableRunDispatches.capabilityGovernedUserId + ) + ) + expect(filter).toBeDefined() + expect(hasMockCondition(filter, (node) => node.type === 'eq' && node.right === 'user-1')).toBe( + true + ) + expect( + hasMockCondition( + filter, + (node) => + node.type === 'inArray' && + node.column === schemaMock.tableRunDispatches.status && + Array.isArray(node.values) && + node.values.join(',') === 'pending,dispatching' + ) + ).toBe(true) + }) + + /** The cancel must precede the delete, or the FK has already nulled the subject. */ + it('orders the cancel ahead of the user delete', async () => { + await deleteUserAccount('user-1') + + const cancelOrder = dbChainMockFns.update.mock.invocationCallOrder.at(-1) + const deleteOrder = dbChainMockFns.delete.mock.invocationCallOrder.at(-1) + expect(cancelOrder).toBeDefined() + expect(deleteOrder).toBeDefined() + expect(cancelOrder as number).toBeLessThan(deleteOrder as number) + }) +}) diff --git a/apps/sim/lib/users/account-deletion-marker-cancel.test.ts b/apps/sim/lib/users/account-deletion-marker-cancel.test.ts new file mode 100644 index 00000000000..11c206c0ae0 --- /dev/null +++ b/apps/sim/lib/users/account-deletion-marker-cancel.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsSoleOwnerOfPaidOrganization, mockGetPersonalSubscription, mockIsUsingCloudStorage } = + vi.hoisted(() => ({ + mockIsSoleOwnerOfPaidOrganization: vi.fn(), + mockGetPersonalSubscription: vi.fn(), + mockIsUsingCloudStorage: vi.fn(), + })) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + isSoleOwnerOfPaidOrganization: mockIsSoleOwnerOfPaidOrganization, +})) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, +})) +vi.mock('@/lib/uploads', () => ({ + isUsingCloudStorage: mockIsUsingCloudStorage, + StorageService: { deleteFiles: vi.fn(async () => ({ failed: [] })) }, +})) +vi.mock('@/lib/workspaces/utils', () => ({ + reassignBilledAccountForUser: vi.fn(async () => ({ unresolved: [] })), + reassignOwnedWorkspacesForUser: vi.fn(async () => ({ unresolved: [] })), +})) + +import { deleteUserAccount } from '@/lib/users/account-deletion' + +/** The `where` filter of the update that targets `table_row_executions`. */ +function markerCancelFilter() { + return dbChainMockFns.where.mock.calls + .map(([condition]) => condition) + .find((condition) => + hasMockCondition( + condition, + (node) => node.left === schemaMock.tableRowExecutions.capabilityGovernedUserId + ) + ) +} + +describe('deleteUserAccount and the account’s pre-stamped cell markers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSoleOwnerOfPaidOrganization.mockResolvedValue({ isSoleOwner: false, name: null }) + mockGetPersonalSubscription.mockResolvedValue(null) + mockIsUsingCloudStorage.mockReturnValue(false) + }) + + /** + * Cancelling only the dispatches leaves the markers those dispatches already + * stamped. A marker is drained by whichever worker holds the row's cascade + * lock, and that worker's guard consults its OWN dispatch — so an unrelated + * active dispatch drains the departing account's marker, whose `SET NULL` + * subject then reads as an actorless run with no per-tool gate. + */ + it('terminalizes the account’s still-unstarted markers', async () => { + await deleteUserAccount('user-1') + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.tableRowExecutions) + const filter = markerCancelFilter() + expect(filter).toBeDefined() + expect(hasMockCondition(filter, (node) => node.type === 'eq' && node.right === 'user-1')).toBe( + true + ) + }) + + /** The same terminal state a cancel writes, so every `isExecCancelled` drain + * guard already refuses to run it. */ + it('writes the canonical cancelled cell state', async () => { + await deleteUserAccount('user-1') + + const cancelled = dbChainMockFns.set.mock.calls + .map(([patch]) => patch as { status?: string; cancelledAt?: Date; error?: string }) + .filter((patch) => patch.status === 'cancelled') + expect(cancelled.some((patch) => patch.error === 'Cancelled')).toBe(true) + expect( + cancelled.every( + (patch) => patch.cancelledAt === undefined || patch.cancelledAt instanceof Date + ) + ).toBe(true) + }) + + /** Only the states a marker sits in before a worker claims it. */ + it('leaves running and terminal cells alone', async () => { + await deleteUserAccount('user-1') + + expect( + hasMockCondition( + markerCancelFilter(), + (node) => + node.type === 'inArray' && + node.column === schemaMock.tableRowExecutions.status && + Array.isArray(node.values) && + node.values.join(',') === 'pending,queued' + ) + ).toBe(true) + }) + + /** After the FK nulls the subject there is nothing left to match on. */ + it('runs before the user row is deleted', async () => { + await deleteUserAccount('user-1') + + const markerCancelOrder = dbChainMockFns.update.mock.calls.reduce( + (found, call, index) => + call[0] === schemaMock.tableRowExecutions + ? dbChainMockFns.update.mock.invocationCallOrder[index] + : found, + undefined as number | undefined + ) + const deleteOrder = dbChainMockFns.delete.mock.invocationCallOrder.at(-1) + expect(markerCancelOrder).toBeDefined() + expect(markerCancelOrder as number).toBeLessThan(deleteOrder as number) + }) +}) diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index 98c063edc0f..447beba1b30 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -6,6 +6,7 @@ import { member, organization, permissions, + tableRunDispatches, user, workspaceFile, workspaceFiles, @@ -22,6 +23,11 @@ import type { import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' import { isSoleOwnerOfPaidOrganization } from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { appendTableEvent, type TableEvent } from '@/lib/table/events' +import { + type CancelledCellMarker, + cancelPendingMarkersForGovernedSubject, +} from '@/lib/table/rows/executions' import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { @@ -509,6 +515,66 @@ async function purgeStorageObjects(batches: StorageKeyBatch[]): Promise { } } +/** One dispatch the deletion stopped, in the shape its terminal event needs. */ +interface CancelledDispatch { + id: string + tableId: string + scope: unknown + cursor: number + mode: string + isManualRun: boolean +} + +/** + * Publishes the terminal events for work the deletion cancelled. + * + * The cancels above are direct writes rather than the ordinary cancel path, so + * nothing had announced them: a collaborator in a surviving workspace kept + * watching a dispatch that will never advance and cells stuck on their in-flight + * pill. These are the same two events `markActiveDispatchesCancelled` and the + * cell writers publish, so the client reconciles exactly as it does for a Stop. + * + * After the commit, never inside it: an event announcing a rollback would be a + * lie, and the SSE log is not transactional. Failures are logged rather than + * raised — the account is already gone, and the periodic refetch is the backstop. + */ +async function announceCancelledTableWork( + dispatches: CancelledDispatch[], + markers: CancelledCellMarker[] +): Promise { + const events = [ + ...dispatches.map((dispatch) => + appendTableEvent({ + kind: 'dispatch' as const, + tableId: dispatch.tableId, + dispatchId: dispatch.id, + status: 'cancelled' as const, + scope: (dispatch.scope ?? undefined) as Extract['scope'], + cursor: dispatch.cursor, + mode: dispatch.mode as 'all' | 'incomplete' | 'new', + isManualRun: dispatch.isManualRun, + }) + ), + ...markers.map((marker) => + appendTableEvent({ + kind: 'cell' as const, + tableId: marker.tableId, + rowId: marker.rowId, + groupId: marker.groupId, + status: 'cancelled' as const, + executionId: null, + jobId: null, + error: 'Cancelled', + }) + ), + ] + const results = await Promise.allSettled(events) + const failed = results.filter((result) => result.status === 'rejected').length + if (failed > 0) { + logger.warn('Some cancellation events were not published during account deletion', { failed }) + } +} + /** * Erases an account and everything only it can reach. * @@ -545,6 +611,9 @@ export async function deleteUserAccount(userId: string): Promise { if (doomedWorkspaceIds.length > 0) { /** @@ -604,9 +673,76 @@ export async function deleteUserAccount(userId: string): Promise extends ApplicationOperation { readonly principalKinds: readonly ['session'] } -function defineUserAccountOperation(id: Id): UserAccountOperation { - return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +/** + * Bakes the session-only principal policy into the operation — + * `requireUserAccountPrincipal` reads `principalKinds` off it at authorization + * time — and refuses a missing capability at definition time, the same guard + * every other operation factory carries. + */ +function defineUserAccountOperation( + operation: ApplicationOperation +): UserAccountOperation { + assertOperationCapability(operation) + return Object.freeze({ ...operation, principalKinds: Object.freeze(['session'] as const) }) } /** - * Operations an account performs on itself. They carry no workspace scope and no - * role: the resource *is* the authenticated principal, so a session is both the - * only acceptable credential and the whole authorization story. That policy is - * enforced where it can actually hold — `internalSessionAuth` on the route and - * the principal guard in each use case — rather than restated as inert data here. + * Operations an account performs on itself. They carry no workspace scope and + * no role: the resource *is* the authenticated principal, so a session is both + * the only acceptable credential and the whole authorization story, enforced by + * `internalSessionAuth` on the route and `requireUserAccountPrincipal` in each + * use case. */ export const userAccountOperations = { - readProfile: defineUserAccountOperation('users.account.profile.read'), - readSettings: defineUserAccountOperation('users.account.settings.read'), - previewDeletion: defineUserAccountOperation('users.account.deletion_preview'), - delete: defineUserAccountOperation('users.account.delete'), + // permission-group-exempt: reading your own profile is not a workspace act, so no group key names it + readProfile: defineUserAccountOperation({ id: 'users.account.profile.read', capability: 'none' }), + // permission-group-exempt: reading your own account settings is not a workspace act, so no group key names it + readSettings: defineUserAccountOperation({ + id: 'users.account.settings.read', + capability: 'none', + }), + // permission-group-exempt: the resource is the account itself, and a permission group scopes a workspace the account may leave rather than the account + previewDeletion: defineUserAccountOperation({ + id: 'users.account.deletion_preview', + capability: 'none', + }), + // permission-group-exempt: deleting your own account is not a workspace act, so no group key names it + delete: defineUserAccountOperation({ id: 'users.account.delete', capability: 'none' }), } as const satisfies Record diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index a9acf0dc61a..a88a6d652b4 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -85,8 +85,18 @@ vi.mock('@/lib/billing/core/subscription', () => ({ hasWorkspaceSandboxAccess: mocks.sandboxAccess, })) vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: mocks.blockVisibility })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ +vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mocks.permissionConfig, + /** + * The use case passes the organization it already loaded, so the resolver + * takes its verified-context branch rather than looking the workspace up + * again. + */ + resolveVerifiedUserAccessControlContext: async ( + userId: string, + workspaceId: string, + _organizationId: string | null + ) => ({ config: await mocks.permissionConfig(userId, workspaceId) }), })) vi.mock('@/blocks/visibility/server-context', () => ({ withBlockVisibility: (_state: unknown, run: () => unknown) => run(), diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index ff5a34aebb9..0aff805b423 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -9,6 +9,7 @@ import { ForbiddenOperationError, principalAuditSource } from '@/lib/core/applic import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { @@ -54,7 +55,6 @@ import { import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { withBlockVisibility } from '@/blocks/visibility/server-context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' @@ -274,7 +274,11 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ const baseGraph = await resolveBaseGraph(principal, input, context) const [permissionConfig, blockVisibility] = await Promise.all([ - getUserPermissionConfig(subjectUserId, context.workspaceId), + resolvePermissionGroupConfig( + subjectUserId, + context.workspaceId, + context.workspaceOrganizationId + ), getBlockVisibility({ userId: subjectUserId, orgId: context.workspaceOrganizationId }), ]) @@ -417,6 +421,12 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, attributedUserId: subjectUserId, + /** + * The same id line 287 already resolves the permission config against. + * This operation denies workspace API keys, so the attribution and the + * governed subject are the same human and cannot diverge here. + */ + subjectUserId, state: { blocks: graph.blocks, edges: graph.edges }, }) diff --git a/apps/sim/lib/workflows/application/chat-deployments.ts b/apps/sim/lib/workflows/application/chat-deployments.ts index eb6dc4f1a70..44b27bede10 100644 --- a/apps/sim/lib/workflows/application/chat-deployments.ts +++ b/apps/sim/lib/workflows/application/chat-deployments.ts @@ -14,7 +14,6 @@ import { getChatDeploymentIdOwningIdentifier, getLiveChatDeploymentForWorkflow, } from '@/lib/chat-deployments/queries' -import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -22,10 +21,7 @@ import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' +import { validateChatDeployAuth } from '@/ee/access-control/utils/permission-check' type ChatAuthType = 'public' | 'password' | 'email' | 'sso' type ChatOutputConfig = { workflowId?: string; blockId: string; path: string } @@ -172,14 +168,7 @@ export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ const subjectUserId = requirePrincipalSubjectUserId(principal) if (authType !== existingDeployment?.authType) { - try { - await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - throw new ForbiddenOperationError('CHAT_AUTH_MODE_NOT_PERMITTED', error.message) - } - throw error - } + await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) } const attribution = resolvePrincipalAttribution(principal, { diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index ecad2d84428..d1cded44422 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' @@ -48,6 +49,7 @@ export interface ExportWorkflowResult { function importErrorCode(status: number): OrchestrationErrorCode { if (status === 400) return 'validation' if (status === 404) return 'not_found' + if (status === 403) return 'forbidden' if (status === 409) return 'conflict' if (status === 423) return 'locked' return 'internal' @@ -70,6 +72,7 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ description: input.description, workflow: input.workflow, userId: attribution.attributedUserId, + capabilityUserId: capabilityGovernedPrincipalUserId(principal), requestId: generateRequestId(), }) if (!result.success) { diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.test.ts b/apps/sim/lib/workflows/application/list-workflow-runs.test.ts new file mode 100644 index 00000000000..17a21a13841 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflow-runs.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + * + * `logs.cost` is a PROJECTION, not a gate — a group withholds the figure from + * the response rather than refusing the read, which is why `workflows.listRuns` + * correctly declares `capability: 'none'`. + * + * This listing carries the same per-run total every other log surface withholds, + * and applied none of it: an enterprise member whose group hides spend read it + * in full here through a personal API key. These run the real use case against + * the real `resolveLogFieldProjection`, so they fail if this surface stops + * projecting. + */ +import { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + listExecutions: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/executor/execution-queries', () => ({ + listWorkflowExecutions: mocks.listExecutions, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' + +const sessionPrincipal = { kind: 'session' as const, userId: 'user-1' } +const workspaceKeyPrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} + +const input = { workflowId: WORKFLOW_ID, limit: 10, order: 'desc' as const } + +function runRow(costTotal: string | null) { + return { rowId: 1, executionId: 'run-1', startedAt: new Date(), status: 'success', costTotal } +} + +beforeEach(() => { + vi.clearAllMocks() + resetPermissionGroupScopeMock() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkflowContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflowId: WORKFLOW_ID, + }) + mocks.listExecutions.mockResolvedValue({ data: [runRow('0.75')], nextCursor: null }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) +}) + +describe('listWorkflowRuns cost projection', () => { + it('blanks the per-run total when the group hides cost', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + + const result = await listWorkflowRuns.execute({ principal: sessionPrincipal, input }) + + expect(result.data[0].costTotal).toBeNull() + }) + + it('returns the total when the group withholds nothing', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + }) + + const result = await listWorkflowRuns.execute({ principal: sessionPrincipal, input }) + + expect(result.data[0].costTotal).toBe('0.75') + }) + + it('returns the total when no group governs the caller', async () => { + const result = await listWorkflowRuns.execute({ principal: sessionPrincipal, input }) + + expect(result.data[0].costTotal).toBe('0.75') + }) + + it('withholds nothing from a workspace API key, and never resolves a group', async () => { + const result = await listWorkflowRuns.execute({ principal: workspaceKeyPrincipal, input }) + + expect(result.data[0].costTotal).toBe('0.75') + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.ts b/apps/sim/lib/workflows/application/list-workflow-runs.ts index ab2b88b6a1c..5e4c078cfbc 100644 --- a/apps/sim/lib/workflows/application/list-workflow-runs.ts +++ b/apps/sim/lib/workflows/application/list-workflow-runs.ts @@ -1,3 +1,4 @@ +import { logProjectionSubjectUserId, resolveLogFieldProjection } from '@/lib/logs/log-projection' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -14,7 +15,23 @@ export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.listRuns, resolveContext: ({ input }: { input: ListWorkflowRunsInput }) => resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), - async execute({ context, input }) { + async execute({ principal, context, input }) { + /** + * The per-run total this listing carries is the same figure `hideCostInfo` + * withholds on every other log surface, so it is projected here rather than + * in the presenter — the withholding travels with the read. + * + * {@link logProjectionSubjectUserId} names nobody for a workspace API key, + * which represents no user and therefore no group — the key's creator is + * never substituted — nor for an executor delegation, which carries a role + * and no capabilities. This listing publishes no cost sort or filter, so + * there is no query surface to refuse alongside the value. + */ + const projection = await resolveLogFieldProjection( + logProjectionSubjectUserId(principal), + context.workspaceId, + context.workspaceOrganizationId + ) const result = await listWorkflowExecutions({ workflowId: context.workflowId, status: input.status, @@ -25,6 +42,13 @@ export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ cursor: input.cursor, order: input.order, }) - return { ...result, workflowId: context.workflowId, order: input.order } + return { + ...result, + data: projection.hideCostInfo + ? result.data.map((row) => ({ ...row, costTotal: null })) + : result.data, + workflowId: context.workflowId, + order: input.order, + } }, }) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 897c626b961..3d83968be1b 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -26,52 +26,68 @@ const COPILOT_WORKFLOW_PRINCIPAL_POLICY = { } as const export const workflowOperations = { + // permission-group-exempt: listing the workflows in a workspace is governed by workspace role; no group hides the workflow module list: defineWorkspaceOperation({ id: 'workflows.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: reading a workflow is governed by workspace role, not by a group capability read: defineWorkspaceOperation({ id: 'workflows.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), + // permission-group-exempt: reporting where a workflow is already deployed is a read of existing state; a group withholds the act of deploying, not the record of it readDeploymentOverview: defineWorkspaceOperation({ id: 'workflows.deployment_overview.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: reading a workflow's run inputs is workflow content; Chat itself is withheld by copilot.use at the chat surface readCopilotRunOptions: defineWorkspaceOperation({ id: 'workflows.copilot.run_options.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: reading a block's declared outputs is workflow content; Chat itself is withheld by copilot.use at the chat surface readCopilotBlockOutputs: defineWorkspaceOperation({ id: 'workflows.copilot.block_outputs.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: resolving which upstream blocks a block may reference is workflow content; Chat itself is withheld by copilot.use at the chat surface readCopilotUpstreamReferences: defineWorkspaceOperation({ id: 'workflows.copilot.upstream_references.read', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow module has no hide key, so creating a workflow is governed by workspace role alone create: defineWorkspaceOperation({ id: 'workflows.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: renaming or re-describing a workflow is governed by workspace role update: defineWorkspaceOperation({ id: 'workflows.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -87,11 +103,14 @@ export const workflowOperations = { * * Personal keys keep the capability, so headless authoring is unaffected for a * credential that names a human. + * + * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations inside replaceWorkflowNormalizedState, which this use case passes the principal's human subject to, not by a capability the authorization funnel can apply */ replaceState: defineWorkspaceOperation({ id: 'workflows.state.replace', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -110,150 +129,195 @@ export const workflowOperations = { * Personal keys keep the capability, so headless editing is unaffected for a * credential that names a human. Re-open this to workspace keys only once the * three lookups can express a workspace-scoped policy that fails closed. + * + * permission-group-exempt: which blocks a member may store is judged against allowedIntegrations inside replaceWorkflowNormalizedState, which this use case passes the principal's human subject to, not by a capability the authorization funnel can apply */ applyOperations: defineWorkspaceOperation({ id: 'workflows.operations.apply', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: restoring a soft-deleted workflow is governed by workspace role restore: defineWorkspaceOperation({ id: 'workflows.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a workflow's run policy is workspace-admin configuration; no group capability withholds it updatePolicy: defineWorkspaceOperation({ id: 'workflows.policy.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session'], }), + // permission-group-exempt: workflow variables are workflow content, governed by workspace role applyVariableOperations: defineWorkspaceOperation({ id: 'workflows.variables.apply_operations', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: toggling a block edits workflow content; which integrations a member may use is allowedIntegrations, enforced against the block type rather than the operation setBlockEnabled: defineWorkspaceOperation({ id: 'workflows.blocks.set_enabled', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: moving workflows between folders is placement, governed by workspace role moveBulk: defineWorkspaceOperation({ id: 'workflows.bulk.move', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role createVfsFolders: defineWorkspaceOperation({ id: 'workflows.vfs.folders.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role moveVfsItems: defineWorkspaceOperation({ id: 'workflows.vfs.move', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role copyVfsItems: defineWorkspaceOperation({ id: 'workflows.vfs.copy', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow file tree has no hide key; arranging it is governed by workspace role deleteVfsItems: defineWorkspaceOperation({ id: 'workflows.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: duplicating copies a graph the caller may already read into the same workspace, so it crosses no capability boundary duplicate: defineWorkspaceOperation({ id: 'workflows.duplicate', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a workflow is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), + // permission-group-exempt: running a workflow is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runUntilFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run_until', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a workflow is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runFromBlockFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run_from_block', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a single block is governed by workspace role; Chat itself is withheld by copilot.use at the chat surface runBlockFromCopilot: defineWorkspaceOperation({ id: 'workflows.copilot.run_block', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: deleting a workflow is governed by workspace role delete: defineWorkspaceOperation({ id: 'workflows.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; reading it is governed by workspace role listFolders: defineWorkspaceOperation({ id: 'workflows.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; arranging it is governed by workspace role createFolder: defineWorkspaceOperation({ id: 'workflows.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; arranging it is governed by workspace role relocateFolder: defineWorkspaceOperation({ id: 'workflows.folders.relocate', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: the workflow folder tree has no hide key; arranging it is governed by workspace role deleteFolder: defineWorkspaceOperation({ id: 'workflows.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), deploy: defineWorkspaceOperation({ id: 'workflows.deploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.api', ...WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY, }), undeploy: defineWorkspaceOperation({ id: 'workflows.undeploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.api', ...WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY, }), deployChat: defineWorkspaceOperation({ id: 'workflows.chat.deploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), undeployChat: defineWorkspaceOperation({ id: 'workflows.chat.undeploy', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.chat', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -263,95 +327,125 @@ export const workflowOperations = { * a principal here: the operation removes the authentication requirement from * a deployed workflow, which needs an accountable human rather than a machine * credential or an agent acting on a prompt. + * + * permission-group-exempt: `public_api.use` is asserted inside the use case and only for the enabling direction, because a group that withholds public execution must still let an admin withdraw execution a workflow already has */ updatePublicApi: defineWorkspaceOperation({ id: 'workflows.public_api.update', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['session', 'personal_api_key'], }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'deploy.api', ...WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY, }), + // permission-group-exempt: reverting the draft to an earlier version edits workflow content; deployment capabilities govern what is served, not what is edited revertVersion: defineWorkspaceOperation({ id: 'workflows.versions.revert', minimumRole: 'admin', workspaceApiKey: 'deny', + capability: 'none', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a version's name and description are metadata on workflow content, governed by workspace role updateVersion: defineWorkspaceOperation({ id: 'workflows.versions.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: version history is workflow content, governed by workspace role listVersions: defineWorkspaceOperation({ id: 'workflows.versions.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), + // permission-group-exempt: version history is workflow content, governed by workspace role readVersion: defineWorkspaceOperation({ id: 'workflows.versions.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...WORKFLOW_READ_PRINCIPAL_POLICY, }), + // permission-group-exempt: comparing references across two versions reads workflow content the caller may already open compareReferences: defineWorkspaceOperation({ id: 'workflows.versions.compare_references', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'none', ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: an export returns the graph its reader can already open; logs.export withholds execution logs, not definitions export: defineWorkspaceOperation({ id: 'workflows.export', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: importing is workflow authoring governed by workspace role; the blocks the payload carries are judged against allowedIntegrations before they are persisted import: defineWorkspaceOperation({ id: 'workflows.import', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: running a workflow from an authenticated surface is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation execute: defineWorkspaceOperation({ id: 'workflows.execute', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a manual run is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation executeManual: defineWorkspaceOperation({ id: 'workflows.manual.execute', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['personal_api_key'], }), + // permission-group-exempt: a manual run is governed by workspace role; public_api.use withholds the unauthenticated surface, which does not reach this operation executeManualFromBlock: defineWorkspaceOperation({ id: 'workflows.manual.execute_from_block', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'none', principalKinds: ['personal_api_key'], }), + // permission-group-exempt: execution history is governed by workspace role; logs.cost and logs.trace_spans withhold fields inside a run, not the right to read one listRuns: defineWorkspaceOperation({ id: 'workflows.runs.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: execution history is governed by workspace role; logs.cost and logs.trace_spans withhold fields inside a run, not the right to read one readRun: defineWorkspaceOperation({ id: 'workflows.runs.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: a paused execution's detail is pause points and resume state, not the run's execution data — the fields logs.cost and logs.trace_spans withhold never appear here readPausedExecution: defineWorkspaceOperation({ id: 'workflows.paused_executions.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), /** @@ -360,23 +454,43 @@ export const workflowOperations = { * the run resource does not; it keeps `readRun`'s policy because the resource * being authorized is still the run — a run file is reachable only through * the run that recorded it, never as a standalone workspace file. + * + * `logs.trace_spans` is deliberately not a gate here, and the distinction is + * the one that capability draws everywhere else: it withholds *fields* inside + * a run, not the right to read one. `readRun` therefore withholds the file + * *listing* from a viewer whose group hides execution data — the descriptors + * are that data, and `includeFileBase64` is its bytes — while this operation, + * which resolves one already-named file id, stays governed by workspace role. + * A run file id exists nowhere but the execution data the same projection + * withholds, so hiding the listing removes the way to name a file rather than + * the right to fetch a named one. + * + * If that ever needs to become a refusal rather than a projection, it belongs + * in `capability` on this operation, where the funnel applies it — not in a + * check at one of the surfaces that reach it. */ + // permission-group-exempt: a run's own output bytes belong to the run its reader may already open; files.bulk_download withholds the workspace file store, and logs.trace_spans withholds the run's listed fields — including readRun's file list — not the right to fetch one named file downloadRunFile: defineWorkspaceOperation({ id: 'workflows.download_run_file', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: stopping a run already in flight is governed by workspace role cancelRun: defineWorkspaceOperation({ id: 'workflows.runs.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + // permission-group-exempt: answering a paused run is governed by workspace role resumeRun: defineWorkspaceOperation({ id: 'workflows.runs.resume', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workflows/application/read-workflow-run.test.ts b/apps/sim/lib/workflows/application/read-workflow-run.test.ts index a4a1d7cb735..10e8e25c54c 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.test.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.test.ts @@ -32,7 +32,7 @@ vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowRunApplicationContext: mocks.resolveContext, })) vi.mock('@/lib/workflows/executor/execution-status', () => ({ - getWorkflowExecutionStatus: mocks.getStatus, + getProjectedWorkflowExecutionStatus: mocks.getStatus, })) vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ getWorkflowRunFiles: mocks.getRunFiles, @@ -53,29 +53,81 @@ const context = { const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const NO_PROJECTION = { hideTraceSpans: false, hideCostInfo: false } + const BLOCK_ID = '2f9c2d4e-1a3b-4c5d-8e7f-0a1b2c3d4e5f' function input(selectedOutputs: string[]) { return { workflowId: 'workflow-1', runId: 'run-1', includeOutput: true, selectedOutputs } } +/** + * `logs.cost` and `logs.trace_spans` withhold fields inside a run, and the shared + * read applies them — but only for the subject this use case names. A workspace + * API key authorizes as the workspace and represents no user, so it must resolve + * to none: substituting the key's creator would apply a bystander's group to + * every caller of a shared credential. + */ +describe('readWorkflowRun projection subject', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getRunFiles.mockResolvedValue(null) + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: {} }, + projection: NO_PROJECTION, + }) + }) + + it('names the acting user as the projection subject', async () => { + await readWorkflowRun.execute({ principal, input: input([]) }) + + expect(mocks.getStatus).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + viewerUserId: 'user-1', + }) + ) + }) + + it('names no subject for a workspace API key', async () => { + const workspaceKey = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await readWorkflowRun.execute({ principal: workspaceKey, input: input([]) }) + + expect(mocks.getStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null })) + }) +}) + describe('readWorkflowRun selector resolution', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolveContext.mockResolvedValue(context) mocks.resolvePermission.mockResolvedValue('read') mocks.getRunFiles.mockResolvedValue(null) - mocks.getStatus.mockResolvedValue({ status: 'completed', blockOutputs: {} }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: {} }, + projection: NO_PROJECTION, + }) }) it('rejects a block-name selector against a recorded output projection', async () => { mocks.getStatus.mockResolvedValue({ - status: 'completed', - blockOutputs: { [BLOCK_ID]: { content: 'hi' } }, + status: { status: 'completed', blockOutputs: { [BLOCK_ID]: { content: 'hi' } } }, + projection: NO_PROJECTION, + }) + await expect( + readWorkflowRun.execute({ principal, input: input(['Agent 1']) }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('did not resolve to any block on this run: Agent 1'), }) - await expect(readWorkflowRun.execute({ principal, input: input(['Agent 1']) })).rejects.toThrow( - /did not resolve to any block on this run: Agent 1/ - ) }) /** @@ -84,14 +136,20 @@ describe('readWorkflowRun selector resolution', () => { * nothing selected — the silent empty answer the check exists to remove. */ it('rejects a block-name selector on a run with no recorded output projection', async () => { - mocks.getStatus.mockResolvedValue({ status: 'queued', blockOutputs: null }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'queued', blockOutputs: null }, + projection: NO_PROJECTION, + }) await expect(readWorkflowRun.execute({ principal, input: input(['Agent 1']) })).rejects.toThrow( /did not resolve to any block on this run: Agent 1/ ) }) it('accepts a well-formed block id on a run with no recorded output projection', async () => { - mocks.getStatus.mockResolvedValue({ status: 'queued', blockOutputs: null }) + mocks.getStatus.mockResolvedValue({ + status: { status: 'queued', blockOutputs: null }, + projection: NO_PROJECTION, + }) await expect( readWorkflowRun.execute({ principal, input: input([`${BLOCK_ID}.content`]) }) ).resolves.toMatchObject({ status: 'queued', blockOutputs: null }) @@ -103,3 +161,63 @@ describe('readWorkflowRun selector resolution', () => { ).resolves.toMatchObject({ status: 'completed', blockOutputs: {} }) }) }) + +/** + * A run's output files are its execution data: the descriptors name what the + * run produced and `includeFileBase64` returns the bytes. When the viewer's + * group withholds execution data under `logs.trace_spans`, the file list has to + * go with `finalOutput` and `blockOutputs` — otherwise the withheld output + * comes back one field over. + */ +describe('readWorkflowRun file projection', () => { + const WITHHELD = { hideTraceSpans: true, hideCostInfo: false } + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getRunFiles.mockResolvedValue({ + terminal: true, + workspaceId: 'workspace-1', + filesById: new Map([['file-1', { key: 'k', name: 'out.csv' }]]), + }) + mocks.describeRunFiles.mockResolvedValue([{ id: 'file-1', name: 'out.csv' }]) + }) + + it('lists the run files when nothing is withheld', async () => { + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: {}, finalOutput: { ok: true } }, + projection: NO_PROJECTION, + }) + + await expect(readWorkflowRun.execute({ principal, input: input([]) })).resolves.toMatchObject({ + files: [{ id: 'file-1' }], + }) + }) + + it('withholds the file list when the group withholds execution data', async () => { + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: null, finalOutput: null }, + projection: WITHHELD, + }) + + await expect(readWorkflowRun.execute({ principal, input: input([]) })).resolves.toMatchObject({ + files: null, + }) + }) + + it('does not read the run files at all when the group withholds execution data', async () => { + mocks.getStatus.mockResolvedValue({ + status: { status: 'completed', blockOutputs: null, finalOutput: null }, + projection: WITHHELD, + }) + + await readWorkflowRun.execute({ + principal, + input: { ...input([]), includeFileBase64: true }, + }) + + expect(mocks.getRunFiles).not.toHaveBeenCalled() + expect(mocks.describeRunFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts index 8674a45208b..cc235d56c7c 100644 --- a/apps/sim/lib/workflows/application/read-workflow-run.ts +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -4,6 +4,7 @@ import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, FunctionalOutputsUnavailableError, } from '@/lib/logs/execution/functional-outputs' +import { logProjectionSubjectUserId } from '@/lib/logs/log-projection' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -12,7 +13,7 @@ import { getWorkflowRunFiles, type WorkflowRunFileDescriptor, } from '@/lib/workflows/executor/execution-run-files' -import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { getProjectedWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' /** * Selectors this resource can never answer, so the caller hears about them. @@ -59,16 +60,32 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ runId: input.runId, assertedWorkflowId: input.workflowId, }), - async execute({ context, input }) { + async execute({ principal, context, input }) { try { - const status = await getWorkflowExecutionStatus({ + /** + * The projection subject, not an attribution: a workspace API key + * authorizes as the workspace and represents no user, so it resolves to + * `undefined` and reads the run whole. Substituting the key's creator would + * apply a bystander's group to every caller of a shared credential. + */ + const projected = await getProjectedWorkflowExecutionStatus({ workflowId: context.workflowId, executionId: context.runId, includeOutput: input.includeOutput, selectedOutputs: input.selectedOutputs, + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + viewerUserId: logProjectionSubjectUserId(principal), }) - if (!status) throw new OrchestrationError('not_found', 'Run not found') + if (!projected) throw new OrchestrationError('not_found', 'Run not found') + const { status, projection } = projected + /** + * A run whose `blockOutputs` the viewer's group withholds joins the same + * set as a queued or `includeOutput: false` run: the selector is judged on + * its shape alone. A block *name* still hears that this resource matches + * ids, and a well-formed id still gets the legitimate empty answer. + */ const unresolvable = unresolvableSelectors(input.selectedOutputs, status.blockOutputs) if (unresolvable.length > 0) { throw new OrchestrationError( @@ -83,14 +100,24 @@ export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ * a list it did not request. Derived from the run's own recording, which * is also where the download endpoint re-derives each storage key. * + * They follow the viewer's projection for the same reason. A run's output + * files *are* its execution data — the descriptors name them, and + * `includeFileBase64` hands back their bytes — so a group that withholds + * `finalOutput` and `blockOutputs` under `logs.trace_spans` and then let + * the file list through would return the withheld output one field over. + * The list is `null`, exactly as for a caller that asked for no output, + * and the read is skipped rather than performed and discarded. + * * This re-reads the run rather than reusing what the status read already * loaded, and must: the status read materializes execution data *for * display*, a projection that strips `key` and `context` — exactly the * fields a file descriptor needs — and it also answers from the job queue * for runs that have no log row yet. + * + * permission-group-enforced: logs.trace_spans */ let files: WorkflowRunFileDescriptor[] | null = null - if (input.includeOutput) { + if (input.includeOutput && !projection.hideTraceSpans) { const runFiles = await getWorkflowRunFiles({ workflowId: context.workflowId, runId: context.runId, diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts index 8e3354813f6..fc8a9171360 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -137,6 +137,7 @@ describe('replaceWorkflowState', () => { }) expect(mocks.replace).toHaveBeenCalledWith({ + subjectUserId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', attributedUserId: 'user-1', diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.ts b/apps/sim/lib/workflows/application/replace-workflow-state.ts index 627fce476a4..5cc97b8d2da 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.ts @@ -135,10 +135,12 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ * the reference pass is skipped for them rather than resolved against the * billing owner. See {@link buildWorkflowLintReport}. */ + const subjectUserId = humanSubjectUserId(principal) + const lint = await buildWorkflowLintReport(graph, { workflowId: context.workflowId, workspaceId: context.workspaceId, - subjectUserId: humanSubjectUserId(principal), + subjectUserId, }) if (input.dryRun) { @@ -180,6 +182,15 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, attributedUserId: attribution.attributedUserId, + /** + * The same human the lint pass resolved above, and never + * `attribution.attributedUserId`: that answers a workspace API key with + * the billing owner, so reusing it would judge a caller-supplied graph + * against a bystander's grants. This operation admits only principals + * that name a human, so the `null` branch is a fail-safe rather than a + * reachable state. + */ + subjectUserId, state: { blocks: graph.blocks, edges: graph.edges, diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts index 86383f35fc8..9b93febdf20 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.test.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -230,6 +230,7 @@ describe('setWorkflowBlockEnabled', () => { ).resolves.toMatchObject({ changed: true, affectedBlockIds: ['block-1'] }) expect(mocks.replace).toHaveBeenCalledWith({ + subjectUserId: null, workflowId: 'workflow-1', workspaceId: 'workspace-1', attributedUserId: 'user-1', diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts index 0ae8ef511a1..a50cda3848b 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -238,6 +238,18 @@ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, attributedUserId: attribution.attributedUserId, + /** + * Actorless on purpose. This operation writes back the graph it just read + * under the row lock with one block's `enabled` flipped — the caller + * supplies no blocks, so there is no caller-chosen block type for an + * allowlist to judge. Governing it would only mean refusing a member the + * ability to *disable* a block their group withholds. + * + * `attribution.attributedUserId` is deliberately not reused: it answers a + * workspace API key with the workspace's billing owner, which is right for + * custom-tool ownership and wrong for anything reading a person's grants. + */ + subjectUserId: null, state: async (tx) => { const locked = await loadWorkflowFromNormalizedTables(context.workflowId, tx) if (!locked) { diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts index e910167f43d..c299a8fcdd2 100644 --- a/apps/sim/lib/workflows/application/workflow-runs.test.ts +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -34,7 +34,7 @@ vi.mock('@/lib/workflows/executor/execution-queries', () => ({ })) vi.mock('@/lib/workflows/executor/execution-status', () => ({ - getWorkflowExecutionStatus: mocks.getStatus, + getProjectedWorkflowExecutionStatus: mocks.getStatus, })) vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ @@ -81,9 +81,8 @@ describe('workflow run application use cases', () => { mocks.resolveRunContext.mockResolvedValue(runContext) mocks.list.mockResolvedValue({ data: [], nextCursor: null }) mocks.getStatus.mockResolvedValue({ - executionId: 'run-1', - workflowId: 'workflow-1', - status: 'completed', + status: { executionId: 'run-1', workflowId: 'workflow-1', status: 'completed' }, + projection: { hideTraceSpans: false, hideCostInfo: false }, }) mocks.getRunFiles.mockResolvedValue({ terminal: true, @@ -130,55 +129,12 @@ describe('workflow run application use cases', () => { executionId: 'run-1', includeOutput: true, selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'], + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + viewerUserId: null, }) }) - it('refuses a selector that is not headed by a block id instead of answering an empty selection', async () => { - mocks.getStatus.mockResolvedValueOnce({ - executionId: 'run-1', - workflowId: 'workflow-1', - status: 'completed', - blockOutputs: {}, - }) - - await expect( - readWorkflowRun.execute({ - principal: principals[2], - input: { - workflowId: 'workflow-1', - runId: 'run-1', - includeOutput: true, - selectedOutputs: ['doubler.doubled'], - }, - }) - ).rejects.toMatchObject({ code: 'validation' }) - }) - - /** - * A well-formed id that produced nothing is a legitimate empty answer — the - * block may simply not have run on this path. - */ - it('allows a block id that produced no output on this run', async () => { - mocks.getStatus.mockResolvedValueOnce({ - executionId: 'run-1', - workflowId: 'workflow-1', - status: 'completed', - blockOutputs: {}, - }) - - const result = await readWorkflowRun.execute({ - principal: principals[2], - input: { - workflowId: 'workflow-1', - runId: 'run-1', - includeOutput: true, - selectedOutputs: ['4f1c2b3a-0000-4000-8000-000000000001.value'], - }, - }) - - expect(result.blockOutputs).toEqual({}) - }) - /** * File descriptors follow `output`'s gating: a caller that did not ask for * output must not receive a file list it did not request. diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 534333e938a..2218ea5915d 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -7,6 +7,8 @@ import { normalizeBlockRetryWaitMs, } from '@sim/workflow-types/workflow' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { createModelAccessGate } from '@/lib/permission-groups/model-access' import { createToolAccessGate, @@ -14,7 +16,6 @@ import { MODEL_SUBBLOCK_ID, OPERATION_SUBBLOCK_ID, } from '@/lib/permission-groups/operation-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' import { @@ -790,7 +791,7 @@ export function filterDisallowedTools( const isToolAllowed = createToolAccessGate(permissionConfig.deniedTools) const allowedTools: any[] = [] for (const tool of deploymentAvailableTools) { - if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) { + if (tool.type === 'custom-tool' && capabilityDeniedBy('custom_tools.use', permissionConfig)) { logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType: 'add', @@ -800,7 +801,7 @@ export function filterDisallowedTools( }) continue } - if (tool.type === 'mcp' && permissionConfig.disableMcpTools) { + if (tool.type === 'mcp' && capabilityDeniedBy('mcp_tools.use', permissionConfig)) { logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType: 'add', diff --git a/apps/sim/lib/workflows/editing/engine.ts b/apps/sim/lib/workflows/editing/engine.ts index afa9aef572e..ba98f968ac4 100644 --- a/apps/sim/lib/workflows/editing/engine.ts +++ b/apps/sim/lib/workflows/editing/engine.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateEdges } from '@/stores/workflows/workflow/edge-validation' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' diff --git a/apps/sim/lib/workflows/editing/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts index c049bc5e34a..c9f8511bc60 100644 --- a/apps/sim/lib/workflows/editing/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { applyOperationsToWorkflowState } from './engine' diff --git a/apps/sim/lib/workflows/editing/types.ts b/apps/sim/lib/workflows/editing/types.ts index c2350f009f2..a775b85e137 100644 --- a/apps/sim/lib/workflows/editing/types.ts +++ b/apps/sim/lib/workflows/editing/types.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' /** Selector subblock types that can be validated */ export const SELECTOR_TYPES = new Set([ diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 1925cf3174a..5a676c57e0c 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -4,7 +4,8 @@ import { omit } from '@sim/utils/object' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' import { getSkillById } from '@/lib/workflows/skills/operations' @@ -1000,7 +1001,16 @@ export function validateTargetHandle(targetHandle: string): EdgeHandleValidation } /** - * Checks if a block type is allowed by the permission group config + * Whether a block may be added to a graph by this viewer. + * + * Two questions, not one: whether the viewer can see the block at all + * (deployment visibility — an unrevealed preview block, a kill-switched type) + * and whether their permission group's integration allowlist permits it. + * Refusing to *add* something a viewer cannot see is right. + * + * Refusing to *store* it is not, which is why the persist-time guard in + * `@/lib/workflows/persistence/block-access-guard` checks the allowlist alone: + * a graph exported before a block was gated must still save. */ export function isBlockTypeAllowed( blockType: string, @@ -1013,7 +1023,9 @@ export function isBlockTypeAllowed( if (!permissionConfig || permissionConfig.allowedIntegrations === null) { return true } - return permissionConfig.allowedIntegrations.includes(blockType.toLowerCase()) + return permissionConfig.allowedIntegrations.includes( + resolveAccessControlBlockType(blockType).toLowerCase() + ) } /** diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index b68b9acd4dc..241e649be37 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -90,6 +90,12 @@ export interface ExecuteWorkflowOptions { * Callers set this only when the surface consumes thinking/tool events. */ agentEvents?: boolean + /** + * Gate subject for this run, separate from the billing actor + * (see {@link ExecutionMetadata.capabilityGovernedUserId}). Omit unless the + * trigger genuinely has a person distinct from the one it bills. + */ + capabilityGovernedUserId?: string | null } export interface WorkflowInfo { @@ -150,6 +156,7 @@ export async function executeWorkflow( workflowId, workspaceId, userId: actorUserId, + capabilityGovernedUserId: streamConfig?.capabilityGovernedUserId, principal, billingAttribution, workflowUserId: workflow.userId, diff --git a/apps/sim/lib/workflows/executor/execution-status-projection.test.ts b/apps/sim/lib/workflows/executor/execution-status-projection.test.ts new file mode 100644 index 00000000000..823629e610c --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status-projection.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + * + * `logs.trace_spans` and `logs.cost` are PROJECTIONS, not gates — a group + * withholds those fields from the response rather than refusing the read. + * + * The run-detail family applied none of it: a member whose group hides spend saw + * `cost` blanked on the log list and then read `cost.total` on the run one click + * deeper, and a member whose group hides execution detail got `finalOutput` and + * `blockOutputs` back whole from both the internal executions route and + * `/api/v2/workflows/{id}/runs/{runId}`. These run the real + * `getWorkflowExecutionStatus` against the real `resolveLogFieldProjection` — the + * same helper `readLogDetail` and the v1 routes resolve their flags through — so + * they fail if this read stops projecting. + */ +import { + dbChainMockFns, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + queueTableRows, + resetDbChainMock, + resetPermissionGroupScopeMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetJob, mockMaterializeForDisplayWithBlockOutputs } = vi.hoisted(() => ({ + mockGetJob: vi.fn(), + mockMaterializeForDisplayWithBlockOutputs: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionDataForDisplayWithBlockOutputs: mockMaterializeForDisplayWithBlockOutputs, +})) + +vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ + getAutomaticResumeWaitingMetadata: vi.fn().mockReturnValue(null), +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +const BLOCK_ID = 'block-1' + +function queueCompletedRun(): void { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + executionData: { executionState: {} }, + costTotal: '0.75', + }, + ]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, []) + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValueOnce({ + executionData: { finalOutput: { answer: 'a customer address' } }, + blockOutputs: new Map([[BLOCK_ID, { answer: 'a customer address' }]]), + }) +} + +function readRun(viewerUserId: string | null | undefined) { + return getWorkflowExecutionStatus({ + workflowId: 'workflow-1', + executionId: 'execution-1', + includeOutput: true, + selectedOutputs: [BLOCK_ID], + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + viewerUserId, + }) +} + +describe('run-detail field projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + resetPermissionGroupScopeMock() + mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({ + executionData: {}, + blockOutputs: new Map(), + }) + }) + + it('reads the run whole for a member no group governs', async () => { + queueCompletedRun() + + const status = await readRun('user-1') + + expect(status?.cost).toEqual({ total: 0.75 }) + expect(status?.finalOutput).toEqual({ answer: 'a customer address' }) + expect(status?.blockOutputs).toEqual({ [BLOCK_ID]: { answer: 'a customer address' } }) + }) + + it('withholds the run total from a member whose group hides spend', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + }) + queueCompletedRun() + + const status = await readRun('user-1') + + expect(status?.cost).toBeNull() + expect(status?.status).toBe('completed') + expect(status?.finalOutput).toEqual({ answer: 'a customer address' }) + }) + + it('withholds the execution payloads from a member whose group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + queueCompletedRun() + + const status = await readRun('user-1') + + expect(status?.finalOutput).toBeNull() + expect(status?.blockOutputs).toBeNull() + expect(status?.cost).toEqual({ total: 0.75 }) + expect(JSON.stringify(status)).not.toContain('a customer address') + }) + + /** + * A workspace API key authorizes as the workspace and represents no user, so + * its caller resolves to no subject. Substituting the key's creator would apply + * a bystander's group to every caller of a shared credential — which is why + * this asserts the resolver is never reached, not merely that the run came back + * whole. + */ + it('reads whole and resolves no group for a subjectless caller', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideCostInfo: true, + hideTraceSpans: true, + }) + queueCompletedRun() + + const status = await readRun(undefined) + + expect(status?.cost).toEqual({ total: 0.75 }) + expect(status?.finalOutput).toEqual({ answer: 'a customer address' }) + expect(status?.blockOutputs).toEqual({ [BLOCK_ID]: { answer: 'a customer address' } }) + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + /** The queue branch answers before any log row exists, and is projected too. */ + it('withholds a queued run output from a member whose group hides trace spans', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideTraceSpans: true, + }) + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([]) + mockGetJob.mockResolvedValue({ + status: 'completed', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + completedAt: new Date('2026-08-05T12:00:01.000Z'), + output: { output: { answer: 'a customer address' } }, + metadata: { workflowId: 'workflow-1', correlation: { triggerType: 'api' } }, + }) + + const status = await readRun('user-1') + + expect(status?.status).toBe('completed') + expect(status?.finalOutput).toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index 94f83e9453b..e593b9614fc 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -29,6 +29,9 @@ const input = { executionId: 'execution-1', includeOutput: false, selectedOutputs: [], + workspaceId: 'workspace-1', + /** No governing subject: field projection has its own suite next door. */ + viewerUserId: undefined, } describe('getWorkflowExecutionStatus queue projection', () => { diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index bc6cf1370df..873dc5182ed 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -5,6 +5,11 @@ import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workfl import { getJobQueue } from '@/lib/core/async-jobs' import type { Job } from '@/lib/core/async-jobs/types' import { materializeExecutionDataForDisplayWithBlockOutputs } from '@/lib/logs/execution/trace-store' +import { + type LogFieldProjection, + projectCostTotal, + resolveLogFieldProjection, +} from '@/lib/logs/log-projection' import { RESUME_EXECUTION_JOB_ID_PREFIX, WORKFLOW_EXECUTION_JOB_ID_PREFIX, @@ -120,10 +125,101 @@ export interface GetWorkflowExecutionStatusInput { executionId: string includeOutput: boolean selectedOutputs: string[] + /** + * The workspace the caller already authorized against. Passed in rather than + * read off the log row because this resource also answers from the job queue, + * for a run that has no log row yet — and that branch must be projected too. + */ + workspaceId: string + /** + * The user whose permission group governs the projection, or `null`/`undefined` + * when none does — a workspace API key (which authorizes as the workspace and + * whose reported user is only the key's creator) and an executor delegation + * (which carries a role but no capabilities) both read whole. + * + * Required rather than optional on purpose: a new consumer of this read has to + * name its subject to compile, instead of silently inheriting an unprojected + * response. + */ + viewerUserId: string | null | undefined + /** The workspace's organization, when the caller already loaded it. */ + workspaceOrganizationId?: string | null +} + +/** + * Applies a viewer's log projection to a run-detail resource. + * + * `finalOutput` and `blockOutputs` are the run-shaped spellings of `finalOutput` + * and `blockExecutions` on the withheld list in `withheldExecutionData` — the + * same per-block execution detail the log-detail path strips — so + * `logs.trace_spans` withholds them here too. A caller that could still name a + * block in `selectedOutputs` and get its output back would read exactly what the + * detail surface refuses, one query parameter later. + * + * `status`, `error` and the timings stay: these are projections, not gates, and + * withholding whether a run failed is not what the two capabilities restrict. + * + * permission-group-enforced: logs.trace_spans + * permission-group-enforced: logs.cost + */ +function projectExecutionStatus( + status: WorkflowExecutionStatusResponse, + projection: LogFieldProjection +): WorkflowExecutionStatusResponse { + if (!projection.hideCostInfo && !projection.hideTraceSpans) return status + return { + ...status, + cost: projectCostTotal(status.cost?.total ?? null, projection), + finalOutput: projection.hideTraceSpans ? null : status.finalOutput, + blockOutputs: projection.hideTraceSpans ? null : status.blockOutputs, + } +} + +/** A projected status resource together with the projection that produced it. */ +export interface ProjectedWorkflowExecutionStatus { + status: WorkflowExecutionStatusResponse + projection: LogFieldProjection +} + +/** + * Reads the execution status resource, projected for the viewer, and reports + * the projection alongside it. + * + * Projection lives in this shared read rather than in each of its route + * adapters so the rule has one copy and the next consumer inherits it, and it + * runs after the caller's authorization, on whichever branch answered. + * + * The projection is returned rather than kept private because a caller that + * appends more of the run's execution data to this resource has to withhold it + * on the same terms — a run's output *files* are the clearest case. Deriving + * that answer from the applied projection is what keeps the two from drifting; + * resolving the viewer's group a second time would be a second copy of the rule. + */ +export async function getProjectedWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput +): Promise { + const status = await readWorkflowExecutionStatus(input) + if (!status) return null + const projection = await resolveLogFieldProjection( + input.viewerUserId, + input.workspaceId, + input.workspaceOrganizationId + ) + return { status: projectExecutionStatus(status, projection), projection } } +/** + * The projected status resource alone, for a caller that renders nothing beyond + * it. + */ export async function getWorkflowExecutionStatus( input: GetWorkflowExecutionStatusInput +): Promise { + return (await getProjectedWorkflowExecutionStatus(input))?.status ?? null +} + +async function readWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput ): Promise { const { workflowId, executionId, includeOutput, selectedOutputs } = input diff --git a/apps/sim/lib/workflows/operations/import-workflow.test.ts b/apps/sim/lib/workflows/operations/import-workflow.test.ts new file mode 100644 index 00000000000..f4d3046d256 --- /dev/null +++ b/apps/sim/lib/workflows/operations/import-workflow.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), + performCreateWorkflow: vi.fn(), + performCreateWorkflowTransition: vi.fn(), + saveWorkflowToNormalizedTables: vi.fn(), + extractAndPersistCustomTools: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mocks.performCreateWorkflow, + performCreateWorkflowTransition: mocks.performCreateWorkflowTransition, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mocks.saveWorkflowToNormalizedTables, +})) +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: mocks.extractAndPersistCustomTools, +})) + +import { importWorkflowIntoWorkspace } from '@/lib/workflows/operations/import-workflow' + +function block(id: string, type: string) { + return { + id, + type, + name: id, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + } +} + +function payload(...blocks: ReturnType[]) { + return { + blocks: Object.fromEntries(blocks.map((entry) => [entry.id, entry])), + edges: [], + loops: {}, + parallels: {}, + } +} + +function params(workflowPayload: Record) { + return { + workspaceId: 'workspace-1', + userId: 'user-1', + capabilityUserId: 'user-1', + requestId: 'request-1', + workflow: workflowPayload, + } +} + +describe('importWorkflowIntoWorkspace block access', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }]) + mocks.getUserPermissionConfig.mockResolvedValue(null) + mocks.performCreateWorkflow.mockResolvedValue({ + success: true, + workflow: { + id: 'workflow-1', + name: 'Imported Workflow', + description: null, + folderId: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + mocks.saveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + mocks.extractAndPersistCustomTools.mockResolvedValue({ saved: 0, errors: [] }) + }) + + /** + * The bypass this closes: import never went through the editing operations, + * so a denied integration reached the normalized tables and was refused only + * at run time, if ever. + */ + it('refuses a payload carrying a block type the permission group withholds', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await importWorkflowIntoWorkspace( + params(payload(block('b1', 'slack'), block('b2', 'gmail'))) + ) + + expect(result).toMatchObject({ success: false, status: 403 }) + expect(result.success === false && result.error).toContain('gmail') + }) + + /** Nothing may be written before the refusal, or the caller is left an orphan. */ + it('refuses before any workflow row is created', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + await importWorkflowIntoWorkspace(params(payload(block('b1', 'gmail')))) + + expect(mocks.performCreateWorkflow).not.toHaveBeenCalled() + expect(mocks.saveWorkflowToNormalizedTables).not.toHaveBeenCalled() + }) + + it('imports a payload whose block types the allowlist names', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await importWorkflowIntoWorkspace(params(payload(block('b1', 'slack')))) + + expect(result.success).toBe(true) + expect(mocks.performCreateWorkflow).toHaveBeenCalledOnce() + }) + + /** + * `workflows.import` allows a workspace API key, which has no user and so no + * permission group. The attribution field still names someone — the billing + * owner, or the key's creator — and judging the payload against that + * bystander's allowlist is what this separates. + */ + it('judges no allowlist for a caller no permission group governs', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + const result = await importWorkflowIntoWorkspace({ + ...params(payload(block('b1', 'gmail'))), + capabilityUserId: null, + }) + + expect(result.success).toBe(true) + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 5103b939c9a..a6c42d03dbf 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -23,6 +23,10 @@ import { performCreateWorkflow, performCreateWorkflowTransition, } from '@/lib/workflows/orchestration' +import { + findWithheldBlockType, + withheldBlockTypeMessage, +} from '@/lib/workflows/persistence/block-access-guard' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -59,7 +63,21 @@ export interface ImportWorkflowParams { description?: string /** Export envelope, bare state, or a JSON string of either. */ workflow: string | Record + /** Legacy attribution field: who the created workflow is recorded against. */ userId: string + /** + * The person whose permission group judges the payload's block types, or + * `null` when no group governs the caller — a workspace API key, which + * `workflows.import` allows and which has no user at all. + * + * Deliberately not {@link ImportWorkflowParams.userId}. That one is an + * attribution field: for a workspace key it holds the billing owner (the + * application path) or the key's creator (v1), and running either one's + * integration allowlist against a shared key's import would refuse it on a + * bystander's policy — and break the key outright once that person's group + * changed. + */ + capabilityUserId: string | null requestId: string } @@ -176,7 +194,7 @@ async function executeImportWorkflowIntoWorkspace( params: ImportWorkflowParams, createWorkflow: (params: PerformCreateWorkflowParams) => Promise ): Promise { - const { workspaceId, folderId, userId, requestId } = params + const { workspaceId, folderId, userId, capabilityUserId, requestId } = params const [workspaceData] = await db .select({ id: workspace.id }) @@ -256,6 +274,27 @@ async function executeImportWorkflowIntoWorkspace( const workflowState: WorkflowState = { ...parsedState, ...preparedState } + /** + * Nothing has been written yet, which is why the check sits here: an import + * carries blocks the caller never added through the editing operations, so + * this is the only place the workspace's integration allowlist is consulted + * before the graph becomes a stored workflow. + */ + const withheldBlockType = capabilityUserId + ? await findWithheldBlockType({ + userId: capabilityUserId, + workspaceId, + blocks: Object.values(workflowState.blocks), + }) + : null + if (withheldBlockType) { + return { + success: false, + status: 403, + error: withheldBlockTypeMessage(withheldBlockType), + } + } + let parsedPayload: unknown = rawWorkflow if (typeof rawWorkflow === 'string') { try { @@ -297,7 +336,19 @@ async function executeImportWorkflowIntoWorkspace( */ try { await db.transaction(async (tx) => { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + /** + * The same subject the pre-check above used. The pre-check stays because + * it renders this door's own 403 before the shell workflow row is + * created — a refusal after that point would have to roll the row back — + * and the two agree by construction: both read `capabilityUserId`, and + * an import with no governed user passes `null` to both. + */ + { workspaceId, subjectUserId: capabilityUserId ?? null }, + tx + ) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to save workflow state') } diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index 08e4f63fce8..7a43664cdfa 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -163,6 +163,8 @@ describe('performRevertToVersion', () => { }, }, }), + /** A revert restores a graph the workspace already deployed, so it writes as nobody. */ + { workspaceId: null, subjectUserId: null }, dbChainMock.db ) expect(dbChainMockFns.set).toHaveBeenCalledWith( diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 9290143972e..097a296a83e 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -966,7 +966,22 @@ export async function performRevertToVersion( restoredState.variables = deployedState.variables || {} } - const result = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx) + const result = await saveWorkflowToNormalizedTables( + workflowId, + restoredState, + { + /** + * Actorless, and deliberately so. This is the executor-adjacent path: + * it writes back a graph the workspace already deployed. A run + * persisting its own state must not be refused because the member who + * triggered it is in a group that withholds a block the deployment + * uses — the deployment was authorized when it was created. + */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) if (!result.success) return result await tx diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 7c5414f63ae..d2098cbd418 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -239,7 +239,19 @@ export async function performCreateWorkflowTransition( variables: {}, }) - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + { + /** + * Actorless: the starter graph a new workflow is seeded with is the + * platform's, not a member's choice of blocks. + */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) }) break } catch (error) { diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.test.ts b/apps/sim/lib/workflows/persistence/block-access-guard.test.ts new file mode 100644 index 00000000000..4e61d7bd556 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/block-access-guard.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +import { findWithheldBlockType } from '@/lib/workflows/persistence/block-access-guard' + +const PARAMS = { userId: 'user-1', workspaceId: 'workspace-1' } + +describe('findWithheldBlockType', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue(null) + }) + + it('permits every block type when no permission group governs the workspace', async () => { + await expect( + findWithheldBlockType({ ...PARAMS, blocks: [{ type: 'gmail' }, { type: 'slack' }] }) + ).resolves.toBeNull() + }) + + it('permits every block type when the allowlist names every integration', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) + + await expect( + findWithheldBlockType({ ...PARAMS, blocks: [{ type: 'gmail' }] }) + ).resolves.toBeNull() + }) + + it('names the first block type the allowlist withholds', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + await expect( + findWithheldBlockType({ + ...PARAMS, + blocks: [{ type: 'slack' }, { type: 'gmail' }, { type: 'notion' }], + }) + ).resolves.toBe('gmail') + }) + + /** + * Containers resolve to no integration, so an allowlist naming every + * permitted one would still withhold them — and a graph the editor happily + * builds could never be written back. + */ + it('does not withhold loop and parallel containers', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) + + await expect( + findWithheldBlockType({ + ...PARAMS, + blocks: [{ type: 'loop' }, { type: 'parallel' }, { type: 'slack' }], + }) + ).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/persistence/block-access-guard.ts b/apps/sim/lib/workflows/persistence/block-access-guard.ts new file mode 100644 index 00000000000..e7d624f012d --- /dev/null +++ b/apps/sim/lib/workflows/persistence/block-access-guard.ts @@ -0,0 +1,124 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { + resolveAccessControlBlockType, + toAccessControlAllowlist, +} from '@/lib/permission-groups/integration-allowlist' +import { BlockType } from '@/executor/constants' + +/** + * Loop and parallel are canvas containers rather than registry blocks, so they + * resolve to no integration and an allowlist naming every permitted integration + * would still withhold them. The editing operations skip them for the same + * reason, and the two paths must agree or a graph the editor accepts would be + * refused when it is written back. + */ +const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set([BlockType.LOOP, BlockType.PARALLEL]) + +/** + * The first block type in `blocks` that the user's permission group withholds, + * or `null` when every one of them is permitted. + * + * `allowedIntegrations` is checked when a block is added through the editing + * operations, but a whole-graph write does not go through that path: the caller + * hands over the finished blocks, naming whatever types it likes. Validating at + * persist time is what makes the allowlist a property of what is *stored* + * rather than of one authoring route — otherwise a withheld integration lands + * in the workspace and is caught only by the executor refusing it mid-run, + * after the workflow has been saved, shared, and possibly deployed. + * + * Only the allowlist, deliberately — not the editor's `isBlockTypeAllowed`, + * which also refuses blocks hidden from the current viewer. Those two questions + * differ on a whole-graph write: refusing to *add* a preview block a viewer + * cannot see is right, while refusing to *store* a graph that already contains + * one would reject an export taken before the block was gated, and would make a + * save fail for a reason no permission group set. + */ +export async function findWithheldBlockType(params: { + userId: string + workspaceId: string + blocks: Iterable<{ type?: string }> +}): Promise { + const permissionConfig = await resolvePermissionGroupConfig( + params.userId, + params.workspaceId, + undefined + ) + const allowed = toAccessControlAllowlist(permissionConfig?.allowedIntegrations ?? null) + + /** + * Hoisted out of the loop: an unrestricted group is the common case, and every + * workflow save in every ungoverned workspace would otherwise pay two registry + * lookups per block to reach the same answer. + */ + if (allowed === null) return null + + for (const block of params.blocks) { + const blockType = block.type + if (!blockType || CONTAINER_BLOCK_TYPES.has(blockType)) continue + if (isBlockTypeAccessControlExempt(blockType)) continue + if (!allowed.has(resolveAccessControlBlockType(blockType).toLowerCase())) return blockType + } + + return null +} + +/** The refusal text every persist path renders for a withheld block type. */ +export function withheldBlockTypeMessage(blockType: string): string { + return `Block type "${blockType}" is not allowed by your organization's permission group` +} + +/** + * Who a normalized-state write is performed *as*, for permission-group purposes. + * + * Both fields are required at every call site, and `null` is spelled out rather + * than omitted, for the reason `capability` is required on + * `defineWorkspaceOperation`: an absent declaration cannot be told apart from an + * unreviewed one. The guard used to sit at individual doors, and the two that + * never grew one — `PUT /api/v2/workflows/{id}/state` and the Copilot + * materialize-import — were exactly the doors nobody remembered to add it to. + * + * `subjectUserId` is `null` only when the write is not a member's authoring + * action: an executor run persisting its own graph, a workspace fork copying + * rows, or workspace creation seeding a starter workflow. A member's group must + * not govern those, because the writer is the platform rather than the member. + */ +export interface WorkflowPersistGovernance { + /** Canonical workspace whose permission groups govern the write, or `null` when it has none. */ + workspaceId: string | null + /** The human this write is performed as, or `null` when it is performed as no human. */ + subjectUserId: string | null +} + +/** + * Refuses a normalized-state write carrying a block type the writer's permission + * group withholds. + * + * Lives on the shared persistence primitive rather than at each door so a new + * caller inherits the check instead of having to remember it. A `null` subject + * or workspace no-ops: there is no group to resolve, and inventing one would + * either fail open against a bystander's grants or block the executor. + * + * Throws {@link OrchestrationError} rather than returning a union, matching the + * rest of the persistence layer: `statusForOrchestrationError` renders + * `forbidden` as the 403 the pre-consolidation doors returned, and + * `messageForOrchestrationError` passes this message through unchanged, so the + * refusal a caller sees is byte-identical to the one it rendered itself. + */ +export async function assertNoWithheldBlockType( + governance: WorkflowPersistGovernance, + blocks: Iterable<{ type?: string }> +): Promise { + const { workspaceId, subjectUserId } = governance + if (!workspaceId || !subjectUserId) return + + const withheldBlockType = await findWithheldBlockType({ + userId: subjectUserId, + workspaceId, + blocks, + }) + if (withheldBlockType) { + throw new OrchestrationError('forbidden', withheldBlockTypeMessage(withheldBlockType)) + } +} diff --git a/apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts b/apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts new file mode 100644 index 00000000000..2645bb64bdd --- /dev/null +++ b/apps/sim/lib/workflows/persistence/persist-block-access-gate.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + * + * The gate lives on the shared write rather than at each door, so this is where + * it is proved: `saveWorkflowToNormalizedTables` is the one primitive every + * normalized-table write funnels through, and the assertions below are about + * the primitive, not about any caller that happens to reach it. + */ +import { + dbChainMock, + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetDbChainMock, + resetPermissionGroupScopeMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + saveRaw: vi.fn(), + lock: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@sim/workflow-persistence/save', () => ({ + saveWorkflowToNormalizedTables: mocks.saveRaw, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +function stateWith(type: string): WorkflowState { + return { + blocks: { + 'block-1': { + id: 'block-1', + type, + name: 'Block', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +const GOVERNED = { workspaceId: 'workspace-1', subjectUserId: 'user-1' } + +describe('saveWorkflowToNormalizedTables permission-group gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + resetPermissionGroupScopeMock() + mocks.saveRaw.mockResolvedValue({ success: true }) + }) + + it('refuses a block type the governed subject’s allowlist withholds, before any write', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables('workflow-1', stateWith('gmail'), GOVERNED) + ).rejects.toMatchObject({ + name: 'OrchestrationError', + code: 'forbidden', + message: expect.stringContaining('gmail'), + }) + expect(mocks.saveRaw).not.toHaveBeenCalled() + }) + + it('writes a block type the allowlist names', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables('workflow-1', stateWith('slack'), GOVERNED, dbChainMock.db) + ).resolves.toMatchObject({ success: true }) + expect(mocks.saveRaw).toHaveBeenCalled() + }) + + /** + * The executor exemption. A run — or a revert, or a fork copy — persists a + * graph the workspace already holds, and blocking it on the triggering + * member's group would fail a run for a block the deployment was authorized + * with. Every such caller states that by passing a `null` subject. + */ + it('writes for an actorless caller even when the workspace withholds the block type', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables( + 'workflow-1', + stateWith('gmail'), + { workspaceId: 'workspace-1', subjectUserId: null }, + dbChainMock.db + ) + ).resolves.toMatchObject({ success: true }) + expect(mocks.saveRaw).toHaveBeenCalled() + expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() + }) + + it('writes when no workspace, and therefore no permission group, scopes the workflow', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + await expect( + saveWorkflowToNormalizedTables( + 'workflow-1', + stateWith('gmail'), + { workspaceId: null, subjectUserId: 'user-1' }, + dbChainMock.db + ) + ).resolves.toMatchObject({ success: true }) + expect(mocks.saveRaw).toHaveBeenCalled() + }) + + /** + * The refusal must not be folded into the `{ success: false }` union: every + * caller renders that as a 500, and this one is a 403. + */ + it('throws the refusal rather than returning it, on the external-transaction path too', async () => { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + allowedIntegrations: ['slack'], + }) + + const thrown = await saveWorkflowToNormalizedTables( + 'workflow-1', + stateWith('gmail'), + GOVERNED, + dbChainMock.db + ).catch((error: unknown) => error) + + expect(thrown).toBeInstanceOf(OrchestrationError) + expect(mocks.saveRaw).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts index 516e73fb092..17c4fb5acc3 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -49,6 +49,7 @@ function input(overrides: Record = {}) { workflowId: 'workflow-1', workspaceId: 'workspace-1', attributedUserId: 'user-1', + subjectUserId: 'user-1', state: { blocks: { 'block-1': BLOCK }, edges: [] }, ...overrides, } as Parameters[0] @@ -107,6 +108,7 @@ describe('replaceWorkflowNormalizedState', () => { expect(mocks.save).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ blocks: PREPARED.blocks, edges: PREPARED.edges }), + { workspaceId: 'workspace-1', subjectUserId: 'user-1' }, expect.anything() ) expect(mocks.prepare).toHaveBeenCalledBefore(mocks.save) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 9355a0bc45e..131645d8499 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -5,6 +5,7 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' +import { assertNoWithheldBlockType } from '@/lib/workflows/persistence/block-access-guard' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { type PreparedWorkflowState, @@ -189,6 +190,18 @@ export interface ReplaceWorkflowNormalizedStateInput { workspaceId: string | null /** Owner recorded on any custom tool this graph defines. */ attributedUserId: string + /** + * The human this replace is performed as, or `null` when it is performed as no + * human. + * + * Deliberately separate from `attributedUserId`, which answers a workspace API + * key with the workspace's billing owner: attribution is a billing question + * and fails open here, where this one decides whether a member's own + * permission group may store a block type. Required, and `null` spelled out, + * so an actorless write is a claim the caller made rather than an argument it + * forgot. + */ + subjectUserId: string | null /** * The graph to write, or a reader that produces it. * @@ -230,9 +243,24 @@ export interface ReplaceWorkflowNormalizedStateResult { export async function replaceWorkflowNormalizedState( input: ReplaceWorkflowNormalizedStateInput ): Promise { - const { workflowId, workspaceId, attributedUserId, state, requestId } = input + const { workflowId, workspaceId, attributedUserId, subjectUserId, state, requestId } = input const logPrefix = requestId ? `[${requestId}] ` : '' + /** + * Hoisted ahead of the transaction even though the shared write checks it + * again: the second call is answered from the request-scoped memo, and + * refusing here means a withheld block type never takes the workflow's row + * lock or reaches drizzle's transaction wrapper — so the thrown + * `OrchestrationError` arrives at callers unwrapped. + * + * A caller that produces its graph from a reader is unaffected: the reader + * composes a graph from what is already stored, and this pass covers the + * blocks it hands back through the inner check. + */ + if (typeof state !== 'function') { + await assertNoWithheldBlockType({ workspaceId, subjectUserId }, Object.values(state.blocks)) + } + let preparedState!: PreparedWorkflowState let warnings: string[] = [] let workflowState!: WorkflowState @@ -277,7 +305,12 @@ export async function replaceWorkflowNormalizedState( let result: Awaited> try { - result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + result = await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + { workspaceId, subjectUserId }, + tx + ) } catch (error) { if (isGraphIdUniqueViolation(error)) { throw new OrchestrationError( diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index a1c2a036513..c5e4239f84b 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -101,6 +101,15 @@ export async function saveWorkflowNormalizedState(params: { workflowId, workspaceId: workflowData.workspaceId ?? null, attributedUserId: userId, + /** + * This door authorizes by bare `userId`, so the writer and the governed + * subject are the same person. The integration allowlist that used to be + * checked inline here now lives on the shared write, which refuses a + * withheld block type as a `forbidden` `OrchestrationError` — read below + * by the same `asOrchestrationError` branch that classifies the rest, and + * rendered as the identical 403 and message. + */ + subjectUserId: userId, state: { blocks: state.blocks as Record, edges: state.edges as WorkflowState['edges'], diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts index 99c1dde6531..b18ca24a384 100644 --- a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -12,6 +12,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ replace: vi.fn(), notify: vi.fn(), + getUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, })) vi.mock('@/lib/workflows/persistence/replace-normalized-state', async () => { @@ -68,6 +73,58 @@ describe('saveWorkflowNormalizedState', () => { }) workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) mocks.replace.mockResolvedValue({ warnings: ['dropped an edge'], state: STATE }) + mocks.getUserPermissionConfig.mockResolvedValue(null) + }) + + /** + * The bypass this closes: a graph replace never went through the editing + * operations, so a member whose group withholds an integration could still + * store a block using it and have it refused only at run time, if ever. + * + * The check itself now lives on the shared write, so what this door owes is + * naming the right subject and rendering the primitive's `forbidden` refusal + * as the 403 it used to build inline. + */ + it('names the authorizing user as the subject the permission group governs', async () => { + await saveWorkflowNormalizedState(params()) + + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ subjectUserId: 'user-1', workspaceId: 'workspace-1' }) + ) + }) + + it('refuses a state carrying a block type the permission group withholds', async () => { + mocks.replace.mockRejectedValue( + new OrchestrationError( + 'forbidden', + 'Block type "gmail" is not allowed by your organization\'s permission group' + ) + ) + + const result = await saveWorkflowNormalizedState(params()) + + expect(result).toMatchObject({ success: false, status: 403 }) + expect(result.success === false && result.error).toContain('gmail') + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('stores a state whose block types the allowlist names', async () => { + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['starter'] }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toMatchObject({ success: true }) + }) + + /** A workflow with no workspace has no permission group to resolve. */ + it('skips the block-type check for a workflow outside any workspace', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1', workspaceId: null }, + workspacePermission: 'write', + }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toMatchObject({ success: true }) + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() }) it('returns success with the preparation warnings and notifies once', async () => { diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index 0059d781832..b4b50ab88a0 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -293,6 +293,13 @@ const mockWorkflowState = createWorkflowState({ }, }) +/** + * The ungoverned write every characterization here performs: these exercise the + * table mechanics, not the permission-group gate, and a `null` subject is how a + * caller declares the write is not a member's authoring action. + */ +const UNGOVERNED = { workspaceId: null, subjectUserId: null } + describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() @@ -510,7 +517,8 @@ describe('Database Helpers', () => { it('should successfully save workflow data to normalized tables', async () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(mockWorkflowState) + asAppState(mockWorkflowState), + UNGOVERNED ) expect(result.success).toBe(true) @@ -523,7 +531,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(emptyWorkflowState) + asAppState(emptyWorkflowState), + UNGOVERNED ) expect(result.success).toBe(true) @@ -534,7 +543,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(mockWorkflowState) + asAppState(mockWorkflowState), + UNGOVERNED ) expect(result.success).toBe(false) @@ -549,7 +559,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(mockWorkflowState) + asAppState(mockWorkflowState), + UNGOVERNED ) expect(result.success).toBe(false) @@ -557,7 +568,11 @@ describe('Database Helpers', () => { }) it('should properly format block data for database insertion', async () => { - await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(mockWorkflowState)) + await dbHelpers.saveWorkflowToNormalizedTables( + mockWorkflowId, + asAppState(mockWorkflowState), + UNGOVERNED + ) const [capturedBlockInserts = []] = insertedRowsFor(schemaMock.workflowBlocks) const [capturedEdgeInserts = []] = insertedRowsFor(schemaMock.workflowEdges) @@ -631,7 +646,11 @@ describe('Database Helpers', () => { staleWorkflowState.loops = {} staleWorkflowState.parallels = {} - await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, asAppState(staleWorkflowState)) + await dbHelpers.saveWorkflowToNormalizedTables( + mockWorkflowId, + asAppState(staleWorkflowState), + UNGOVERNED + ) const [capturedSubflowInserts = []] = insertedRowsFor(schemaMock.workflowSubflows) @@ -737,7 +756,8 @@ describe('Database Helpers', () => { const result = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(largeWorkflowState) + asAppState(largeWorkflowState), + UNGOVERNED ) expect(result.success).toBe(true) @@ -869,7 +889,8 @@ describe('Database Helpers', () => { const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - workflowState + workflowState, + UNGOVERNED ) expect(saveResult.success).toBe(true) @@ -940,7 +961,8 @@ describe('Database Helpers', () => { const saveResult = await dbHelpers.saveWorkflowToNormalizedTables( mockWorkflowId, - asAppState(testWorkflowState) + asAppState(testWorkflowState), + UNGOVERNED ) expect(saveResult.success).toBe(true) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 2143226e51f..4e21627e27a 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -33,6 +33,10 @@ import { migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' import { backfillWhatsAppInteractiveType } from '@/lib/workflows/migrations/whatsapp-interactive-type' +import { + assertNoWithheldBlockType, + type WorkflowPersistGovernance, +} from '@/lib/workflows/persistence/block-access-guard' import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' @@ -638,11 +642,30 @@ export function buildWorkflowDeploymentSnapshot( } } +/** + * The one door every normalized-table write goes through, and therefore the one + * place the workspace's integration allowlist can be enforced for all of them. + * + * `governance` is required rather than optional: a whole-graph write hands over + * finished blocks naming whatever types it likes, so every caller has to state + * whose grants judge them. Passing `{ subjectUserId: null }` is how a caller + * declares itself actorless — the executor persisting a run's own graph, a fork + * copying rows, workspace creation seeding a starter workflow — and that is a + * claim a reader can check, where an omitted argument was not. + * + * The check runs before any transaction is opened so a refusal never holds the + * workflow's row lock, and it throws rather than folding into the `{ success }` + * union: the union collapses to a 500 at every caller, and this refusal is a + * 403. + */ export async function saveWorkflowToNormalizedTables( workflowId: string, state: WorkflowState, + governance: WorkflowPersistGovernance, externalTx?: DbOrTx ): Promise<{ success: boolean; error?: string }> { + await assertNoWithheldBlockType(governance, Object.values(state.blocks)) + if (externalTx) { return saveWorkflowToNormalizedTablesRaw(workflowId, state, externalTx) } diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 83e98bbf3cc..6d0c8ea4ba7 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -416,7 +416,14 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { }) const { workflowState } = buildDefaultWorkflowArtifacts() - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, { + /** + * Actorless: `buildDefaultWorkflowArtifacts` produces the platform's starter + * graph, so there is no caller-chosen block type for a group to judge. + */ + workspaceId: null, + subjectUserId: null, + }) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to save workflow state') } diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index d858615398a..e8374108526 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -14,6 +14,7 @@ const { mockIsGenerated, mockIsRenderable, mockIsDocNotReady, + mockGetUserPermissionConfig, } = vi.hoisted(() => ({ events: [] as string[], mockLoadContext: vi.fn(), @@ -25,6 +26,15 @@ const { mockIsGenerated: vi.fn(), mockIsRenderable: vi.fn(), mockIsDocNotReady: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: mockGetUserPermissionConfig, + /** The use case passes the organization the authorized context already loaded. */ + resolveVerifiedUserAccessControlContext: async (userId: string, workspaceId: string) => ({ + config: await mockGetUserPermissionConfig(userId, workspaceId), + }), })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ @@ -60,6 +70,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } @@ -105,6 +116,7 @@ describe('downloadWorkspaceFileItems', () => { mockIsGenerated.mockReturnValue(false) mockIsRenderable.mockReturnValue(false) mockIsDocNotReady.mockReturnValue(false) + mockGetUserPermissionConfig.mockResolvedValue(null) }) it('authorizes the workspace once and returns the bounded selection', async () => { @@ -264,4 +276,66 @@ describe('downloadWorkspaceFileItems', () => { expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) }) + + describe('permission-group capability', () => { + beforeEach(() => { + mockGetUserPermissionConfig.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableBulkFileDownload: true, + }) + mockListFolders.mockResolvedValue([{ id: 'folder-1', parentId: null, name: 'Reports' }]) + mockListFiles.mockImplementation(async () => { + events.push('execute') + return [file('f1', 'clip.mp4'), file('f2', 'notes.txt', 'folder-1')] + }) + }) + + it('refuses a folder archive when the group withholds files.bulk_download', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + ).rejects.toMatchObject({ capability: 'files.bulk_download' }) + + expect(events).not.toContain('execute') + }) + + it('refuses a multi-file archive, which is the same bulk extraction', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1', 'f2'], folderIds: [] }, + }) + ).rejects.toMatchObject({ capability: 'files.bulk_download' }) + }) + + /** + * A run carries the role of whoever triggered it but not their capabilities + * — `authorizeWorkspaceOperation` exempts a subject-bearing executor — and + * an assertion that read the subject straight off the principal re-applied + * here exactly what the funnel exempts. + */ + it('does not apply the capability to a delegated executor carrying a subject', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal: { + ...delegatedPrincipal, + serviceId: 'executor' as const, + resourceScope: {}, + }, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + ).resolves.toMatchObject({ filesToZip: [expect.objectContaining({ id: 'f2' })] }) + }) + + it('still allows downloading a single named file, which the key does not withhold', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + ).resolves.toMatchObject({ filesToZip: [expect.objectContaining({ id: 'f1' })] }) + }) + }) }) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index 69bedcc7bc7..011dabaf0c7 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,8 +1,12 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { + type AuthorizedWorkspaceUseCaseContext, + capabilityGovernedPrincipalUserId, +} from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { parseFolderPath } from '@/lib/folders/paths' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { buildWorkspaceFileFolderPathMap, listWorkspaceFileFolders, @@ -118,6 +122,31 @@ async function executeDownloadWorkspaceFileItems({ validationError('No files selected for download') } + /** + * permission-group-enforced: files.bulk_download — one operation serves both + * a single file and a whole folder tree, and only the archive is what the key + * withholds; declaring the capability on `files.download` would take away + * saving one file too. `context.fileId` is the same single-file predicate the + * resource authorization already resolved, reused so the two cannot drift. + * Asserted against whoever the funnel would have judged, from its own rule — + * nobody, for a workspace key or an executor run. A run carries the role of + * whoever triggered it but not their capabilities, and reading the subject + * straight off the principal would have re-applied here exactly the + * capability `authorizeWorkspaceOperation` exempts a subject-bearing executor + * from. + */ + if (context.fileId === undefined) { + const actingUserId = capabilityGovernedPrincipalUserId(principal) + if (actingUserId) { + await assertWorkspaceCapability( + actingUserId, + context.workspaceId, + 'files.bulk_download', + context.workspaceOrganizationId + ) + } + } + const [files, folders] = await Promise.all([ listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), listWorkspaceFileFolders(context.workspaceId), diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 9c4c6174435..09b480c84fb 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -21,48 +21,56 @@ export const fileOperations = { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), readMetadata: defineWorkspaceOperation({ id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), readContent: defineWorkspaceOperation({ id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), searchContent: defineWorkspaceOperation({ id: 'files.search_content', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), download: defineWorkspaceOperation({ id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), compiledCheck: defineWorkspaceOperation({ id: 'files.compiled_check', minimumRole: 'read', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['session'], }), create: defineWorkspaceOperation({ id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), rename: defineWorkspaceOperation({ id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), /** @@ -81,30 +89,35 @@ export const fileOperations = { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateMetadata: defineWorkspaceOperation({ id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), move: defineWorkspaceOperation({ id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), createVfsFolders: defineWorkspaceOperation({ id: 'files.vfs.folders.create', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -112,6 +125,7 @@ export const fileOperations = { id: 'files.vfs.relocate', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -119,6 +133,7 @@ export const fileOperations = { id: 'files.vfs.delete', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', principalKinds: ['delegated'], delegatedServices: ['copilot'], }), @@ -126,60 +141,70 @@ export const fileOperations = { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), restore: defineWorkspaceOperation({ id: 'files.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), readShare: defineWorkspaceOperation({ id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateShare: defineWorkspaceOperation({ id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'deny', + capability: 'files.use', ...HUMAN_FILE_TOOL_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), createFolder: defineWorkspaceOperation({ id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateFolder: defineWorkspaceOperation({ id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), deleteFolder: defineWorkspaceOperation({ id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), restoreFolder: defineWorkspaceOperation({ id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), /** @@ -192,24 +217,28 @@ export const fileOperations = { id: 'files.upload.read', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), uploadComplete: defineWorkspaceOperation({ id: 'files.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), uploadCancel: defineWorkspaceOperation({ id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + capability: 'files.use', ...UPLOAD_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts index cd69a123118..2601f6f55d3 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -90,6 +90,7 @@ describe('workspace file reference application service', () => { minimumRole: 'write', workspaceApiKey: 'deny', principalKinds: ['session'], + capability: 'files.use', }) await expect( diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index b1584f0f977..b854f42b43a 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' -import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getShareForResource, @@ -13,10 +12,7 @@ import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-fil import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' +import { validatePublicFileSharing } from '@/ee/access-control/utils/permission-check' const logger = createLogger('WorkspaceFileShare') @@ -87,13 +83,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ if (input.isActive) { const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(userId, context.workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) - throw new ForbiddenOperationError('PUBLIC_SHARING_NOT_ALLOWED', error.message) - throw error - } + await validatePublicFileSharing(userId, context.workspaceId, effectiveAuthType) } let share: ShareRecord diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts index e9db632e151..b1ee6303784 100644 --- a/apps/sim/lib/workspaces/application/operations.ts +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -3,22 +3,28 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const export const workspaceOperations = { + // permission-group-exempt: the public API's own view of the workspaces a key can reach; it answers what that credential already proves, and `disablePublicApi` governs whether the key reaches the surface at all listPublic: defineWorkspaceOperation({ id: 'workspaces.list_public', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + // permission-group-exempt: the same surface as the list, describing one workspace the key already reaches readPublicDetail: defineWorkspaceOperation({ id: 'workspaces.read_public_detail', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), + // permission-group-exempt: names of people the caller already shares a workspace with; `hideOrgMemberDirectory` covers the organization-wide roster and its email addresses, which is the materially different disclosure listPublicMembers: defineWorkspaceOperation({ id: 'workspaces.members.list_public', minimumRole: 'read', workspaceApiKey: 'allow', + capability: 'none', principalKinds: PUBLIC_API_PRINCIPAL_KINDS, }), } as const diff --git a/apps/sim/lib/workspaces/create.ts b/apps/sim/lib/workspaces/create.ts index 8ca1b60ef71..70d433c0e18 100644 --- a/apps/sim/lib/workspaces/create.ts +++ b/apps/sim/lib/workspaces/create.ts @@ -139,7 +139,16 @@ export async function createWorkspaceInTransaction( variables: {}, }) const { workflowState } = buildDefaultWorkflowArtifacts() - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + await saveWorkflowToNormalizedTables( + workflowId, + workflowState, + { + /** Actorless: workspace creation seeds a platform-authored starter workflow. */ + workspaceId: null, + subjectUserId: null, + }, + tx + ) } return { diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 1c30e6c1d1d..95d60d2f643 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -17,11 +17,20 @@ const { mockGetUserOrganization, mockGetOrganizationSubscription, mockGetHighestPrioritySubscription, + mockGetUserPermissionConfigForOrganization, + mockGetUserPermissionConfig, } = vi.hoisted(() => ({ mockAcquireOrganizationUserMutationLocks: vi.fn(), mockGetUserOrganization: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), + mockGetUserPermissionConfigForOrganization: vi.fn(), + mockGetUserPermissionConfig: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mockGetUserPermissionConfigForOrganization, + getUserPermissionConfig: mockGetUserPermissionConfig, })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -43,6 +52,7 @@ import { getWorkspaceInvitePolicy, lockWorkspaceCreationContext, WORKSPACE_MODE, + WorkspaceCreationCapabilityWithheldError, WorkspaceCreationContextChangedError, } from '@/lib/workspaces/policy' import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' @@ -128,6 +138,75 @@ describe('lockWorkspaceCreationContext', () => { ) }) + /** + * The preflight in `getWorkspaceCreationPolicy` and the insert are separate + * requests. A group that withheld creation in between has to be caught under + * the lock, or the in-flight create lands a workspace that carries no + * `permissionGroupWorkspace` row to bring it back under the regime. + */ + it('rejects when the group withheld workspace creation after the preflight', async () => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isBillingEnabled: false }) + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'admin' }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ userId: 'owner-1' }]) + const tx = dbChainMock.db as unknown as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: 'org-1', + observedOrganizationId: 'org-1', + }) + ).rejects.toBeInstanceOf(WorkspaceCreationCapabilityWithheldError) + expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + }) + + /** + * A personal workspace is precisely the escape from a scoped group, so the + * re-check reads the caller's membership organization even when the workspace + * being inserted carries none — the same organization the preflight used. + */ + it('rejects a personal workspace when the membership organization withheld creation', async () => { + vi.clearAllMocks() + resetDbChainMock() + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', role: 'member' }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + const tx = dbChainMock.db as unknown as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: null, + observedOrganizationId: 'org-1', + }) + ).rejects.toBeInstanceOf(WorkspaceCreationCapabilityWithheldError) + }) + + it('leaves an unaffiliated creator alone, with no group to read', async () => { + vi.clearAllMocks() + resetDbChainMock() + mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined) + mockGetUserOrganization.mockResolvedValue(null) + const tx = dbChainMock.db as unknown as DbOrTx + + await expect( + lockWorkspaceCreationContext(tx, { + userId: 'creator-1', + organizationId: null, + observedOrganizationId: null, + }) + ).resolves.toEqual({ billedAccountUserId: 'creator-1' }) + expect(mockGetUserPermissionConfigForOrganization).not.toHaveBeenCalled() + }) + it('rejects when the paid org entitlement disappeared before insertion', async () => { vi.clearAllMocks() resetDbChainMock() @@ -158,6 +237,69 @@ describe('getWorkspaceCreationPolicy', () => { mockGetUserOrganization.mockResolvedValue(null) mockGetOrganizationSubscription.mockResolvedValue(null) mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockGetUserPermissionConfigForOrganization.mockResolvedValue(null) + }) + + it('blocks a member whose permission group disables workspace creation', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.canCreate).toBe(false) + expect(result.status).toBe(403) + expect(result.blockedReasonCode).toBe('permission-group-denied') + expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + }) + + it('governs the personal workspace a scoped-group member would otherwise escape into', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1', pinOrganization: true }) + + expect(result.canCreate).toBe(false) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL) + expect(result.blockedReasonCode).toBe('permission-group-denied') + }) + + /** + * Creating a workspace names no workspace, so there is no workspace whose + * group could govern it — and a member may be governed by different groups in + * different workspaces, so there is no single scoped group to pick. The + * decision is read from the organization's default group and from nothing + * else, which is what `disableWorkspaceCreation`'s admin hint has to say. + */ + it("reads workspace creation from the organization's default group and no workspace group", async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + mockGetUserPermissionConfigForOrganization.mockResolvedValue({ + disableWorkspaceCreation: true, + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.blockedReasonCode).toBe('permission-group-denied') + expect(mockGetUserPermissionConfigForOrganization).toHaveBeenCalledWith('org-1') + expect(mockGetUserPermissionConfig).not.toHaveBeenCalled() }) it('blocks free users once they already own one non-organization workspace', async () => { diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 8f727647c01..1b18a336358 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -14,6 +14,10 @@ import { getPlanType, isEnterprise, isMaxTier, isPro, isTeam } from '@/lib/billi import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' +import { + capabilityRefusal, + isOrganizationCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' import { CONTACT_OWNER_TO_UPGRADE_REASON, UPGRADE_TO_INVITE_REASON, @@ -92,16 +96,31 @@ export interface WorkspaceCreationPolicy { */ observedOrganizationId: string | null /** Discriminant for blocked states the workspace mode cannot distinguish. */ - blockedReasonCode?: 'organization-subscription-inactive' + blockedReasonCode?: 'organization-subscription-inactive' | 'permission-group-denied' } export class WorkspaceCreationContextChangedError extends Error { - constructor() { - super('Workspace creation context changed before the workspace was inserted') + constructor(message = 'Workspace creation context changed before the workspace was inserted') { + super(message) this.name = 'WorkspaceCreationContextChangedError' } } +/** + * The permission-group case of {@link WorkspaceCreationContextChangedError}. + * + * A subclass rather than a sibling so every caller that already treats a changed + * context as retryable keeps working unchanged, while a surface that wants to + * say *why* — the create route answers 403 with the capability refusal instead + * of 409 "membership changed" — can narrow to it. + */ +export class WorkspaceCreationCapabilityWithheldError extends WorkspaceCreationContextChangedError { + constructor() { + super(capabilityRefusal('workspace.create')) + this.name = 'WorkspaceCreationCapabilityWithheldError' + } +} + /** * Serializes the final creation-policy check with membership/ownership * mutations and row-locks the paid entitlement used by organization mode. @@ -132,6 +151,24 @@ export async function lockWorkspaceCreationContext( throw new WorkspaceCreationContextChangedError() } + /** + * permission-group-enforced: workspace.create — re-read under the lock because + * the preflight in `getWorkspaceCreationPolicy` and the insert are separate + * requests: a group that withheld creation in between would otherwise still + * let the in-flight create land, and a new workspace carries no + * `permissionGroupWorkspace` row to bring it back under the regime afterwards. + * Governed by the same organization the preflight used — the explicit one, or + * the caller's membership when the workspace would be personal — so a personal + * workspace stays as governed here as it is there. + */ + const governingOrganizationId = organizationId ?? currentMembership?.organizationId ?? null + if ( + governingOrganizationId && + (await isOrganizationCapabilityWithheld(governingOrganizationId, 'workspace.create')) + ) { + throw new WorkspaceCreationCapabilityWithheldError() + } + if (!organizationId) return { billedAccountUserId: userId } if (isBillingEnabled) { @@ -350,6 +387,41 @@ export async function getWorkspaceCreationPolicy({ .limit(1) )[0]?.role + const governingOrganizationId = organizationId ?? membership?.organizationId ?? null + if (governingOrganizationId) { + /** + * A new workspace carries no `permissionGroupWorkspace` row, so a member of + * a scoped group would land in a workspace that group does not target — the + * one place the whole regime can be stepped out of. Gating the policy rather + * than the create route also covers forking and the "can I create?" signal + * the sidebar renders from the same decision. + * + * Governed by the organization the caller belongs to even when the resulting + * workspace would be personal: a personal workspace is precisely the escape, + * so exempting it would leave the gate answering only the case it is not for. + */ + // permission-group-enforced: workspace.create — no workspace exists yet, so the workspace-scoped funnel has nothing to resolve a group against + if (await isOrganizationCapabilityWithheld(governingOrganizationId, 'workspace.create')) { + return { + canCreate: false, + workspaceMode: + organizationId === null ? WORKSPACE_MODE.PERSONAL : WORKSPACE_MODE.ORGANIZATION, + organizationId, + billedAccountUserId: + organizationId === null + ? userId + : ((await getOrganizationOwnerId(organizationId)) ?? userId), + maxWorkspaces: null, + currentWorkspaceCount: 0, + reason: + 'Your permission group does not allow creating workspaces. Ask an organization admin to change it.', + status: 403, + observedOrganizationId: membership?.organizationId ?? null, + blockedReasonCode: 'permission-group-denied', + } + } + } + if (activeOrganizationId && !orgRole) { const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 4a820ca84c2..10991a66bf7 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -107,13 +107,9 @@ vi.mock('@/lib/core/security/encryption', () => ({ vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed: mockAssertPermissionsAllowed, validateBlockType: vi.fn().mockResolvedValue(undefined), - validateMcpToolsAllowed: vi.fn().mockResolvedValue(undefined), - validateCustomToolsAllowed: vi.fn().mockResolvedValue(undefined), - validateSkillsAllowed: vi.fn().mockResolvedValue(undefined), validateModelProvider: vi.fn().mockResolvedValue(undefined), validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), validatePublicApiAllowed: vi.fn().mockResolvedValue(undefined), - getUserPermissionConfig: vi.fn().mockResolvedValue(null), ProviderNotAllowedError: class ProviderNotAllowedError extends Error {}, IntegrationNotAllowedError: class IntegrationNotAllowedError extends Error {}, McpToolsNotAllowedError: class McpToolsNotAllowedError extends Error {}, @@ -123,6 +119,10 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {}, })) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: vi.fn().mockResolvedValue(null), +})) + vi.mock('@/lib/billing/core/usage-log', () => ({})) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) diff --git a/package.json b/package.json index 116aa290faf..f5e952fb383 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,18 @@ "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", - "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:migrations-safety && bun run test:generators && turbo run test", + "test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:capability-subject && bun run test:application-graph && bun run test:migrations-safety && bun run test:generators && turbo run test", "test:setup": "bun run --cwd packages/sim-setup test", "test:npm-package-versions": "bunx vitest run scripts/bump-npm-package-versions.test.ts", "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", "test:tool-registry-boundary": "bunx vitest run scripts/check-tool-registry-boundary.test.ts", "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", "test:actorless-executor-operations": "bunx vitest run scripts/check-actorless-executor-operations.test.ts", + "test:permission-group-enforcement": "bunx vitest run scripts/check-permission-group-enforcement.test.ts", + "test:capability-subject": "bunx vitest run scripts/check-capability-subject.test.ts", + "test:application-graph": "bunx vitest run scripts/check-application-graph.test.ts", "test:migrations-safety": "bunx vitest run scripts/check-migrations-safety.test.ts", - "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", + "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts scripts/generate-block-successors.test.ts", "format": "turbo run format", "format:check": "turbo run format:check", "lint": "turbo run lint", @@ -47,6 +50,11 @@ "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", "check:actorless-executor-operations": "bun run scripts/check-actorless-executor-operations.ts", + "check:permission-group-enforcement": "bun run scripts/check-permission-group-enforcement.ts", + "check:capability-subject": "bun run scripts/check-capability-subject.ts", + "check:application-graph": "bun run scripts/check-application-graph.ts", + "generate:block-successors": "bun run scripts/generate-block-successors.ts", + "check:block-successors": "bun run scripts/generate-block-successors.ts --check", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts --check", "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", "check:import-specifiers": "bun run scripts/check-import-specifiers.ts", diff --git a/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql new file mode 100644 index 00000000000..70107ff48e4 --- /dev/null +++ b/packages/db/migrations/0315_table_dispatch_capability_governed_user.sql @@ -0,0 +1,51 @@ +-- Adds the permission-group subject a table run's cells are gated against, separate from +-- `triggered_by_user_id` (an attribution that substitutes the workspace billed account when the +-- credential names no human). Additive and nullable: NULL means "no acting person, no per-tool +-- gate". +-- +-- Rows written before this column existed would all read NULL, which silently drops the +-- triggered-by gate they were running under. That window is NOT short: a dispatch has no time +-- ceiling on the in-process path (`runDispatcherToCompletion` loops until the scope is exhausted, +-- `lib/table/dispatcher.ts`), and the trigger.dev path allows up to 90 minutes, so a queued +-- dispatch can outlive the deploy by hours. The backfill below therefore gives every non-terminal +-- pre-migration row exactly the subject it was already gated on — `triggered_by_user_id` — while +-- rows written by the new code carry the corrected acting-person/attribution distinction. +-- +-- Transaction shape: the runner batches every pending file into ONE transaction (drizzle's +-- `session.transaction()`, see packages/db/scripts/migrate.ts), so `NOT VALID` + `VALIDATE` in a +-- single file is inert — the validation scan holds exactly the locks the two-step exists to shed, +-- and holds them until the last pending file commits. The `COMMIT;` breakpoint below ends that +-- batch transaction so the FK is added and validated in their own autocommit statements. From that +-- breakpoint on, this file and every later pending file run in autocommit and a failed run replays +-- unjournaled files from the top, so every statement here is written to survive a second run. +ALTER TABLE "table_run_dispatches" ADD COLUMN IF NOT EXISTS "capability_governed_user_id" text;--> statement-breakpoint +-- migration-safe: bounded backfill over non-terminal dispatches only (status pending/dispatching — a few rows at any instant, indexed by `table_run_dispatches_active_idx`), idempotent under the IS NULL guard, and it preserves rather than changes the gate those rows already had. A replay after the new writers are live could only re-gate a still-queued actorless row, which fails closed. +UPDATE "table_run_dispatches" +SET "capability_governed_user_id" = "triggered_by_user_id" +WHERE "status" IN ('pending', 'dispatching') + AND "capability_governed_user_id" IS NULL + AND "triggered_by_user_id" IS NOT NULL;--> statement-breakpoint +-- Ends the runner's batch transaction. Everything above committed together: the column add takes +-- ACCESS EXCLUSIVE on `table_run_dispatches`, and the backfill that follows it is index-driven over +-- the handful of live dispatches, so the exclusive lock is held for milliseconds rather than +-- through the next file's validation scan. +COMMIT;--> statement-breakpoint +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the replay guard is an explicit pg_constraint +-- lookup. Scoped to conrelid so an identically named constraint on another table cannot mask it. +-- NOT VALID keeps this to a metadata change: ACCESS EXCLUSIVE on `table_run_dispatches` and SHARE +-- ROW EXCLUSIVE on `user` (which blocks concurrent writes to `user` — signups included) for this +-- one statement, instead of for the whole batch. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'table_run_dispatches_capability_governed_user_id_user_id_fk' + AND "conrelid" = '"table_run_dispatches"'::regclass + ) THEN + ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +-- Own transaction, which is the entire point of the breakpoint above: VALIDATE takes only SHARE +-- UPDATE EXCLUSIVE on `table_run_dispatches` and ROW SHARE on `user`, so the scan runs alongside +-- ordinary reads and writes. VALIDATE on an already-validated constraint is a no-op, so a replay +-- needs no guard of its own. +ALTER TABLE "table_run_dispatches" VALIDATE CONSTRAINT "table_run_dispatches_capability_governed_user_id_user_id_fk"; diff --git a/packages/db/migrations/0316_table_row_execution_capability_subject.sql b/packages/db/migrations/0316_table_row_execution_capability_subject.sql new file mode 100644 index 00000000000..a92ce451c81 --- /dev/null +++ b/packages/db/migrations/0316_table_row_execution_capability_subject.sql @@ -0,0 +1,52 @@ +-- Adds the permission-group subject a queued cell is gated against to the cell sidecar, and adds +-- the index the account-deletion cancel needs. +-- +-- The cell column exists because a dispatcher pre-stamp outlives the worker that wrote it: a cell +-- task that finds the row's cascade lock held bails, and whoever owns the lock drains the marker. +-- Without the subject on the marker that drain runs someone else's request under its own subject. +-- Additive and nullable; NULL means "no acting person, no per-tool gate", which is exactly what a +-- marker written before this column existed was already doing. There is no backfill: every +-- pre-existing row is NULL by definition and NULL is the correct, pre-migration-equivalent value. +-- +-- Transaction shape: the runner batches every pending file into ONE transaction and only an +-- embedded `COMMIT;` ends it (packages/db/scripts/migrate.ts). `table_row_executions` is the large +-- table in this pair, so its `VALIDATE CONSTRAINT` scan must not run inside that batch — there it +-- would hold 0315's ACCESS EXCLUSIVE on `table_run_dispatches` and the FK's SHARE ROW EXCLUSIVE on +-- `user` (blocking every signup) for the length of the scan. The `COMMIT;` below puts the scan in +-- its own autocommit statement, where it takes only SHARE UPDATE EXCLUSIVE and runs alongside +-- ordinary traffic. +-- +-- Replay-safe: this file runs entirely in autocommit (0315 already committed), so a failure at any +-- statement leaves it unjournaled and replays the whole file from the top. Every statement below +-- has to survive being run twice. +ALTER TABLE "table_row_executions" ADD COLUMN IF NOT EXISTS "capability_governed_user_id" text;--> statement-breakpoint +-- Ends the runner's batch transaction if one is still open — it is not when 0315 applies in the +-- same run, and a redundant COMMIT is a Postgres WARNING, not an error. Keeping it unconditional +-- is what makes this file correct on its own, for a database that already has 0315. +COMMIT;--> statement-breakpoint +-- Postgres has no ADD CONSTRAINT IF NOT EXISTS, so the replay guard is an explicit pg_constraint +-- lookup. Scoped to conrelid so an identically named constraint on another table cannot mask it. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'table_row_executions_capability_governed_user_id_user_id_fk' + AND "conrelid" = '"table_row_executions"'::regclass + ) THEN + ALTER TABLE "table_row_executions" ADD CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk" FOREIGN KEY ("capability_governed_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +-- Own transaction. The scan is the price of a constraint the schema snapshot describes as an +-- ordinary FK: leaving it NOT VALID forever would make a migrated database differ from a freshly +-- created one, which every later drift check and snapshot diff would then have to special-case. +-- VALIDATE on an already-validated constraint is a no-op, so this needs no replay guard. +ALTER TABLE "table_row_executions" VALIDATE CONSTRAINT "table_row_executions_capability_governed_user_id_user_id_fk";--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- Account deletion cancels every still-active dispatch the departing account governs, and that is +-- the only query keyed on the subject. The other two indexes on this table lead with `table_id` / +-- `status`, so without this one the deletion scans every active dispatch while holding its +-- transaction open. Partial on the two live statuses: a terminal row is never a cancellation +-- target, and dispatch history is what grows. +-- migration-safe: replay removes an invalid build created by this migration; concurrent operations preserve row writes. +DROP INDEX CONCURRENTLY IF EXISTS "table_run_dispatches_governed_active_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "table_run_dispatches_governed_active_idx" ON "table_run_dispatches" USING btree ("capability_governed_user_id","status") WHERE "table_run_dispatches"."status" IN ('pending', 'dispatching');--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0315_snapshot.json b/packages/db/migrations/meta/0315_snapshot.json new file mode 100644 index 00000000000..8da654ca8c1 --- /dev/null +++ b/packages/db/migrations/meta/0315_snapshot.json @@ -0,0 +1,20802 @@ +{ + "id": "1c5e9470-9b1b-4290-9952-7adfaac8ae89", + "prevId": "71728d90-1e30-40bf-aa5e-edc370e34999", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/0316_snapshot.json b/packages/db/migrations/meta/0316_snapshot.json new file mode 100644 index 00000000000..dbc24cab9e7 --- /dev/null +++ b/packages/db/migrations/meta/0316_snapshot.json @@ -0,0 +1,20839 @@ +{ + "id": "1fafbd78-3e0f-4cc8-9a51-053a3b11af9e", + "prevId": "1c5e9470-9b1b-4290-9952-7adfaac8ae89", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index b0baf0d54d6..286f0463fbf 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2199,6 +2199,20 @@ "when": 1788208209301, "tag": "0314_superb_daimon_hellstrom", "breakpoints": true + }, + { + "idx": 315, + "version": "7", + "when": 1788222849643, + "tag": "0315_table_dispatch_capability_governed_user", + "breakpoints": true + }, + { + "idx": 316, + "version": "7", + "when": 1788229583797, + "tag": "0316_table_row_execution_capability_subject", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index b8e07056576..c7aa581629e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -5003,6 +5003,25 @@ export const tableRowExecutions = pgTable( runningBlockIds: text('running_block_ids').array().notNull().default(sql`'{}'::text[]`), blockErrors: jsonb('block_errors').notNull().default({}), cancelledAt: timestamp('cancelled_at'), + /** + * Person whose permission group gates this cell's tools, persisted with the + * dispatcher's `pending` pre-stamp. + * + * The stamp and the worker that runs it are not the same run: a cell task + * that finds the row's cascade lock held bails, and the lock owner drains + * the marker itself. That owner belongs to whatever dispatch queued IT, so + * without the subject on the marker the drained cell would run under the + * wrong person's group — or under none, when the owner is an actorless + * auto-fire. Read only while the marker is unclaimed (`pending` with a null + * `execution_id`); later writes on the same cell carry no subject and null + * it, which is why nothing reads it after pickup. + * + * `ON DELETE SET NULL`, matching `table_run_dispatches`: a deleted person's + * runs are stopped by that table's cancel, not held open by this reference. + */ + capabilityGovernedUserId: text('capability_governed_user_id').references(() => user.id, { + onDelete: 'set null', + }), /** * Enrichment cascade breakdown (provider outcomes, cost, timing) for * `enrichment`-type groups. Null for workflow groups and pre-feature runs. @@ -5071,6 +5090,24 @@ export const tableRunDispatches = pgTable( triggeredByUserId: text('triggered_by_user_id').references(() => user.id, { onDelete: 'set null', }), + /** The person whose permission group governs what this run's cells may do. + * Distinct from `triggered_by_user_id`, which is an *attribution* and + * substitutes the workspace billed account when the credential names no + * human — right for a meter, wrong for a gate, since it would run a + * bystander's tool denylist against an actorless request. Null when the + * run has no acting person (workspace API key, schedule, auto-fire), which + * means no per-tool gate applies. Producers set it explicitly, `null` + * included: it is required on every dispatch input precisely so a new one + * cannot inherit the attribution by omission. + * + * `set null` on delete, paired with a cancel of this account's non-terminal + * dispatches inside `deleteUserAccount`. Nulling alone would be a silent + * un-gate — the worker cannot tell a subject erased by deletion from one + * that was never there — and `restrict` would block account deletion behind + * background work. Going terminal first makes the nulled row unreachable. */ + capabilityGovernedUserId: text('capability_governed_user_id').references(() => user.id, { + onDelete: 'set null', + }), requestedAt: timestamp('requested_at').notNull().defaultNow(), /** Last time the dispatcher loop made progress on this dispatch. Stamped by * the same per-window writes that advance `cursor` and `processed_count`, @@ -5086,6 +5123,15 @@ export const tableRunDispatches = pgTable( (table) => ({ activeIdx: index('table_run_dispatches_active_idx').on(table.tableId, table.status), watchdogIdx: index('table_run_dispatches_watchdog_idx').on(table.status, table.requestedAt), + /** Account deletion cancels every still-active dispatch the departing + * account governs, and that is the only query keyed on the subject. The + * other two indexes lead with `table_id` / `status`, so without this one + * the deletion scans every active dispatch in the deployment while holding + * its transaction open. Partial on the two live statuses: a terminal row is + * never a cancellation target, and dispatch history is what grows. */ + governedActiveIdx: index('table_run_dispatches_governed_active_idx') + .on(table.capabilityGovernedUserId, table.status) + .where(sql`${table.status} IN ('pending', 'dispatching')`), }) ) diff --git a/packages/testing/src/mocks/hybrid-auth.mock.ts b/packages/testing/src/mocks/hybrid-auth.mock.ts index ab5c6c0439f..e9739496172 100644 --- a/packages/testing/src/mocks/hybrid-auth.mock.ts +++ b/packages/testing/src/mocks/hybrid-auth.mock.ts @@ -52,6 +52,22 @@ export const hybridAuthMockFns = { */ export const hybridAuthMock = { AuthType: AuthTypeMock, + /** + * The real derivation, not a vi.fn(): it is a pure branch on fields the test + * already controls through the auth result, and a stub returning undefined + * would silently un-gate every consumer. Mirrors `capabilityGovernedAuthUserId` + * in `@/lib/auth/hybrid` — only a session or a personal API key names a + * governed subject. + */ + capabilityGovernedAuthUserId: (auth?: { + userId?: string + authType?: string + apiKeyType?: string + }): string | null => { + if (!auth?.userId) return null + if (auth.authType === 'session') return auth.userId + return auth.authType === 'api_key' && auth.apiKeyType === 'personal' ? auth.userId : null + }, checkHybridAuth: hybridAuthMockFns.mockCheckHybridAuth, checkSessionOrInternalAuth: hybridAuthMockFns.mockCheckSessionOrInternalAuth, checkInternalAuth: hybridAuthMockFns.mockCheckInternalAuth, diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 2616c0cee1b..e8b26d408fc 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -123,6 +123,11 @@ export { OauthStepTimeoutErrorMock, } from './mcp-oauth.mock' // Permission mocks +export { + permissionGroupScopeMock, + permissionGroupScopeMockFns, + resetPermissionGroupScopeMock, +} from './permission-group-scope.mock' export { permissionsMock, permissionsMockFns } from './permissions.mock' // PostHog server mocks (for @/lib/posthog/server) export { posthogServerMock, posthogServerMockFns } from './posthog-server.mock' @@ -172,6 +177,14 @@ export { } from './terminal-console.mock' // URL mocks export { LOCALHOST_HOSTNAMES_MOCK, resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' +// v1 public API ambient request-admission mocks and credential factories +export { + v1PersonalKeyCredential, + v1RateLimitContextModuleMock, + v1RateLimiterModuleMock, + v1SubscriptionModuleMock, + v1WorkspaceKeyCredential, +} from './v1-route.mock' export { MockV2ApiKeyUnauthenticatedError, V2_OPERATION_RATE_LIMIT_ALLOWED, diff --git a/packages/testing/src/mocks/permission-group-scope.mock.ts b/packages/testing/src/mocks/permission-group-scope.mock.ts new file mode 100644 index 00000000000..01f332298cf --- /dev/null +++ b/packages/testing/src/mocks/permission-group-scope.mock.ts @@ -0,0 +1,53 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/permission-groups/config-scope.server`. + * + * `mockResolvePermissionGroupConfig` is the one seam every capability gate reads + * — `assertWorkspaceCapability`, `isWorkspaceCapabilityWithheld`, and the + * authorization funnel all resolve the governing config through it. Mock this + * rather than `@/ee/access-control/utils/permission-check`, which sits a layer + * below the memo and is not what the gates call. + * + * Resolve `null` for the ungoverned case (a personal workspace, or any + * non-enterprise organization) and a spread of `DEFAULT_PERMISSION_GROUP_CONFIG` + * for a group that governs the user. + * + * @example + * ```ts + * import { permissionGroupScopeMock, permissionGroupScopeMockFns } from '@sim/testing' + * + * vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + * + * permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ + * ...DEFAULT_PERMISSION_GROUP_CONFIG, + * hideInboxTab: true, + * }) + * ``` + */ +export const permissionGroupScopeMockFns = { + mockResolvePermissionGroupConfig: vi.fn(), +} + +/** + * Static mock module for `@/lib/permission-groups/config-scope.server`. + * + * Only the resolver lives there. `withPermissionGroupScope` — which + * `withRouteHandler` calls to wrap every route handler — lives in the + * import-free `@/lib/permission-groups/request-scope.server`, so mocking this + * module leaves the real scope wrapper in place and there is nothing to stub. + * + * @example + * ```ts + * vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) + * ``` + */ +export const permissionGroupScopeMock = { + resolvePermissionGroupConfig: permissionGroupScopeMockFns.mockResolvePermissionGroupConfig, +} + +/** Restores the ungoverned default — no group governs the user. */ +export function resetPermissionGroupScopeMock(): void { + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockReset() + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null) +} diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 025fa9951b3..3833a8c9392 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1444,6 +1444,7 @@ export const schemaMock = { runningBlockIds: 'tableRowExecutions.runningBlockIds', blockErrors: 'tableRowExecutions.blockErrors', cancelledAt: 'tableRowExecutions.cancelledAt', + capabilityGovernedUserId: 'tableRowExecutions.capabilityGovernedUserId', updatedAt: 'tableRowExecutions.updatedAt', }, tableRunDispatches: { @@ -1459,6 +1460,7 @@ export const schemaMock = { processedCount: 'tableRunDispatches.processedCount', isManualRun: 'tableRunDispatches.isManualRun', triggeredByUserId: 'tableRunDispatches.triggeredByUserId', + capabilityGovernedUserId: 'tableRunDispatches.capabilityGovernedUserId', requestedAt: 'tableRunDispatches.requestedAt', heartbeatAt: 'tableRunDispatches.heartbeatAt', completedAt: 'tableRunDispatches.completedAt', diff --git a/packages/testing/src/mocks/v1-route.mock.ts b/packages/testing/src/mocks/v1-route.mock.ts new file mode 100644 index 00000000000..344068543b1 --- /dev/null +++ b/packages/testing/src/mocks/v1-route.mock.ts @@ -0,0 +1,89 @@ +import { vi } from 'vitest' + +/** + * Ambient request-admission modules for the v1 public API. + * + * `app/api/v1/middleware.ts` consults a subscription, a rate bucket, and the + * rate-limit snapshot context on the way into every handler. A suite whose + * subject is what the handler does — a capability gate, a field projection — + * needs all three to pass, and needs none of them to be controllable. These + * pass-through module mocks say exactly that, so the per-suite `vi.mock` block + * is left with only the seams the suite actually steers. + * + * Use `@/app/api/v1/middleware`'s own mocks instead when admission itself is + * the subject; these deliberately expose no handles to assert on. + * + * @example + * ```ts + * vi.mock('@/lib/billing/core/subscription', () => v1SubscriptionModuleMock) + * vi.mock('@/lib/core/rate-limiter', () => v1RateLimiterModuleMock) + * vi.mock('@/lib/api/server/rate-limit-context', () => v1RateLimitContextModuleMock) + * ``` + */ +export const v1SubscriptionModuleMock = { + getHighestPrioritySubscription: vi.fn(async () => null), +} + +/** Always-allowed bucket, so admission never decides a v1 test's outcome. */ +export const v1RateLimiterModuleMock = { + RateLimiter: class RateLimiter { + checkRateLimitWithSubscription() { + return Promise.resolve({ allowed: true, remaining: 100, resetAt: new Date() }) + } + }, + getRateLimit: () => ({ maxTokens: 200 }), +} + +/** Header plumbing the routes call unconditionally; it emits nothing to assert. */ +export const v1RateLimitContextModuleMock = { + buildRateLimitHeaders: () => ({}), + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: () => null, +} + +/** + * The credential `authenticateV1Request` resolves for a personal API key. + * + * The gate and projection suites steer this deliberately — it is the caller a + * permission group governs — so it is a factory rather than a module mock. + * + * @example + * ```ts + * mockAuthenticateV1Request.mockResolvedValue(v1PersonalKeyCredential(USER_ID)) + * ``` + */ +export function v1PersonalKeyCredential(userId: string, keyId = 'key-1') { + return { + authenticated: true, + userId, + keyType: 'personal' as const, + principal: { kind: 'personal_api_key' as const, userId, keyId }, + } +} + +/** + * The credential `authenticateV1Request` resolves for a workspace API key. + * + * It still reports a `userId` — the key's CREATOR — which is the trap every + * caller of this factory exists to pin: a gate keyed on the presence of a user + * id rather than on the principal kind applies that bystander's permission + * group to every caller of a shared credential. + * + * @example + * ```ts + * mockAuthenticateV1Request.mockResolvedValue(v1WorkspaceKeyCredential(WORKSPACE_ID)) + * ``` + */ +export function v1WorkspaceKeyCredential( + workspaceId: string, + creatorUserId = 'key-creator', + keyId = 'key-2' +) { + return { + authenticated: true, + userId: creatorUserId, + workspaceId, + keyType: 'workspace' as const, + principal: { kind: 'workspace_api_key' as const, workspaceId, keyId }, + } +} diff --git a/scripts/check-application-graph.test.ts b/scripts/check-application-graph.test.ts new file mode 100644 index 00000000000..a477dd07bc6 --- /dev/null +++ b/scripts/check-application-graph.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + deferredSpecifiers, + FORBIDDEN_PREFIXES, + findViolations, + GUARDED_ROOTS, + resolveSpecifier, + runtimeSpecifiers, +} from './check-application-graph' + +describe('runtimeSpecifiers', () => { + it('collects import and re-export specifiers', () => { + expect( + runtimeSpecifiers( + "import { a } from '@/lib/a'\nexport { b } from '@/lib/b'\nimport '@/lib/c'\n" + ) + ).toEqual(['@/lib/a', '@/lib/b', '@/lib/c']) + }) + + /** + * The heaviest edge of all — the module is loaded purely to run — and the one + * nothing in the importing file names, so it was walked straight past. + */ + it('collects a side-effect import, in source order', () => { + expect( + runtimeSpecifiers("import '@/lib/uploads/core/setup.server'\nimport { a } from '@/lib/a'\n") + ).toEqual(['@/lib/uploads/core/setup.server', '@/lib/a']) + }) + + it('leaves a dynamic import out of the module-evaluation set', () => { + expect(runtimeSpecifiers("const a = await import('@/lib/a')\n")).toEqual([]) + }) + + it('ignores type-only statements, which the compiler erases', () => { + expect( + runtimeSpecifiers( + "import type { A } from '@/lib/a'\nimport type B from '@/lib/b'\nexport type { C } from '@/lib/c'\n" + ) + ).toEqual([]) + }) + + it('keeps an inline type import, which still emits a runtime load', () => { + expect(runtimeSpecifiers("import { type A, b } from '@/lib/a'\n")).toEqual(['@/lib/a']) + }) +}) + +describe('resolveSpecifier', () => { + it('resolves an @/ specifier against apps/sim', () => { + expect(resolveSpecifier('@/lib/permission-groups/capabilities', __filename)).toMatch( + /apps\/sim\/lib\/permission-groups\/capabilities\.ts$/ + ) + }) + + it('returns null for a bare package specifier', () => { + expect(resolveSpecifier('drizzle-orm', __filename)).toBeNull() + }) +}) + +describe('the guarded roots', () => { + it('guards the universal route wrapper against the billing graph', () => { + const wrapper = GUARDED_ROOTS.find( + (guarded) => guarded.root === 'lib/core/utils/with-route-handler.ts' + ) + expect(wrapper?.forbidden['lib/billing/']).toBeTruthy() + }) + + it('reaches no forbidden module tree at runtime', () => { + for (const guarded of GUARDED_ROOTS) { + expect({ root: guarded.root, violations: findViolations(guarded) }).toEqual({ + root: guarded.root, + violations: [], + }) + } + }) + + it('reports the shortest chain when a forbidden module is reachable', () => { + /** + * Walked from a module that legitimately imports the provider registry, so + * the walker is proven able to fail. Without this the suite above would + * still pass if `findViolations` silently stopped finding anything. + */ + const violations = findViolations({ + root: 'lib/permission-groups/model-access.ts', + forbidden: FORBIDDEN_PREFIXES, + }) + expect(violations).toHaveLength(1) + expect(violations[0].forbidden).toBe('providers/utils.ts') + expect(violations[0].reason).toBe(FORBIDDEN_PREFIXES['providers/']) + expect(violations[0].path).toEqual([ + 'lib/permission-groups/model-access.ts', + 'providers/utils.ts', + ]) + }) +}) + +describe('deferredSpecifiers', () => { + it('collects a dynamic import, awaited or not', () => { + expect( + deferredSpecifiers( + "const a = await import('@/lib/a')\nvoid import('@/lib/b').then(noop)\n" + + "const { c } = await import(\n '@/lib/c'\n)\n" + ) + ).toEqual(['@/lib/a', '@/lib/b', '@/lib/c']) + }) + + it('ignores a `typeof import(…)` type query, which the compiler erases', () => { + expect(deferredSpecifiers("type A = typeof import('@/lib/a')\n")).toEqual([]) + }) + + it('leaves static forms to runtimeSpecifiers', () => { + expect(deferredSpecifiers("import { a } from '@/lib/a'\nimport '@/lib/b'\n")).toEqual([]) + }) +}) + +describe('a deferred edge into a forbidden tree', () => { + /** + * The evasion: a root that goes red on a static import is one keystroke from + * green if `await import(…)` produces no edge. On the funnel's hot path the + * deferral moves nothing — the registry loads on the first gated request + * instead of on the first import — so the edge is reported. + */ + it('is reported when a root defers the load of a forbidden module', () => { + /** + * Walked from a module that defers the block registry — `const + * { getBlockRegistry } = await import('@/blocks/registry')` — and nothing + * else about it matters here. Before the deferred pass this root was green + * on `blocks/`, which is the whole evasion in one line. + */ + const violations = findViolations({ + root: 'lib/copilot/chat/process-contents.ts', + forbidden: { 'blocks/': FORBIDDEN_PREFIXES['blocks/'] }, + }) + + expect(violations).toHaveLength(1) + expect(violations[0].forbidden).toBe('blocks/registry.ts') + expect(violations[0].reason).toContain('deferred') + expect(violations[0].path).toEqual([ + 'lib/copilot/chat/process-contents.ts', + 'blocks/registry.ts', + ]) + }) + + /** + * The other half of the rule, and the reason it is not "walk dynamic imports + * like static ones": `lib/billing/core/subscription.ts` sits in the funnel's + * static graph and lazily loads `@/components/emails` on a plan-upgrade + * webhook — a template that statically imports the workflow graph. Walking + * past the deferred hop reports a module nothing loads until that webhook + * fires, which is a false alarm about what an authorization decision costs. + */ + it('is not walked through, so a deferred module’s own graph stays out', () => { + expect( + findViolations({ + root: 'lib/billing/core/subscription.ts', + forbidden: { 'lib/workflows/': FORBIDDEN_PREFIXES['lib/workflows/'] }, + }) + ).toEqual([]) + }) +}) diff --git a/scripts/check-application-graph.ts b/scripts/check-application-graph.ts new file mode 100644 index 00000000000..0281bde0a2c --- /dev/null +++ b/scripts/check-application-graph.ts @@ -0,0 +1,320 @@ +#!/usr/bin/env bun +/** + * Asserts the authorization funnel's and the route wrapper's module graphs stay + * light. + * + * `@/lib/core/application` is imported by ~every domain `operations.ts`, and + * `lib/permission-groups/capabilities.ts` sits below it, so anything either one + * reaches at runtime is loaded by every surface that authorizes anything — + * routes, jobs, the realtime prune graph and every use-case unit test. The + * provider registry, the block/tool registries, the executor and the + * uploads/workflow graph are all far heavier than an authorization decision, + * and none of them has anything to say about one. + * + * `lib/core/utils/with-route-handler.ts` is guarded on the same principle with a + * wider list: it wraps every API route, so its graph is loaded by every route + * and every route test. + * + * The edge this guards against is invisible without a check: adding one import + * to a permission-group helper once widened this graph as far as + * `lib/uploads/utils/file-utils.ts`, and the only symptom was two unrelated + * knowledge tests failing on a partial mock of a module they never meant to + * load. Later, one import in the route wrapper pulled the whole billing graph + * into every route test, and the only symptom was an unrelated OTP-route test + * failing on its own partial `zod` mock. + * + * Walks `import`/`export … from` specifiers. `import type` is erased by the + * compiler and costs nothing at runtime, so a type-only edge into a forbidden + * module is allowed and deliberately not reported. A dynamic `import(…)` is + * reported when its own target is forbidden but is not walked through — see + * {@link DYNAMIC_IMPORT_PATTERN} for both halves of that rule. + * + * ## Exactly what is guarded, and what is not + * + * FIVE entry points, listed in {@link GUARDED_ROOTS}: `lib/core/application`, + * three permission-group modules (`capabilities`, `capability-assertions`, + * `config-scope.server`) and the route wrapper. It is NOT "all of + * `lib/permission-groups/`", and the difference is not a rounding error: + * + * - `lib/permission-groups/model-access.ts` imports `providers/utils.ts` + * directly, on purpose — deciding which models a group allows is the one + * permission-group question that genuinely needs the provider registry. It is + * unguardable by construction, and the graph test uses it as its proof that + * the walker can still fail. + * - `lib/permission-groups/user-scope.server.ts` — the user-global resolver — + * reaches the workflow graph today, through + * `lib/billing/organizations/membership.ts` -> `lib/billing/core/usage.ts` -> + * `components/emails`. Guarding it is therefore not free: it would go red on + * arrival. It is left unguarded rather than added with an exception, because + * an exception list is how a root stops meaning anything. What holds it + * instead is `check-capability-subject.ts`, which bans a v1 route from + * importing it at all. + * + * The rule for adding a root is the one the two cases above illustrate: guard an + * entry point whose graph EVERY authorizing surface pays for, and only while the + * guard passes without exceptions. A module reached by one gate on one path is + * not that, however capability-shaped it looks. + * + * ## What this audit cannot see + * + * - A specifier that is not a literal — `import(someVariable)`, or a require + * built from a template string. There is no call graph here, only source + * text. + * - Weight that is not a forbidden prefix. A root can reach an arbitrarily + * expensive module and stay green if that module is not under one of the + * listed trees; the list is a record of what has actually gone wrong, not a + * budget. + * - Whether a deferred edge is hot or cold. A dynamic import on a per-request + * path and one on a once-a-month webhook read identically, which is why the + * deferred check stops at the edge's own target rather than walking past it. + */ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(SCRIPT_DIR, '..') +const APP_ROOT = resolve(REPO_ROOT, 'apps/sim') + +/** + * Path prefixes no guarded root may reach at runtime, with the reason a reviewer + * needs to understand the failure without re-deriving this file. + */ +export const FORBIDDEN_PREFIXES: Record = { + 'providers/': 'the LLM provider registry — an authorization decision never picks a model', + 'blocks/': 'the block registry — it pulls every block definition into the graph', + 'tools/': 'the executable tool registry — see the tool-registry-boundary skill', + 'executor/': 'the workflow execution engine', + 'lib/uploads/': 'the uploads graph, which reaches file parsing and archive handling', + 'lib/workflows/': 'the workflow graph, which reaches the editor and serializer', +} + +/** + * `withRouteHandler` wraps every API route in the app, so its graph is the one + * graph *every* route and every route test pays for — a strictly wider blast + * radius than the authorization funnel's. It is a request-lifecycle wrapper: it + * stamps a request id, records timings, maps typed errors to statuses, and opens + * the permission-group memo. It decides nothing about billing, identity, or any + * domain, so it has no business loading those graphs. + * + * These sit on top of FORBIDDEN_PREFIXES for this root only. They are NOT + * app-wide bans — `lib/permission-groups/resolve.server.ts` legitimately reads + * the subscription to decide whether an organization is on an enterprise plan, + * which is why `lib/billing/` stays allowed for the funnel roots below. + */ +const ROUTE_WRAPPER_FORBIDDEN_PREFIXES: Record = { + 'lib/billing/': 'the billing graph — the route wrapper makes no plan or subscription decision', + 'lib/permission-groups/resolve.server': + 'the permission-group resolver — the wrapper only opens the memo scope; the resolver ' + + 'belongs to the gate call sites, and it is what dragged billing in', + 'lib/auth': 'the auth graph — the wrapper wraps handlers that authenticate, it does not', + 'lib/copilot/': 'the copilot graph', + 'lib/knowledge/': 'the knowledge-base graph', +} + +export interface GuardedRoot { + /** Path under `apps/sim`. */ + root: string + forbidden: Record +} + +/** + * Entry points whose graph every authorization decision — or, for the route + * wrapper, every request — pays for. + */ +export const GUARDED_ROOTS: readonly GuardedRoot[] = [ + { root: 'lib/core/application/index.ts', forbidden: FORBIDDEN_PREFIXES }, + { root: 'lib/permission-groups/capabilities.ts', forbidden: FORBIDDEN_PREFIXES }, + { root: 'lib/permission-groups/capability-assertions.ts', forbidden: FORBIDDEN_PREFIXES }, + { root: 'lib/permission-groups/config-scope.server.ts', forbidden: FORBIDDEN_PREFIXES }, + { + root: 'lib/core/utils/with-route-handler.ts', + forbidden: { ...FORBIDDEN_PREFIXES, ...ROUTE_WRAPPER_FORBIDDEN_PREFIXES }, + }, +] + +/** + * Matches a runtime `import … from '…'` or `export … from '…'`. + * + * The negative lookahead drops `import type {` and `import type X`, which the + * compiler erases; `import { type A }` still counts, because that statement + * emits a runtime require for the module. + * + * The clause between the keyword and `from` never contains a quote, so matching + * only non-quote characters there keeps this from swallowing a side-effect + * import that precedes a clause import and reporting one edge for two. + */ +const IMPORT_PATTERN = + /(?:^|\n)\s*(?:import|export)\s+(?!type[\s{])[^'"]*?\s*from\s*['"]([^'"]+)['"]/g + +/** + * Matches a side-effect import — `import '…'`, with no clause and so no + * `from`. It is the heaviest edge of all: the module is loaded purely to run, + * and nothing in the importing file names it, so it is also the easiest one to + * miss by eye. It does not match a dynamic `import(…)`: the quote must follow + * the keyword directly, and a call opens a parenthesis first. + */ +const SIDE_EFFECT_IMPORT_PATTERN = /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g + +/** + * Matches a dynamic `import('…')`, which is CHECKED but not TRAVERSED. + * + * Checked, because "make it lazy" is the first thing anyone reaches for when + * this audit goes red, and on the hot path it moves nothing — a helper the + * funnel calls on every gated request still drags the provider registry in, one + * request later instead of one import earlier. The pattern is a real one in this + * repo (see `scripts/generate-block-successors.ts`, which defers the block + * registry so its unit test need not resolve it), so it is a form the walker has + * to know about rather than one it can assume absent. + * + * Not traversed, because a deferred module's own graph is deferred with it. + * `lib/billing/core/subscription.ts` — squarely inside the funnel's static graph + * — lazily loads `@/components/emails` on a plan-upgrade webhook, and that + * template statically imports the workflow graph. Walking through the deferred + * hop reports `lib/workflows/schedules/disable-reasons.ts` as an edge every + * authorization decision pays for, which it is not: nothing loads it until that + * webhook fires. The edge worth reporting is the deferred one itself, when its + * TARGET is forbidden. + * + * `typeof import('…')` is excluded: that is a type query the compiler erases, + * the same reason `import type` is dropped above. + */ +const DYNAMIC_IMPORT_PATTERN = /(? (first.index ?? 0) - (second.index ?? 0)) + .map((match) => match[1]) +} + +/** The specifiers `source` loads on demand through a dynamic `import(…)` call. */ +export function deferredSpecifiers(source: string): string[] { + return [...source.matchAll(DYNAMIC_IMPORT_PATTERN)].map((match) => match[1]) +} + +export interface GraphViolation { + root: string + forbidden: string + reason: string + path: string[] +} + +/** + * Breadth-first walk from `root`, reporting the shortest import chain into each + * forbidden prefix. Breadth-first on purpose: the shortest chain is the one a + * reader can act on, and it names the single edge worth deleting. + */ +export function findViolations({ root, forbidden }: GuardedRoot): GraphViolation[] { + const start = resolve(APP_ROOT, root) + const violations: GraphViolation[] = [] + const reported = new Set() + const seen = new Set([start]) + const queue: Array<[string, string[]]> = [[start, [start]]] + + while (queue.length > 0) { + const [file, path] = queue.shift() as [string, string[]] + let source: string + try { + source = readFileSync(file, 'utf8') + } catch { + continue + } + + /** + * A deferred edge is reported when its target is forbidden and then dropped: + * the module it names is not loaded until the call runs, so its own graph is + * not part of what the funnel costs. See {@link DYNAMIC_IMPORT_PATTERN}. + */ + for (const specifier of deferredSpecifiers(source)) { + const next = resolveSpecifier(specifier, file) + if (next === null) continue + const rel = relative(APP_ROOT, next) + const prefix = Object.keys(forbidden).find((candidate) => rel.startsWith(candidate)) + if (prefix === undefined || reported.has(prefix)) continue + reported.add(prefix) + violations.push({ + root, + forbidden: rel, + reason: `${forbidden[prefix]} (reached by a deferred \`import()\`, which defers the load but not the dependency)`, + path: [...path, next].map((entry) => relative(APP_ROOT, entry)), + }) + } + + for (const specifier of runtimeSpecifiers(source)) { + const next = resolveSpecifier(specifier, file) + if (next === null || seen.has(next)) continue + + const rel = relative(APP_ROOT, next) + const prefix = Object.keys(forbidden).find((candidate) => rel.startsWith(candidate)) + if (prefix !== undefined) { + if (!reported.has(prefix)) { + reported.add(prefix) + violations.push({ + root, + forbidden: rel, + reason: forbidden[prefix], + path: [...path, next].map((entry) => relative(APP_ROOT, entry)), + }) + } + continue + } + + seen.add(next) + queue.push([next, [...path, next]]) + } + } + + return violations +} + +function main(): void { + const violations: GraphViolation[] = [] + for (const guarded of GUARDED_ROOTS) { + if (!existsSync(resolve(APP_ROOT, guarded.root))) { + console.error( + `Application-graph audit could not find its own root '${guarded.root}'.\n` + + 'The module was renamed or moved. Update GUARDED_ROOTS rather than leaving this\n' + + 'audit passing over a file that no longer exists.\n' + ) + process.exit(1) + } + violations.push(...findViolations(guarded)) + } + + if (violations.length > 0) { + console.error('❌ The authorization funnel reaches modules it must not load at runtime:\n') + for (const violation of violations) { + console.error(` ${violation.forbidden} — ${violation.reason}`) + console.error(` ${violation.path.join('\n -> ')}\n`) + } + console.error( + 'Move the code that needs the heavy module out of the funnel, or import it only as a\n' + + "type. Do not add the module to FORBIDDEN_PREFIXES' exceptions.\n" + ) + process.exit(1) + } + + const trees = new Set(GUARDED_ROOTS.flatMap((guarded) => Object.keys(guarded.forbidden))) + console.log( + `✅ Application graph clean: ${GUARDED_ROOTS.length} roots reach none of ` + + `${trees.size} forbidden module trees` + ) +} + +if (import.meta.main) main() diff --git a/scripts/check-capability-subject.test.ts b/scripts/check-capability-subject.test.ts new file mode 100644 index 00000000000..56604fa2078 --- /dev/null +++ b/scripts/check-capability-subject.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { auditMiddlewareExport, auditSource } from './check-capability-subject' + +const ROUTE = 'apps/sim/app/api/v1/tables/route.ts' +const MIDDLEWARE = 'apps/sim/app/api/v1/middleware.ts' + +describe('assertion B — a v1 route may not decide a capability for itself', () => { + /** + * The user-global resolver takes a bare `userId` and falls back to the + * organization's default group, so a route reaching for it is one property + * access away from `rateLimit.userId` — the key's creator. It was absent from + * the module list, which is exactly the shape of gap that passes in silence. + */ + it('reports a route that imports the user-global resolver directly', () => { + const { findings } = auditSource( + ROUTE, + "import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server'\n" + ) + + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('user-scope.server') + }) + + it.each([ + '@/lib/permission-groups/capability-assertions', + '@/lib/permission-groups/capabilities', + '@/lib/permission-groups/resolve.server', + '@/lib/permission-groups/config-scope.server', + '@/lib/permission-groups/user-scope.server', + ])('reports a route that imports %s', (module) => { + const { findings } = auditSource(ROUTE, `import { thing } from '${module}'\n`) + + expect(findings).toHaveLength(1) + }) + + it('allows the middleware itself, which is where the decision belongs', () => { + const { findings } = auditSource( + MIDDLEWARE, + "import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server'\n" + ) + + expect(findings).toEqual([]) + }) +}) + +describe('assertion C — the subject came from capabilityGovernedUserId', () => { + it('accepts a subject bound to the governed id', () => { + const { findings, sinks } = auditSource( + MIDDLEWARE, + [ + 'const governedUserId = capabilityGovernedUserId(rateLimit)', + "await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'personal_api_key.use')", + ].join('\n') + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(1) + }) + + it('reports the key creator read straight off the rate-limit result', () => { + const { findings, sinks } = auditSource( + MIDDLEWARE, + "await isWorkspaceCapabilityWithheld(rateLimit.userId, workspaceId, 'personal_api_key.use')\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('rateLimit.userId') + }) +}) + +describe('assertion C — the two renames that made it a no-op', () => { + /** + * The alias leaves the sink's own name on the import line and nowhere else, + * so the audit read a file full of ungoverned calls as a file with none. + */ + it('follows an import alias to the call it renamed', () => { + const { findings, sinks } = auditSource( + MIDDLEWARE, + [ + "import { assertWorkspaceCapability as assertCap } from '@/lib/permission-groups/capability-assertions'", + "await assertCap(rateLimit.userId, workspaceId, 'tables.use')", + ].join('\n') + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('rateLimit.userId') + }) + + it('accepts an aliased call whose subject is still governed', () => { + const { findings, sinks } = auditSource( + 'apps/sim/app/api/v1/logs/route.ts', + [ + 'import { resolveLogFieldProjection as project } from "@/lib/logs/log-projection"', + 'const governed = capabilityGovernedUserId(rateLimit)', + 'await project(governed, workspaceId)', + ].join('\n') + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(1) + }) + + it('refuses a route that declares the governed-subject name for itself', () => { + const { findings } = auditSource( + 'apps/sim/app/api/v1/logs/route.ts', + [ + 'function capabilityGovernedUserId(rateLimit) { return rateLimit.userId }', + "await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit), ws, 'tables.use')", + ].join('\n') + ) + + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('shadowing') + }) +}) + +describe('assertion A — the name the audit is written in terms of', () => { + it('reports a middleware that no longer exports it', () => { + expect(auditMiddlewareExport('export function someOtherName() {}')).toHaveLength(1) + }) + + it('accepts a middleware that still does', () => { + expect( + auditMiddlewareExport('export function capabilityGovernedUserId(rateLimit) { return null }') + ).toEqual([]) + }) +}) + +describe('assertion C — a fallback welded to the governed subject', () => { + /** + * The verified evasion. `capabilityGovernedUserId(rateLimit) ?? …` is what a + * reviewer writes when the governed subject's `null` reads as a gap rather + * than an answer: it satisfies the prefix match, it reintroduces the + * key-creator substitution verbatim, and before this assertion it INCREMENTED + * the liveness counter — the audit reported itself more alive for the evasion. + */ + it('reports a nullish fallback on an inline governed call, and does not count it', () => { + const { findings, sinks } = auditSource( + ROUTE, + 'const withheld = await isWorkspaceCapabilityWithheld(\n' + + ' capabilityGovernedUserId(rateLimit) ?? requireRateLimitUserId(rateLimit),\n' + + " workspaceId,\n 'tables.use'\n)\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + expect(findings[0].message).toContain('falls back when') + }) + + it('reports a logical-or fallback the same way', () => { + const { findings, sinks } = auditSource( + ROUTE, + "const withheld = await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit) || rateLimit.userId, workspaceId, 'tables.use')\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(1) + }) + + /** + * The bound-local form of the same evasion. The local is refused rather than + * registered: registering it would make every sink taking it read as governed. + */ + it('reports a fallback on the binding, and refuses the local it binds', () => { + const { findings, sinks } = auditSource( + ROUTE, + 'const subject = capabilityGovernedUserId(rateLimit) ?? rateLimit.userId\n' + + "await assertWorkspaceCapability(subject, workspaceId, 'tables.use')\n" + ) + + expect(sinks).toBe(0) + expect(findings).toHaveLength(2) + expect(findings[0].message).toContain('falls back when') + expect(findings[1].message).toContain('did not come from') + }) + + it('leaves an un-welded governed call alone, inline and through a local', () => { + const { findings, sinks } = auditSource( + ROUTE, + 'const subject = capabilityGovernedUserId(rateLimit)\n' + + "await assertWorkspaceCapability(subject, workspaceId, 'tables.use')\n" + + "await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit), workspaceId, 'tables.use')\n" + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(2) + }) + + /** + * A `??` inside a nested argument list is not a fallback applied to the + * subject, so the depth tracking has to survive one. + */ + it('does not mistake a nested ?? inside the governed call for a fallback', () => { + const { findings, sinks } = auditSource( + ROUTE, + "await isWorkspaceCapabilityWithheld(capabilityGovernedUserId(rateLimit ?? auth), workspaceId, 'tables.use')\n" + ) + + expect(findings).toEqual([]) + expect(sinks).toBe(1) + }) +}) diff --git a/scripts/check-capability-subject.ts b/scripts/check-capability-subject.ts new file mode 100644 index 00000000000..52ce08ebf0c --- /dev/null +++ b/scripts/check-capability-subject.ts @@ -0,0 +1,433 @@ +#!/usr/bin/env bun +/** + * Keeps an API key's creator out of every permission-group decision the v1 + * public API makes. + * + * A capability belongs to a *person*, so it may only ever be evaluated against a + * user-bearing principal. `authenticateApiKeyFromHeader` reports a `userId` for + * BOTH key kinds, and for a workspace key that id is the key's *creator* — a + * bystander. Asking "does this user's group withhold Tables?" with the creator's + * id applies one employee's group to every caller of a shared credential, and + * CLAUDE.md forbids it verbatim: "Never substitute a billing owner, uploader, + * creator, or API-key owner for the acting principal." + * + * The bug has shipped twice and been fixed twice. Both times the fix was to read + * `keyType` instead of the presence of a user id, and both times nothing stopped + * the next route from reaching for `rateLimit.userId` again — the raw id sits + * one property access away from every handler, and it is the right value for the + * role check, the audit actor and the log line sitting beside the gate. This + * audit is the thing that stops it. + * + * It asserts, over `apps/sim/app/api/v1/**` (excluding `admin/`, the + * platform-admin surface, and test files): + * + * A `capabilityGovernedUserId` is still exported from the v1 middleware. + * Every other assertion is written in terms of it, so a rename that went + * unnoticed would turn this whole audit into a no-op that still passes. + * B no v1 file outside the middleware imports the permission-group modules + * directly. A capability decision made at a route reaches for whatever id + * is in scope; routing every one through the middleware is what puts it + * under assertion C. + * C every call to a capability sink passes a subject that came from + * `capabilityGovernedUserId` — the call expression inline, or a local bound + * to it — and passes it WITHOUT a fallback. An id read off + * `rateLimit`/`auth` is the failure this exists for. Import aliases + * (`sink as local`) are folded in, and a local of the audit's own name is + * refused outright: either one turns a source-text match into a no-op that + * still passes. + * D at least one governed sink call was actually found. The assertions are + * source-text matches, so a refactor into a form the parser cannot follow + * would otherwise be indistinguishable from a clean tree. + * + * The fallback half of C is the one evasion that survived the first version of + * this audit. `capabilityGovernedUserId(rateLimit) ?? rateLimit.userId` is what + * a reviewer writes when the governed subject's `null` reads as a gap to be + * filled — it satisfies the prefix match, it reintroduces the key-creator + * substitution verbatim, and it used to increment the liveness counter, so the + * audit reported itself MORE alive for the evasion. Both the inline form and the + * bound-local form (`const id = capabilityGovernedUserId(x) ?? x.userId`) are + * findings. + * + * Scope is v1 on purpose. It is the one surface that authorizes in a middleware + * of its own rather than through `authorizeWorkspaceOperation`, which decides + * from a `Principal` that has no user to substitute in the first place. + * + * ## What this audit does not cover + * + * - Assertion B bans a fixed list of modules, and `@/lib/logs/log-projection` is + * deliberately absent from it: three v1 logs routes import it by design, + * because a log FIELD PROJECTION is not a gate the middleware can apply — the + * route reads the whole log and blanks fields. `resolveLogFieldProjection` is + * on the sink list instead, which is the stronger check of the two: B asks + * only whether a module was imported, C asks what subject its sink was given. + * A capability decider added under `lib/permission-groups/` belongs on B; one + * whose call site is legitimately a route belongs on C. + * - The sink list is enumerated, not derived. A new helper that takes a user id + * and resolves a capability is invisible until it is added to + * `CAPABILITY_SINKS` — assertion D catches only the case where EVERY governed + * call disappears, not the case where one new ungoverned sink appears beside + * them. + * - `subject` is matched as source text. A subject laundered through a helper + * (`subjectFor(rateLimit)`) reads as ungoverned and is reported, which is the + * safe direction; a subject laundered through an object property + * (`ctx.governed`, assigned from the governed call elsewhere) is also reported. + * Neither is followed across files — this audit has no call graph. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * `import.meta.url` rather than Bun's `import.meta.dir`, so the module also + * imports cleanly under vitest — the assertions below are unit-tested, and + * `import.meta.dir` is undefined outside Bun. + */ +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const V1_ROOT = 'apps/sim/app/api/v1' +const MIDDLEWARE = `${V1_ROOT}/middleware.ts` +/** Out of scope: the platform-admin surface authenticates platform admins, not workspace keys. */ +const EXCLUDED_DIRECTORIES = ['admin'] +const GOVERNED = 'capabilityGovernedUserId' + +/** + * Modules that decide a permission-group capability. A v1 route importing one of + * these has stepped around the middleware and is deciding for itself. + */ +const CAPABILITY_MODULES = [ + '@/lib/permission-groups/capability-assertions', + '@/lib/permission-groups/capabilities', + '@/lib/permission-groups/resolve.server', + '@/lib/permission-groups/config-scope.server', + /** + * The user-global resolver, which answers a capability for a caller who names + * no workspace by falling back to the organization's default group. It takes + * a bare `userId` like every other sink, so a v1 route that imported it would + * be one property access away from the key creator with nothing in between — + * and being absent from this list is precisely how it would stay green. + */ + '@/lib/permission-groups/user-scope.server', +] + +/** + * Functions whose named argument IS the person a group governs, by argument + * index. Add a sink here when one is introduced; a helper that resolves a + * config or asserts a capability from a user id belongs on this list. + */ +const CAPABILITY_SINKS: Record = { + isCapabilityWithheldForUser: 0, + isWorkspaceCapabilityWithheld: 0, + assertWorkspaceCapability: 0, + resolvePermissionGroupConfig: 0, + getUserPermissionConfigForOrganization: 0, + resolveLogFieldProjection: 0, +} + +interface Finding { + file: string + line: number + message: string +} + +function walk(directory: string, into: string[]): void { + for (const entry of readdirSync(directory)) { + const full = join(directory, entry) + if (statSync(full).isDirectory()) { + if (!EXCLUDED_DIRECTORIES.includes(entry)) walk(full, into) + } else if (full.endsWith('.ts') && !full.endsWith('.test.ts')) { + into.push(full) + } + } +} + +/** Splits a call's argument list on top-level commas, ignoring nested groups and strings. */ +function splitArguments(argumentText: string): string[] { + const parts: string[] = [] + let depth = 0 + let quote: string | null = null + let current = '' + for (let index = 0; index < argumentText.length; index++) { + const char = argumentText[index] + if (quote) { + if (char === quote && argumentText[index - 1] !== '\\') quote = null + current += char + continue + } + if (char === "'" || char === '"' || char === '`') { + quote = char + current += char + continue + } + if (char === '(' || char === '[' || char === '{') depth++ + else if (char === ')' || char === ']' || char === '}') depth-- + if (char === ',' && depth === 0) { + parts.push(current.trim()) + current = '' + continue + } + current += char + } + if (current.trim()) parts.push(current.trim()) + return parts +} + +/** Text inside the `(...)` that starts at `openIndex`. */ +function callArguments(source: string, openIndex: number): string | null { + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + const char = source[index] + if (char === '(') depth++ + else if (char === ')') { + depth-- + if (depth === 0) return source.slice(openIndex + 1, index) + } + } + return null +} + +/** + * Whether an expression contains a top-level `??` or `||`. + * + * `capabilityGovernedUserId(rateLimit) ?? rateLimit.userId` is the natural fix + * for the null the governed subject returns on a workspace key, and it is the + * exact substitution this audit exists to stop: the prefix match sees the + * governed call, the fallback supplies the key creator, and the sink is asked + * about the creator on every workspace-key request. Nested groups and string + * bodies are skipped so a `??` inside an argument list is not mistaken for one + * applied to the subject. + */ +export function hasTopLevelFallback(expression: string): boolean { + let depth = 0 + let quote: string | null = null + for (let index = 0; index < expression.length; index++) { + const char = expression[index] + if (quote) { + if (char === quote && expression[index - 1] !== '\\') quote = null + continue + } + if (char === "'" || char === '"' || char === '`') { + quote = char + continue + } + if (char === '(' || char === '[' || char === '{') depth++ + else if (char === ')' || char === ']' || char === '}') depth-- + else if ( + depth === 0 && + ((char === '?' && expression[index + 1] === '?') || + (char === '|' && expression[index + 1] === '|')) + ) { + return true + } + } + return false +} + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length +} + +/** One v1 source file's findings, so the assertions are testable without a tree on disk. */ +export function auditSource(file: string, source: string): { findings: Finding[]; sinks: number } { + const findings: Finding[] = [] + let sinks = 0 + + if (file !== MIDDLEWARE) { + for (const module of CAPABILITY_MODULES) { + const index = source.indexOf(`from '${module}'`) + if (index === -1) continue + findings.push({ + file, + line: lineOf(source, index), + message: + `imports ${module} directly. A v1 capability decision goes through ` + + `${MIDDLEWARE}, which resolves its subject with \`${GOVERNED}\` — deciding here ` + + 'reaches for whatever id is in scope, and the id in scope is the key creator.', + }) + } + } + + /** + * Locals bound to the governed id, so a call may pass the variable rather than + * the call. + * + * A binding that falls back — `const id = capabilityGovernedUserId(x) ?? x.userId` + * — is refused rather than registered: the local then holds the key creator on + * exactly the requests the governed subject was written to exclude, and every + * sink taking it would be counted as governed. + */ + const governedLocals = new Set() + for (const match of source.matchAll( + new RegExp( + `(?:const|let)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]+)?=\\s*(?:await\\s+)?${GOVERNED}\\(`, + 'g' + ) + )) { + const openIndex = match.index + match[0].length - 1 + const argumentText = callArguments(source, openIndex) + const closeIndex = argumentText === null ? -1 : openIndex + 1 + argumentText.length + const statementEnd = source.slice(closeIndex + 1).search(/[;\n]/) + const trailing = + closeIndex === -1 + ? '' + : source.slice( + closeIndex + 1, + statementEnd === -1 ? undefined : closeIndex + 1 + statementEnd + ) + if (/^\s*(\?\?|\|\|)/.test(trailing)) { + findings.push({ + file, + line: lineOf(source, match.index), + message: + `\`${match[1]}\` falls back when \`${GOVERNED}\` names nobody. That null is the ` + + 'answer, not a gap: a workspace API key authorizes as the workspace and its reported ' + + "user id is the key's creator, so the fallback hands a bystander's permission group to " + + 'every caller of a shared credential — the substitution this audit exists to stop.', + }) + continue + } + governedLocals.add(match[1]) + } + + /** + * An import alias is a rename the source-text match cannot see: + * `import { assertWorkspaceCapability as assertCap }` leaves every later call + * spelled `assertCap(...)`, and the sink's own name still appears once — on + * the import line — so the audit stayed green with a governed-call count that + * never moved. Aliases are folded into the sink table for this file. + */ + const fileSinks = { ...CAPABILITY_SINKS } + for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/g)) { + const subjectIndex = CAPABILITY_SINKS[match[1]] + if (subjectIndex !== undefined) fileSinks[match[2]] = subjectIndex + } + + /** + * A local of the audit's own name defeats every assertion below at once: the + * governed-locals scan matches `= capabilityGovernedUserId(` whatever that + * name now resolves to. Refused outright rather than resolved, because the + * name has exactly one legitimate meaning here — the middleware's export. + */ + if (file !== MIDDLEWARE) { + for (const match of source.matchAll( + new RegExp(`(?:(?:const|let|var|function)\\s+${GOVERNED}\\b|\\bas\\s+${GOVERNED}\\b)`, 'g') + )) { + findings.push({ + file, + line: lineOf(source, match.index), + message: + `declares its own \`${GOVERNED}\`, shadowing the middleware's. Every ` + + 'assertion here is written in terms of that name, so a local one makes a ' + + 'governed subject unverifiable. Import it from the middleware instead.', + }) + } + } + + for (const [sink, subjectIndex] of Object.entries(fileSinks)) { + for (const match of source.matchAll(new RegExp(`\\b${sink}\\s*\\(`, 'g'))) { + const openIndex = match.index + match[0].length - 1 + /** A declaration or an import of the sink, not a call into it. */ + const preceding = source.slice(Math.max(0, match.index - 40), match.index) + if (/\b(function|import)\s*$|\bexport\s+(async\s+)?function\s*$/.test(preceding)) continue + + const argumentText = callArguments(source, openIndex) + if (argumentText === null) continue + const subject = splitArguments(argumentText)[subjectIndex] + if (subject === undefined) continue + + const governed = + subject.startsWith(`${GOVERNED}(`) || + subject.startsWith(`await ${GOVERNED}(`) || + governedLocals.has(subject) + if (governed) { + /** + * A governed call with a fallback welded to it is worse than an ungoverned + * one: it reads as the fix, it passes the prefix match, and it used to + * INCREMENT the liveness counter below — so the evasion made the audit + * look more alive, not less. + */ + if (hasTopLevelFallback(subject)) { + findings.push({ + file, + line: lineOf(source, match.index), + message: + `${sink}(...) is asked about \`${subject}\`, which falls back when ` + + `\`${GOVERNED}\` names nobody. The null is the answer: a workspace API key has no ` + + "governed person, and the fallback substitutes the key's creator. Refuse, project, " + + 'or pass the null through — do not replace it.', + }) + continue + } + sinks++ + continue + } + + findings.push({ + file, + line: lineOf(source, match.index), + message: + `${sink}(...) is asked about \`${subject}\`, which did not come from ` + + `\`${GOVERNED}\`. A workspace API key reports its creator's user id, so this ` + + "applies a bystander's permission group to every caller of a shared credential.", + }) + } + } + + return { findings, sinks } +} + +/** Assertion A: the name every other assertion is written in terms of still exists. */ +export function auditMiddlewareExport(middlewareSource: string): Finding[] { + if (new RegExp(`export function ${GOVERNED}\\s*\\(`).test(middlewareSource)) return [] + return [ + { + file: MIDDLEWARE, + line: 1, + message: + `${MIDDLEWARE} no longer exports \`${GOVERNED}\`. Every assertion in this audit is ` + + 'written in terms of it; rename it here and in this script together, or the audit ' + + 'silently stops checking anything.', + }, + ] +} + +function main(): void { + const files: string[] = [] + walk(join(ROOT, V1_ROOT), files) + const relativeFiles = files.map((file) => relative(ROOT, file)).sort() + + const findings: Finding[] = auditMiddlewareExport(readFileSync(join(ROOT, MIDDLEWARE), 'utf8')) + let governedSinkCalls = 0 + + for (const file of relativeFiles) { + const result = auditSource(file, readFileSync(join(ROOT, file), 'utf8')) + findings.push(...result.findings) + governedSinkCalls += result.sinks + } + + if (governedSinkCalls === 0 && findings.length === 0) { + findings.push({ + file: V1_ROOT, + line: 1, + message: + `no call to a capability sink passed through \`${GOVERNED}\` anywhere under ${V1_ROOT}. ` + + 'Either v1 stopped gating capabilities, or it now gates them in a form this audit ' + + 'cannot read — both mean the audit is passing without checking anything.', + }) + } + + if (findings.length > 0) { + console.error( + `check:capability-subject — ${findings.length} finding${findings.length === 1 ? '' : 's'}:\n` + ) + for (const finding of findings) { + console.error(` ${finding.file}:${finding.line}\n ${finding.message}\n`) + } + process.exit(1) + } + + console.log( + `check:capability-subject — ${relativeFiles.length} v1 files, ${governedSinkCalls} capability ` + + `subject${governedSinkCalls === 1 ? '' : 's'} resolved through ${GOVERNED}.` + ) +} + +if (import.meta.main) main() diff --git a/scripts/check-permission-group-enforcement.test.ts b/scripts/check-permission-group-enforcement.test.ts new file mode 100644 index 00000000000..f1da09f02fa --- /dev/null +++ b/scripts/check-permission-group-enforcement.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest' +import { + parseCapabilityIds, + parseFieldEnforcement, + parseOperationCapabilities, + parseOperationRegistryMembers, +} from './check-permission-group-enforcement' + +describe('operation capability parsing', () => { + it('reads a direct declaration', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + export const tableOperations = { + create: defineWorkspaceOperation({ + id: 'tables.create', + minimumRole: 'write', + capability: 'tables.create', + }), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ id: 'tables.create', capability: 'tables.create' }), + ]) + expect(unreadable).toEqual([]) + }) + + it('resolves call sites of a function factory without reporting the factory itself', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + function tableOperation(id: string, capability: string) { + return defineWorkspaceOperation({ id, minimumRole: 'write', capability }) + } + + export const listRows = tableOperation('tables.rows.list', 'tables.use') + export const readRow = tableOperation('tables.rows.read', 'tables.use') + `) + + expect(declarations.map((declaration) => declaration.id)).toEqual([ + 'tables.rows.list', + 'tables.rows.read', + ]) + expect(declarations.every((declaration) => declaration.capability === 'tables.use')).toBe(true) + expect(unreadable).toEqual([]) + }) + + /** + * The two silent-drop forms. Each used to vanish from the count with the audit + * still printing a tick; both are now findings. + */ + it('reports a declaration whose id is a const reference', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + const TABLE_CREATE_ID = 'tables.create' + + export const create = defineWorkspaceOperation({ + id: TABLE_CREATE_ID, + minimumRole: 'write', + capability: 'tables.create', + }) + `) + + expect(declarations).toEqual([]) + expect(unreadable).toHaveLength(1) + }) + + it('reports a wrapper written as an arrow const rather than a function', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + const tableOperation = (id: string, capability: string) => + defineWorkspaceOperation({ id, minimumRole: 'write', capability }) + + export const listRows = tableOperation('tables.rows.list', 'tables.use') + `) + + expect(declarations).toEqual([]) + expect(unreadable).toHaveLength(1) + }) +}) + +describe('registry parsing', () => { + it('reads capability ids in declaration order', () => { + expect( + parseCapabilityIds(` + export const CAPABILITY_IDS = ['tables.use', 'files.use'] as const + `) + ).toEqual(['tables.use', 'files.use']) + }) + + /** Keys are matched at the registry's own two-space indentation. */ + it('reads each config key declared enforcement', () => { + const enforcement = parseFieldEnforcement( + [ + 'export const PERMISSION_GROUP_FIELDS = {', + " allowedIntegrations: allowlist(z.string(), 'executor', {", + " limited: 'x',", + " empty: 'y',", + ' }),', + " hideTablesTab: booleanRestriction('capability', {", + " id: 'hide-tables',", + " hint: 'Hide the Tables module from the sidebar.',", + ' }),', + '} satisfies Record', + ].join('\n') + ) + + expect(enforcement.get('allowedIntegrations')).toBe('executor') + expect(enforcement.get('hideTablesTab')).toBe('capability') + }) +}) + +/** + * The blind spot that shipped five ungated OAuth-connection operations: a domain + * that mints operations through a builder of its own, never calling + * `defineWorkspaceOperation`, so nothing read what it declared and the audit + * still printed a tick. Both halves of the fix are pinned here — the parsers now + * follow the `define*Operation` family, and the registry check names any member + * they still could not read. + */ +describe('operation builders other than defineWorkspaceOperation', () => { + it('reads a domain builder that takes an id and a capability positionally', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + function defineCredentialUserOperation(id: string, capability: string) { + return Object.freeze({ id, capability, principalKinds: ['session'] }) + } + + export const credentialUserOperations = { + listOAuthConnections: defineCredentialUserOperation( + 'credentials.oauth_connections.list', + 'integrations.manage' + ), + disconnectOAuth: defineCredentialUserOperation( + 'credentials.oauth_connections.disconnect', + 'integrations.manage' + ), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ + id: 'credentials.oauth_connections.list', + capability: 'integrations.manage', + }), + expect.objectContaining({ + id: 'credentials.oauth_connections.disconnect', + capability: 'integrations.manage', + }), + ]) + expect(unreadable).toEqual([]) + }) + + it('reads a domain builder that passes an object literal straight through', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + export const auditLogOperations = { + list: defineAuditLogOperation({ + id: 'audit_logs.list', + capability: 'none', + }), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ id: 'audit_logs.list', capability: 'none' }), + ]) + expect(unreadable).toEqual([]) + }) + + /** + * The wrapper form. Counting the outer and the inner call separately would + * double every credential operation, so the nested match is skipped — its id + * and capability are already carried by the outer call's text. + */ + it('counts a wrapped operation once', () => { + const { declarations } = parseOperationCapabilities(` + export const credentialOperations = { + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + capability: 'integrations.manage', + }), + 'member' + ), + } as const + `) + + expect(declarations).toEqual([ + expect.objectContaining({ id: 'credentials.read', capability: 'integrations.manage' }), + ]) + }) + + it('reports a builder that mints the operation itself from a bare id argument', () => { + const { declarations, unreadable } = parseOperationCapabilities(` + function defineCredentialUserOperation(id: string) { + return Object.freeze({ id, principalKinds: ['session'] }) + } + + export const credentialUserOperations = { + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + } as const + `) + + expect(declarations).toEqual([]) + expect(unreadable).toHaveLength(1) + }) +}) + +describe('registry completeness', () => { + const registrySource = ` + export const probeOperations = { + list: Object.freeze({ id: 'probe.list' as const }), + read: defineWorkspaceOperation({ + id: 'probe.read', + minimumRole: 'read', + capability: 'tables.use', + }), + } as const + ` + + it('enumerates each member with the line span it occupies', () => { + const members = parseOperationRegistryMembers(registrySource) + + expect(members.map((member) => `${member.registry}.${member.member}`)).toEqual([ + 'probeOperations.list', + 'probeOperations.read', + ]) + const [list, read] = members + expect(list.startLine).toBe(3) + expect(read.startLine).toBe(4) + expect(read.endLine).toBe(8) + }) + + /** + * The check the audit runs: a member no parsed declaration falls inside is a + * member nothing read. `list` is minted by no builder at all, so it yields + * nothing — and yielding nothing is what used to read as success. + */ + it('leaves a member no parser read outside every parsed line', () => { + const { declarations, unreadable } = parseOperationCapabilities(registrySource) + const readLines = [...declarations.map((declaration) => declaration.line), ...unreadable] + + const unread = parseOperationRegistryMembers(registrySource).filter( + (member) => !readLines.some((line) => line >= member.startLine && line <= member.endLine) + ) + + expect(unread.map((member) => member.member)).toEqual(['list']) + }) + + it('ignores a comment or a string that looks like a member key', () => { + const members = parseOperationRegistryMembers(` + export const probeOperations = { + // permission-group-exempt: nothing: here is a member + read: defineWorkspaceOperation({ + id: 'probe.read', + minimumRole: 'read', + capability: 'tables.use', + }), + } as const + `) + + expect(members.map((member) => member.member)).toEqual(['read']) + }) + + it('reads no registry from a module that exports none', () => { + expect( + parseOperationRegistryMembers(` + export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.applyOperations, + }) + `) + ).toEqual([]) + }) +}) + +describe('a factory that admits a Partial override of the operation', () => { + /** + * Nothing in the tree does this today, which is why it is probed here rather + * than caught in the wild: the capability the parsers read is the literal in + * the factory body, and a `Partial` spread over the result + * can replace it with `'none'` after the audit has already approved it. The + * factory is reported as unparseable rather than resolved — the override's + * value lives at the call site, and following it is the call graph this audit + * does not have. + */ + it('reports an `overrides?: Partial` parameter', () => { + const { overridable } = parseOperationCapabilities( + 'function defineTableOperation(id: string, overrides?: Partial) {\n' + + " return defineWorkspaceOperation({ id, capability: 'tables.use', ...overrides })\n" + + '}\n' + ) + + expect(overridable).toEqual([1]) + }) + + it('reports the override however the parameter is spelled', () => { + const { overridable } = parseOperationCapabilities( + 'function defineKnowledgeOperation(\n' + + ' id: string,\n' + + ' patch: Partial = {}\n' + + ') {\n' + + " return defineWorkspaceOperation({ id, capability: 'knowledge.use', ...patch })\n" + + '}\n' + ) + + expect(overridable).toEqual([1]) + }) + + /** + * `Partial` over something that is not an operation is ordinary code — a + * factory taking a partial audit payload has nothing to say about capability. + */ + it('leaves a Partial of an unrelated type alone', () => { + const { overridable } = parseOperationCapabilities( + 'function defineTableOperation(id: string, audit?: Partial) {\n' + + " return defineWorkspaceOperation({ id, capability: 'tables.use', audit })\n" + + '}\n' + ) + + expect(overridable).toEqual([]) + }) + + it('leaves a factory with named parameters alone', () => { + const { declarations, overridable } = parseOperationCapabilities( + 'function defineTableOperation(id: string, capability: string) {\n' + + ' return defineWorkspaceOperation({ id, capability })\n' + + '}\n' + + "defineTableOperation('table.read', 'tables.use')\n" + ) + + expect(overridable).toEqual([]) + expect(declarations).toEqual([{ id: 'table.read', line: 4, capability: 'tables.use' }]) + }) +}) diff --git a/scripts/check-permission-group-enforcement.ts b/scripts/check-permission-group-enforcement.ts new file mode 100644 index 00000000000..6f16b5c825f --- /dev/null +++ b/scripts/check-permission-group-enforcement.ts @@ -0,0 +1,680 @@ +#!/usr/bin/env bun +/** + * Connects a permission-group config key to the server gate that enforces it. + * + * Twelve keys shipped with an admin checkbox, a hint describing what they + * restrict, and no server check at all — an organization that set + * `hideCopilot` or `hideDeployChatbot` believed it had withheld a capability + * while every API route still answered. Nothing connected "this key is offered + * to admins" to "something refuses when it is set", because the two live in + * different files and neither knows about the other. This audit connects them. + * + * It asserts, in order of what actually goes wrong: + * + * A every workspace operation declares a capability, or `'none'` with a + * reason — an omission cannot be told apart from an unreviewed operation + * B every declared capability exists + * C every capability in the registry is reachable: named by an operation, or + * by an annotated call site for the ones the funnel cannot apply + * D every key claiming `enforcement: 'capability'` is read by some rule + * E no key claiming a weaker mechanism is read by one, so a key cannot gain + * enforcement while still documented as cosmetic or execution-scoped + * F every member of an exported `*Operations` registry was read by A's + * parsers. Everything above can only speak about what they found, and an + * operation minted in a form they do not follow yields nothing — which + * reads exactly like a clean file + * + * A capability the funnel cannot apply — one needing a request value, like an + * auth mode — is declared at its call site instead: + * + * // permission-group-enforced: deploy.chat.auth_mode — asserted from the use case + * + * An operation no group governs says so explicitly: + * + * // permission-group-exempt: + * + * `capability` is a required field on `defineWorkspaceOperation`, so the half of + * assertion A that asks whether an operation declared one cannot fail through + * the type system. It survives because this audit reads source text rather than + * the type: an operation written in a form the parsers cannot follow yields no + * capability, and without the check it would be skipped in silence — counted as + * reviewed while nothing had actually read what it declares. + * + * ## What "enforced" means here, and where it stops + * + * Two different strengths of evidence sit behind assertion C, and the printed + * total does not distinguish them: + * + * - A capability NAMED BY AN OPERATION is enforced by construction. The funnel + * applies it; the declaration and the gate are the same fact. + * - A capability reachable only through a `permission-group-enforced:` comment + * is enforced by ASSERTION OF THE AUTHOR. This audit matches the comment + * text; it does not verify that anything below it gates. Today that is 18 of + * 35 capabilities — among them `logs.cost`, `inbox.use`, `personal_api_key.use` + * and `copilot.tool_auto_approval` — so it is the majority of the registry, + * not a rounding error. {@link parseEnforcedAnnotations} records which cheap + * lookahead shapes were measured against the tree and why each is wrong more + * often than right; closing this properly wants a call graph. What still + * holds is narrow but real: deleting a gate AND its comment is reported by C + * immediately, and assertion E stops an annotation from inventing enforcement + * for a key whose field says `ui-only` or `executor`. The uncovered case is + * exactly one — a gate deleted while its comment is left behind. + * + * {@link SCAN_ROOTS} is the other boundary. `background/`, `connectors/`, + * `tools/`, `enrichments/`, `triggers/` and every `.tsx` are unscanned, and as + * of this writing each contains ZERO operation declarations and ZERO capability + * gate calls — checked, not assumed. The boundary is documented rather than + * widened because a scan root that finds nothing costs walk time and teaches a + * reader that operations might live there. If one ever does, two things change + * together: `SCAN_ROOTS`, and the `.ts`-only filter in `walk`. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +/** + * Where a `defineWorkspaceOperation` can live. Every operation declared today is + * under one of these, and `.tsx` is excluded because an operation is policy data + * that no component declares. Both are deliberate rather than incidental: an + * operation landing in `background`, `tools`, `triggers`, `connectors` or + * `enrichments`, or in a `.tsx`, would be invisible here — so widen this list + * (and `walk`) at the same time as moving one, not afterwards. + */ +const SCAN_ROOTS = ['apps/sim/lib', 'apps/sim/app', 'apps/sim/ee', 'apps/sim/executor'] +const CAPABILITIES_FILE = 'apps/sim/lib/permission-groups/capabilities.ts' +const FIELDS_FILE = 'apps/sim/lib/permission-groups/fields.ts' +const ENFORCED_ANNOTATION = 'permission-group-enforced:' +const EXEMPT_ANNOTATION = 'permission-group-exempt:' +const MAX_ANNOTATION_LOOKBACK = 3 + +/** + * The naming convention every operation-minting builder follows: + * `defineWorkspaceOperation`, `defineOperation`, and each domain's own + * `defineOperation`. + * + * The audit used to look for `defineWorkspaceOperation` and nothing else, so a + * domain that minted operations through a builder of its own — an object frozen + * by hand rather than passed to the shared one — was read by neither the + * builder's definition-time guard nor this script. Twenty-one operations across + * six domains were invisible that way, and the failure was silent: the files + * were scanned, some other operation in them was counted, and the audit printed + * a tick. Matching the family rather than the one name is what makes a new + * builder visible by default instead of on purpose. + */ +const MINTING_NAME = /^define[A-Za-z0-9_$]*Operation$/ +/** + * A factory parameter that admits a `Partial<…>` override of the operation it + * mints — `overrides?: Partial` and its relatives. + * + * The capability this audit reads is the one written in the factory body or at + * the call site. A parameter spread over the result afterwards can replace it, + * including with `'none'`, and the audit would keep reporting whatever the + * literal said — a green tick over an operation whose capability is decided by + * whoever calls it. No factory in the tree does this today; the point of + * refusing it here is that the first one to try is reported rather than + * discovered later. + * + * `Partial` and not `Omit`/`Pick`: those narrow a type, they do not make a + * declared field optional to overwrite. + */ +const OVERRIDE_PARAMETER = /\bPartial\s*<[^>]*Operation\b/ +const MINTING_CALL_SOURCE = String.raw`\b(define[A-Za-z0-9_$]*Operation)\s*\(` +const mintingCallPattern = () => new RegExp(MINTING_CALL_SOURCE, 'g') +/** Whether a module mints an operation at all, so files that do not are skipped cheaply. */ +const MINTS_AN_OPERATION = new RegExp(MINTING_CALL_SOURCE) +/** An exported `*Operations` registry object, the second route by which operations reach a surface. */ +const OPERATION_REGISTRY = + /(?:^|\n)\s*export const ([A-Za-z0-9_$]+Operations)\s*(?::[^=\n]*)?=\s*\{/g +const DECLARES_A_REGISTRY = /(?:^|\n)\s*export const [A-Za-z0-9_$]+Operations\s*(?::[^=\n]*)?=\s*\{/ + +interface Finding { + file: string + line?: number + message: string +} + +/** The 1-based line `index` falls on. */ +function lineAt(source: string, index: number): number { + return source.slice(0, index).split('\n').length +} + +const CLOSING: Record = { '(': ')', '{': '}', '[': ']' } + +/** Text of the balanced `(...)`, `{...}` or `[...]` group that starts at `openIndex`. */ +function balancedGroup(source: string, openIndex: number): string { + const open = source[openIndex] + const close = CLOSING[open] + let depth = 0 + for (let index = openIndex; index < source.length; index++) { + const char = source[index] + if (char === open) depth++ + else if (char === close) { + depth-- + if (depth === 0) return source.slice(openIndex, index + 1) + } + } + return source.slice(openIndex) +} + +function walk(directory: string, into: string[]): string[] { + for (const entry of readdirSync(directory)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(directory, entry) + if (statSync(full).isDirectory()) walk(full, into) + else if (full.endsWith('.ts') && !full.endsWith('.test.ts')) into.push(full) + } + return into +} + +/** The capability ids the registry declares, in declaration order. */ +export function parseCapabilityIds(source: string): string[] { + const start = source.indexOf('CAPABILITY_IDS = [') + if (start === -1) return [] + const group = balancedGroup(source, source.indexOf('[', start)) + return [...group.matchAll(/'([a-z0-9_.]+)'/g)].map((match) => match[1]) +} + +/** Each capability's rule kind and the config keys it reads. */ +export function parseCapabilityRules( + source: string +): Map { + const rules = new Map() + const start = source.indexOf('CAPABILITY_RULES = {') + if (start === -1) return rules + + const body = balancedGroup(source, source.indexOf('{', start)) + const entryPattern = /'([a-z0-9_.]+)'\s*:\s*\{/g + for (let match = entryPattern.exec(body); match; match = entryPattern.exec(body)) { + const entry = balancedGroup(body, body.indexOf('{', match.index + match[0].length - 1)) + const kind = /kind\s*:\s*'([a-z]+)'/.exec(entry)?.[1] ?? '' + const keysGroup = /configKeys\s*:\s*\[([^\]]*)\]/.exec(entry)?.[1] ?? '' + const configKeys = [...keysGroup.matchAll(/'([A-Za-z0-9_]+)'/g)].map((key) => key[1]) + rules.set(match[1], { kind, configKeys }) + } + return rules +} + +/** Each config key's declared enforcement, from the field registry. */ +export function parseFieldEnforcement(source: string): Map { + const enforcement = new Map() + const start = source.indexOf('PERMISSION_GROUP_FIELDS = {') + if (start === -1) return enforcement + + const body = balancedGroup(source, source.indexOf('{', start)) + const entryPattern = + /(?:^|\n)\s{2}([A-Za-z0-9_]+)\s*:\s*(allowlist|denylist|booleanRestriction)\(/g + for (let match = entryPattern.exec(body); match; match = entryPattern.exec(body)) { + const call = balancedGroup(body, body.indexOf('(', match.index + match[0].length - 1)) + const declared = /'(capability|executor|ui-only)'/.exec(call)?.[1] + if (declared) enforcement.set(match[1], declared) + } + return enforcement +} + +interface OperationDeclaration { + id: string + line: number + capability: string | undefined +} + +interface ParsedOperations { + declarations: OperationDeclaration[] + /** + * Lines of same-file factories whose parameters admit a `Partial<…>` override + * of the operation itself. See {@link OVERRIDE_PARAMETER}. + */ + overridable: number[] + /** + * Lines of operation-minting calls this parser could not read an id from, + * and which no recognized factory accounts for. + * + * A call whose `id` is a const reference, or one minted by a wrapper written + * as an arrow const rather than a `function`, used to be dropped in silence — + * the operation simply stopped being counted, and the audit still printed a + * tick. Reported instead, because an audit that quietly stops watching a + * domain is the failure it exists to prevent. + */ + unreadable: number[] +} + +/** + * Every operation minted in a module and the capability it declares, resolved + * through a same-file factory when a domain wraps a builder (the table + * operations take only an id and a capability). + */ +export function parseOperationCapabilities(source: string): ParsedOperations { + const declarations: OperationDeclaration[] = [] + const unreadable: number[] = [] + const overridable: number[] = [] + + /** + * Domains that wrap a builder in a same-file factory declare the capability + * one of three ways: fixed in the factory body, when every operation it makes + * belongs to one capability; taken as a second argument when they differ; or + * passed straight through from an object literal at the call site. The first + * two are read from their call sites below, the third by the direct scan, + * which reads the literal exactly as it reads a builder's own. + */ + const factoryCapabilities = new Map() + const factoryRanges: Array<[number, number]> = [] + const factoryPattern = /(?:^|\n)\s*(?:export\s+)?function\s+([A-Za-z0-9_$]+)\s*[<(]/g + for (let match = factoryPattern.exec(source); match; match = factoryPattern.exec(source)) { + const bodyIndex = source.indexOf('{', match.index + match[0].length - 1) + if (bodyIndex === -1) continue + const body = balancedGroup(source, bodyIndex) + if (!MINTS_AN_OPERATION.test(body) && !MINTING_NAME.test(match[1])) continue + factoryRanges.push([bodyIndex, bodyIndex + body.length]) + const parameterIndex = source.indexOf('(', match.index + match[0].length - 1) + if (parameterIndex !== -1 && parameterIndex < bodyIndex) { + const parameters = balancedGroup(source, parameterIndex) + if (OVERRIDE_PARAMETER.test(parameters)) overridable.push(lineAt(source, match.index)) + } + const fixed = /capability\s*:\s*'([a-z0-9_.]+)'/.exec(body)?.[1] + if (fixed) factoryCapabilities.set(match[1], fixed) + else if (/capability\s*[,:}]/.test(body)) factoryCapabilities.set(match[1], 'positional') + } + + /** A call inside a recognized factory takes its id from a parameter; its call sites are read below. */ + const insideFactory = (index: number) => + factoryRanges.some(([start, end]) => index >= start && index < end) + + /** + * Ranges of calls already read. A domain wrapper such as + * `defineCredentialOperation(defineWorkspaceOperation({...}), 'admin')` matches + * twice over one operation; the outer match already carries the inner's `id` + * and `capability`, so the nested one is skipped rather than counted again. + */ + const accepted: Array<[number, number]> = [] + + const directPattern = mintingCallPattern() + for (let match = directPattern.exec(source); match; match = directPattern.exec(source)) { + const name = match[1] + if (/\bfunction\s+$/.test(source.slice(Math.max(0, match.index - 16), match.index))) continue + if (factoryCapabilities.has(name)) continue + if (insideFactory(match.index)) continue + const openIndex = source.indexOf('(', match.index) + if (accepted.some(([start, end]) => openIndex > start && openIndex < end)) continue + const call = balancedGroup(source, openIndex) + accepted.push([openIndex, openIndex + call.length]) + const id = /id\s*:\s*'([^']+)'/.exec(call)?.[1] + if (!id) { + unreadable.push(lineAt(source, match.index)) + continue + } + declarations.push({ + id, + line: lineAt(source, match.index), + capability: /capability\s*:\s*'([a-z0-9_.]+)'/.exec(call)?.[1], + }) + } + + for (const [factory, capability] of factoryCapabilities) { + const callPattern = + capability === 'positional' + ? new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'\\s*,\\s*'([a-z0-9_.]+)'`, 'g') + : new RegExp(`\\b${factory}\\s*\\(\\s*'([^']+)'`, 'g') + for (let match = callPattern.exec(source); match; match = callPattern.exec(source)) { + if (insideFactory(match.index)) continue + declarations.push({ + id: match[1], + line: lineAt(source, match.index), + capability: capability === 'positional' ? match[2] : capability, + }) + } + } + + return { declarations, unreadable, overridable } +} + +export interface OperationRegistryMember { + registry: string + member: string + startLine: number + endLine: number +} + +/** Index just past the string literal that starts at `openIndex`. */ +function skipStringLiteral(body: string, openIndex: number): number { + const quote = body[openIndex] + for (let index = openIndex + 1; index < body.length; index++) { + if (body[index] === '\\') { + index++ + continue + } + if (body[index] === quote) return index + 1 + } + return body.length +} + +/** The keyed members of an object literal, at its own top level only. */ +function topLevelMembers(body: string): Array<{ key: string; start: number; end: number }> { + const members: Array<{ key: string; start: number; end: number }> = [] + let depth = 0 + let index = 0 + let pending: { key: string; start: number } | null = null + const flush = (end: number) => { + if (pending) members.push({ ...pending, end }) + pending = null + } + + while (index < body.length) { + const char = body[index] + if (char === '/' && body[index + 1] === '/') { + const newline = body.indexOf('\n', index) + index = newline === -1 ? body.length : newline + 1 + continue + } + if (char === '/' && body[index + 1] === '*') { + const close = body.indexOf('*/', index) + index = close === -1 ? body.length : close + 2 + continue + } + if (char === "'" || char === '"' || char === '`') { + index = skipStringLiteral(body, index) + continue + } + if (char === '{' || char === '(' || char === '[') { + depth++ + index++ + continue + } + if (char === '}' || char === ')' || char === ']') { + depth-- + if (depth === 0) flush(index) + index++ + continue + } + if (depth === 1) { + if (char === ',') { + flush(index) + index++ + continue + } + if (!pending && /[\s{,]/.test(body[index - 1] ?? '{')) { + const key = /^([A-Za-z0-9_$]+)\s*:/.exec(body.slice(index)) + if (key) { + pending = { key: key[1], start: index } + index += key[0].length + continue + } + } + } + index++ + } + flush(body.length) + return members +} + +/** + * The members of every exported `*Operations` registry, with the line span each + * one occupies. + * + * This is the completeness half of the audit, and it asks a different question + * from everything above: not *does this operation declare a capability*, but + * *did this audit read this operation at all*. Assertion A can only speak about + * operations the parsers found; a member minted by a form they do not follow + * yields nothing, and nothing is indistinguishable from a clean file. Comparing + * the registry a surface actually imports against what was parsed is what turns + * that silence into a failure — an undercount reads exactly like success, which + * is how five OAuth-connection operations shipped ungated. + */ +export function parseOperationRegistryMembers(source: string): OperationRegistryMember[] { + const members: OperationRegistryMember[] = [] + OPERATION_REGISTRY.lastIndex = 0 + for ( + let match = OPERATION_REGISTRY.exec(source); + match; + match = OPERATION_REGISTRY.exec(source) + ) { + const openIndex = source.indexOf('{', match.index + match[0].length - 1) + const body = balancedGroup(source, openIndex) + for (const member of topLevelMembers(body)) { + members.push({ + registry: match[1], + member: member.key, + startLine: lineAt(source, openIndex + member.start), + endLine: lineAt(source, openIndex + member.end), + }) + } + } + return members +} + +/** + * Capabilities declared enforced at a call site the funnel cannot reach. + * + * Deliberately a bare scan of the whole file, with no check that anything below + * the annotation actually gates: an annotation left behind after its gate was + * deleted still counts the capability as reachable, and assertion C stays + * green. That is a real gap, and it is left open because every cheap shape that + * would close it is wrong more often than it is right. + * + * The obvious shapes were measured against the 70-odd annotations in the tree: + * + * - "the capability id appears in code in the same file" misses 7, among them + * `logs/application/list-public-logs.ts` and `get-public-log.ts`, which + * annotate `logs.cost` and `logs.trace_spans` over a call to + * `resolveLogFieldProjection` — the one place those two are read, named + * nowhere else because naming them twice is how one copy stops redacting. + * - "a capability sink is called within N lines below" misses 7 at any N, + * including `core/application/workspace-authorization.ts` (delegates to + * `requirePersonalApiKeysAllowed`), `invitations/workspace-invitations.ts` + * (`validateInvitationsAllowed`) and the three `integrations.manage` sites in + * `auth/oauth/credentials/route.ts` (`checkOAuthCredentialAccess`). + * + * Both fail on the same case, and it is the common one: the annotation sits + * above a call to a DOMAIN helper that enforces the group somewhere else. A + * lookahead would have to enumerate every such helper — which is the open-ended + * set the annotation exists to describe in the first place, so the list would + * go stale in exactly the direction that makes the audit lie. + * + * What does hold the line is assertion E's other half: an annotation cannot + * invent enforcement for a key whose field says `ui-only` or `executor`, and a + * capability whose gate is deleted along with its annotation is reported by + * assertion C immediately. The gap is narrow — a gate deleted while its comment + * is kept — and closing it wants a call-graph, not a regex. + */ +export function parseEnforcedAnnotations(source: string): string[] { + return [...source.matchAll(new RegExp(`${ENFORCED_ANNOTATION}\\s*([a-z0-9_.]+)`, 'g'))].map( + (match) => match[1] + ) +} + +/** Whether an operation's `capability: 'none'` carries a reason. */ +export function hasExemptAnnotation(source: string, line: number): boolean { + const lines = source.split('\n') + for (let back = line - 2; back >= 0 && back >= line - 2 - MAX_ANNOTATION_LOOKBACK; back--) { + const candidate = lines[back]?.trim() ?? '' + if (candidate === '') continue + if (!candidate.startsWith('//') && !candidate.startsWith('*')) break + if (candidate.includes(EXEMPT_ANNOTATION)) { + return ( + candidate.slice(candidate.indexOf(EXEMPT_ANNOTATION) + EXEMPT_ANNOTATION.length).trim() !== + '' + ) + } + } + return false +} + +function main(): void { + const capabilitiesSource = readFileSync(join(ROOT, CAPABILITIES_FILE), 'utf8') + const fieldsSource = readFileSync(join(ROOT, FIELDS_FILE), 'utf8') + + const capabilityIds = new Set(parseCapabilityIds(capabilitiesSource)) + const rules = parseCapabilityRules(capabilitiesSource) + const enforcement = parseFieldEnforcement(fieldsSource) + + /** + * This audit reads source text, so a rename it does not know about makes its + * parsers return nothing — and every assertion below would then pass over an + * empty set. An audit that goes quiet when it breaks is worse than no audit, + * so refuse to report success on an obviously empty parse. + */ + if (capabilityIds.size === 0 || rules.size === 0 || enforcement.size === 0) { + console.error( + 'Permission-group enforcement audit could not read its own inputs:\n' + + ` capabilities parsed: ${capabilityIds.size}, rules: ${rules.size}, config keys: ${enforcement.size}\n\n` + + 'One of CAPABILITY_IDS, CAPABILITY_RULES or PERMISSION_GROUP_FIELDS was renamed or\n' + + 'reshaped. Update the parsers in this script rather than leaving it passing vacuously.\n' + ) + process.exit(1) + } + if (rules.size !== capabilityIds.size) { + console.error( + `Permission-group enforcement audit parsed ${capabilityIds.size} capabilities but ${rules.size} rules; the registry and its rules disagree.\n` + ) + process.exit(1) + } + + const sourceFiles = SCAN_ROOTS.flatMap((root) => walk(join(ROOT, root), [])) + + const findings: Finding[] = [] + const usedCapabilities = new Set() + let declaredOperations = 0 + + for (const file of sourceFiles) { + const relativePath = relative(ROOT, file) + const source = readFileSync(file, 'utf8') + + for (const capability of parseEnforcedAnnotations(source)) { + usedCapabilities.add(capability) + if (!capabilityIds.has(capability)) { + findings.push({ + file: relativePath, + message: `declares enforcement for unknown capability '${capability}'`, + }) + } + } + + if (!MINTS_AN_OPERATION.test(source) && !DECLARES_A_REGISTRY.test(source)) continue + + const { declarations, unreadable, overridable } = parseOperationCapabilities(source) + + for (const line of overridable) { + findings.push({ + file: relativePath, + line, + message: + "mints operations through a factory that takes a `Partial<…Operation>` override. The capability this audit reads is the one in the factory body or at the call site, and a partial spread over the result can replace it — including with 'none' — so what is declared here stops being what ships. Take the fields the factory varies as named parameters rather than an open override", + }) + } + + for (const line of unreadable) { + findings.push({ + file: relativePath, + line, + message: + 'operation-minting call this audit cannot read an id from — a const-reference id, an id taken as a bare argument by a builder that mints the operation itself, or a wrapper written as an arrow const rather than a `function`. Teach parseOperationCapabilities the form rather than letting the operation drop out of the count in silence', + }) + } + + /** + * A file that calls the builder and yields nothing means the parsers no + * longer understand it. Per-file rather than a count floor: a floor rots on + * every legitimate addition and invites bumping the number. + */ + if (MINTS_AN_OPERATION.test(source) && declarations.length === 0 && unreadable.length === 0) { + findings.push({ + file: relativePath, + message: + 'mints an operation but this audit parsed none from it — the declaration form changed and every operation in this file is now unchecked', + }) + } + + /** + * Assertion F: every member of an exported registry was read. + * + * Everything above can only speak about what the parsers found. This asks + * whether they found each thing a surface can actually import, which is the + * only question whose answer distinguishes a clean file from one the + * parsers walked straight past. + */ + const readLines = [...declarations.map((declaration) => declaration.line), ...unreadable] + for (const member of parseOperationRegistryMembers(source)) { + if (readLines.some((line) => line >= member.startLine && line <= member.endLine)) continue + findings.push({ + file: relativePath, + line: member.startLine, + message: `'${member.registry}.${member.member}' is exported as an operation but this audit read no operation from it — it is minted by a form the parsers do not follow, so nothing here checks what capability it declares. Name the builder \`defineOperation\` and pass it an object literal with a string \`id\` and \`capability\`, or teach parseOperationCapabilities the form; do not leave the member counted as reviewed while unread`, + }) + } + + for (const declaration of declarations) { + declaredOperations++ + if (declaration.capability === undefined) { + findings.push({ + file: relativePath, + line: declaration.line, + message: `operation '${declaration.id}' declares a capability this audit cannot read — the field is required, so this is a declaration form the parsers do not follow; teach parseOperationCapabilities about it rather than leaving the operation unchecked`, + }) + continue + } + if (declaration.capability === 'none') { + if (!hasExemptAnnotation(source, declaration.line)) { + findings.push({ + file: relativePath, + line: declaration.line, + message: `operation '${declaration.id}' declares capability 'none' without a reason — put '${EXEMPT_ANNOTATION} ' in a comment directly above it`, + }) + } + continue + } + usedCapabilities.add(declaration.capability) + if (!capabilityIds.has(declaration.capability)) { + findings.push({ + file: relativePath, + line: declaration.line, + message: `operation '${declaration.id}' names unknown capability '${declaration.capability}'`, + }) + } + } + } + + /** A capability nothing names is a key an admin can set to no effect. */ + for (const capability of capabilityIds) { + if (usedCapabilities.has(capability)) continue + findings.push({ + file: CAPABILITIES_FILE, + message: `capability '${capability}' is declared but nothing enforces it — name it on an operation, or annotate its call site with '${ENFORCED_ANNOTATION} ${capability} — '`, + }) + } + + const enforcedByRule = new Set([...rules.values()].flatMap((rule) => rule.configKeys)) + for (const [key, declared] of enforcement) { + if (declared === 'capability' && !enforcedByRule.has(key)) { + findings.push({ + file: FIELDS_FILE, + message: `config key '${key}' claims capability enforcement but no rule reads it — give it a rule, or declare it 'executor' or 'ui-only'`, + }) + } + if (declared !== 'capability' && enforcedByRule.has(key)) { + findings.push({ + file: FIELDS_FILE, + message: `config key '${key}' is declared '${declared}' but a capability rule reads it — set enforcement to 'capability' so the key stops being documented as something weaker`, + }) + } + } + + if (findings.length > 0) { + console.error('Permission-group enforcement audit failed:\n') + for (const finding of findings) { + const where = finding.line ? `${finding.file}:${finding.line}` : finding.file + console.error(` ${where}\n ${finding.message}\n`) + } + console.error( + 'A permission-group key that reaches the admin editor without a server gate is a\n' + + 'restriction an organization believes it applied. Wire the gate, or declare the\n' + + "key 'ui-only' so it is documented as a rendering hint rather than a control.\n" + ) + process.exit(1) + } + + console.log( + `✓ permission-group enforcement: ${declaredOperations} operations declare a capability, ${capabilityIds.size} capabilities all enforced` + ) +} + +if (import.meta.main) main() diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index ab28c0ee702..083704723c2 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,76 +6,76 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1574, + "modules": 1591, "gateways": { "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 444, - "apps/sim/lib/api/server/routes/index.ts": 375, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 333, - "apps/sim/lib/auth/index.ts": 320, + "apps/sim/blocks/registry.ts": 446, + "apps/sim/lib/api/server/routes/index.ts": 391, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 347, + "apps/sim/lib/auth/index.ts": 334, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/blocks/route.ts": { - "modules": 1573, + "modules": 1590, "gateways": { "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 444, - "apps/sim/lib/api/server/routes/index.ts": 368, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 335, - "apps/sim/lib/auth/index.ts": 322, + "apps/sim/blocks/registry.ts": 446, + "apps/sim/lib/api/server/routes/index.ts": 384, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 349, + "apps/sim/lib/auth/index.ts": 336, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1636, + "modules": 1653, "gateways": { "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 444, - "apps/sim/lib/api/server/routes/index.ts": 377, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 335, - "apps/sim/lib/auth/index.ts": 322, + "apps/sim/blocks/registry.ts": 446, + "apps/sim/lib/api/server/routes/index.ts": 393, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 349, + "apps/sim/lib/auth/index.ts": 336, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1571, + "modules": 1588, "gateways": { "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 444, - "apps/sim/lib/api/server/routes/index.ts": 375, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 333, - "apps/sim/lib/auth/index.ts": 320, + "apps/sim/blocks/registry.ts": 446, + "apps/sim/lib/api/server/routes/index.ts": 391, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 347, + "apps/sim/lib/auth/index.ts": 334, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/tools/route.ts": { - "modules": 1572, + "modules": 1589, "gateways": { "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 444, - "apps/sim/lib/api/server/routes/index.ts": 366, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 333, - "apps/sim/lib/auth/index.ts": 320, + "apps/sim/blocks/registry.ts": 446, + "apps/sim/lib/api/server/routes/index.ts": 382, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 347, + "apps/sim/lib/auth/index.ts": 334, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/chat/[chatId]/layout.tsx": { @@ -83,42 +83,42 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2818, + "modules": 2843, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1283, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 911, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 763, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 760, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1290, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 914, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 766, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 763, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 323, - "apps/sim/lib/auth/index.ts": 240, + "apps/sim/blocks/registry.ts": 324, + "apps/sim/lib/auth/index.ts": 246, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/files/[fileId]/loading.tsx": { - "modules": 145, + "modules": 146, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1905, + "modules": 1925, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 348, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 301, - "apps/sim/lib/auth/index.ts": 254, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 175, + "apps/sim/blocks/registry.ts": 349, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 303, + "apps/sim/lib/auth/index.ts": 260, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 177, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 145, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 @@ -132,40 +132,40 @@ } }, "app/workspace/[workspaceId]/files/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/files/loading.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1905, + "modules": 1925, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 348, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 303, - "apps/sim/lib/auth/index.ts": 254, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 175, + "apps/sim/blocks/registry.ts": 349, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 305, + "apps/sim/lib/auth/index.ts": 260, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 177, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 145, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/home/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -173,192 +173,192 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2818, + "modules": 2843, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1283, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 911, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 763, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 760, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1290, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 914, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 766, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 763, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 323, - "apps/sim/lib/auth/index.ts": 240, + "apps/sim/blocks/registry.ts": 324, + "apps/sim/lib/auth/index.ts": 246, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1152, + "modules": 1158, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1124, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1129, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 395, + "apps/sim/blocks/registry.ts": 397, "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx": 60, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 57, - "apps/sim/blocks/blocks/credential-group.ts": 45, - "apps/sim/lib/api/contracts/index.ts": 36 + "apps/sim/blocks/blocks/credential-group.ts": 46, + "apps/sim/lib/api/contracts/index.ts": 35 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1129, + "modules": 1134, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1128, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1133, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 364, + "apps/sim/blocks/registry.ts": 365, "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 54, - "apps/sim/lib/api/contracts/index.ts": 42, "apps/sim/components/permissions/index.ts": 41, + "apps/sim/lib/api/contracts/index.ts": 41, "apps/sim/components/permissions/add-people-modal.tsx": 35, "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 33 } }, "app/workspace/[workspaceId]/integrations/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1137, + "modules": 1141, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 994, - "apps/sim/blocks/registry.ts": 909, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 997, + "apps/sim/blocks/registry.ts": 911, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 58, - "apps/sim/blocks/blocks/credential-group.ts": 46, - "apps/sim/lib/api/contracts/index.ts": 38, + "apps/sim/blocks/blocks/credential-group.ts": 47, + "apps/sim/lib/api/contracts/index.ts": 37, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx": { - "modules": 145, + "modules": 146, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1349, + "modules": 1355, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1203, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1208, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 356, - "apps/sim/blocks/registry-maps.ts": 353, + "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry-maps.ts": 354, "apps/sim/connectors/registry.ts": 65, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 59, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 51, - "apps/sim/lib/api/contracts/index.ts": 40 + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 53, + "apps/sim/lib/api/contracts/index.ts": 39 } }, "app/workspace/[workspaceId]/knowledge/[id]/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/knowledge/[id]/loading.tsx": { - "modules": 146, + "modules": 147, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1352, + "modules": 1358, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1205, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1210, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 364, - "apps/sim/blocks/registry-maps.ts": 361, + "apps/sim/blocks/registry.ts": 365, + "apps/sim/blocks/registry-maps.ts": 362, "apps/sim/connectors/registry.ts": 65, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 59, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 44, - "apps/sim/lib/api/contracts/index.ts": 40 + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 46, + "apps/sim/lib/api/contracts/index.ts": 39 } }, "app/workspace/[workspaceId]/knowledge/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/knowledge/loading.tsx": { - "modules": 145, + "modules": 146, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2116, + "modules": 2136, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 348, + "apps/sim/blocks/registry.ts": 349, "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 302, "apps/sim/lib/knowledge/application/knowledge-bases.ts": 246, - "apps/sim/lib/auth/index.ts": 198, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 159, + "apps/sim/lib/auth/index.ts": 203, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 161, "apps/sim/lib/knowledge/orchestration/index.ts": 146, "apps/sim/lib/knowledge/orchestration/connectors.ts": 142 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 1982, + "modules": 2005, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 347, - "apps/sim/lib/auth/index.ts": 330, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 329, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 324, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 221, + "apps/sim/blocks/registry.ts": 348, + "apps/sim/lib/auth/index.ts": 338, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 332, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 327, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 224, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/logs/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/logs/loading.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1632, + "modules": 1641, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1488, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1496, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 395, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 345, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 339, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 302, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 291 + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 400, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 350, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 344, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 307, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 296 } }, "app/workspace/[workspaceId]/not-found.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -366,11 +366,11 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/settings/[section]/layout.tsx": { @@ -382,16 +382,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2070, + "modules": 2168, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 497, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 565, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry.ts": 344, "apps/sim/lib/auth/index.ts": 303, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108, - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 56, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts": 52 + "apps/sim/ee/access-control/components/access-control.tsx": 72, + "apps/sim/ee/access-control/components/group-detail.tsx": 70 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -403,24 +403,24 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1527, + "modules": 1545, "gateways": { - "apps/sim/lib/auth/index.ts": 1390, + "apps/sim/lib/auth/index.ts": 1408, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 441, - "apps/sim/blocks/registry-maps.ts": 438, + "apps/sim/blocks/registry.ts": 443, + "apps/sim/blocks/registry-maps.ts": 440, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108, - "apps/sim/blocks/blocks/credential-group.ts": 93 + "apps/sim/blocks/blocks/credential-group.ts": 94 } }, "app/workspace/[workspaceId]/settings/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -432,28 +432,28 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/loading.tsx": { - "modules": 1098, + "modules": 1102, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1095, - "apps/sim/components/permissions/index.ts": 975, - "apps/sim/components/permissions/add-people-modal.tsx": 966, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 964, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1099, + "apps/sim/components/permissions/index.ts": 977, + "apps/sim/components/permissions/add-people-modal.tsx": 968, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 966, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 366, - "apps/sim/blocks/registry-maps.ts": 363, - "apps/sim/lib/api/contracts/index.ts": 47 + "apps/sim/blocks/registry.ts": 367, + "apps/sim/blocks/registry-maps.ts": 364, + "apps/sim/lib/api/contracts/index.ts": 46 } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1176, + "modules": 1180, "gateways": { "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 366, - "apps/sim/blocks/registry-maps.ts": 363, + "apps/sim/blocks/registry.ts": 367, + "apps/sim/blocks/registry-maps.ts": 364, "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 77, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 56, "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 51, - "apps/sim/lib/api/contracts/index.ts": 43, + "apps/sim/lib/api/contracts/index.ts": 42, "apps/sim/components/permissions/index.ts": 40 } }, @@ -466,116 +466,116 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/usage/events/page.tsx": { - "modules": 1536, + "modules": 1554, "gateways": { - "apps/sim/lib/auth/index.ts": 1392, + "apps/sim/lib/auth/index.ts": 1410, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 443, - "apps/sim/blocks/registry-maps.ts": 440, + "apps/sim/blocks/registry.ts": 445, + "apps/sim/blocks/registry-maps.ts": 442, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108, - "apps/sim/blocks/blocks/credential-group.ts": 95 + "apps/sim/blocks/blocks/credential-group.ts": 96 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1290, + "modules": 1296, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1289, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1295, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/blocks/registry-maps.ts": 358, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 128, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 125, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 61, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 59 + "apps/sim/blocks/registry.ts": 361, + "apps/sim/blocks/registry-maps.ts": 359, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 130, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 127, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 63, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 61 } }, "app/workspace/[workspaceId]/skills/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1288, + "modules": 1294, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1287, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1293, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 360, - "apps/sim/blocks/registry-maps.ts": 358, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 128, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 125, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 61, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 59 + "apps/sim/blocks/registry.ts": 361, + "apps/sim/blocks/registry-maps.ts": 359, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 130, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 127, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 63, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 61 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1118, + "modules": 1122, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 975, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 963, - "apps/sim/blocks/registry.ts": 951, - "apps/sim/blocks/registry-maps.ts": 949, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 978, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 966, + "apps/sim/blocks/registry.ts": 954, + "apps/sim/blocks/registry-maps.ts": 952, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 61, + "apps/sim/blocks/blocks/credential-group.ts": 62, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 58 } }, "app/workspace/[workspaceId]/tables/[tableId]/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/tables/[tableId]/loading.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 1736, + "modules": 1747, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1592, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1602, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 334, - "apps/sim/blocks/registry.ts": 324, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 288, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 284, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 252, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 241 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 337, + "apps/sim/blocks/registry.ts": 325, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 291, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 287, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 255, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 244 } }, "app/workspace/[workspaceId]/tables/error.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/tables/loading.tsx": { - "modules": 143, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1738, + "modules": 1759, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/auth/index.ts": 352, - "apps/sim/blocks/registry.ts": 348, + "apps/sim/lib/auth/index.ts": 366, + "apps/sim/blocks/registry.ts": 349, "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 124, "apps/sim/lib/webhooks/providers/index.ts": 110, "apps/sim/lib/webhooks/providers/registry.ts": 108, @@ -584,100 +584,100 @@ } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 132, + "modules": 133, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 125, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 77, - "apps/sim/lib/billing/client/upgrade.ts": 69, - "apps/sim/hooks/queries/organization.ts": 65, - "apps/sim/hooks/queries/workspace.ts": 56, - "apps/sim/lib/api/contracts/index.ts": 54 + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 126, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 78, + "apps/sim/lib/billing/client/upgrade.ts": 70, + "apps/sim/hooks/queries/organization.ts": 66, + "apps/sim/hooks/queries/workspace.ts": 57, + "apps/sim/lib/api/contracts/index.ts": 55 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 145, + "modules": 146, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 144, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 142, - "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, - "apps/sim/hooks/queries/copilot-feedback.ts": 70 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 145, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 108, + "apps/sim/hooks/queries/copilot-feedback.ts": 71 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 1985, + "modules": 1994, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 1984, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 1993, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 345, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 308, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 246, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 152, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 145 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 346, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 309, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 247, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 153, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 146 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 1967, + "modules": 1976, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 834, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 544, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 839, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 547, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 311, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 154, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 147, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 141 + "apps/sim/blocks/registry.ts": 344, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 312, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 155, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 148, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 142 } }, "app/workspace/layout.tsx": { - "modules": 1064, + "modules": 1068, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1054, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1058, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 366, - "apps/sim/blocks/registry-maps.ts": 363, - "apps/sim/stores/workflows/registry/store.ts": 80, - "apps/sim/hooks/queries/deployments.ts": 77, + "apps/sim/blocks/registry.ts": 367, + "apps/sim/blocks/registry-maps.ts": 364, + "apps/sim/stores/workflows/registry/store.ts": 81, + "apps/sim/hooks/queries/deployments.ts": 78, "apps/sim/lib/workflows/comparison/describe.ts": 66, - "apps/sim/lib/api/contracts/index.ts": 47 + "apps/sim/lib/api/contracts/index.ts": 46 } }, "app/workspace/page.tsx": { - "modules": 1063, + "modules": 1067, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 974, + "apps/sim/lib/auth/stale-session-recovery.ts": 977, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 366, - "apps/sim/blocks/registry-maps.ts": 363, - "apps/sim/lib/api/contracts/index.ts": 41, - "apps/sim/stores/workflows/registry/store.ts": 33, + "apps/sim/blocks/registry.ts": 367, + "apps/sim/blocks/registry-maps.ts": 364, + "apps/sim/lib/api/contracts/index.ts": 40, + "apps/sim/stores/workflows/registry/store.ts": 34, "apps/sim/triggers/clickup/index.ts": 32 } }, "lib/catalog/projection/block-detail.ts": { - "modules": 1042, + "modules": 1046, "gateways": { "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/lib/catalog/projection/block-summary.ts": 499, - "apps/sim/blocks/registry-maps.ts": 495, - "apps/sim/blocks/blocks/credential-group.ts": 121, - "apps/sim/stores/workflows/registry/store.ts": 91, - "apps/sim/hooks/queries/deployments.ts": 82, + "apps/sim/lib/catalog/projection/block-summary.ts": 502, + "apps/sim/blocks/registry-maps.ts": 498, + "apps/sim/blocks/blocks/credential-group.ts": 122, + "apps/sim/stores/workflows/registry/store.ts": 92, + "apps/sim/hooks/queries/deployments.ts": 83, "apps/sim/lib/workflows/comparison/describe.ts": 71 } }, "lib/catalog/projection/block-summary.ts": { - "modules": 1038, + "modules": 1042, "gateways": { - "apps/sim/blocks/registry.ts": 1030, - "apps/sim/blocks/registry-maps.ts": 1027, + "apps/sim/blocks/registry.ts": 1034, + "apps/sim/blocks/registry-maps.ts": 1031, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 122, - "apps/sim/stores/workflows/registry/store.ts": 91, - "apps/sim/hooks/queries/deployments.ts": 82, + "apps/sim/blocks/blocks/credential-group.ts": 123, + "apps/sim/stores/workflows/registry/store.ts": 92, + "apps/sim/hooks/queries/deployments.ts": 83, "apps/sim/lib/workflows/comparison/describe.ts": 71 } }, @@ -694,16 +694,16 @@ "gateways": {} }, "lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts": { - "modules": 1164, + "modules": 1146, "gateways": { "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 460, - "apps/sim/blocks/registry-maps.ts": 458, - "apps/sim/ee/access-control/utils/permission-check.ts": 114, - "apps/sim/lib/billing/index.ts": 109, - "apps/sim/blocks/blocks/credential-group.ts": 108, - "apps/sim/stores/workflows/registry/store.ts": 83 + "apps/sim/blocks/registry.ts": 491, + "apps/sim/blocks/registry-maps.ts": 489, + "apps/sim/blocks/blocks/credential-group.ts": 117, + "apps/sim/lib/permission-groups/config-scope.server.ts": 92, + "apps/sim/lib/permission-groups/resolve.server.ts": 90, + "apps/sim/stores/workflows/registry/store.ts": 88 } } } diff --git a/scripts/generate-block-successors.test.ts b/scripts/generate-block-successors.test.ts new file mode 100644 index 00000000000..3a6d40e581a --- /dev/null +++ b/scripts/generate-block-successors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { flattenSuccessors } from './generate-block-successors' + +const registered = (blocks: readonly string[]) => (blockType: string) => blocks.includes(blockType) + +describe('flattenSuccessors', () => { + it('follows a chain to the current version so one lookup answers it', () => { + const map = flattenSuccessors({ a: 'b', b: 'c' }, registered(['a', 'b', 'c'])) + + expect(map.get('a')).toBe('c') + expect(map.get('b')).toBe('c') + }) + + it('stops rather than looping when successors point at each other', () => { + const map = flattenSuccessors({ a: 'b', b: 'a' }, registered(['a', 'b'])) + + expect(map.get('a')).toBe('b') + expect(map.get('b')).toBe('a') + }) + + /** + * A `replacedBy` naming a block that was never registered — a typo, or a + * successor removed later — must leave the retired id as its own answer. The + * editor still offers it as an allowlist row under that id, so resolving it to + * a type nothing can be permitted as would deny it with no row to fix it. + */ + it('keeps its own identity when the named successor is not registered', () => { + const map = flattenSuccessors({ a: 'gone' }, registered(['a'])) + + expect(map.has('a')).toBe(false) + }) + + it('emits nothing for a block that is already current', () => { + expect(flattenSuccessors({}, registered(['slack_v2'])).size).toBe(0) + }) + + /** No key may also be a value, or the runtime's single lookup would be short. */ + it('produces a closed map', () => { + const map = flattenSuccessors({ a: 'b', b: 'c' }, registered(['a', 'b', 'c'])) + + for (const successor of map.values()) expect(map.has(successor)).toBe(false) + }) +}) diff --git a/scripts/generate-block-successors.ts b/scripts/generate-block-successors.ts new file mode 100644 index 00000000000..5a60ddaa047 --- /dev/null +++ b/scripts/generate-block-successors.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env bun +/** + * Generates the access-control successor map from the block registry. + * + * The map answers one question — "which block type is an allowlist decision + * about this id really made against?" — and it has to be answerable from + * `lib/permission-groups/`, which `scripts/check-application-graph.ts` forbids + * from importing `blocks/`: the authorization funnel would pull every block + * definition into every surface that authorizes anything. Before this file the + * answer was reachable only through `getBlock`, so the env allowlist was + * intersected with the group allowlist *textually*, and a deployment naming + * `slack` against a group naming `slack_v2` intersected to nothing — refusing an + * integration both policies allow. + * + * Entries are flattened to the terminal successor; see {@link flattenSuccessors} + * for the walk and its stopping rules. + * + * Usage: + * bun run scripts/generate-block-successors.ts + * bun run scripts/generate-block-successors.ts --check + */ +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/permission-groups/block-successors.generated.ts') +const CHECK_MODE = process.argv.includes('--check') + +interface SunsetBlock { + sunset?: { status: string; replacedBy?: string } +} + +/** + * The block registry, loaded lazily. + * + * A static import would run on every import of this module, including the unit + * test for {@link flattenSuccessors}, which needs no registry and cannot + * resolve the `@/` specifiers every block file uses from the repo-root vitest + * project. + */ +async function loadRegistry(): Promise> { + const { BLOCK_REGISTRY } = await import('../apps/sim/blocks/registry-maps') + return BLOCK_REGISTRY as unknown as Record +} + +/** + * Flattens one-hop `replacedBy` edges to terminal successors. + * + * Reproduces the walk the runtime used to perform against the registry, with + * both of its stopping rules: a cycle stops at the last id visited rather than + * spinning, and an edge naming an unregistered block is not followed, leaving + * the id as its own answer. Only ids whose answer differs from themselves are + * returned, so a lookup that misses is a block with no successor. + */ +export function flattenSuccessors( + directSuccessors: Readonly>, + isRegistered: (blockType: string) => boolean +): ReadonlyMap { + const terminal = (blockType: string): string => { + const seen = new Set([blockType]) + let current = blockType + + while (true) { + const successor = directSuccessors[current] + if (!successor || seen.has(successor) || !isRegistered(successor)) return current + seen.add(successor) + current = successor + } + } + + const successors = new Map() + for (const blockType of Object.keys(directSuccessors).sort()) { + const resolved = terminal(blockType) + if (resolved !== blockType) successors.set(blockType, resolved) + } + return successors +} + +export async function buildBlockSuccessors(): Promise> { + const registry = await loadRegistry() + + /** `getBlock`'s own-key lookup, with its dash-to-underscore normalization. */ + const lookup = (type: string): SunsetBlock | undefined => { + if (Object.hasOwn(registry, type)) return registry[type] + const normalized = type.replace(/-/g, '_') + return Object.hasOwn(registry, normalized) ? registry[normalized] : undefined + } + + const directSuccessors: Record = {} + for (const blockType of Object.keys(registry)) { + const successor = registry[blockType]?.sunset?.replacedBy + if (successor) directSuccessors[blockType] = successor + } + return flattenSuccessors(directSuccessors, (blockType) => lookup(blockType) !== undefined) +} + +function render(successors: ReadonlyMap): string { + const quote = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + const entries = [...successors] + .map( + ([blockType, successor]) => + ` ${/^[A-Za-z_$][\w$]*$/.test(blockType) ? blockType : quote(blockType)}: ${quote(successor)},` + ) + .join('\n') + + return `/** + * Generated by \`bun run generate:block-successors\` from the block registry. + * Do not edit this file directly. + * + * Maps a retired block type to the *terminal* type an access-control decision + * about it is made against — \`sunset.replacedBy\`, followed transitively. It + * exists as a generated projection because \`lib/permission-groups/\` may not + * import \`blocks/\`; see \`scripts/generate-block-successors.ts\`. + */ +export const BLOCK_ACCESS_SUCCESSORS: Record = { +${entries} +} +` +} + +async function main(): Promise { + const successors = await buildBlockSuccessors() + + /** + * The map must be closed: no key may also be a value, or a lookup would need + * a second hop and the runtime does exactly one. Flattening guarantees it, so + * a violation means the flattening itself regressed. + */ + for (const successor of successors.values()) { + if (successors.has(successor)) { + throw new Error( + `Block successor map is not flattened: '${successor}' is both a successor and a retired id.` + ) + } + } + + const generated = formatGeneratedSource(render(successors), OUTPUT_PATH, ROOT) + + if (CHECK_MODE) { + const current = await readFile(OUTPUT_PATH, 'utf8').catch(() => '') + if (current !== generated) { + console.error( + 'Block successor map is stale. Run `bun run generate:block-successors` and commit the result.' + ) + process.exit(1) + } + process.stdout.write('Block successor map is current.\n') + return + } + + await writeFile(OUTPUT_PATH, generated) + process.stdout.write(`Generated ${OUTPUT_PATH}\n`) +} + +if (import.meta.main) await main()