0f8c227105
**Version:** twenty-partners `1.6.1` (`on-application-created` gains behaviour; no schema change). ## The bug On https://partners.twenty.com every partner could list **all** applications, not just their own. The Partner role's row-level predicate on `Application` was: ``` (partnerUser IS the current member) OR (lastActivityAt IS EMPTY) ``` The `IS EMPTY` branch was an insert escape hatch: the Apply workflow creates the row before `on-application-created` can stamp it, and RLS validates an insert against the row as submitted. Only that self-apply path ever writes `lastActivityAt` (`resolve-candidacy.service.ts`). Every other creation — admin invite, TFT import, seed — returned early, so `lastActivityAt` stayed `null` forever and the hatch never closed. All 7 applications in production had `lastActivityAt = null`, which made the whole table readable by every partner. ## The fix 1. **Drop the OR group.** `application` becomes a plain `partnerUser IS the current member` predicate, like the other partner-scoped objects. The upsert reconciles per (role, object), so the stale group and predicate are soft-deleted on re-run — a leaking workspace self-heals. 2. **Keep admin invites visible.** The OR branch was also the only reason an admin-created invite reached its recipient. `resolve-candidacy` now stamps `partnerUser` from the partner on the admin path, instead of returning early. 3. **Backfill the rows created before the narrowing.** Neither writer covers an application an admin created for an already-linked partner; those existed only behind the leak. `stampPartnerUserFromPartner` now covers `application` (its three copy-pasted branches collapsed into an accessor map routed through `shared/graphql/`), and the walk runs from the app's post-install logic function, gated on `previousVersion < 1.6.1`. No manual step. 4. **Preserve the insert path.** The Apply workflow must map exactly Opportunity + Partner User. `partnerUser` is writable at insert only because the server exempts RLS predicate fields there (`permissions.utils.ts`, insert case only); every other Application field is locked, so mapping `State` fails the insert — and `state` already defaults to `APPLIED`. ## Order of operations, per workspace 1. Publish the Apply workflow with the Partner User mapping (edit it if it already exists). 2. `yarn rls:configure` (`:prod`). `app:install` stamps the pre-existing rows before step 2 runs, so no window exists where a partner reads nothing. The script prints these steps before and after its writes, because the deploy path never opens the runbook. ## Verification (local bundle, real Partner-role account) | Case | Result | |---|---| | Another partner's application | not visible | | Own application (`lastActivityAt` null) | visible | | Admin invite created with `partnerUser` null | stamped from the partner within seconds | | All applications stripped of `partnerUser`, then upgraded from 1.6.0 | post-install returns `{ stamped: 3 }`; the 4th belongs to a partner with no member | | Upgrade from 1.6.1 | post-install returns `{ skipped: true }` | | Insert without `partnerUser` | rejected — *Record does not satisfy row-level security constraints of your current role* | | Insert with `partnerUser` = self | accepted | | Insert with `state` mapped | rejected — *no permission to write field "state"* | Lint 0, typecheck 0, 221 unit tests. ## Production notes - The fix lands on prod by running `yarn rls:configure:prod`. Installing this version alone does not narrow the predicate. - The 7 production applications were already backfilled by hand; the post-install hook makes that reproducible for any other workspace. ## Out of scope - Creating an OR predicate group fails on server 2.23.2 in a fresh workspace (`Migration action 'create' for 'rowLevelPermissionPredicateGroup' failed`). Pre-existing and unrelated; production is unaffected because its `opportunity` group already exists. It does block `rls:configure` on newly provisioned local bundles. - Partners still see the `Matching Admin Workspace` navigation folder. Navigation menu items cannot be scoped by role in the SDK; the views are row-filtered. - `configure-partner-rls.ts` should not exist. #21919 made `rowLevelPermissionPredicates` declarable on the role manifest, and the SDK we depend on already ships it, so the predicates belong in `partner.role.ts`. The predicates on the workspace today were written through the metadata API and are not app-owned, so adopting them needs its own migration and test pass. Follow-up. - Deferred cleanups are listed in the thermo review comments below.
Twenty Partners
Turns the CRM into the operating system for the Twenty partner program: intake partner-eligible deals, match them to vetted marketplace partners, and track the matching pipeline end-to-end.
What's inside
- Partner object — slug, status, availability, served geographies, languages spoken, deployment expertise, Calendly link, and last-match timestamp.
- Opportunity extensions — match status, design-doc status, intro and relance timestamps, and a relation to the matched partner.
- Automatic matching — when an opportunity is set to auto-match, the longest-idle available partner is assigned and the deal is marked matched; if none is available it is handed off for manual matching with an explanatory note.
- Views — a waiting-for-match queue, a matching-funnel overview grouped by status, the partner index, and a log of matched deals, all surfaced in the sidebar.
Match status pipeline
matchStatus follows the deal lifecycle:
| Status | Meaning |
|---|---|
TO_BE_MATCHED |
Default — deal entered, awaiting assignment |
MANUAL_MATCH |
Needs a human to pick a partner |
AUTO_MATCH |
Triggers automatic partner assignment |
MATCHED |
Partner assigned |
INTRODUCED_TO_A_PARTNER |
Customer intro sent |
WORKING_WITH_A_PARTNER |
Engagement underway |
IMPLEMENTING |
Active implementation |
WON |
Deal closed won |
RECONNECT_LATER |
Paused — reconnect in future |
LOST |
Deal closed lost |