Skip to content

Commit d7b31a8

Browse files
authored
feat(permission-groups): enforce every config key server-side (#7347)
* test(permission-groups): pin config coercion before deriving it 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. * refactor(permission-groups): derive the config from one field registry 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. * feat(permission-groups): add the capability gate to the authorization 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. * feat(permission-groups): enforce the twelve UI-only keys on the server 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. * fix(permission-groups): close the legacy-block and enrichment bypasses 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. * feat(permission-groups): govern personal API keys per group `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. * feat(permission-groups): govern log export 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. * chore(docs): publish the permission-group 403 code `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. * feat(permission-groups): declare the remaining governed capabilities 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. * fix(permission-groups): gate the MCP route that bypasses its use case `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. * fix(permission-groups): gate API-key management, closing the workspace-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. * feat(permission-groups): declare capabilities on the remaining resource 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. * feat(permission-groups): enforce workspace.create, member directory and 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. * feat(permission-groups): enforce the knowledge capabilities `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. * feat(permission-groups): enforce tables.create, tables.export and files.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. * feat(permission-groups): annotate workflow operations and close the persist-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. * feat(permission-groups): enforce the four declared-but-unwired capabilities 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. * chore(permission-groups): re-record the settings page module baseline * feat(permission-groups): require a capability on every workspace operation 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. * refactor(permission-groups): one way to ask whether a capability is withheld 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. * docs(skills): add add-permission-group-item and validate-permission-group-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. * refactor(permission-groups): remove the count-down mode and settle the 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 * perf(permission-groups): hoist the block-allowlist check out of the save 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. * refactor(permission-groups): one API for every capability gate Five bespoke gates and eleven inline config-key reads all answered the same question in their own vocabulary, so a renamed key would have silently stopped denying anything at each of them and the refusal wording drifted per surface. Every one now asks through `capability-assertions`, which reads `CAPABILITY_RULES`. Deleted: - `inboxWithheldResponse` (`lib/mothership/inbox/access.ts`) - `apiKeyManagementWithheldResponse` and `personalApiKeyManagementWithheldResponse` (`lib/api-key/access.ts`) - `isOrgMemberDirectoryHidden` and `isCliAccessDisabled` (`ee/access-control/utils/permission-check.ts`) Routes that render their own response shape use `isWorkspaceCapabilityWithheld`/`isOrganizationCapabilityWithheld` and build their own 403 from `capabilityRefusal`; the projections that strip fields out of a log response keep reading the config but ask `capabilityDeniedBy` rather than naming the key. Every `permission-group-enforced:` annotation moved with its decision. * fix(permission-groups): stop capabilities refusing workflow runs A delegated executor principal usually carries a user subject — the subject resolver recurses into the delegation context — so a run was getting the triggering member's capabilities as well as their role. That turned every capability on an executor-reachable operation into a runtime kill-switch: ticking "hide the Knowledge Base module from the sidebar" made retrieval 403 mid-run, "hide Tables" broke Table blocks, "hide the Files settings tab" broke `/api/files/serve` and with it every rendered image and PDF, and "hide the Integrations settings tab" failed every OAuth block in the workspace, including credentials an admin had shared deliberately. A capability names what a *person* may reach in the product. A run reaches those resources because a block in the graph does, and what a run may do is governed separately by `assertPermissionsAllowed`, which gates every block, tool and model against the same group. So an executor delegation now carries the triggering user's role but not their capabilities — the same reasoning already documented for a subject-less deployment run, which was only ever half the case. Copilot is deliberately not exempt: it acts as the person, so it must not reach what the person may not. Tested both ways. Found by an adversarial review of the branch, not by a failing test — the existing test pinned the broken behavior as if it were intended, which is why it is now inverted with the reasoning written down. * refactor(permission-groups): keep only the validators that outlive the rules `permission-check.ts` predates `CAPABILITY_RULES`, so several of its `validate*Allowed` helpers restated a config key the rule registry already names, and two of its error classes existed only to be caught one frame later and rethrown as the `ForbiddenOperationError` the rule would have raised. Collapsed: - `validateMcpToolsAllowed`, `validateCustomToolsAllowed` and `validateSkillsAllowed` were `assertPermissionsAllowed`'s `toolKind` branch spelled out three more times. Their four call sites now pass `toolKind` directly, which keeps the `ExecutionContext` config memo and the same error classes. Their enforcement annotations move to `assertPermissionsAllowed`. - `ChatDeployAuthNotAllowedError` and `PublicFileSharingNotAllowedError` were each thrown from one place and immediately translated by every caller into `ForbiddenOperationError` with the very detail code their rule declares. Both gates now raise `PermissionGroupCapabilityError` once, and the five try/catch translations are gone. `validatePublicFileSharing`, `validateChatDeployAuth`, `validateInvitationsAllowed` and `validatePublicApiAllowed` stay: the first two decide on a request auth mode the authorization funnel never sees, and the last two OR a permission group against a deployment-wide env flag. All four now read `CAPABILITY_RULES` rather than a config key, so a renamed key breaks the build instead of silently ceasing to deny anything. `assertPermissionsAllowed` stays for the same reason it always existed — it governs what a run may do, not what an operation is — with its tool-kind branch driven off the rules. The refusal wording for a blocked chat auth mode and a blocked file share now matches the funnel's sentence ("... is not available under your organization's permission group"). HTTP status and detail code are unchanged. * fix(permission-groups): close the defects an adversarial review found `disableTableExport` withheld nothing. `tableOperations.downloadExport` was gated, but a second raw route handed back a presigned URL for a finished export behind workspace-read alone, and the workspace job listing named every colleague's `jobId`. List, take one, download. Both are gated now — the listing withholds as an empty list rather than an error, because the caller has no exports to act on and erroring the tray would report a failure where the honest answer is that there is nothing to show. `tables.create` and `tables.export` now subsume `hideTablesTab`, the way the knowledge rules already did. An operation declares exactly one capability, so a narrower one replacing a broader one lets the broader key through: a group that hid the whole Tables module could still create and export tables. `triggers.webhook` gated creation but not reactivation, which is the act the key names. Only `false → true`: deactivating stays open, or a policy change would strand a member with a live webhook they cannot turn off. Enrichment ran under the workspace's billing owner. For a system-triggered cell the billing attribution names the payer, and running a member's tool denylist against a bystander is wrong both ways — it fails cells nobody meant to govern, and skips the denylist for whoever actually triggered one. It now names the triggering user or nobody, which is the documented behavior for an actorless run and what CLAUDE.md requires. Two projection leaks: the CSV export filled its message column from `finalOutput`, which the detail view deletes for that same viewer, and `stripSpanCosts` cleared `cost` but not `tokens` — the same spend in another unit, recoverable by anyone who knows the model's rate. Organization admins are exempt from the member-directory capability. The response is the only source for the team-management page and its seat snapshot, so withholding it took away the page an admin would use to change the setting. Workspace API-key revocation is no longer gated. Withholding key management must not withhold key revocation — an admin unable to revoke a leaked credential turns a policy into a security hazard. The personal-key delete route was already ungated for that reason. Also: the logs list and detail resolve through the per-request memo with the organization id they already hold. The list is polled, its operation declares no capability, so this was a net-new workspace query and plan check on every request including for personal workspaces that can never be governed. * refactor(permission-groups): route every config read through the request memo Eighteen sites resolved a permission-group config by calling `getUserPermissionConfig` directly, so a request that authorized several operations paid one lookup per site instead of sharing the per-request memo the funnel already establishes. Capability gates now go through the canonical assertion helpers, so the decision reads CAPABILITY_RULES rather than a config the call site then tests itself. Projection sites — the copilot catalogs, the VFS, the integration allowlists — still read the config, but obtain it from `resolvePermissionGroupConfig` so they share the same memo. `lib/workspaces/policy.ts` uses `isOrganizationCapabilityWithheld`: no workspace exists yet, so the org-scoped form is the one that applies. Also merges a stacked TSDoc pair on `assertConnectorTypeAllowed`, where the first block sat orphaned above the second. * test(permission-groups): cover the inbox, workflow-MCP, and Chat capability gates Three capability gates shipped without a test. Each now asserts both directions — refused when the group withholds the capability, and allowed both when a group governs the user but withholds nothing and when no group governs at all, which is every personal workspace and non-enterprise org. Extracts the config-scope mock the eight existing permission-group tests each hand-rolled into permissionGroupScopeMock in @sim/testing. The shared version exports withPermissionGroupScope as a real passthrough, which a route-level test needs: withRouteHandler wraps every handler in it, so a factory exporting only resolvePermissionGroupConfig turns the gate under test into an unrelated 500. * docs(permission-groups): tell admins what they are actually revoking Twelve `hide*` keys are now server-enforced, but their admin copy still described the cosmetic behavior they used to have. A hint reading "Hide the Tables module from the sidebar" tells an admin they are tidying a nav bar while they are revoking a module — and the same string is read a second time by `getActivePermissionGroupRestrictions` as the prose for an active restriction, where "hide" is simply false. Rewrite every misleading label and hint to name the access withheld, and re-file the categories: `Sidebar`, `Settings Tabs`, `Deploy Tabs` and `Workflow Panel` named UI chrome that no longer decides anything. Also deduplicate the model and block-type gates that `assertPermissionsAllowed` copied verbatim from `validateModelProvider` / `validateBlockType`, drop four dead exports from the access-control hooks, and correct the two permission-group skills against the current code. * refactor(permission-groups): remove indirection and close the audit's silent-drop hole Deletes what no longer earns its keep in the permission-groups module and the application-operation boundary, and hardens the enforcement audit against parses that go quiet. Removals, each proven unused by a whole-worktree grep: - `assertOrganizationCapability` — no caller anywhere. - `authModeDeniedBy` and the `ShareAuthMode` alias — the same predicate `allowlistDenies` already expressed, spelled with different words. - `capabilityRefusal` was defined twice, in two files each documented as "the one sentence every capability refusal uses". It now has one definition beside `CAPABILITY_RULES`, which `refuseCapability` also uses. - The `schema` member on every field entry: `readSchema` already carried each key's wire type, and the config type now derives from it. - Re-export shims for `PrincipalKind` and `PermissionGroupCapabilityError`; the barrel names their real modules. - The duplicated restriction shape in `queries.ts`, now the exported type. The audit no longer drops operations in silence. A `defineWorkspaceOperation` call whose id it cannot read — a const reference, or a wrapper written as an arrow const rather than a `function` — is a finding rather than an absence, and a file that calls the builder and yields nothing is reported too. Both forms previously took the count from 287 to less with a passing tick. `resolvePermissionGroupConfig` keeps its key: `organizationId` is a function of `workspaceId`, so adding it would split the cache and double the queries. Said so in TSDoc and pinned it with a test. * refactor(permission-groups): drop the types.ts re-export shim CLAUDE.md forbids re-exporting from a non-barrel file. types.ts re-exported six names that live in fields.ts, so importers had two paths to the same symbol. Repoint them at the source. What was left held no types: two DB constraint-name maps, now constraints.ts, and permissionGroupConfigSchema, which joins the other derivations in fields.ts. types.test.ts covers the derived parser, so it becomes fields.test.ts. * fix(permission-groups): enforce tables.use on the raw internal table routes `hideTablesTab` declares `booleanRestriction('capability', …)`, and `tables.use` is declared on the 20 table operations that govern `/api/v2/tables/**` and the Copilot table tools. Sixteen internal routes under `/api/table/**` never became operations: they authorize with `checkAccess`, which did `getUserEntityPermissions` + `permissionSatisfies` and nothing else, then called the table service directly. A member of a group denied Tables could still add a TTL column, import, export, delete, restore, and read every row through them; the only other guard was a client-side redirect. `checkAccess` is the choke point all sixteen share, so the gate goes there — one place to forget rather than sixteen, and a route added later inherits it. Three more routes name a workspace rather than a table (`import-csv`, the root `import-async`, `restore`) and assert inline through the same shared refusal response. Capability runs strictly after the 404 and the role check, matching `authorizeWorkspaceOperation`: a role failure conceals whether the table exists, and refusing on capability first would tell a non-member which modules the organization withholds. The denial variant of `AccessResult` carries the capability so `accessError` can raise the shared refusal sentence and `PERMISSION_GROUP_CAPABILITY_BLOCKED` rather than the role message it is not. * test(permission-groups): make every operation fixture declare its capability `capability` is a required field, but `apps/sim/tsconfig.json` excludes test files and `check-permission-group-enforcement.ts` walks past them, so 22 of the 26 `defineWorkspaceOperation` fixtures across these seven suites omitted it and nothing complained. Each now names the capability it is actually modelling — `files.use` and `integrations.manage` where the fixture stands for a real file or credential operation, `'none'` where the operation is scaffolding for an assertion about roles, principal kinds, audit, or resource policy. The guard in `defineWorkspaceOperation` is kept and tightened rather than removed as unreachable. It previously skipped an `undefined` capability instead of refusing it, so a capability-less operation defined cleanly and then threw `Cannot read properties of undefined` from `capabilityDeniedBy` — but only for a caller whose organization has a permission group, meaning it passed every personal workspace and every non-enterprise test and failed exactly in the tenants that bought the feature. It now refuses at definition time, its TSDoc says why it survives a required field, and two tests hold it to that. * refactor(permission-groups): share the memo, the refusal sentence, and the contract Route validatePublicFileSharing, validateChatDeployAuth, validatePublicApiAllowed and validateInvitationsAllowed through resolvePermissionGroupConfig so a request that authorizes an operation and then asserts one of these resolves the group once. Outside a scope the resolver degrades to a direct call, so the executor and job paths are unaffected. Raise the knowledge-connector allowlist refusal through refuseCapability instead of a hand-written copy of the shared sentence. PermissionGroupCapabilityError is a ForbiddenOperationError carrying the same detail code, so the status and error contract are unchanged. Make resolveUserAccessControlContext module-private — it had no consumer outside its own file, every caller elsewhere already holds the organization id — and move its context-shape coverage onto the verified variant that callers actually use, keeping the workspace-lookup behavior covered through getUserPermissionConfig. Export the permission-group request type aliases from the contract and consume them in the hook, replacing four hand-written wire types. * test(knowledge): assert the shared connector refusal sentence The refusal now comes from refuseCapability, so it reads like every other capability refusal and no longer names the connector — matching its two sibling parameterized capabilities, which already say 'This chat authentication mode' and 'This file-share authentication mode'. * fix(permission-groups): enforce declared capabilities on the v1 public API `app/api/v1/middleware.ts` authorizes in its own middleware rather than through `authorizeWorkspaceOperation`, so none of the capabilities the funnel applies reached it. It asserted exactly one — `personal_api_key.use` — and nothing else. A member of a group that withholds Tables was correctly refused on `/api/v2/tables/**` and on every internal `/api/table/**` route, and could still list, create and write tables through `/api/v1/tables/**` with a personal API key; the same held for Knowledge Base and Files, and for deploying a workflow through `/api/v1/workflows/[id]/deploy`. That left `hideTablesTab` and its siblings bypassable by the very credential the branch exists to gate. The declaration is threaded through the middleware rather than hand-asserted per handler: `validateWorkspaceAccess` takes a required `V1RouteCapability`, and the two shared resolvers routes compose it through — `resolveKnowledgeBase` and `resolveV1DeploymentWorkflow` — take one too. Required, 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. Every value is the capability the route's v2 or internal counterpart already declares; v1 gets no mapping of its own. Three invariants are preserved. The check runs strictly after the key-scope and workspace-role checks, matching `authorizeWorkspaceOperation`, so a capability refusal never reaches a non-member who would learn from it that the workspace exists and which modules the organization withholds. A workspace API key passes through ungated — it has no user and so no group, and its `rateLimit.userId` is the key's *creator*, a bystander whose group must not govern every caller of a shared credential. And no execution route is gated: `/api/v1` contains none, and the deployment routes gate `deploy.api` exactly as `workflows.deploy` and `workflows.versions.activate` already do, which changes who may deploy without touching a workflow already running. * refactor(permission-groups): keep the authorization funnel's module graph light `capability-assertions.ts` and `config-scope.server.ts` sit under `@/lib/core/application`, which ~24 domain `operations.ts` modules import, and both reached into `ee/access-control/utils/permission-check.ts`. That module also holds the model, block and tool gates, so every authorization decision transitively loaded the provider registry, the block registry and the billing barrel — and through them `lib/workflows/**` and `lib/uploads/**`. Nothing reported the edge. The only symptom was two knowledge use-case suites failing on a partial mock of `@/lib/uploads/utils/validation`, a module they never meant to load. Move the resolution layer — `mergeEnvAllowlist`, group resolution, and the config readers — into `lib/permission-groups/resolve.server.ts`, where the funnel can reach it without the gates. `permission-check.ts` re-exports them, so the surfaces that read every validator from one module are unchanged, and its enterprise-plan check, `isHosted`/`isAccessControlEnabled` short-circuit and env-allowlist merge keep their exact semantics and ordering. This also breaks the import cycle the two modules had formed. `check:application-graph` fails the build if the edge returns, walking runtime `import`/`export … from` specifiers from the funnel's roots. It joins `check:audits` by living in the `check:*` namespace. * refactor(permission-groups): keep the universal route wrapper's graph light `withRouteHandler` wraps every API route in the app, so anything it reaches at runtime is loaded by every route and every route test. It imported `withPermissionGroupScope` from `config-scope.server.ts`, which imports the resolver at module scope, which reaches `lib/billing/types` — so every route eagerly loaded the billing graph to get an AsyncLocalStorage wrapper it may never use. The visible symptom was `app/api/chat/[identifier]/otp/route.test.ts` failing with `z.coerce.number is not a function` on its own partial `zod` mock. Splits the import-free scope wrapper into `request-scope.server.ts`, leaving `config-scope.server.ts` as the resolver the gate call sites already import heavy things to use. The guarded require of `node:async_hooks`, the React `cache()` fallback outside a scope, promise-caching, negative caching, and the `userId:workspaceId` key semantics are unchanged. Extends `check-application-graph.ts` so the roots each carry their own forbidden list: the route wrapper is now a guarded root, and additionally may not reach `lib/billing/`, the permission-group resolver, auth, copilot or knowledge. Those stay allowed for the funnel roots, where `resolve.server.ts` legitimately reads a subscription to decide enterprise gating. * fix(permission-groups): keep a workspace API key out of its creator's group `checkAccess` gained a `tables.use` gate for the raw `/api/table/**` routes, which authenticate with `checkSessionOrInternalAuth` and so only ever serve a real person. `/api/v1/tables/[tableId]/**` shares the same helper and passes `rateLimit.userId`, which for a WORKSPACE key is the key's creator — so a shared credential started failing whenever the bystander who minted it sat in a group that withholds Tables. `checkAccess` now takes a required `TableAccessPrincipal` discriminated union instead of a bare user id. A caller cannot reach the gated behavior by passing a string, and the only way to skip the gate is to name the `workspace_api_key` kind explicitly. v1 builds the principal through `tableAccessPrincipal`, which reads `keyType` through the one shared `capabilityGovernedUserId` helper that `resolveCapabilityRefusal` now uses too. The internal routes keep the gate. Separately, `logs.trace_spans` and `logs.cost` are projections rather than gates, so the v1 logs routes correctly declare `capability: 'none'` and still have to withhold the fields themselves — which they did not, handing a governed member trace spans, block payloads, the final output and every cost figure via `?details=full&includeTraceSpans=true`. The flags and the field-stripping now live in `lib/logs/log-projection.ts`, which `readLogDetail`'s caller resolves through as well, so there is one copy of the redaction rule. * fix(permission-groups): make the key-creator substitution structurally impossible in v1 The sweep across every remaining /api/v1 domain (knowledge, files, workflows, logs, audit-logs, copilot, tables) found no route deciding a permission-group capability from an API key's creator: every capability decision already funnels through `capabilityGovernedUserId`, either via `validateWorkspaceAccess` / `resolveKnowledgeBase` / `resolveV1DeploymentWorkflow`, via `tableAccessPrincipal`, or directly at the three log-projection call sites. What was left was the shape the bug takes rather than the bug. `resolveCapabilityRefusal` guarded on `capabilityGovernedUserId(rateLimit)` and then asserted against a *different* variable — the `userId` the role check uses. The two agreed, but nothing made them agree. It now takes no caller-supplied id at all and asserts against the guard's own return value, and `resolveWorkspaceScope`'s `personal_api_key.use` check reads the same helper instead of `rateLimit.userId`. `check:capability-subject` is what stops the next route. It asserts that no v1 file outside the middleware imports the permission-group modules, that every capability sink's subject argument came from `capabilityGovernedUserId`, and that it found at least one such call — so a refactor into a form it cannot read fails loudly instead of passing vacuously. It joins `check:audits` by name. Tests cover the two v1 capability paths the gate suite did not reach: the `personal_api_key.use` key-kind refusal and the `logs.cost` field projection, each asserting that a workspace key passes where its creator's group would deny and that a personal key is still refused. * docs(skills): realign add-permission-group-item with the refactored code types.ts is gone (fields.ts + constraints.ts), group resolution moved out of ee/ into resolve.server.ts, the request scope split in two, and assertOrganizationCapability was deleted. Adds the definition-time capability guard and why it exists given tsconfig excludes tests, the satisfies rule, the Array.isArray parser guard, the executor/Copilot principal split, the v1 and table-route capability subjects, the log-projection distinction, and the two new audits. * docs(permission-groups): state what the executor exemption actually does The exemption's rationale claimed a deployed workflow runs with the workspace's authority rather than any member's group. It does not. For an actorless run the actor falls back to the billing owner, so the block, tool and model gates resolve the payer's group — which is both a bypass (schedule a run to escape a denial) and a wrong denial (the payer's group narrows every unattended run). That predates this branch, but justifying the exemption with a premise that does not hold is our doing. * docs(skills): realign validate-permission-group-item with the refactored code Drops the stale standing finding that assertConnectorTypeAllowed writes its own refusal sentence (it calls refuseCapability now), repoints types.test.ts at fields.test.ts and the deleted assertOrganizationCapability at isOrganizationCapabilityWithheld, and adds the projection-vs-gate case, the subject audit for v1 and the table routes, the executor/Copilot split, the Array.isArray and satisfies guards, and the two new audits. * fix(permission-groups): enforce secrets.manage and integrations.manage where they were only claimed The workspace environment route is raw `withRouteHandler` and never reaches the `secrets.*` operations, so `hideSecretsTab` restricted nothing on the one route the Secrets UI actually calls. Gate GET/PUT/DELETE on `secrets.manage`, after the workspace role check so a non-member still learns only that the workspace is out of reach. `defineCredentialUserOperation` mints operations without calling `defineWorkspaceOperation`, so neither that builder's definition-time guard nor `check:permission-group-enforcement` ever read them, and all five shipped with no capability at all. Give the interface a required `capability`, name `integrations.manage` on each, and apply it in `defineAuthorizedCredentialUserUseCase` — org-scoped, since a user's own OAuth connections belong to no workspace. * test(permission-groups): pin the secrets.manage gate on the environment route Projects the refusal off the canonical PermissionGroupCapabilityError rather than rebuilding the sentence, so the message and the PERMISSION_GROUP_CAPABILITY_BLOCKED detail code match every other surface. Without the gate the read and the delete both answer 200 — the read hands back every stored workspace secret the tab would show. * fix(permission-groups): gate the CLI key mint on api_keys.manage The CLI handoff minted a workspace API key with no capability check. An admin denied `api_keys.manage` was refused by `/api/workspaces/[id]/api-keys` and got the identical key from `sim login --workspace`. That is worse than one missing gate. A `workspace_api_key` principal resolves no user and therefore no permission group, so the authorization funnel's capability gate never applies to it. That pass-through is deliberate — substituting the key's creator would apply a bystander's group to every caller of a shared key — and its entire safety argument, stated in `workspace-authorization.ts`, `app/api/table/utils.ts` and `app/api/v1/middleware.ts`, is that minting such a key is itself capability-gated. With the terminal ungated, a governed member could mint a key that escaped every capability their group withheld. Gated at approve, not poll. The poll is unauthenticated by necessity, so it has no session to check anything against; approve is the only moment a human is present. The approval record is already the sole carrier of the decision — the poll body is a request id and a secret, and cannot assert a scope, a workspace, or a binding — so a refusal writes no record and a poll driven directly answers `pending` forever. Resolved from the key's own scope: a bound key belongs to its workspace, an unbound personal key belongs to none and falls back to the organization's default group, matching `/api/users/me/api-keys`. Runs after the role check, so a non-admin still learns nothing about the group. The copilot scope mints from a separate key space the API-keys surface does not manage, so `cli.use` remains its whole gate. Also makes two admin hints honest. `disableWorkspaceCreation` and `disableCliAccess` gate actions that name no workspace, so both are read from the organization's default group and neither can be denied by a group scoped to specific workspaces — correct, since a member may be governed by different groups in different workspaces and there is no one scoped group to pick. The editor offers both checkboxes on such a group regardless, and the hints said nothing about it. * test(permission-groups): pin the integrations.manage gate on current-user credential operations Without the gate all three routes answer 200: a member whose group revokes Integrations can enumerate every OAuth connection and disconnect any of them. * fix(permission-groups): close the cost oracle and two capability-before-role inversions `logs.cost` is a projection: the field is blanked, the row still comes back. That is only a withholding if the query surface cannot select on the number either. It could — `sortBy=cost` and `costOperator`/`costValue` reach the same indexed `cost_total` column on the first-party list, the dashboard stats read, and the CSV export, so a member whose group hides spend recovered every run's cost by bisecting `cost > X` and reading which rows (or how many, with `includeTotal`) came back. `assertLogCostQueryAllowed` in `log-projection.ts` refuses those queries rather than dropping the clause: a list of every run under a `cost > 5` chip, in an order nobody asked for, is a wrong answer presented as the right one, and the refusal discloses nothing — the workspace role check has already passed, so the caller is a member being told about their own group. `logs.trace_spans` needs no counterpart; nothing it withholds is filterable or sortable on any log surface. Two capability checks also ran ahead of their role check, inverting the concealment ordering the rest of the branch keeps. The funnel asserted the group's `personal_api_key.use` before resolving the caller's workspace permission, and `prepareWorkspaceInvitationContext` asserted `invitations.send` before `hasWorkspaceAdminAccess`. Both now follow the role check, so a caller with no reach into the workspace gets the concealed refusal instead of a 403 naming how the organization configured a cohort. The workspace-level `allowPersonalApiKeys` column keeps its fail-fast: it is a property of the workspace, not of a group. Three tests could not fail without the gate they named. The `logs.export` 403 fired in no test at all; the MCP operation refusals selected their subjects with `operation.capability === x`, a filter over the field under test, so dropping the capability from `mcp_servers.create` just removed it from the filter and left the suite green. Both are pinned now, along with the new refusals and a first test file for the stats route. * fix(permission-groups): make an operation the audit cannot read a failure `check:permission-group-enforcement` found operations by scanning for `defineWorkspaceOperation(` call sites, so a domain that minted operations through a builder of its own …
1 parent 358af42 commit d7b31a8

458 files changed

Lines changed: 67639 additions & 2652 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/add-permission-group-item/SKILL.md

Lines changed: 274 additions & 0 deletions
Large diffs are not rendered by default.

.agents/skills/validate-permission-group-item/SKILL.md

Lines changed: 157 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.agents/skills/add-permission-group-item
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.agents/skills/validate-permission-group-item

apps/docs/openapi-v2-billing.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@
452452
"description": "Human-readable explanation of the error."
453453
},
454454
"details": {
455-
"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."
455+
"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."
456456
}
457457
},
458458
"required": ["code", "message"],

apps/docs/openapi-v2-files-audit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2793,7 +2793,7 @@
27932793
"description": "Human-readable explanation of the error."
27942794
},
27952795
"details": {
2796-
"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."
2796+
"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."
27972797
}
27982798
},
27992799
"required": ["code", "message"],

apps/docs/openapi-v2-knowledge.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4499,7 +4499,7 @@
44994499
"description": "Human-readable explanation of the error."
45004500
},
45014501
"details": {
4502-
"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."
4502+
"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."
45034503
}
45044504
},
45054505
"required": ["code", "message"],

0 commit comments

Comments
 (0)