Appearance
PR 4 — Field-Level Redaction (Billed Units)
This PR introduces a single field-level redaction pattern for usage rows and invoice line items. The first concrete use case: customers, viewers, and any non-finance role can see usage counts (calls, GB, seats) but not the billed amount in money. A finance-permitted role unlocks the amount.
This is not column-level ACL infrastructure — it's a serializer-time check against the existing permission catalog, applied to one or two endpoints. The pattern is reusable for the next field-level case (probably PII), but no generic framework is built.
Independent. Does not depend on PR1/PR2/PR3, though it composes cleanly with them.
1. Goal
- Add
view_billed_unitspermission action to the catalog. - Apply redactor to:
GET /api/v1/customers/:id/usagerowsGET /api/v1/customers/:id/invoices/:iidline itemsGET /api/v1/tenants/:id/usage(per-tenant breakdown)
- Roles that retain visibility:
platform_admin,account_manager,finance_admin, customerowner,admin,billing,compliance_admin,reader. - Roles that get redacted:
qa_admin, customerviewer, future partner group members without billing role. - Redacted fields return
null(not removed) so the schema is stable client-side.
Non-goals
- Column-level ACL in Postgres.
- Field-level redaction config UI.
- Per-customer redaction overrides ("Acme wants extra fields hidden").
2. Permission action
Add to internal/app/permissions.go:
go
const ActionViewBilledUnits = "view_billed_units"Roles allowed in portalPermissions map:
go
ActionViewBilledUnits: {
"platform_admin", "account_manager", "finance_admin",
"compliance_admin", "reader",
"owner", "admin", "billing",
}
// Excluded: qa_admin, viewer, member, group-members-without-billingMirror in web/src/auth/permissions.ts.
3. Redactor pattern
New file: internal/handler/redact/redact.go
go
package redact
import (
"github.com/exto360-inc/console/internal/app"
)
// UsageRow redacts billed-money fields when caller lacks
// view_billed_units. Mutates in place.
func UsageRow(row *model.UsageRow, rc *app.RequestContext) {
if app.CanPerform(rc, app.ActionViewBilledUnits) {
return
}
row.BilledAmount = nil
row.UnitPrice = nil
row.LineTotal = nil
}
// InvoiceLine same shape for invoice-line items.
func InvoiceLine(line *model.InvoiceLine, rc *app.RequestContext) { ... }The redactor is the only place that decides which fields are billed. New billed fields added later → update redactor + add test row.
Application
In internal/handler/billing/handler.go ListUsage:
go
rows, err := repo.ListUsage(ctx, customerID, ...)
if err != nil { ... }
for i := range rows {
redact.UsageRow(&rows[i], rc)
}
return c.JSON(200, rows)Repo returns full data; handler redacts before serializing. Keeps the repository pure (no awareness of who's asking).
4. JSON shape
Redacted fields return null rather than being absent:
json
{
"tenantId": "...",
"meter": "api_calls",
"units": 12500,
"billedAmount": null, // null when redacted
"unitPrice": null,
"lineTotal": null,
"currency": "USD",
"periodStart": "2026-04-01T00:00:00Z",
"periodEnd": "2026-04-30T23:59:59Z"
}Frontend renders null as a dash or "—". Keeps the table column count stable; no schema branch on the client.
5. Frontend
Permission helper
ts
export function canViewBilledUnits(roles?: PortalRole[]) {
return canPerform(roles, 'view_billed_units')
}Usage page
[web/src/pages/UsagePage.tsx]:
- Render the billed-amount column always; the value renders
—when null. No conditional column hiding. - Optional: add a small "ⓘ" tooltip next to the column header for non-finance users: "Billed amounts visible to finance-permitted users."
- For finance-permitted users, the column shows the value normally.
Invoice detail page
[web/src/pages/InvoiceDetailPage.tsx]:
- Line items render
lineTotalas—when null. - Invoice totals (subtotal, tax, total) also redact for non-finance users — the redactor extends to invoice-level numbers, not just line-level.
Why null-not-omit
If billed fields disappear entirely, the column toggles between modes based on viewer. Users sharing a screen would see different schemas. The null pattern keeps shape stable; only values vary.
6. Tests
| Layer | Test |
|---|---|
| Permission | view_billed_units allowed for expected role set, denied for others |
| Redactor | UsageRow with finance role passes through unchanged |
| Redactor | UsageRow with qa_admin / viewer nulls out billed fields, leaves units |
| Handler | ListUsage end-to-end — finance sees amounts, viewer sees null |
| Frontend | UsagePage renders — for null billedAmount |
| Frontend | canViewBilledUnits toggles tooltip |
7. Implementation order
| Step | Layer | Description |
|---|---|---|
| 1 | Backend | internal/app/permissions.go — ActionViewBilledUnits + role list |
| 2 | Backend | internal/handler/redact/redact.go — redactors for usage + invoice |
| 3 | Backend | Wire redactor into usage handler |
| 4 | Backend | Wire redactor into invoice detail handler |
| 5 | Backend | Wire redactor into tenant usage breakdown |
| 6 | Frontend | permissions.ts — canViewBilledUnits |
| 7 | Frontend | UsagePage — null rendering + tooltip |
| 8 | Frontend | InvoiceDetailPage — null rendering + totals |
| 9 | Tests | Per Section 6 |
| 10 | Docs | Update docs/roles-and-permissions.md permission matrix |
8. Risk register
| Risk | Mitigation |
|---|---|
| New billed field added without redactor update | Add a unit test that asserts every *Amount/*Total/*Price field on UsageRow and InvoiceLine is null when redacted. Reflection-based, fails on a new monetary field with a familiar name. |
Aggregate computed client-side from line items leaks billedAmount | Aggregates always come from server (with totals also redacted); no client-side sum |
| Export endpoint (CSV) bypasses handler redactor | CSV exporter calls the same redactor; covered by handler tests |
| Frontend caches a billed value from a previous role and shows it later (impersonation, role change) | Cache keyed by user id + role; on role change, query cache invalidates |

