Skip to content

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_units permission action to the catalog.
  • Apply redactor to:
    • GET /api/v1/customers/:id/usage rows
    • GET /api/v1/customers/:id/invoices/:iid line items
    • GET /api/v1/tenants/:id/usage (per-tenant breakdown)
  • Roles that retain visibility: platform_admin, account_manager, finance_admin, customer owner, admin, billing, compliance_admin, reader.
  • Roles that get redacted: qa_admin, customer viewer, 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-billing

Mirror 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

web/src/auth/permissions.ts:

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 lineTotal as 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

LayerTest
Permissionview_billed_units allowed for expected role set, denied for others
RedactorUsageRow with finance role passes through unchanged
RedactorUsageRow with qa_admin / viewer nulls out billed fields, leaves units
HandlerListUsage end-to-end — finance sees amounts, viewer sees null
FrontendUsagePage renders for null billedAmount
FrontendcanViewBilledUnits toggles tooltip

7. Implementation order

StepLayerDescription
1Backendinternal/app/permissions.goActionViewBilledUnits + role list
2Backendinternal/handler/redact/redact.go — redactors for usage + invoice
3BackendWire redactor into usage handler
4BackendWire redactor into invoice detail handler
5BackendWire redactor into tenant usage breakdown
6Frontendpermissions.tscanViewBilledUnits
7FrontendUsagePage — null rendering + tooltip
8FrontendInvoiceDetailPage — null rendering + totals
9TestsPer Section 6
10DocsUpdate docs/roles-and-permissions.md permission matrix

8. Risk register

RiskMitigation
New billed field added without redactor updateAdd 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 billedAmountAggregates always come from server (with totals also redacted); no client-side sum
Export endpoint (CSV) bypasses handler redactorCSV 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