(reported via email on 25 May 2026 but did not get any response.)
I am reporting a missing-authorization vulnerability in the team.delete tRPC mutation. A regular team member (default role for any invited user) can irreversibly delete the entire team, including all financial records, bank connections (with the access tokens used by the downstream cleanup job), invoices, documents, and OAuth integrations. The team owner cannot undo the deletion.
Affected: current main (commit e5f45ed, 2026-05-07).
Root cause. apps/api/src/trpc/routers/team.ts at lines 148-214. team.delete calls hasTeamAccess(db, input.teamId, session.user.id), which returns true for any user in usersOnTeam regardless of role. The destructive sibling endpoints in the same router (team.deleteMember at line 216, team.updateMember at line 266) correctly require callerRole === "owner". The data-layer function deleteTeam at packages/db/src/queries/teams.ts:388 only re-checks hasTeamAccess.
Reproduction. With Alice as owner of team T and Bob accepted as a regular member of T, Bob calls:
POST /trpc/team.delete HTTP/1.1
Cookie:
Content-Type: application/json
{"0":{"json":{"teamId":"<TEAM_ID_OF_T>"}}}
The team is deleted; the delete-team cleanup job is enqueued with Alice's stored bank-connection access tokens, which it then uses to revoke / wipe state at Plaid / GoCardless / Teller.
Impact. CVSS 3.1 AV:N AC:L PR:L UI:N S:U C:N I:H A:H = 8.1 High. Member is the default role for any invitee (accountant, contractor, employee). The deletion cascades to every team-scoped table and is irreversible.
Adjacent issues in the same router worth fixing in the same PR:
- team.update (line 53) has no role check either; any member can change name, email, baseCurrency, fiscalYearStartMonth, exportSettings.accountantEmail. The last one redirects future automated export emails.
- apiKeys.upsert (apps/api/src/trpc/routers/api-keys.ts:18) and oauthApplications.{create,update,delete,regenerateSecret} (apps/api/src/trpc/routers/oauth-applications.ts) are also member-accessible with no role check.
Suggested fix. Add the same owner check the deleteMember/updateMember handlers already use:
const callerRole = await getTeamMemberRole(db, input.teamId, session.user.id);
if (callerRole !== "owner") {
throw new TRPCError({ code: "FORBIDDEN", message: "Only team owners can delete a team" });
}
Validation. Exercised end-to-end against PostgreSQL 16. Two independent reproductions:
-
SQL trace of the exact queries that team.delete and deleteTeam issue. With Alice as owner and Bob as member of the same team, hasTeamAccess returns true for Bob, then DELETE FROM teams WHERE id = <alice's team> RETURNING id deletes the team. Alice's team is gone.
-
Drizzle ORM execution. Copied hasTeamAccess and deleteTeam verbatim from packages/db/src/queries/teams.ts into a standalone Node script. deleteTeam(db, {userId: , teamId: <alice's team>}) returns {id: <alice's team>, memberUserIds: [, ]}. That return value is the exact payload the tRPC procedure passes to triggerJob("delete-team", ...) which uses each bank-connection accessToken to wipe upstream Plaid/GoCardless/Teller state.
The full Supabase + workers + Next.js stack was not stood up because the bug is at the data layer and does not depend on any of those components. team.delete reads input.teamId from the request body and session.user.id from the JWT, then passes both directly to deleteTeam without any intermediate logic. deleteTeam is what I validated.
(reported via email on 25 May 2026 but did not get any response.)
I am reporting a missing-authorization vulnerability in the team.delete tRPC mutation. A regular team member (default role for any invited user) can irreversibly delete the entire team, including all financial records, bank connections (with the access tokens used by the downstream cleanup job), invoices, documents, and OAuth integrations. The team owner cannot undo the deletion.
Affected: current main (commit e5f45ed, 2026-05-07).
Root cause. apps/api/src/trpc/routers/team.ts at lines 148-214. team.delete calls hasTeamAccess(db, input.teamId, session.user.id), which returns true for any user in usersOnTeam regardless of role. The destructive sibling endpoints in the same router (team.deleteMember at line 216, team.updateMember at line 266) correctly require callerRole === "owner". The data-layer function deleteTeam at packages/db/src/queries/teams.ts:388 only re-checks hasTeamAccess.
Reproduction. With Alice as owner of team T and Bob accepted as a regular member of T, Bob calls:
POST /trpc/team.delete HTTP/1.1
Cookie:
Content-Type: application/json
{"0":{"json":{"teamId":"<TEAM_ID_OF_T>"}}}
The team is deleted; the delete-team cleanup job is enqueued with Alice's stored bank-connection access tokens, which it then uses to revoke / wipe state at Plaid / GoCardless / Teller.
Impact. CVSS 3.1 AV:N AC:L PR:L UI:N S:U C:N I:H A:H = 8.1 High. Member is the default role for any invitee (accountant, contractor, employee). The deletion cascades to every team-scoped table and is irreversible.
Adjacent issues in the same router worth fixing in the same PR:
Suggested fix. Add the same owner check the deleteMember/updateMember handlers already use:
const callerRole = await getTeamMemberRole(db, input.teamId, session.user.id);
if (callerRole !== "owner") {
throw new TRPCError({ code: "FORBIDDEN", message: "Only team owners can delete a team" });
}
Validation. Exercised end-to-end against PostgreSQL 16. Two independent reproductions:
SQL trace of the exact queries that team.delete and deleteTeam issue. With Alice as owner and Bob as member of the same team, hasTeamAccess returns true for Bob, then DELETE FROM teams WHERE id = <alice's team> RETURNING id deletes the team. Alice's team is gone.
Drizzle ORM execution. Copied hasTeamAccess and deleteTeam verbatim from packages/db/src/queries/teams.ts into a standalone Node script. deleteTeam(db, {userId: , teamId: <alice's team>}) returns {id: <alice's team>, memberUserIds: [, ]}. That return value is the exact payload the tRPC procedure passes to triggerJob("delete-team", ...) which uses each bank-connection accessToken to wipe upstream Plaid/GoCardless/Teller state.
The full Supabase + workers + Next.js stack was not stood up because the bug is at the data layer and does not depend on any of those components. team.delete reads input.teamId from the request body and session.user.id from the JWT, then passes both directly to deleteTeam without any intermediate logic. deleteTeam is what I validated.