# Customer Portal Access Control

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 82  
**Tier:** All tiers  
**Depends on:** `tenant-portals`, `invoices-core`, `projects-module`, `customers-module`, `foundation-auth-rbac`  
**Referenced by:** `tenant-portals`

---

## Overview

Defines the exact data isolation rules for the customer portal (spec 26). Spec 26 created the portal auth layer and page structure but left per-customer data scoping underspecified. This spec defines: which entities each portal user can access, the JWT claims that encode that scope, the query filter applied on every portal API endpoint, and the staff-side configuration for what portal users can see.

---

## Portal JWT Claims

When a portal user authenticates (magic link or email/password), the session JWT issued by `POST /portal/auth/session` includes:

```json
{
  "sub": "portal_user_uuid",
  "customerId": "customer_uuid",
  "tenantId": "tenant_uuid",
  "portalRole": "client",
  "iat": 1234567890,
  "exp": 1234567890
}
```

All portal API handlers extract `customerId` from JWT claims — never from URL params or request body.

---

## Access Rules by Entity

| Entity | Portal can see | Portal cannot see |
|--------|---------------|-------------------|
| Invoices | Own customer's invoices with `status NOT IN ('DRAFT')` | Draft invoices, other customers' invoices |
| Projects | Own customer's projects | Internal projects (no `customer_id`), other customers' |
| Tickets | Own customer's tickets | Other customers' tickets |
| Contracts | Contracts where `customer_id` matches | Other customers' contracts |
| Proposals | Proposals where `customer_id` matches + `status != 'DRAFT'` | Draft proposals, other customers' |
| Time entries | `billable = true` time for own customer's projects | Non-billable, internal time, other customers' |
| Expenses | None (never exposed to portal) | — |
| Team members | None | — |

---

## Query Filter

Every portal API route applies a tenant + customer scope filter via `portalQuery(db, tenantId, customerId)` — analogous to `tenantQuery(db, tenantId)` for staff routes:

```ts
export function portalQuery(db: DrizzleDB, tenantId: string, customerId: string) {
  return {
    filter: <T extends { tenant_id: string; customer_id: string }>(table: T) => ({
      tenant_id: tenantId,
      customer_id: customerId,
    }),
  }
}
```

No portal route may use `tenantQuery` (staff factory). Portal routes live under `apps/zync-api/src/server/portal/` and are separated from staff routes by directory + auth middleware.

---

## Staff-Side Portal Visibility Config

Tenants can restrict which sections portal users see. Per-tenant config in `tenant_settings.portal_visibility`:

```json
{
  "show_invoices": true,
  "show_projects": true,
  "show_tickets": true,
  "show_contracts": false,
  "show_proposals": false,
  "show_files": false,
  "show_time_summary": false
}
```

Config UI at `/settings/portal` (spec 26 extension):

```
┌────────────────────────────────────────────────────────────┐
│  Portal Visibility                                         │
│                                                            │
│  ☑ Invoices          — customers see their own invoices   │
│  ☑ Projects          — project status and description     │
│  ☑ Support tickets   — submit and track tickets           │
│  ☐ Contracts         — signed contracts (PDF download)    │
│  ☐ Proposals         — sent proposals                     │
│  ☐ Files             — shared documents and deliverables  │
│  ☐ Time summary      — billable hours by period           │
└────────────────────────────────────────────────────────────┘
```

---

## Portal User Permissions

Portal users are **read-only** on all entities except tickets (can create + add messages) and their own profile. They cannot:
- Edit invoices, approve/reject invoices
- Edit project details or task assignments
- View or download other customers' data
- Access any staff-facing endpoints

---

## Invoice Download in Portal

Portal users can download PDF for invoices they can see: `GET /portal/api/invoices/:id/pdf`. Auth middleware confirms `invoice.customer_id == JWT.customerId` before streaming.

---

## API (portal-scoped routes)

```
GET /portal/api/invoices
    → list invoices for JWT customer
      query: status?, from?, to?, cursor

GET /portal/api/invoices/:id
    → invoice detail + line items

GET /portal/api/invoices/:id/pdf
    → PDF download (R2 signed URL)

GET /portal/api/projects
    → list projects for JWT customer

GET /portal/api/projects/:id
    → project detail (name, status, description, milestones only)

GET /portal/api/tickets
    → list + create tickets

GET /portal/api/contracts/:id
    → contract detail + PDF (if show_contracts enabled)
```

All portal API routes require valid portal JWT (`portalAuthMiddleware`). Attempting to access staff endpoints with a portal JWT returns 403.

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `customerId` from JWT only | Not from URL params | URL params are user-controlled; JWT claim is server-issued and tamper-proof |
| `portalQuery` factory | Not ad-hoc WHERE clauses | Enforces consistent scoping; a route that forgets the filter is a compile-time error (typed factory) |
| Expenses never exposed | Not optional | Expenses contain vendor/employee data; customer has no business need for tenant expense records |
| Per-tenant visibility config | Not hardcoded | Different tenants have different relationships with clients; some want full transparency, others minimal |
