feat(permission-groups): enforce every config key server-side - #7313
feat(permission-groups): enforce every config key server-side#7313waleedlatif1 wants to merge 161 commits into
Conversation
Adds a golden corpus and a seeded fuzz loop over `parsePermissionGroupConfig`, written against the current hand-written implementation so a derived one has something to prove itself against. Every row states what a stored `jsonb` value coerces to today; a row that changes in a later diff is a decision someone has to defend rather than a silent regression. Two properties the corpus pins are easy to break by accident: - `typeof [] === 'object'`, so an array-valued column coerces to defaults rather than throwing. A parser built on `z.object()` throws here unless it guards `Array.isArray`. - An emptied allowlist denies everything while `null` allows everything, so the two must never collapse into one another. It also pins a live defect. `allowedIntegrations` and `allowedModelProviders` are the only keys that skip element validation, so a corrupted row coerces to a value `permissionGroupFullConfigSchema` then refuses — the route reading it fails response validation instead of returning a usable allowlist. Filtering non-strings on the way in is fail-closed and removes the class; the test inverts when that lands. Adds the two coverage guards that were missing: the write schema, the defaults and the read schema declare the same keys, and every boolean config key is registered as a platform feature. Neither was asserted, so a key omitted from the write schema would have made an admin checkbox silently no-op on save. Drops the `@/lib/permission-groups/types` mock in the permission-check suite and its hand-copied `DEFAULT_PERMISSION_GROUP_CONFIG`. That module imports only zod and a type, so there was nothing to mock, and the mock's permissive merge was strictly looser than the real parser — those 63 tests were asserting against a fake that could not reproduce a coercion bug. The factory also returned two exports, leaving `FILE_SHARE_AUTH_TYPES` and `PERMISSION_GROUP_CONSTRAINTS` undefined for that module graph.
The config shape was maintained by hand in five parallel places — the write schema, the `PermissionGroupConfig` interface, the defaults, the tolerant parser, and the contract's read schema — plus the platform-feature list. Key order was load-bearing across all of them, because the group editor's dirty check compares stringified configs, so a key added at a different index read as an unsaved change forever. Nothing checked that the write schema had the same keys as the rest, and a key missing there made an admin checkbox silently no-op on save. `PERMISSION_GROUP_FIELDS` now declares each key once, carrying its schema, its default, whether it is server-enforced, and (for a boolean) its editor descriptor. Everything else is projected from it, so declaration order is the wire order by construction rather than by agreement. Two things this could have broken quietly, both pinned by the corpus added in the previous commit: - `z.object().parse([])` throws, but `typeof [] === 'object'`, so an array-valued jsonb column used to coerce to defaults. The guard keeps `Array.isArray` for exactly that row. - `.catch(default)` is whole-value tolerant while the old parser was element-wise. On an allowlist that would have been fail-open: one bad member would yield `null`, and `null` means unrestricted. `tolerantArray` filters instead, so a corrupt member narrows the allowlist. One deliberate behavior change. `allowedIntegrations` and `allowedModelProviders` were the only keys that skipped element validation, so a corrupted row produced a config the read schema then refused — the route reading it failed response validation instead of returning a usable allowlist. They now filter like every other array, which is fail-closed. Type-level assertions live in the source rather than a test because type-check excludes test files; a zod generic degrading to `unknown` would otherwise be invisible, since the runtime values would still be correct while every call site lost its narrowing.
… funnel A permission-group key only means something if a server refuses when it is set. Twelve did not: `hideCopilot`, `hideSecretsTab`, `hideDeployChatbot` and the rest were read in a sidebar filter and nowhere else, so an organization that set one had hidden a nav item, not withheld a capability. The gap was structural — nothing connected "this key is offered to admins" to "something refuses when it is set" — so this adds the connection rather than one more check. Operations already declare their policy as frozen data, so capability joins it: `defineWorkspaceOperation` takes a capability id, and `authorizeWorkspaceOperation` refuses when the caller's group withholds it. One insert covers every surface — internal routes, v2 routes, the Copilot adapter, trusted tools — because all of them funnel through it, including the HEAD probe, which has to answer the same question its GET would or it becomes an existence oracle. Capability is checked after the role check. `requirePermission` throws the refusal the v2 surface conceals as a 404, so asking about capability first would tell a complete outsider which capabilities the organization withholds. It is also the cheaper check, and it names the remedy the caller can actually act on. Two principals pass through, as policy rather than oversight. A workspace API key authorizes as the workspace and has no user, so no group resolves; substituting the key's creator would apply a bystander's group to every caller and break the key when that person left. A deployment run has no subject either, and denying there would 403 every schedule and webhook in the organization the moment a group withheld anything — a deployed workflow runs with the workspace's authority, like any service account, and what it *does* is still gated by the executor. Capabilities are ids with a rule registry, not predicates on the operation: a closure cannot be logged, compared, or read by an audit, and fifty table operations naming one capability should be fifty strings rather than fifty identical functions. Rules split on whether the decision needs a request value; a parameterized one is refused at definition time, because declared on an operation it would silently never fire. `check:permission-group-enforcement` is what stops this recurring. It asserts every capability is reachable, every key claiming capability enforcement is read by a rule, and no key claiming something weaker is — so a key cannot reach the admin editor while still documented as cosmetic. It runs in count-down mode (0/233) until the operations are annotated, and refuses to report success if its own parsers come back empty, since an audit that goes quiet when it breaks is worse than none. Config resolution is memoized per request, keyed on user and workspace and caching the promise so concurrent callers share one query. The gate returns before touching the database when the operation declares nothing or the workspace has no organization, so a personal workspace pays nothing.
Every capability in the registry now has something that refuses when the key behind it is set. An organization that hides Tables, Knowledge Bases, Files, Secrets, Integrations, API keys, the inbox, Chat, or any of the three deploy surfaces gets a 403 from the API rather than a hidden nav item. Most of it is declaration. 163 operations across tables, knowledge, files, secrets, credentials, workflows, MCP and API keys name their capability, and the funnel does the rest — the gate added in the previous commit already covered every surface those operations reach. The table domain fixes its capability in the factories rather than repeating it at ten call sites, since every operation in that module is one capability; the audit reads both that form and the positional one. Four sites are not workspace operations and are gated where they actually run: - Chat is a raw handler, checked after the request parses and before the send is claimed. That also settles the resume stream: with no run created there is nothing for it to replay. - `hideTraceSpans` becomes a projection rather than a refusal — the log stays readable, its trace spans, block I/O and final output do not. Applied before child traces hydrate, so a withheld view does not pay for a cross-workspace join it discards, and by deleting rather than omitting, because the execution-data schema is a passthrough and would otherwise let the fields through. - The inbox routes are raw handlers with inline queries; a shared guard keeps the six of them from drifting. - The auth-mode and tool-kind capabilities are annotated at the validators that already enforce them, which is what lets the audit prove every capability is reachable rather than assuming it. The audit now reports no pending enforcement: nothing can reach the admin editor claiming a restriction it does not apply. 120 operations remain unannotated and are counted, not enforced — they are the domains whose capabilities land with the creation-versus-invocation work. Three test files fail to load on this branch (`create-credential-connection`, `add-workspace-files`, `upload-sessions`); they fail identically on the base commit, and are unrelated to this change.
Two controls were configured and applied to nothing. **Legacy blocks defeated the integration allowlist.** Any block marked `hideFromToolbar` was exempt from access control, which covered 44 blocks — including fully functional superseded versions of Slack, GitHub, Notion, SharePoint and Google Sheets. Legacy `slack` talks to Slack exactly as `slack_v2` does, so an allowlist naming `slack_v2` was satisfied by `slack`, reachable through workflow import, the API, or a Copilot-built workflow. The admin editor filtered out exactly those blocks, so the hole was invisible to the person configuring the allowlist. A superseded block is now judged as the successor its `sunset.replacedBy` names, transitively, so allowing or denying an integration covers every version of it and the editor's single row means what it appears to. The exemption narrows to what it was for: the universal entry point, and a retired block with no successor — that one has no row to be permitted on and nothing to be permitted *as*, so denying it would break older workflows an admin could not rescue. **Enrichments sent row data with the tool denylist not applied.** The per-tool gate keys off the acting user and skips entirely when a call carries none; enrichment runs passed only a workspace. So `deniedTools` blocked a provider when a workflow called it and not when a table enrichment did — the same tool, the same row data, one path governed. The user is now threaded from all three callers, each of which already had one: the table run resolves it for billing attribution, the internal tool surface validated it and then dropped it, and the Copilot tool context carries it. `EnrichmentRunContext.userId` documents why it is load-bearing rather than attribution, since an omission fails open and silently.
`allowPersonalApiKeys` was a workspace column and nothing else, so the policy was all-or-nothing for the whole workspace: an organization could not let one team hold personal keys while another could not. `disablePersonalApiKeys` adds that, and the two combine with AND rather than one overriding the other. The column stays the coarse switch every workspace has, including the ones no group governs; the group key narrows it for one cohort inside an enterprise organization. Either saying no is a no, which is also why the column is checked first — it costs nothing and skips the group lookup entirely. It is not declarable on an operation, and the capability registry says so. Every other capability withholds something about a resource, so an operation opts into it; this one refuses a *principal kind*, and applies to every operation a personal key could reach. It is asserted in the funnel's personal-key branch instead, annotated so the audit can still prove the key is enforced. v1 authorizes in its own middleware rather than through the funnel, so the check is repeated there. Without it the same key v2 refused would keep working against v1 — which is the shape of the coverage gap this whole change exists to remove, so leaving it would have been the same mistake in a smaller place. The settings toggle now reads both layers, so the UI never offers a key type the server will refuse. The key is appended last in the field registry: declaration order is the wire order, and moving an existing key would read as an unsaved change in every open group editor.
The CSV export hands over every execution log the workspace ever recorded, including a column holding the full trace spans, to any member with read access. It was the widest read in the product and the only one with no control of its own — an organization could withhold a single log's trace spans in the UI and still have the whole history downloaded in bulk. `disableLogExport` withholds the export separately from reading a log, because those are different exposures: one payload someone is looking at, versus the entire history in a file. The export also applies the same trace-span projection the detail view does, so the two agree — a group that withholds spans no longer discloses them here. Checked inline rather than through an application use case: this route queries the log tables directly and predates that boundary. Migrating it is worth doing on its own, and is not a reason to leave the export ungoverned until then.
`FORBIDDEN_DETAIL_CODES` generates the v2 `403` description, so the new code has to reach the OpenAPI documents or `check:openapi` fails. That coupling is the point: a refusal a caller can branch on is published rather than left to be discovered by matching on prose.
Adds the thirteen keys the coverage audit found missing, and the rules that give each one meaning. Nothing enforces them yet — the enforcement audit lists all thirteen as pending, which is the point: the registry cannot quietly ship a key that refuses nothing. They divide into the two exposures the audit kept turning up. Extraction: table export, bulk file download, execution cost. Provenance and scope: which connectors may pull an external corpus in, whether a member may create a knowledge base or a table rather than only use one, whether they may attach personal credentials, approve a CLI login, or create a workspace that no existing group would govern. `allowedKnowledgeConnectors` is parameterized on the connector id, which required widening the parameterized rule from an auth mode to any request value. That is the right shape for it: an organization that sanctions Drive rarely sanctions the other sixty, and a connector is the one integration that copies a whole external corpus into the workspace. `maxLoopIterations` is deliberately not here. It is a resource ceiling rather than an access control — nothing is withheld from anyone — so it needs a numeric control the group editor has no affordance for, and folding it in as a boolean would misrepresent it.
`POST /api/mcp/workflow-servers` calls `performCreateWorkflowMcpServer` directly rather than going through `createWorkflowDeploymentServer`, so the `deploy.mcp` capability declared on that operation never fired here. A group that hid MCP deployment stopped the v2 route and not this one. Gating both is what keeps the two doors agreeing. Migrating this handler onto the use case is the better end state and is worth doing on its own; it is not a reason to leave the second door open until then.
…e-key escape
The key CRUD routes are raw handlers with inline queries, so `hideApiKeysTab`
hid the settings tab while `POST /api/workspaces/{id}/api-keys` and
`POST /api/users/me/api-keys` still minted keys.
This is also the mitigation the capability gate's design depends on. A
workspace API key authorizes as the workspace and resolves no permission
group, so the funnel's capability check does not apply to it — deliberately,
since substituting the key's creator would apply a bystander's group to every
caller and break the key when that person left. That leaves one escape: a
governed member minting themselves a workspace key that outranks their own
group. Gating the minting closes it at the door.
Keys that already exist keep working. Revoking those is an admin decision,
not something a policy change should do silently to live integrations.
Personal keys are user-global and belong to no workspace, so they resolve the
organization's default group — the same resolution invitations already use for
an organization-level action.
…ce operations
Annotates 71 `defineWorkspaceOperation` declarations across eleven domains so
the permission-group audit can tell an unreviewed operation from a deliberately
ungoverned one.
Capability mappings:
mcp_servers.* mcp_tools.use registering an MCP server and
storing its credentials was a
side door around the key that
blocks calling MCP tools
mcp_servers.workflow_* deploy.mcp reads included, so a group with
the surface hidden is not still
told what is published on it
skills.* skills.use
custom_tools.* custom_tools.use
chat_deployments.* deploy.chat
chat.send copilot.use
catalog.connector_types.list knowledge.use it enumerates knowledge-base
connectors and nothing else
Declared `'none'` with a reason: the block and tool catalogs (the set the editor
renders at all), memory and function execution (the executor's own per-run work,
where a gate fails runs the group permits rather than withholding anything),
credential groups (an admin-only, entitlement-gated section no key names), the
BYOK inherited-status read, and platform context.
Adds funnel-level refusal tests for MCP, skills, and the catalog split, so a
capability cannot be declared on an operation and then read by nothing.
…nd CLI access Three capabilities reached the admin editor with a checkbox, a hint, and no server gate: an organization that set `disableWorkspaceCreation`, `hideOrgMemberDirectory` or `disableCliAccess` believed it had withheld something while every route still answered. None is workspace-operation shaped, so each is wired at an annotated call site rather than through the declarative funnel. workspace.create — extended `getWorkspaceCreationPolicy` rather than the POST route, so forking and the sidebar's "can I create?" signal are covered by the same decision with no route changes. A new workspace carries no `permissionGroupWorkspace` row, so a scoped-group member creating one lands outside every group targeting them — the cleanest escape from the regime today. The gate resolves the caller's organization even when the resulting workspace would be personal, because a personal workspace is precisely that escape. `blockedReasonCode` gains `'permission-group-denied'`; the one caller that switches on it (`app/workspace/page.tsx`) gets matching copy, since the existing organization branch would have told a blocked member to ask for workspace access they already have. organization.member_directory — `/api/organizations/[id]/members` and `/api/organizations/[id]/roster` gated on bare organization membership, so every member could list every colleague's name and email. Both now consult the organization's default group. No role exemption: the default group governs owners and admins for every other capability, and carving one out here would make this the only key whose meaning depended on who was asking. cli.use — gated at `/api/cli/auth/approve`, the only moment a human is present in the device-auth handoff. The poll route that redeems the approval for an API key is deliberately unauthenticated and is left alone: it has no session to resolve a group against, and re-deciding there would duplicate this check while racing a config change between the two calls. `workspaceId` is set only for platform scope, so a personal-scope login falls back to the organization's default group instead of being the unguarded path. The workspace-level member list (`/api/workspaces/[id]/members`) is deliberately NOT gated. It returns id, name and image for people who already share a workspace with the caller — identities the collaboration surfaces publish continuously anyway (presence cursors, canvas avatars, log actors, the sharing dialog), and it exposes no email at all. Hiding it would blank those surfaces while disclosing the same names over the realtime channel, so the restriction would read as breakage rather than policy. An organization-wide roster of colleagues with their email addresses is a materially different disclosure from "who is in this room with me", and that is what the capability is named for.
`knowledge.create`, `knowledge.upload` and `knowledge.connectors` shipped as declared capabilities with nothing reading them, so an admin who set the matching keys got a checkbox and no refusal. - knowledge.create governs the one operation that opens a knowledge base, so a group may query, populate and organize the bases it has without creating new ones. - knowledge.upload governs every path carrying caller-supplied bytes: the single-request document upload and the four upload-session operations. The connector sync path is untouched — a connector's documents are the sanctioned source, which is the point of the key. - Both rules also read `hideKnowledgeBaseTab`. An operation declares exactly one capability, so moving these off `knowledge.use` would otherwise have let a group that withheld the whole module still reach them through the API. - knowledge.connectors is parameterized on the connector id, which the authorization funnel never sees, so it is asserted inside `createKnowledgeConnector` ahead of the write, through `CAPABILITY_RULES`. Update is not gated: it cannot change a connector's type, and re-asserting would strand an existing connector the moment an admin narrowed the allowlist. - The admin editor grows a nested connector picker under Knowledge Base, keyed off the client-safe connector meta registry.
…and credential groups
…es.bulk_download Three capabilities shipped with an admin checkbox and no server gate: an organization that set disableTableCreation, disableTableExport or disableBulkFileDownload believed it had withheld something while every path still answered. tables.create The table operation factories that mint more than one kind of operation now take the capability as an argument with no default — a default would let a new operation inherit tables.use without anyone deciding it should, which is the unreviewed omission this gate exists to prevent. tables.create and the copilot create-from-workspace-file import declare it. An import targeting `new` also creates a table, but one targeting `existing` only fills one and the operation cannot tell them apart: the target is request input the funnel never sees. Asserted inside the use case instead. tables.export Declared on createExport and downloadExport. Generating the file is the extraction and handing over its bytes completes it; readExport carries no rows and cancelExport stops an extraction rather than performing one, so gating either would strand a member with an export they can neither watch nor stop after the group changed. Both raw export routes — the synchronous CSV/JSON stream and the async job — bypass the use case entirely and query directly, so they gate inline after their existing access check and before any data moves. files.bulk_download files.download serves both a single file and a zipped folder tree, so declaring the capability on the operation would also take away saving one file, which is not what the key means. The use case asserts it only when the request is actually bulk, reusing the same single-file predicate the resource authorization already resolved so the two cannot drift. Every gate decides through CAPABILITY_RULES rather than a config key spelled out at the call site, so a renamed key cannot silently stop denying anything.
…ersist-time block bypass Every `defineWorkspaceOperation` in the workflow registry now declares what a permission group withholds, so an unfilled field can no longer be mistaken for an unreviewed one. Most workflow CRUD is honestly `'none'` — the workflow module has no hide key, and its reads and writes are governed by workspace role — and each of those carries a reason naming why. `workflows.versions.activate` gains `deploy.api`: activating a different deployed version changes what the deployed API serves, so a group that withholds API deployment must withhold it too. `workflows.public_api.update` stays exempt on purpose: `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. Closes a real bypass on the two paths that persist a whole graph. Import and the graph replace never went through the editing operations, so a member could save or import a workflow containing a block their group's `allowedIntegrations` denies; the allowlist was then a property of one authoring route rather than of what is stored, and the block was refused only by the executor mid-run — after the workflow had been saved, shared, and possibly deployed. Both paths now resolve the caller's permission config and refuse with 403 before anything is written, so there is nothing to roll back, and `importErrorCode` maps 403 to `forbidden` instead of letting a refusal surface as a 500.
Closes the persist-time bypass: a whole-graph write hands over finished blocks naming whatever types it likes, so a member could import or save a workflow containing an integration their group denies. It was caught only by the executor mid-run, after the workflow had been saved, shared and possibly deployed. Narrowed the guard from the editor's `isBlockTypeAllowed` to the allowlist alone. That helper also refuses blocks hidden from the current viewer, which is right when *adding* a preview block but wrong when *storing* a graph that already contains one: it would reject an export taken before the block was gated, and fail a save for a reason no permission group set. The guard also resolves a superseded block to its successor, so it agrees with the runtime gate rather than letting a legacy type through. Left `updatePublicApi` on `'none'` rather than declaring `public_api.use`, against the original brief. The use case already asserts the capability inside `execute`, guarded on the enabling direction only; declaring it on the operation would close nothing new while newly trapping a workspace that has public execution it could no longer withdraw.
…lities logs.cost, credentials.personal, triggers.webhook and copilot.tool_auto_approval each shipped with an admin checkbox and no server check, so an organization that set one believed it had withheld a capability while every surface still answered. logs.cost is a projection rather than a refusal, following the logs.trace_spans precedent: the log stays readable and its spend does not. Applied to the detail (run total, itemized ledger, per-block and per-span cost and tokens), to the list summaries, and to the export CSV — a hidden detail cost still printed in the list or downloaded in bulk withholds nothing. The withheld detail still satisfies the wire contract: `cost` is nullable and `costLedger` optional. credentials.personal is operation-shaped for the three OAuth connection operations, which can only ever produce a personal account-linked grant, and request-shaped for `credentials.create`, whose `type` decides scope — so that one asserts inside the use case through the capability rule rather than reimplementing the predicate. triggers.webhook gates creation only. An already-created webhook must keep firing: inbound delivery has no session to resolve a group against, and refusing there would silently break live integrations. copilot.tool_auto_approval is honoured at read time as well as at write time, so a stored auto-allow saved before the key was set stops silencing the prompt immediately rather than only for new entries.
… auto-approval Every capability the registry declares is now enforced somewhere. `hideCostInfo` follows the trace-span precedent as a projection, and goes further than the brief in one way that matters: it strips per-block and per-span cost too, since a viewer could otherwise sum the spans back to the total that was withheld. It reaches the list and the CSV export as well, because a cost hidden on the detail view and printed in the list withholds nothing. `disableToolAutoApproval` refuses at read time, not only at write. An entry saved before the policy changed would otherwise keep silencing the prompt forever, so turning the key on would take effect for nobody who had already clicked "always allow" — which is exactly the population it is for.
…ation All 287 operations now declare one, so the field becomes required and omitting it is a compile error rather than an unreviewed gap. That is the whole point: an absent field could not be told apart from an operation nobody had looked at, which is how twelve config keys shipped with an admin checkbox and no server gate. The last seven are logs reads and the public-API workspace reads, all `'none'` with reasons. The logs ones say the thing worth remembering: a group withholds *fields* inside a run — trace spans, cost — not the fact that it ran, so refusing the read would be a different restriction from the one the admin set. Also made the connector allowlist actorless-safe. It required a human subject, so a scheduled sync would have hit a 500 instead of a refusal; it now passes through with no user, exactly as the funnel treats an actorless caller. `check:actorless-executor-operations` caught that — one audit catching the other's blind spot is the argument for having both.
…ithheld Four had accumulated: the authorization funnel, a separate assertions module, five bespoke per-domain helpers, and raw config-key reads at call sites. Two of them resolved the config through different helpers, so the assertions module silently bypassed the per-request memo the funnel uses, and the two refusal messages were built independently and could drift. `capability-assertions.ts` is now the single API — workspace-scoped and organization-scoped, throwing and non-throwing — and the funnel delegates to it. Everything reads `CAPABILITY_RULES` rather than a config key spelled out locally, so a renamed key cannot quietly stop denying anything. `resolvePermissionGroupConfig` takes an optional organization id: a caller that already loaded the workspace passes it, a raw route omits it, and both share one memo keyed on user and workspace. Previously the second case had no memo at all. `PermissionGroupCapabilityError` moved into the permission-groups module. The assertions need to throw it and the funnel needs to call them, so leaving it beside the funnel made the two import each other. The personal-API-key check now reads its rule instead of the config key directly, so it cannot disagree with the capability of the same name.
…roup-item Two skills for the enterprise permission-group system: the end-to-end procedure for wiring a new governed item (field registry -> capability rule -> operation declaration or use-case assertion -> golden corpus), and the procedure for auditing an existing one by proving the refusal rather than assuming it.
…e module's wording
`capability` became a required field, so the audit's count-down mode could no
longer be reached by an operation that had simply not been annotated yet — and
while it lingered, an un-annotated `capability: 'none'` silently suppressed the
check that every declared capability is enforced. Both causes now fail:
- a capability the parsers cannot read, which the type system guarantees is a
declaration form this text-based audit does not follow, not an omission
- `'none'` without a `permission-group-exempt:` reason
The unreached-capability assertion runs unconditionally as a result.
Also collapses `capabilityRefusalMessage` into its only caller, drops the
callerless `isStaticCapability`, un-exports the compile-time assertion aliases
in `fields.ts` and `capabilities.ts` (the constraint is checked at the
declaration; the export implied a consumer that never existed), and moves the
`logs.trace_spans` rule into `CAPABILITY_IDS` order.
Wording, all of it user- or admin-facing:
- sixteen capability `describe` strings now agree with the verb in the shared
refusal sentence ("Knowledge bases is not available" -> "The Knowledge Base
module is not available"; "Skills" -> "Loading skills")
- five field hints stated what stayed permitted rather than what the key
withholds, which reads backwards where the same string is reported as an
active restriction
…ave loop An unrestricted group is the common case, so every workflow save in every ungoverned workspace was paying two registry lookups per block to reach an answer that could not change. The allowlist becomes a Set via the existing `toAllowedIntegrationTypes`, and the null case returns before the loop. Also corrects `isBlockTypeAllowed`'s doc, which restated its signature and had gone stale: it asks two questions, and the second — deployment visibility — is exactly why the persist-time guard cannot reuse it.
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 365 files
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Note: This PR contains a large number of files. cubic selects up to 200 of the highest-priority eligible files for this review, so some files may not have been reviewed.
Re-trigger cubic
…dy gated A synchronous CSV import passed an explicit null governed subject, so the workflow and enrichment cells the appended rows auto-fire ran with no per-tool gate — including when a session member with a restricting permission group started the import. The subject now comes from the same `TableAccessPrincipal` `checkAccess` gated `tables.use` against, so a route cannot gate one subject and dispatch under another.
…scade An output backfill writes cells that satisfy downstream groups' deps, and `batchUpdateRows` starts those groups. The write passed an explicit null governed subject, so a cascade started by a person's schema change ran its tools ungated. The subject now rides the backfill payload beside `actorUserId` — a billing attribution that names the workspace billed account when the change carried no human.
Cancelling a dispatch row stops the next window, not the one already queued — the dispatcher blocks on a whole window at a time. Account deletion cancels the departing account's dispatches and then deletes the user row, so those in-flight cells kept invoking tools and writing results under a subject that no longer existed. The cell now reads its owning dispatch before executing and terminalizes itself as cancelled; there is no per-cell alternative, since table_row_executions carries no dispatch column.
A dispatcher pre-stamp outlives the worker that wrote it: a cell task that finds the row's cascade lock held bails, and the lock owner drains the marker under its own payload. With two dispatches overlapping on one row that ran another dispatch's request against the wrong person's tool denylist — or, when the owner was an actorless auto-fire, against none. The marker now carries its governed subject and the drain reads it back; 0316 also adds the partial index account deletion's cancel filter needs and repeats 0315's backfill to close its rolling-deploy window.
…on filter, and the mock The shared schemaMock lacked `capability_governed_user_id`, so the account-deletion cancellation ran its filter against `undefined` and the test could not tell the governed subject from the billing attribution it replaced. Adds both columns to the mock and asserts the filter names the subject and only the two live statuses.
|
@cubic-dev-ai review |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
2 issues found across 386 files
Confidence score: 2/5
apps/sim/lib/table/orchestration/import.tsleaves workflow and enrichment auto-fires skipped inmode === 'replace', so governed subjects are not applied to imported rows; threadcapabilityGovernedUserIdthroughimportReplaceRowsand dispatch the required auto-fires.apps/sim/app/api/table/import-csv/route.tscan create a table using stale preflight results if the group changes during asynchronous work, risking unauthorized or incorrect table creation; recheck immediately before thetables.createboundary.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/table/orchestration/import.ts">
<violation number="1" location="apps/sim/lib/table/orchestration/import.ts:292">
P1: When `mode === 'replace'`, the import still skips workflow/enrichment auto-fires, so the new governed subject is never applied to those rows. Thread `capabilityGovernedUserId` through `importReplaceRows` and dispatch the inserted rows after the replace transaction commits.
(Based on your team's feedback about threading the governed subject through CSV import.)</violation>
</file>
<file name="apps/sim/app/api/table/import-csv/route.ts">
<violation number="1" location="apps/sim/app/api/table/import-csv/route.ts:83">
P2: When the group changes after this preflight, the import can still create a table because creation happens after prolonged asynchronous work. Recheck `tables.create` immediately before the creation boundary, or make the check and creation atomic.
(Based on your team's feedback about re-checking table-creation capability during imports.) .</violation>
</file>
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Note: This PR contains a large number of files. cubic selects up to 200 of the highest-priority eligible files for this review, so some files may not have been reviewed.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Re-trigger cubic
| fallbackDelimiter, | ||
| mode, | ||
| timezone, | ||
| capabilityGovernedUserId, |
There was a problem hiding this comment.
P1: When mode === 'replace', the import still skips workflow/enrichment auto-fires, so the new governed subject is never applied to those rows. Thread capabilityGovernedUserId through importReplaceRows and dispatch the inserted rows after the replace transaction commits.
(Based on your team's feedback about threading the governed subject through CSV import.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/table/orchestration/import.ts, line 292:
<comment>When `mode === 'replace'`, the import still skips workflow/enrichment auto-fires, so the new governed subject is never applied to those rows. Thread `capabilityGovernedUserId` through `importReplaceRows` and dispatch the inserted rows after the replace transaction commits.
(Based on your team's feedback about threading the governed subject through CSV import.) </comment>
<file context>
@@ -271,8 +280,17 @@ export interface PerformTableCsvImportResult {
+ fallbackDelimiter,
+ mode,
+ timezone,
+ capabilityGovernedUserId,
+ } = params
const requestId = params.requestId ?? generateRequestId()
</file context>
| * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating | ||
| * on it still refuses a group that hides Tables entirely. | ||
| */ | ||
| if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.create')) { |
There was a problem hiding this comment.
P2: When the group changes after this preflight, the import can still create a table because creation happens after prolonged asynchronous work. Recheck tables.create immediately before the creation boundary, or make the check and creation atomic.
(Based on your team's feedback about re-checking table-creation capability during imports.) .
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/app/api/table/import-csv/route.ts, line 83:
<comment>When the group changes after this preflight, the import can still create a table because creation happens after prolonged asynchronous work. Recheck `tables.create` immediately before the creation boundary, or make the check and creation atomic.
(Based on your team's feedback about re-checking table-creation capability during imports.) .</comment>
<file context>
@@ -71,6 +73,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
+ * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating
+ * on it still refuses a group that hides Tables entirely.
+ */
+ if (await isWorkspaceCapabilityWithheld(userId, workspaceId, 'tables.create')) {
+ return capabilityRefusalResponse('tables.create')
+ }
</file context>
…oups-coverage The block-successors map regenerates with one new entry from staging's table sunset; the check that failed in CI compares against the merge ref, which is exactly the drift it exists to catch.
The file ends in post-COMMIT CONCURRENTLY steps, so a failed concurrent build replays it from the top. The column add, the FK, and the VALIDATE now all survive a second run.
An omitted subject and a deliberately actorless one read identically at the call site, and the omitted form ran the backfill's downstream cells with no gate. Matches the add/update group payloads.
Both import routes accept an internal JWT, whose user id is the run's actor. Reading a governed subject off it gated and dispatched under a bystander's permission group; an executor call now names nobody.
|
Closing to reopen fresh — the review history here has grown past the point of usefulness. All changes live on this branch; a clean PR will follow. |
Summary
hide*) only hid UI — the API answered normally. All are now enforced server-side, and ~15 new keys were added (log/table/file export, knowledge connectors, workspace/CLI/webhook creation, cost/trace-span withholding, personal API keys moved into groups)lib/permission-groups/fields.ts) now derives the write schema, read schema, config type, defaults, tolerant parser, and admin feature list — adding a key is one entry instead of six hand-maintained listsdefineWorkspaceOperationdeclares a requiredcapability(or explicit'none'with an annotated reason), enforced centrally inauthorizeWorkspaceOperationafter the role check; raw routes, v1, and projections gate through shared helpers so refusal wording and detail codes never drifthideCostInfo/hideTraceSpansare projections: fields are stripped on every log/run surface (list, detail, export, snapshot, Chat mentions), and cost-selective queries are refused so counts can't be used as a bisection oraclecheck:permission-group-enforcement(every operation/registry accounted for, unparseable declarations are findings),check:application-graph(the authorization funnel and route wrapper can't pull heavy module trees),check:capability-subject(v1 capability sinks only take the governed subject)/add-permission-group-itemand/validate-permission-group-itemskills documenting the procedure and audit checklistType of Change
Testing
Full suite green (38,513 tests, 0 failing files — parity with staging), 43 audits pass, type-check clean. Every capability gate has tests verified to fail with the gate removed; workspace-key pass-through, executor exemption, and check ordering are pinned by dedicated tests.
Checklist