## Why `runAgent()` always runs with the agent's own role, so an app has no way to scope a run to the person who triggered it. Chat-style apps (Slack, Discord, Teams) need the opposite: the agent should never be able to do more than the member who asked. This is the server/SDK prerequisite for per-user permissions in the Slack app (#22984). It is self-contained and reviewable without any Slack context. > **Scope note.** Review surfaced two authorization problems adjacent to this code that are not part of the original feature — a cross-app agent hole (#21157) and two fail-open branches in application-token auth. Both are fixed here rather than deferred, since they sit directly on the path this PR changes. They are called out separately below so they can be reviewed on their own terms. ## The feature - **`runAsWorkspaceMemberId` (optional) on `RunAgentInput`** — shared type, DTO, and the generated GraphQL artifacts. - **`AgentActorContextService.buildRunAsWorkspaceMemberContext`** resolves member → userWorkspace → role and returns an actor context, a *user* auth context, and the role id. Mirrors what `WorkflowExecutionContextService` already does for acting on behalf of a user. - **`AgentRunService`** swaps the application auth context for the member's, passes their actor context, and attributes AI credit usage to them. - **`buildAgentRolePermissionConfig`** (new util) returns `intersectionOf: [agentRoleId, runAsRoleId]`, agent role first — explicit object grants in `database-tool.provider` resolve against the first role, so it defines which objects are in scope at all and later roles only narrow permissions on them. Collapses to a single entry when the member already holds the agent role, because the permission-flag checks reject an intersection listing the same role twice. - **`ToolContext`** gains an optional `rolePermissionConfig`. The lazy tool path resolved permissions from a single `roleId`, so without this the narrowing would not reach the tool catalog or call-time `execute_tool` — and lazy is the strategy `runAgent` uses. Falls back to the previous `unionOf: [roleId]` default when absent. - Docs: a "Running on behalf of a workspace member" section in `skills-and-agents.mdx`. Omitting the field preserves today's behavior exactly, which is what autonomous runs (scheduled jobs, database-event triggers) need. **Fails closed:** an unresolvable member errors rather than falling back to the agent role, which would grant more than the caller asked for. ## Who may name a member `runAsWorkspaceMemberId` names another person, so it needs an authorization rule of its own. An application token is not sufficient on its own: `frontComponent(id)` is guarded by `UserAuthGuard, NoPermissionGuard` and mints an `APPLICATION_ACCESS` token pair for the requesting user, so any authenticated user can obtain one for an installed app. Those tokens record who they were minted for, and the caller cannot strip that. The rule keys on that binding: | Token | May name | | --- | --- | | No application token | nothing — rejected | | Application token **with** a user binding | only that user's own member | | Application token with **no** user binding | any member | The third row is unattended app code — a database-event-triggered logic function is the Slack worker's path, and `client_credentials` or API-key-minted tokens land here too. ## Adjacent fixes **Cross-app agents** (pre-existing, #21157). The agent lookup was not scoped to the caller, so any app token could run any agent in the workspace, including one belonging to an app with wider permissions — while `skills-and-agents.mdx` promised an app can only run its own. Now rejected with `RUN_AGENT_NOT_ALLOWED`. Guarded on `isDefined(callerApplication)`, so callers without an app token are unaffected; `twenty-front` never calls `runAgent`. **Two fail-open branches in `validateApplicationToken`.** Both populated the auth context conditionally instead of failing closed, and both are now asserted, making the application path structurally identical to `validateAccessToken`: 1. An unresolvable user left the token presenting as *unbound*, so removing someone from a workspace widened their live token instead of revoking it, until it expired. 2. A missing workspace member let the token carry on with the application's own permissions after that member was removed or deactivated. Both mirror `validateAccessToken`, down to its `PENDING_CREATION` / `ONGOING_CREATION` escape hatch. **Behaviour change beyond this PR:** an application token whose user has been removed now 401s where it previously degraded to app-only. That is the point, and it matches access-token semantics, but it is shared auth and worth a careful look. ## Known limitation If the app's own agent role declares row-level predicates, those are not applied in run-as mode, because the query builders resolve row-level rules from a single role via the auth context. The member's own row-level rules do apply, which is the direction that matters here. Multi-role row-level support does not exist anywhere in the codebase today. ## Tests | Check | Result | | --- | --- | | ai-agent-execution, tool-provider, record-crud, user-workspace, full auth tree | 79 suites, 666 passed | | `nx typecheck twenty-server` | clean | | oxlint + oxfmt on the changed server files | clean | Both auth regression tests were verified against the pre-fix code — each fails when the fix is reverted, so they guard the behaviour rather than passing incidentally. ## Note for reviewers Rebased onto `main`, then merged `main` in once more after #23395 landed. The `getObjectsPermissionsFromRolePermissionConfig` intersection fix this PR originally carried has since landed on main independently, and main's version is stricter — it denies when an intersected role is missing from the cache rather than treating it as empty — so this PR takes main's and no longer touches that file. `RunAgentInput` now composes with the `prompt` | `messages` XOR from #23395: `runAsWorkspaceMemberId` sits on the base object, so it is available to both variants.
The #1 Open-Source CRM
Website ·
Documentation ·
Roadmap ·
Discord ·
Figma
Why Twenty
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
Learn more about why we built Twenty
Installation
Cloud
The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
Build an app
Scaffold a new app with the Twenty CLI:
npx create-twenty-app my-app
Define objects, fields, and views as code:
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
nameSingular: 'deal',
namePlural: 'deals',
labelSingular: 'Deal',
labelPlural: 'Deals',
fields: [
{ name: 'name', label: 'Name', type: FieldType.TEXT },
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
],
});
Then ship it to your workspace:
npx twenty app:publish --private
See the app development guide for objects, views, agents, and logic functions.
Self-hosting
Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.
Everything you need
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the User Guide for product walkthroughs, or the
Documentation for developer reference.
|
|
|
|
|
|
Stack
TypeScript
Nx
NestJS, with BullMQ,
PostgreSQL,
Redis
React, with Jotai, Linaria and Lingui
Thanks
Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
Join the Community
Star the repo ·
Discord ·
Feature requests ·
Releases ·
X ·
LinkedIn ·
Crowdin ·
Contribute





