Security Report: Missing RBAC on Payment Endpoints
Summary
All 3 payment API endpoints check team membership (throwIfNoTeamAccess) but skip RBAC permission enforcement (throwIfNotAllowed), unlike every other team-scoped endpoint.
Affected Endpoints
POST /api/teams/[slug]/payments/create-checkout-session
POST /api/teams/[slug]/payments/create-portal-link
GET /api/teams/[slug]/payments/products
Root Cause (1-of-N Inconsistency)
Every other team-scoped endpoint (SSO, DSYNC, API keys, webhooks, invitations, members, team CRUD) consistently calls both:
throwIfNoTeamAccess(req, res) — verifies team membership
throwIfNotAllowed(teamMember, 'team_payments', ...) — enforces RBAC permissions
The payment routes only call the first, skipping RBAC. The RBAC configuration in lib/permissions.ts defines team_payments as an OWNER-only resource.
Example
Correct pattern (from pages/api/teams/[slug]/api-keys/index.ts):
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_api_key', 'read');
Payment endpoint (from pages/api/teams/[slug]/payments/create-checkout-session.ts):
const teamMember = await throwIfNoTeamAccess(req, res);
// Missing: throwIfNotAllowed(teamMember, 'team_payments', 'create');
Impact
Any MEMBER or ADMIN of a team (not just OWNER) can:
- Create Stripe checkout sessions (change subscription plan)
- Generate Stripe billing portal links (view/modify payment methods, invoices)
- View subscription and product data
The UI correctly hides the billing tab via client-side canAccess('team_payments', ...) checks, but direct API calls bypass this.
Recommended Fix
Add throwIfNotAllowed(teamMember, 'team_payments', 'create'|'read') to each payment endpoint handler, matching the pattern used by all other team resources.
Environment
- Version: Latest (commit at time of audit)
- Identified via: Static code analysis
Note
The rest of the auth/authz implementation is excellent — consistent RBAC enforcement, proper team membership validation, Zod schema validation, and privilege escalation protection via validateMembershipOperation.
Security Report: Missing RBAC on Payment Endpoints
Summary
All 3 payment API endpoints check team membership (
throwIfNoTeamAccess) but skip RBAC permission enforcement (throwIfNotAllowed), unlike every other team-scoped endpoint.Affected Endpoints
POST /api/teams/[slug]/payments/create-checkout-sessionPOST /api/teams/[slug]/payments/create-portal-linkGET /api/teams/[slug]/payments/productsRoot Cause (1-of-N Inconsistency)
Every other team-scoped endpoint (SSO, DSYNC, API keys, webhooks, invitations, members, team CRUD) consistently calls both:
throwIfNoTeamAccess(req, res)— verifies team membershipthrowIfNotAllowed(teamMember, 'team_payments', ...)— enforces RBAC permissionsThe payment routes only call the first, skipping RBAC. The RBAC configuration in
lib/permissions.tsdefinesteam_paymentsas an OWNER-only resource.Example
Correct pattern (from
pages/api/teams/[slug]/api-keys/index.ts):Payment endpoint (from
pages/api/teams/[slug]/payments/create-checkout-session.ts):Impact
Any MEMBER or ADMIN of a team (not just OWNER) can:
The UI correctly hides the billing tab via client-side
canAccess('team_payments', ...)checks, but direct API calls bypass this.Recommended Fix
Add
throwIfNotAllowed(teamMember, 'team_payments', 'create'|'read')to each payment endpoint handler, matching the pattern used by all other team resources.Environment
Note
The rest of the auth/authz implementation is excellent — consistent RBAC enforcement, proper team membership validation, Zod schema validation, and privilege escalation protection via
validateMembershipOperation.