Files
twenty/packages
Rashad Karanouh 0f8c227105 v1.6.1 — fix(partners): scope Application RLS to the partner's own partnerUser (#23597)
**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.
2026-07-31 15:35:21 +00:00
..