Documentation
¶
Overview ¶
Package projection is the PEP-side (policy enforcement point) column-masking framework for the GoCell data-permission model. It provides ResourceProjection, the sealed carrier for a column-masked resource view, and the NewProjection / NewProjectionList funnel that is the only way to build one.
PDP vs PEP split ¶
The authorization vocabulary in pkg/authz is the PDP (decision) output: authz.FieldMask is an open obligation that merely names the columns to mask — the names are inert strings carrying no runtime security. This package is the PEP that discharges that obligation. It imports authz.FieldMask rather than redefining it (the obligation lives with the Decision it travels in; see docs/plans/specs/1220-tenancy-abac-dataperm/research.md §1.1).
authz.FieldMask (PDP obligation) → NewProjection → ResourceProjection (PEP-enforced view)
Masking semantics ¶
A masked column's value is replaced by redaction.Mask ("<REDACTED>"); the column stays present. The visible field set is therefore invariant under masking, and an empty FieldMask is the identity projection. This avoids the MVP→masking response-shape churn the obligation model is designed to prevent: a Decision carrying FieldMask{} projects identically to no obligation at all. Masking is top-level only; an un-dischargeable (dotted/nested) obligation fails closed rather than leaking an un-masked column (see requireEnforceable).
On the wire the masked sentinel is HTML-escaped by encoding/json: the JSON bytes are \u003cREDACTED\u003e, not a literal <REDACTED>. A consumer matching the masked value in a raw JSON body must compare that \u003cREDACTED\u003e escaped form (or json-decode first, which restores the literal <REDACTED>).
Immutability contract ¶
Construction never mutates the caller's input. A masked column's value is REPLACED (by the sentinel), so masked data is never aliased — there is no leak path through a shared reference. Un-masked values, however, share their reference with the caller's map (they are meant to be visible), so the caller MUST NOT mutate the input map after construction if a stable snapshot matters.
Seal (RESOURCE-PROJECTION-SEALED-01) ¶
ResourceProjection's single field is unexported, so an external populated literal does not compile — the same sealed-construction pattern as errcode.PublicDetail, outbox.Entry, and authz.Decision. A handler whose response type is a ResourceProjection cannot hand back a raw, un-masked map: the only ResourceProjection it can obtain comes from the constructors, which always apply the mask.
The AI-robust rating is an honestly-split funnel:
- Upstream (type-system Hard): unexported field ⇒ no external populated literal; reflect field-freeze guards a regression that re-exports it.
- Downstream (go/types Hard): the sole-reconstruction-surface check pins the only exported producers to {NewProjection, NewProjectionList}, so no second forge path can be added inside this package without tripping the archtest.
SCOPE — the carrier seal is the UPSTREAM half of a now-CLOSED funnel. PR-11 shipped the unforgeable carrier (this package). PR-12 (#1350) landed the downstream callsite lock: archtest RESOURCE-PROJECTION-CALLSITE-LOCK-01 pins every responseProjection-marked contract's generated Response.Data to projection.ResourceProjection / []projection.ResourceProjection, so a raw, un-masked view is non-assignable at the handler callsite; archtest RESOURCE-PROJECTION-COVERAGE-01 closes it globally by requiring every resource-bearing GET read to carry the marker. The seal makes the carrier unforgeable; those two invariants make the unforgeability load-bearing at the read endpoint, so column leakage is now closed end to end. This package still owns only the carrier + masking funnel; the field-type pin and coverage live in the archtest layer.
Note: the 9 non-audit GET reads added in PR-12 all pass authz.IdentityFieldMask() (identity projection — all columns visible). That NAMED helper, rather than an anonymous authz.FieldMask{}, makes every not-yet-constrained read greppable, so when the ABAC decision engine is wired in PR-10 (#1348) each site is a one-line swap to Decision.Obligations().FieldMask.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ResourceProjection ¶
type ResourceProjection struct {
// contains filtered or unexported fields
}
ResourceProjection is the sealed, column-masked view of a resource that a PEP (policy enforcement point) may serialize to the wire. Its only field is unexported, so a populated literal `projection.ResourceProjection{data: ...}` outside this package does not compile — the sole way to obtain a populated value is NewProjection / NewProjectionList, which apply the FieldMask obligation. A handler therefore cannot fabricate an un-masked ("full") view; see package doc and RESOURCE-PROJECTION-SEALED-01 for the funnel proof.
The zero value marshals to JSON null (it carries no forged data) — the only literal expressible outside the package is harmless.
func NewProjection ¶
NewProjection builds the sealed, masked view of a single resource. It first validates the obligation (authz.FieldMask.Validate — canonical keys, no duplicates), then refuses obligations this PEP cannot discharge (requireEnforceable — fail-closed), then applies the mask onto a private copy of data.
The mask is expected to come from Decision.Obligations().FieldMask. An empty FieldMask is valid and yields the identity projection (every value visible, response field set stable) — passing an empty mask where a populated one was intended therefore returns the FULL view silently; supplying the right mask is the caller's responsibility (locked at the handler callsite in PR-12). A read that is deliberately unconstrained should pass authz.IdentityFieldMask() (the named identity marker) rather than a bare authz.FieldMask{}, so the site stays greppable for the eventual ABAC-mask swap.
Error kinds let the handler map status codes: a validation failure carries KindInvalid (→ 400, client-correctable bad mask); an un-dischargeable obligation carries KindInternal (→ 500, a PEP misconfiguration, not user error). A nil data map projects to an empty JSON object ("{}"), distinct from the zero-value ResourceProjection which marshals to null.
func NewProjectionList ¶
NewProjectionList builds the sealed, masked view of every row under one mask. The mask is validated once (an invalid or un-enforceable obligation fails the whole call before any row is projected — fail-closed); each row is then masked onto its own private copy. A nil/empty rows slice yields an empty result.
func (ResourceProjection) MarshalJSON ¶
func (p ResourceProjection) MarshalJSON() ([]byte, error)
MarshalJSON renders the masked view. The zero value (data == nil) marshals to JSON null so an externally-expressible empty literal carries no data. Note the redaction.Mask sentinel is HTML-escaped by encoding/json like any string value.