# Field-Level Permissions

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 121  
**Tier:** Business+  
**Depends on:** `foundation-auth-rbac`, `staff-portal-detail`, `projects-module`, `invoices-core`  
**Referenced by:** `foundation-auth-rbac`, `staff-portal-detail`

---

## Overview

Spec 5 (`foundation-auth-rbac`) defines role-based access at the module level (e.g., `invoices:read`). This spec adds field-level permissions: tenant admins can hide or make read-only specific fields for specific roles. Use cases: hide invoice amounts from subcontractors; hide staff hourly rate from project managers; make client phone numbers read-only for support agents.

---

## Permission Types Per Field

| Type | Behavior |
|------|----------|
| `visible` | Default — field shown and editable (subject to module write scope) |
| `read_only` | Field shown but cannot be edited |
| `hidden` | Field not shown and not returned in API responses for this role |

---

## Configurable Fields

Field-level permissions can be applied to these fields per entity type:

### Customers (`entity_type = 'customer'`)
- `phone`, `email`, `address`, `notes`

### Invoices (`entity_type = 'invoice'`)
- `total` (invoice total column), `vat_amount`, `notes`

### Projects (`entity_type = 'project'`)
- `billing_config` (hides rate/pricing info in the billing config JSONB)

### Contractors (`entity_type = 'contractor'`)
- `hourly_rate` (from `contractors.hourly_rate` — spec 21)

### Leads (`entity_type = 'lead'`)
- `estimated_value`, `score` (spec 118), `notes`

---

## Configuration UI

`/settings/permissions` → **Field Permissions** tab (admin only, Business+):

```
┌──────────────────────────────────────────────────────────────┐
│  Field Permissions                                           │
│                                                              │
│  Entity: [Invoices ▾]                                        │
│                                                              │
│  Field              Manager    Staff     Contractor          │
│  ─────────────────────────────────────────────────────────  │
│  Total              ● Visible  ● Visible  ○ Hidden           │
│  VAT amount         ● Visible  ○ Read-only ○ Hidden          │
│  Notes              ● Visible  ○ Read-only ○ Hidden          │
│                                                              │
│  [Save changes]                                              │
└──────────────────────────────────────────────────────────────┘
```

Radio buttons per field per role: `Visible` / `Read-only` / `Hidden`.

---

## Schema Delta

```sql
CREATE TABLE field_permission_rules (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  entity_type TEXT NOT NULL,      -- 'invoice'|'customer'|'project'|'contractor'|'lead'
  field_name TEXT NOT NULL,       -- e.g. 'total_amount', 'hourly_rate'
  role TEXT NOT NULL,             -- role name from rbac roles
  permission TEXT NOT NULL DEFAULT 'visible'
    CHECK (permission IN ('visible', 'read_only', 'hidden')),
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, entity_type, field_name, role)
);

CREATE INDEX idx_fpr_tenant ON field_permission_rules(tenant_id, entity_type);
```

No records = all fields visible (default behavior unchanged).

---

## Enforcement

### API Layer

For each API response, apply field rules based on the requesting user's role:

```
GET /api/invoices/:id
  → server: fetch field_permission_rules WHERE tenant_id = :tenantId AND entity_type = 'invoice'
  → for each `hidden` field for user's role: delete field from response object
  → for each `read_only` field: response includes field value; client marks as non-editable
```

Hidden fields are stripped server-side — never sent to client.

### Client Layer

Read-only fields: input rendered as `<input disabled>` or `<span>` (non-interactive). "Read-only" tooltip on hover/focus: "You don't have permission to edit this field."

---

## Performance

Field permission rules cached in Cloudflare KV per tenant (key: `fpr:{tenantId}`). TTL: 5 minutes. Invalidated on save. Avoids DB lookup on every API call.

---

## Default Behavior

If no `field_permission_rules` exist for a tenant: all fields visible and editable (controlled by existing module-level scope). Business+ gate only applies to the configuration UI — existing tenants without Business+ are unaffected (rules stay at `visible`).

---

## API

```
GET /api/settings/field-permissions
    → list all rules for tenant
      Returns: [{ entity_type, field_name, role, permission }]
      Requires: admin (tenant admin only)

PUT /api/settings/field-permissions
    → replace all rules for tenant
      body: [{ entity_type, field_name, role, permission }]
      Returns: saved rules
      Requires: admin (tenant admin only, Business+)

GET /api/field-permissions/effective
    → permissions effective for the current user's role
      Returns: { invoice: { total_amount: 'hidden', ... }, contact: {...} }
      Requires: authenticated
      Used by: client to pre-compute which fields to hide/disable on load
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Rule table per field+role | Not JSONB blob | Rows are indexable; atomic updates to single rules; no merge conflicts on concurrent edits |
| Server-side strip hidden | Not client-only | Hidden fields must never reach unauthorized clients; relying on client would be a security gap |
| KV cache per tenant | Not uncached | Field rules are read on every API call; DB lookup per request would add unacceptable latency; 5-min TTL is acceptable staleness |
| PUT replaces all | Not PATCH | Config UIs show the full grid; replacing the full set avoids partial-update edge cases |
| Business+ UI gate only | Not enforcement gate | Enforcement happens regardless of tier; the gate controls whether admins can configure rules. A future downgrade doesn't expose data — existing rules remain enforced. |
