Commit d7b31a8
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
- .agents/skills
- add-permission-group-item
- validate-permission-group-item
- .claude/skills
- apps
- docs
- sim
- app
- api
- auth/oauth
- connections
- credentials
- disconnect
- chat
- manage/[id]
- cli/auth
- approve
- poll
- copilot
- checkpoints/revert
- tool-permission
- invitations/[id]/resend
- logs
- export
- stats
- mcp
- oauth/start
- servers
- [id]
- refresh
- test-connection
- tools
- discover
- stored
- workflow-servers
- [id]
- tools
- [toolId]
- organizations/[id]
- members
- permission-groups
- [groupId]
- members
- bulk
- roster
- selectors/execute
- superuser/import-workflow
- table
- [tableId]
- cancel-runs
- columns
- run
- delete-async
- dispatches
- events/stream
- export-async
- export
- download
- import-async
- import
- job/cancel
- metadata
- restore
- rows/find
- views
- [viewId]
- import-async
- import-csv
- jobs
- users/me/api-keys
- v1
- admin
- workflows/import
- workspaces/[id]/import
- audit-logs
- [id]
- copilot/chat
- files
- [fileId]
- knowledge
- [id]
- documents
- [documentId]
- search
- logs
- [id]
- executions/[executionId]
- tables
- [tableId]
- columns
- rows
- [rowId]
- upsert
- workflows
- [id]
- deploy
- export
- rollback
- import
- v2
- chat-deployments
- chat
- workflows/[workflowId]/deployments/chat
- webhooks
- [id]
- workflows/[id]/executions/[executionId]
- workspaces
- [id]
- api-keys
- [keyId]
- environment
- inbox
- senders
- tasks
- workspace
- [workspaceId]/settings/components/api-keys
- components/create-api-key-modal
- background
- ee
- access-control
- components
- hooks
- utils
- workspace-forking/lib
- copy
- promote
- enrichments
- executor
- execution
- handlers/agent
- hooks
- lib
- api-key/application
- api
- application
- contracts
- v1
- audit-logs/application
- auth
- sso/application
- billing
- application
- organization-billing-summary
- organization-usage
- core
- catalog/application
- chat-deployments/application
- copilot
- application
- async-runs
- chat
- request
- context
- go
- handlers
- lifecycle
- tools
- tools
- handlers
- deployment
- server
- blocks
- enrichment
- user
- vfs
- core
- application
- utils
- credential-groups/application
- credentials/application
- custom-tools/application
- data-drains
- function-execution/application
- integrations
- internal/enrichment
- invitations
- knowledge/application
- logs
- application
- execution
- mcp
- application
- memory/application
- permission-groups
- platform-context/application
- secrets/application
- selectors
- application
- server
- providers
- skills/application
- table
- application
- orchestration
- rows
- workflow-groups
- users
- application
- workflows
- application
- editing
- executor
- operations
- orchestration
- persistence
- workspace-files/application
- workspaces
- application
- tools
- packages
- db
- migrations
- meta
- testing/src/mocks
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
452 | 452 | | |
453 | 453 | | |
454 | 454 | | |
455 | | - | |
| 455 | + | |
456 | 456 | | |
457 | 457 | | |
458 | 458 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2793 | 2793 | | |
2794 | 2794 | | |
2795 | 2795 | | |
2796 | | - | |
| 2796 | + | |
2797 | 2797 | | |
2798 | 2798 | | |
2799 | 2799 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4499 | 4499 | | |
4500 | 4500 | | |
4501 | 4501 | | |
4502 | | - | |
| 4502 | + | |
4503 | 4503 | | |
4504 | 4504 | | |
4505 | 4505 | | |
| |||
0 commit comments