Files
twenty/packages/twenty-apps/internal/twenty-partners/package.json
T
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

48 lines
1.4 KiB
JSON

{
"name": "twenty-partners",
"version": "1.6.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.23.0"
},
"keywords": [],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"rls:configure": "tsx src/scripts/configure-partner-rls.ts",
"rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts"
},
"dependencies": {
"react-markdown": "^10.1.0",
"twenty-client-sdk": "2.23.0-alpha.2",
"twenty-sdk": "2.23.0-alpha.2",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19",
"dotenv": "^16.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsx": "^4.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}