# Permissions & view-as — current-system spec (parity bar for the rebuild)

> This feature is the app's entire authorization surface: a tiny set of permission functions computed from an "effective user" (the real signed-in user, or a target user a super admin is impersonating), an RLS layer that scopes what non-owners can see ("Adi sees only what he uploaded"), and **view-as** — rian's high-fidelity impersonation mode ("exactly like logging in as that user"), implemented since 2026-06-09 as a database-level JWT swap (decisions.md ADR #043) with app-layer guards kept as defense-in-depth. The truth tables in this doc are the R1 parity contract per 02-domain-model.md §4, with one deliberate exception: `canManageTeam` is dead code (declared, never called) — its table is recorded for the record, NOT as the parity bar. For team management the parity bar is the EFFECTIVE enforcement: team owner OR org owner OR super admin (02-domain-model.md §4). Everything else the rebuild's named capabilities must reproduce exactly before any new semantics land.

## User workflows

- **Rian (owner + super admin) owns the money.** Only he can create/delete invoices, attach/detach entries, mark/unmark "transferred," see the `/transfer` page, see income & margin on Projects, see billout dollars on Summary and Team, and use the Tools area (CC expenses). He also gets the "Imported by" filter on Entries so he can view only Adi's uploads or all.
- **Adi (manager) runs the pipeline.** He uploads Clockify CSVs, resolves imports, manages team-member rows, and edits entries — but only entries from batches *he* uploaded, never locked (invoiced/transferred) rows, and never any income/billout number. Stated rule (2026-05-22): "For now... adi only sees data that he uploaded, and I see all data."
- **View-as is rian's debugging tool.** "I truly want it to be view as adi as if I logged in with his username and password. The only difference I should see is the little note saying I'm viewing as adi." He enters via a nav dropdown, sees an amber banner, uses the app with Adi's exact visibility and RLS errors, then exits. It exists because app-layer-only impersonation failed him twice (view-as showed all data 05-22; showed rian's team 05-23; failed to reproduce Adi's `projects` RLS import error 06-10 → JWT swap).

## Complete behavior

### Effective-user resolution (`lib/effective-user.ts`)

- **B1.** `EffectiveUser` type: `{ real_user_id, effective_user_id, email, full_name, is_super_admin, org_role, is_viewing_as, view_as_label }`. `org_role` is `'owner' | 'manager' | 'member' | null` (from `organization_members.role` in the given org; null = no membership). `view_as_label` = target's `full_name || email`, null when not viewing-as.
- **B2.** **`is_super_admin` on `EffectiveUser` is the EFFECTIVE user's flag, not the real user's.** While viewing-as a non-admin it is false. Every permission function compensates with the idiom `is_super_admin && !is_viewing_as` (see B13).
- **B3.** `getEffectiveUser(supabase, orgId)` reads the REAL user id by decoding the Supabase auth cookie directly (`readRealUserIdFromCookie`), NOT via `supabase.auth.getUser()` — because under view-as the client's JWT is swapped and `getUser()` returns the *impersonated* user. Mechanics: collect cookies matching `/^sb-.*-auth-token(\.[0-9]+)?$/`, sort by name (`localeCompare`), concatenate values, strip optional `base64-` prefix + base64-decode, `JSON.parse` → `access_token`, `jose.decodeJwt` (decode WITHOUT verifying) → `sub`. Any failure → null → treated as signed out.
- **B4.** If the `view_as_user_id` cookie is set AND differs from the real user id: load the target's profile with the (possibly impersonated) client. If it loads, return the TARGET's identity (`effective_user_id = target.id`, target's email/full_name/is_super_admin, target's `org_role` in `orgId`, `is_viewing_as: true`). If the target profile does NOT load (bogus id / no permission), **fall through silently to the real user** — no error.
- **B5.** Otherwise return the real user's profile + role with `effective_user_id === real_user_id`, `is_viewing_as: false`.
- **B6.** Profile loads use `profiles.select('id, email, full_name, is_super_admin')` `.maybeSingle()`; role loads use `organization_members.select('role').eq('organization_id', orgId).eq('user_id', userId).maybeSingle()`.
- **B7.** RLS makes B4 work in both modes: `profiles` policies allow self-read plus super-admin-reads-everyone (`profiles_super_admin_select` using `current_user_is_super_admin()`, migration 0003) — so an impersonated client can read the target's own row, and a non-swapped super-admin client can read anyone's.
- **B8.** Exported constant `VIEW_AS_COOKIE = 'view_as_user_id'`; duplicated as `VIEW_AS_COOKIE_NAME` in `lib/supabase/server.ts` (deliberate, avoids an import cycle — keep in sync).
- **B9.** Exported sentinel `NO_MATCH_UUID = '00000000-0000-0000-0000-000000000000'` — used as a "matches nothing" value in `.in()` filters so PostgREST returns zero rows.

### Permission functions (`lib/permissions.ts`) — the exact truth tables

- **B10.** All five functions take `EffectiveUser | null` and return `false` for null.
- **B11.** `canTransfer(eu)`: `true` if `eu.is_super_admin && !eu.is_viewing_as`; else `true` iff `eu.org_role === 'owner'`. Semantics: mark/unmark transferred, everything invoice, AND (by reuse) all owner-only visibility — income/margin (`canSeeIncome`), billout dollars (`canSeeBillout`), owner-only nav links, whole Tools area.
- **B12.** `canManageTeam(eu)`: `true` if super-admin-not-viewing; else `org_role === 'owner' || org_role === 'manager'`. **⚠ Dead code — zero call sites in the app today; recorded for the record, NOT the parity bar.** Team mutations are actually enforced by `teams`/`team_members` RLS (owner-of-team OR `is_org_owner` OR super admin) plus the view-as guards (B49–B50) — that EFFECTIVE rule is the parity bar (02-domain-model.md §4). Rebuild: implement the effective rule as the `can_manage_team` capability, enforced at the choke point — NOT the dead function's broader any-manager grant.
- **B13.** `canManageImports(eu)`: identical truth table to `canManageTeam` (super-admin-not-viewing OR owner OR manager).
- **B14.** `canEnterViewAs(eu)`: `eu.is_super_admin && !eu.is_viewing_as` — super admins only, **no nesting**. **⚠ Also zero call sites** — the nav switcher and `enterViewAs` action re-implement the rule inline (B39, B41).
- **B15.** `canEditEntry(eu, entry: { invoice_id?, transferred_at? })`: if `entry.invoice_id || entry.transferred_at` (locked) → `canTransfer(eu)`; else → `canManageImports(eu)`. The `transferred_at` branch is kept post transfer→invoice migration as a safety net for any legacy row missing `invoice_id`. Detaching from an invoice unlocks the row for manage-imports holders again.
- **B16.** Truth table (the R1 parity contract, EXCEPT the `canManageTeam` row — dead code, recorded for the record, NOT the parity bar (B12); "viewing-as X" columns are what a super admin sees while impersonating X):

| Function | super admin (real, not viewing) | owner | manager | member | no org role (null) | viewing-as an owner | viewing-as a manager | viewing-as a member | viewing-as another super admin |
|---|---|---|---|---|---|---|---|---|---|
| `canTransfer` | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | target's `org_role === 'owner'` only (super-admin power stripped by `!is_viewing_as`) |
| `canManageTeam` *(dead code — not the parity bar; see B12)* | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | target's role owner/manager only |
| `canManageImports` | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | target's role owner/manager only |
| `canEnterViewAs` | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (no nesting) |
| `canEditEntry` (unlocked row) | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | as `canManageImports` |
| `canEditEntry` (locked: `invoice_id` or `transferred_at` set) | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | as `canTransfer` |

- **B17.** Action-level matrix (from the production feature doc, still accurate):

| Action | super admin (real) | super admin (viewing-as) | owner | manager | member |
|---|---|---|---|---|---|
| Manage team | ✓ | (effective role) | ✓ | ✓ | ✗ |
| Upload import / delete batch | ✓ | (effective role) | ✓ | ✓ | ✗ |
| Edit / delete entry (unlocked) | ✓ | (effective role) | ✓ | ✓ | ✗ |
| Edit / delete entry (locked) | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| See `/transfer` page | ✓ | ✗ unless effective role = owner | ✓ | ✗ (redirect `/`) | ✗ |
| Mark as transferred / undo batch | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| Invoices (create/delete/attach/detach, see nav link) | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| See income/margin, billout $, Tools | ✓ | ✗ unless effective role = owner | ✓ | ✗ | ✗ |
| Enter view-as | ✓ | ✗ (no nesting) | ✗ | ✗ | ✗ |

### Capability wiring — exact call sites (what each function actually gates)

- **B18.** `canTransfer` gates, server-side (every mutation re-checks; UI hiding is never the only enforcement):
  - `/transfer` page: non-holders `redirect('/')` (`app/(app)/transfer/page.tsx`). The "Mark as transferred" column/buttons render only when true (`transfer-table.tsx`).
  - Entries bulk actions (`app/(app)/entries/actions.ts`): bulk mark transferred, bulk unmark, and the invoice-attach path each fail without it.
  - `bulkUpdateFields` (entries and projects actions): when `!canTransfer(eu)`, sets `restrictToUntransferred = true` — a manager's bulk edit silently skips rows with `transferred_at` set (ENT-015). The confirm dialog appends "Locked rows (attached to an invoice) are skipped." for non-holders.
  - Invoices (`app/(app)/invoices/actions.ts`): create → `'Only an owner can create invoices.'`; delete → `'Only an owner can delete invoices.'`; detach → `'Only an owner can detach entries.'`; attach also checked.
  - Projects (`app/(app)/projects/actions.ts`): editing project income/adjustments, `restamp_billout_for_project`, and adjustment writes are `canTransfer`-gated.
  - Tools area: `/tools`, `/tools/cc-expenses`, `/tools/cc-expenses/[id]`, `/tools/cc-expenses/rules` pages all render an owner-only message for non-holders; every CC-expense action fails with `'Only an owner can …'`.
- **B19.** Visibility reuse of `canTransfer` (there is no separate function):
  - **Income/margin**: `app/(app)/projects/page.tsx` computes `userCanTransfer = canTransfer(eu)` and passes `canSeeIncome={userCanTransfer}` to `projects-table.tsx`, which drops the 2 income+margin columns, the income edit field, and the totals income/margin cards when false.
  - **Billout dollars**: `app/(app)/page.tsx` (Summary) computes `canSeeBillout = canTransfer(eu)`; when false the headline billout card and the per-row billout amounts are hidden (cost/hours-only view). `app/(app)/team/page.tsx` uses the same flag for per-member `billout_rate_usd` visibility/editability — the team form only includes the `billout_rate_usd` field for holders, and the update action keeps the column out of the payload when the field is absent (so a manager save can't null it).
  - **Nav**: `LINKS` entries `/invoices` and `/tools` carry `ownerOnly: true`; `Nav` filters with `visibleLinks = LINKS.filter((l) => !l.ownerOnly || canTransfer)`. `/transfer` has NO nav link at all anymore (reachable only by URL, then gated per B18).
- **B20.** `canManageImports` gates: CSV upload (`import/actions.ts` → `'You do not have permission to import.'`), delete batch (`'You do not have permission to delete batches.'`), resolver commit (`import/resolve/actions.ts`), entry create/edit UI flag (`canEdit` on `/entries` and `/projects` pages), bulk edit (`'You do not have permission to bulk edit.'`), and all entity rename/merge/delete actions in `projects/manage/actions.ts` (`'You do not have permission to rename entities.'` etc.).
- **B21.** `canEditEntry` gates per-row `updateEntry`, `deleteEntry`, and re-resolve in `entries/actions.ts` (three call sites), using the row's `{ invoice_id, transferred_at }`.
- **B22.** Nav role label: `{effective.full_name || effective.email}` + `(super admin)` if `effective.is_super_admin`, else `(org_role)`, else `(no role)`. Note this is the *effective* flag — while viewing-as Adi it shows `(manager)`.

### Imported-by scoping — "Adi sees only what he uploaded" (RLS layer)

- **B23.** SQL helper `user_can_access_entry(p_org_id uuid, p_import_id uuid)` (SECURITY DEFINER, migration `20260523000001_import_scoping.sql`) returns true iff: `current_user_is_super_admin()` OR caller is an `organization_members` row with `role = 'owner'` in `p_org_id` OR (`p_import_id` NOT NULL AND a `clockify_imports` row exists with `id = p_import_id AND org_id = p_org_id AND imported_by = auth.uid()`).
- **B24.** `time_entries` RLS policies: `time_entries_scoped_select` / `_update` / `_delete` all require `is_org_member(org_id) AND user_can_access_entry(org_id, import_id)` (update checks both USING and WITH CHECK); `time_entries_scoped_insert` requires only `is_org_member(org_id)`.
- **B25.** Consequence: a manager/member can NEVER select/update/delete an entry with `import_id IS NULL` (manual entries) — the third branch of B23 requires a non-null import. Owners/super-admins see everything.
- **B26.** `clockify_imports` RLS is org-wide (`clockify_imports_org_all`: any org member, all operations). **Batch *metadata* is NOT importer-scoped** — a real manager login can list every batch row on `/import`; the entry *counts* inside foreign batches come back 0 because `time_entries` RLS filters them. (Known asymmetry — see B48 and Edge cases.)
- **B27.** `imported_by` attribution: stamped at upload with the **effective** user id (so an upload performed under view-as-Adi lands in Adi's batch list — decisions.md #033). Same rule for `comments.author_id` / `resolved_by`.
- **B28.** Owner-only "Imported by" filter: `/entries` builds `importerOptions` (distinct `clockify_imports.imported_by` → profiles) only when `userCanTransfer && !viewAsScope`; the RPC arg is `p_imported_by`.
- **B29.** Supporting RLS helpers (all SECURITY DEFINER, `set search_path = public`): `current_user_is_super_admin()` (reads own `profiles.is_super_admin`), `current_user_org_ids()` (own membership org ids — added in `20260521120001_fix_recursive_rls.sql` to break a recursive-policy bug that silently returned no rows), `is_org_member(org)`, `is_org_owner(target_org)`, `is_super_admin_caller()`.
- **B30.** `teams` RLS: select = own team (`owner_user_id = auth.uid()`) OR `is_org_owner(org_id)` (+ super admin); modify = org owner/admin; owners of a team may update their own row. `team_members` RLS: select/modify iff the member's team is owned by the caller OR caller `is_org_owner` on the team's org. This is why a real Adi login sees only his own team on `/team`.

### View-as: cookie, enter, exit

- **B31.** Cookie `view_as_user_id`, value = target's auth UUID, set `httpOnly: true, sameSite: 'lax', path: '/', maxAge: 60*60*8` (**8 hours**). Not signed/encrypted — safety comes from re-verifying super-admin server-side on every request (B36).
- **B32.** `enterViewAs(formData)` (`app/actions/view-as.ts`, 'use server'): reads `user_id`; empty → `redirect('/')`. Calls private `isSuperAdmin()` (via `createClient()` → `auth.getUser()` → own `profiles.is_super_admin`); non-super-admin → **silent no-op** `redirect('/')`. Else set cookie, `redirect('/')`. No validation that the target exists or shares an org (a bogus id just falls through per B4). Note nesting is blocked implicitly: under active view-as, `isSuperAdmin()` runs on the impersonated client, so `getUser()` returns the (non-admin) target → false.
- **B33.** `exitViewAs()`: `cookieStore.delete(VIEW_AS_COOKIE)`; `redirect('/')`.
- **B34.** Switcher (`components/view-as-switcher.tsx`, client): renders nothing with 0 candidates; otherwise a "View as ▾" toggle opening a dropdown; each candidate is its own `<form action={enterViewAs}>` with hidden `user_id`; label = `full_name || email` + muted email.
- **B35.** Candidate loading (`app/(app)/layout.tsx`): only when `effective.is_super_admin && !effective.is_viewing_as`. Two queries — `organization_members.select('user_id')` across ALL orgs (no org filter), dedupe, drop own id, then `profiles.select('id, email, full_name').in('id', otherIds)`. Two queries because there is **no FK between `organization_members` and `profiles`** (both reference `auth.users`) so no PostgREST embed. Nav shows the switcher iff `is_super_admin && !is_viewing_as && candidates.length > 0`.

### View-as: the JWT swap (ADR #043 — true RLS impersonation)

- **B36.** `lib/supabase/server.ts > createClient()`: no view-as cookie → plain `@supabase/ssr` cookie client, zero overhead. Cookie present → verification chain BEFORE minting: (a) `auth.getUser()` on the real cookie client — no user → return real client, cookie ignored; (b) real user's `profiles.is_super_admin` must be true — else **silently ignore** the cookie and return the real client; (c) `viewAsUserId === real.id` → no-op, real client. Only then mint.
- **B37.** `mintJwtForUser(userId)` (`lib/supabase/view-as-jwt.ts`): `jose` `SignJWT`, header `{ alg: 'HS256', typ: 'JWT' }`, signed with `TextEncoder().encode(SUPABASE_JWT_SECRET)` (env var NAME; the Supabase *legacy* JWT secret). Claims mirror a real Supabase sign-in token: `sub: userId`, `role: 'authenticated'`, `aud: 'authenticated'`, `iss = NEXT_PUBLIC_SUPABASE_URL (trailing slashes stripped) + '/auth/v1'`, `iat` now, `exp` **5 minutes**. Throws `'SUPABASE_JWT_SECRET missing — view-as impersonation requires the legacy JWT secret in .env'` if unset.
- **B38.** The impersonated client is a **direct** `createClient` from `@supabase/supabase-js` (not ssr) with `global.headers.Authorization = 'Bearer <jwt>'` and `auth: { persistSession: false, autoRefreshToken: false }` — one-shot per-request token, no cookies, no refresh. JWT is re-minted every request, so the 5-minute TTL only bounds a single request; the 8-hour cookie is the real impersonation window.
- **B39.** Effect: every DB query runs with the target's identity; RLS (`is_org_owner`, `user_can_access_entry`, `current_user_is_super_admin`…) downgrades naturally; the target's own RLS *errors* reproduce faithfully (the original motivation — Adi's import failing on a `projects` RLS error that pre-swap view-as could not reproduce).
- **B40.** Cost: 1 extra round-trip per request while active (super-admin verification). Env var names in play: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_JWT_SECRET`.
- **B41.** `supabase.auth.getUser()` under view-as returns the **impersonated** user. The only callers that need the REAL identity — `getEffectiveUser` (B3) and nothing else in prod code — decode the auth cookie via `jose.decodeJwt`. Any code assuming `getUser()` = real user under view-as is a security bug.

### View-as: app-layer data overlay + mutation guards (defense-in-depth since the swap)

- **B42.** `getViewAsImportScope(supabase, orgId, eu)` → `string[] | null`: returns `null` (no extra scoping) when view-as is OFF, or when the effective user is super-admin or org owner. Otherwise returns the ids from `clockify_imports.select('id').eq('org_id', orgId).eq('imported_by', eu.effective_user_id)`, or `[NO_MATCH_UUID]` when the target has no imports. Callers apply `.in('import_id', ids)` on `time_entries` or `.in('id', ids)` on `clockify_imports`.
- **B43.** Read-side application: `/entries` (count + data queries, batch pivot, and forcing `effectiveImportedBy = eu.effective_user_id` so the URL param cannot escape the scope; the Imported-by dropdown is hidden), `/import` (batch list), `/projects` (batch pivot + imported-by). `/` (Summary) passes the scope through its RPC path via `p_imported_by`-style args where applicable.
- **B44.** `filterTeamsForViewAs(teams, eu)`: not viewing-as → all teams; effective super-admin or owner → all; else only `t.owner_user_id === eu.effective_user_id`. Applied on `/team` after `listVisibleTeams` (loaded under whatever JWT is active).
- **B45.** Mutation guards (`lib/view-as-guards.ts`). Contract: each returns an error **message string** to deny or `null` to allow; callers do `const violation = await guardX(...); if (violation) fail(violation)` so the redirect URL stays action-specific. Shared denial constant: `"That row isn't visible to the user you're viewing as. Exit view-as to make this change."` (`OUT_OF_VIEW_AS_SCOPE`).
  - `guardEntry(supabase, eu, orgId, entryId)`: scope null → allow; entry missing → `'Entry not found.'`; `import_id` null or not in scope → denial. (Manual entries = out of scope for view-as'd non-owners — matches RLS B25.)
  - `guardEntryIds(...)`: empty list or null scope → allow; loaded rows count ≠ requested count → `'Some selected entries were not found.'`; **fails if ANY id is out of scope** (no silent filtering — the caller asserts a row count).
  - `guardImport(...)`: null scope → allow; else `scope.includes(importId)` or denial.
  - `guardTeam(...)`: not viewing-as / effective super-admin / owner → allow; team missing → `'Team not found.'`; `owner_user_id !== effective_user_id` → denial.
  - `guardTeamMember(...)`: same short-circuits; loads `team_members.select('team:teams(owner_user_id)')`; missing → `'Team member not found.'`; wrong owner → denial.
- **B46.** Guards are wired into team actions (`team/actions.ts`: `guardTeam` on create-member's team, `guardTeamMember` on update/delete) and entry/import mutations. Since ADR #043 they are **redundant with RLS by design** — kept as belt-and-suspenders. (Their file-header comments still describe the pre-swap world; stale, see Edge cases.)
- **B47.** Attribution under view-as uses the **effective** user id everywhere it matters: `clockify_imports.imported_by`, `comments.author_id`, `comments.resolved_by` (decisions.md #033).
- **B48.** Known fidelity asymmetry: under view-as of a manager, `/import`'s batch list is narrowed to the target's own batches (B42/B43), but a *real* manager login sees ALL batch metadata rows (B26). View-as is therefore *stricter than reality* for batch metadata. Entries visibility matches exactly.

### View-as: banner + UX

- **B49.** While `is_viewing_as`, `components/nav.tsx` renders a full-width amber banner (`background:#fde68a; color:#78350f`, centered, 0.85rem): `Viewing as **{view_as_label}**. Some actions are restricted to match their role.` with an inline "Exit view" underlined button (`<form action={exitViewAs}>`). It is the ONLY visual difference from the target's real session — by explicit requirement.
- **B50.** Owner-only nav links, buttons, columns all disappear per the effective role (B18–B19); pages the target can't access redirect the same way they would for the target.

### Change-password interaction

- **B51.** `changePassword` (`app/change-password/actions.ts`) refuses to run if the `view_as_user_id` cookie is present AT ALL: `redirect('/change-password?error=' + encodeURIComponent('Exit view-as before changing your password.'))`. Rationale: under view-as, `createClient()` is impersonated, so `auth.updateUser({ password })` would change the **impersonated user's** password. The guard triggers on cookie *presence*, even if the cookie would be ignored (non-super-admin setter) — deliberately conservative.
- **B52.** Sign-out (`POST /auth/signout`) does **NOT** clear the view-as cookie (known wart; 8h expiry is the only cleanup). It calls `signOut()` on whatever client `createClient()` returns — under active view-as that's the direct-JWT client whose `signOut()` doesn't touch cookies.

## Data model

Reads/writes touched by this feature (semantics only; full schema in the schema extraction):

| Table / column | Role here |
|---|---|
| `profiles.is_super_admin` | The one global flag. Read by `createClient()` (swap decision), `getEffectiveUser`, `enterViewAs`. rian only. |
| `profiles.must_change_password` | Read by middleware every request; cleared by `changePassword`. RLS must allow self-update of this column. |
| `organization_members(organization_id, user_id, role)` | `role in ('owner','manager','member')` → `org_role`. No FK to `profiles` (both → `auth.users`) → two-query candidate loading. |
| `clockify_imports.imported_by` | Importer attribution (→ `auth.users`, `on delete set null`). Written with the *effective* user id. Drives `user_can_access_entry` and `getViewAsImportScope`. |
| `time_entries.import_id` | The scoping key: RLS row access for non-owners flows through the import's `imported_by`. NULL `import_id` ⇒ invisible to non-owners. |
| `time_entries.invoice_id`, `time_entries.transferred_at` | The lock inputs to `canEditEntry`. |
| `teams.owner_user_id` | "Whose team" — per-user team scoping (unique per `(org_id, owner_user_id)`). |
| `team_members.billout_rate_usd` | Owner-only visible/editable column on `/team` (form field omitted for non-owners; action skips the column when field absent). |
| `comments.author_id`, `resolved_by` | Effective-user attribution under view-as. |
| SQL functions | `current_user_is_super_admin()`, `current_user_org_ids()`, `is_org_member(uuid)`, `is_org_owner(uuid)`, `is_super_admin_caller()`, `user_can_access_entry(uuid, uuid)` — all SECURITY DEFINER helpers the policies compose. |
| Cookie (not a table) | `view_as_user_id` — the entire impersonation state. No server-side session row, no audit trail. |

## Permissions

This feature *is* the permission system; the functions are `canTransfer`, `canManageTeam`, `canManageImports`, `canEnterViewAs`, `canEditEntry` (`lib/permissions.ts`) — exact truth tables in B16. Enforcement layers, outermost first:

1. **Middleware** (`lib/supabase/middleware.ts`): auth gate (only `/login` public), forced `/change-password` redirect. View-as-unaware (always the real user).
2. **RLS** (primary since ADR #043): org membership + owner/super-admin helpers + importer scoping (B23–B30). Under view-as the swapped JWT makes RLS evaluate as the target.
3. **Server actions**: every mutation re-checks the relevant `can*` function server-side (UI hiding is never trusted) and, where row-scoped, runs the `guard*` functions.
4. **UI**: hides links/buttons/columns per the same functions.

Notable: `canSeeIncome` and `canSeeBillout` are NOT functions — they're page-local aliases for `canTransfer(eu)` (B19). `canManageTeam` and `canEnterViewAs` are currently declared-but-uncalled (B12, B14).

## Edge cases & error behavior

- **E1.** Forged/stale view-as cookie by a non-super-admin: silently ignored by `createClient()` (B36b) and `enterViewAs` (B32) — but it still **blocks password changes** (B51) with "Exit view-as before changing your password.", which such a user can only clear by cookie expiry or manual deletion. Known wart.
- **E2.** View-as target with no membership in the current org: `org_role = null` → every role-based permission false; pages render the empty/no-role state.
- **E3.** Bogus target UUID: `getEffectiveUser` falls through to the real user (B4); no error surfaced.
- **E4.** Request outliving the 5-minute minted JWT fails with a JWT-expired error — accepted for interactive flows.
- **E5.** Guard denial messages (exact strings): `"That row isn't visible to the user you're viewing as. Exit view-as to make this change."`, `'Entry not found.'`, `'Some selected entries were not found.'`, `'Team not found.'`, `'Team member not found.'` Batch guard fails on ANY out-of-scope id rather than filtering.
- **E6.** Permission-denial messages from actions (representative exact strings): `'You do not have permission to import.'`, `'You do not have permission to delete batches.'`, `'You do not have permission to bulk edit.'`, `'You do not have permission to rename entities.'`, `'Only an owner can create invoices.'`, `'Only an owner can delete invoices.'`, `'Only an owner can detach entries.'`, `'Only an owner can manage rules.'`, `'Only an owner can create expense batches.'`, `'Only an owner can delete batches.'` All delivered via redirect with `?error=` param.
- **E7.** Sign-out leaves the view-as cookie behind (B52). Re-login as a different super admin within 8h could resurrect the old impersonation. Rebuild must clear impersonation state on logout.
- **E8.** `readRealUserIdFromCookie` re-implements supabase-ssr cookie internals and decodes without verification (B3); a cookie-format change breaks real-user detection silently (users appear logged out).
- **E9.** Stale comments: `lib/view-as-guards.ts` and `getViewAsImportScope` headers still describe pre-JWT-swap enforcement ("queries still run as the super-admin"); `lib/supabase/server.ts` labels the swap "**ADR #033 v2**" while decisions.md numbers it **#043**. Code behavior is correct; the labels/comments are stale.
- **E10.** Dead code: `app/(app)/transfer/page.tsx` contains an unreachable "Viewing as {label} — they can't mark entries as transferred…" note (the page already `redirect('/')`s every non-`canTransfer` visitor earlier in the function, including view-as'd managers).
- **E11.** Security gap (judgment-calls #20): the 2026-06-09 `projects` RLS relaxation (`20260609000010_projects_rls_org_member.sql`) made `projects.income_usd` + billout adjustment columns readable/writable by any org member **through the raw PostgREST API** — income confidentiality currently holds only at the UI layer. Accepted until cutover; the rebuild's server-side authz fixes it structurally.
- **E12.** CC-expense tables have org-member-wide RLS but owner-only UI (`canTransfer`) — same permissive-DB/restrictive-app mismatch class as E11.
- **E13.** View-as shows *fewer* import batches than the target's real login would (B48) — the only known place view-as under-reproduces reality.
- **E14.** No audit log of view-as enters/exits, and no server-side revocation short of cookie deletion.
- **E15.** RBAC-001 in the old test catalog ("Adi sees /transfer but no Mark button") is stale — superseded 2026-05-21 by the redirect gate (RBAC-005/006 are the correct semantics).

## UI notes

- Current UI: "View as ▾" dropdown on the nav's right side (next to the comments bell and Sign out); amber banner (`#fde68a`/`#78350f`) with bold target name + underlined "Exit view" inline button; nav user chip shows `name (role)`. Owner-only nav links simply vanish for non-owners. All styling inline (no design system).
- Rian's explicit bar for this feature (transcript §10): *"I always want it to be exactly like logging in as that user… The only difference I should see is the little note saying I'm viewing as adi."* Fidelity beats convenience — he personally caught every under-impersonation bug (data 05-22, teams 05-23, RLS errors 06-10).
- Taste profile (transcript §17) as it applies here: hates important info demoted to small grey text — the banner's high-contrast amber treatment is right; keep it unmistakable but single-line. Hates actions with no feedback — enter/exit currently full-page-redirects, which at least makes the state change obvious; the rebuild's SPA must keep an equally unambiguous mode indicator. Likes icons + detail-on-hover, compact chrome; the switcher dropdown (name + muted email) fits that. Nothing in the transcripts registers a complaint about the view-as UI itself — the complaints were all about *fidelity*, which is the thing to preserve.

## Rebuild deltas

- **Impersonation returns to the app layer — and is correct this time** (07-judgment-calls.md #7, 00-vision-and-scope.md phase-outs): no RLS in the rebuild means a context swap in `resolve_actor` IS logging-in-as-Adi by construction. ADR #043's JWT minting, `SUPABASE_JWT_SECRET`, `jose`, and `readRealUserIdFromCookie` all retire. Watch item: any future DB-level identity re-breaks this.
- **Permission functions become named capabilities with identical R1 truth tables** (02-domain-model.md §4; `canManageTeam` per the effective rule, not the dead function — B12): `canTransfer`/`canManageTeam`/`canManageImports`/`canEditEntry`/`canSeeIncome`/`canEnterViewAs` → capability grid in `services/authz/capabilities.py`, derived from party/engagement grants (rian = official_partnership/owner; Adi = subcontract/worker + `can_manage_imports` grant). Ported truth-table tests must pass before new semantics.
- **One choke point + reasons + audit** (04-architecture.md): `can()`/`scope()` everywhere, `Decision` reason chains in a super-admin debug panel, audit rows recording `(actor, impersonated_by, …)` for every mutation — fixing E11/E12/E14 structurally. View-as guard rails (no credential actions while impersonating) carry over; logout clears impersonation (fixes E7).
- **Auth swaps to id-auth Pattern B** (04-architecture.md; kit shipped 2026-07-07): `must_change_password`, Supabase login, and the middleware DB check per request all retire; the Pattern B id_token's BW username → `users.idauth_username`.
- **`canTransfer`-as-generic-"is owner" is split** into purpose-named capabilities (invoice management vs. income visibility vs. billout visibility vs. tools access) — same R1 truth values, separable later.

## Test specs

Carried IDs (from docs/tests-catalog.md) and new PRM-/VAS- IDs for uncovered behaviors:

- T-RBAC-002: given rian (super admin, not viewing-as) with ≥1 other user / when the nav renders / then the "View as ▾" switcher appears. *(carried RBAC-002)*
- T-RBAC-003: given the view-as cookie set by a super admin / when any page renders / then the amber banner shows the target's label and owner-only buttons/links are absent per the target's role. *(carried RBAC-003)*
- T-RBAC-004: given a manager (Adi) and a locked entry (`invoice_id` or `transferred_at` set) / when he edits, deletes, or re-resolves it / then the server action rejects and the UI shows the locked state. *(carried RBAC-004)*
- T-RBAC-005: given a user with `canTransfer` false / when the nav renders / then no Transfer/Invoices/Tools links appear. *(carried RBAC-005; Transfer link is now absent for everyone)*
- T-RBAC-006: given a user with `canTransfer` false / when they GET `/transfer` directly / then they are redirected to `/`. *(carried RBAC-006; retire stale RBAC-001)*
- T-PRM-01: given each cell of the B16 truth table / when the corresponding capability is evaluated / then the result matches exactly (enumerated grid test: {real-super-admin, owner, manager, member, no-role, viewing-as-each} × {5 functions × lock states}). *Carried as T-AZ-001 in 06-test-plan.md, which pins team management to the EFFECTIVE rule — manager × team-management = deny (team owner OR org owner OR super admin; 02-domain-model.md §4) — not the dead `canManageTeam` row's ✓.*
- T-PRM-02: given `eu = null` / when any permission function runs / then false.
- T-PRM-03: given a super admin viewing-as another super admin / when `canTransfer`/`canEnterViewAs` run / then super-admin power is stripped (`!is_viewing_as` clause) and only the target's org_role counts.
- T-PRM-04: given a manager / when they call `bulkUpdateFields` over a selection including transferred rows / then only `transferred_at IS NULL` rows change. *(overlaps ENT-015)*
- T-PRM-05: given a manager / when the Projects and Summary pages render / then income, margin, and billout dollar values are absent from the payload/UI (`canSeeIncome`/`canSeeBillout` false).
- T-PRM-06: given a manager-rendered `/team` edit form (no `billout_rate_usd` field) / when saved / then the member's existing `billout_rate_usd` is untouched (column omitted from payload).
- T-PRM-07: given a manager (non-owner) under RLS / when selecting `time_entries` / then only rows whose `import_id` belongs to an import with `imported_by = self` return; rows with NULL `import_id` never return.
- T-PRM-08: given an owner or super admin / when selecting `time_entries` / then all org rows return regardless of importer.
- T-VAS-01: given a non-super-admin who hand-sets the view-as cookie / when any request runs / then the cookie is ignored (real identity, real RLS) and `enterViewAs` no-ops.
- T-VAS-02: given rian entering view-as Adi / when any DB query runs / then it executes under a freshly-minted 5-minute HS256 JWT with `sub = Adi`, `role/aud = authenticated`, correct `iss` — and RLS errors match a real Adi login.
- T-VAS-03: given view-as active / when `getEffectiveUser` runs / then `real_user_id` = rian (decoded from the `sb-*-auth-token` cookie), `effective_user_id` = Adi, `is_viewing_as` = true, `view_as_label` = Adi's name.
- T-VAS-04: given view-as of a manager with no imports / when `/entries` loads / then zero entries (sentinel `NO_MATCH_UUID` scope), and the Imported-by dropdown is hidden.
- T-VAS-05: given view-as of a manager / when a mutation targets an entry outside the target's import scope / then it fails with "That row isn't visible to the user you're viewing as. Exit view-as to make this change." and writes nothing.
- T-VAS-06: given view-as of a manager and a batch mutation where ANY id is out of scope / when `guardEntryIds` runs / then the whole batch is denied (no partial application).
- T-VAS-07: given view-as of a non-owner / when `/team` renders / then only teams with `owner_user_id = effective_user_id` appear (`filterTeamsForViewAs`); `guardTeam`/`guardTeamMember` deny cross-team mutations.
- T-VAS-08: given view-as active (any cookie presence) / when `changePassword` is submitted / then it redirects with "Exit view-as before changing your password." and no password changes.
- T-VAS-09: given view-as active / when a CSV is imported or a comment posted/resolved / then `imported_by`/`author_id`/`resolved_by` = the EFFECTIVE user id (visible as the target's after exit).
- T-VAS-10: given view-as active / when "Exit view" is clicked / then the cookie is deleted and the next request is fully the real user.
- T-VAS-11: given `canEnterViewAs` while already viewing-as / then false (no nesting); the switcher is not rendered.
- T-VAS-12: given a bogus target UUID in the cookie (set by a super admin) / when pages render / then the app behaves as the real user (silent fall-through).
- T-VAS-13 (rebuild-only, from E7): given sign-out during view-as / then impersonation state is cleared and cannot survive into the next session.
- T-VAS-14 (rebuild-only, from E14): given any view-as enter/exit / then an audit row records both identities.
