# AuthZ Governance — the `policy_json` reference (#942) > Reference material split out of the dev skill (v2.1) to keep that file inside its size > budget. The dev skill carries the working rules — when policies fail, how they are > tested, and the HTTP contract. **This file is the lookup:** grammar, worked examples, > delivery paths and the verification log. > > Read the dev skill first: it tells you *when* you need this. This file tells you *what to > write*. Row-level entity access control is a **declarative `policy_json` block per entity**, enforced at the unbypassable data-layer chokepoint (`MapiEntityHelper` → `MapiPolicyGate` → `AccessPolicy`). This is the reference the customer skill points to. **Opt-in per entity.** An entity with **no** `policy_json` is ungoverned — the gate is a byte-identical pass-through (`public_read` still controls anonymous visibility). Enforcement activates **only** when an entity carries a `policy_json`. Strict mode (governed-by-default) is the end state; design sensitive entities with an explicit policy now. ## Setting a policy Set / change / clear via the `entities` tool (operation `update`): ``` entities(operation: "update", entity_name: "customer", policy_json: { … }) // activate / change entities(operation: "update", entity_name: "customer", policy_json: null) // deactivate → reverts to public_read behaviour entities(operation: "schema", entity_name: "customer") // inspect current policy ``` The deprecated granular tools (`update_entity`, `create_entity`) do **not** carry a `policy_json` parameter — always use `entities`. The server validates that the JSON is well-formed, **not** that the rules are semantically correct. ## Grammar **Grants** (per action, per tier): `deny` | `all` | `own` | `self` - `deny` — no access. - `all` — every row (privileged). - `own` — only rows where `owner_field` == the caller's identity. - `self` — on `create`, stamp `owner_field` = caller identity (degrades to `own` on any non-create action). **Tiers** (`AccessPolicy::VALID_TIERS`): `project`, `site_admin`, `verified`, `anon`, `public`. `PRIV = {project, site_admin}` default to `all` unless a rule explicitly lists another grant (an explicit `deny` on a privileged tier still wins). Every other tier defaults to `deny`. Identity mapping (`MapiPolicyGate`): owner → `project`; verified SAPI visitor → `verified` (identity = email); `wst_` tenant → `verified` (identity = per-user scope, or the bare `tenant_code` under `owner_scope:"tenant"`); unknown → `public`. Non-owner identity is set only on the SAPI route (`SapiExecuteController`); raw MAPI HTTP (`MapiAuthenticate`) is owner-only (`mapi.principal` = v2). **Rules** — keys `read`/`list`/`create`/`update`/`delete`, each mapping tier → grant. Always specify `"project": "all"`. An omitted non-privileged tier = `deny`. **`owner_field`** — must be a **real entity property (column)**; the engine fails closed (HTTP 500) on a phantom field. **`fields..hide[]`** — columns projected out (hidden) from that tier on read/list. **State guards** (optional) — `"guards": {"update": {"when": "status in (draft, pending)"}}`. Only the `field in (a, b)` form is evaluated; anything else is treated as permissive. Guards bind all tiers, including owner/admin. **`owner_scope`** (optional — #942 org-wide tenant) — `"owner_scope": "tenant"` makes the ownership identity the **bare `tenant_code`** for a tenant caller, so all users of a tenant share visibility of that tenant's rows (org-wide isolation for sub-tenancy inside one project). Requires a real `tenant_code` column and `wst_` tenant sessions. A non-tenant caller resolves to a null identity and is denied. Absent = per-caller identity (email for visitors, per-user scope for tenants). ## Delivery path — read this before configuring a tenant policy A tenant-scoped entity is only reachable through a service that runs under a tenant identity. Two exist: **`gated-files`** (file delivery) and, since September 2026, the **`records`** bridge (#1310) — `POST /sapi/project/{id}/execute/records/{list|get}`, read-only, and it refuses any entity without a `policy_json` because an ungoverned entity is a gate pass-through. **Verified end-to-end, 1 September 2026.** Tested from outside the cluster against a live tenant session: two users of one tenant read the same rows — rows created by the *owner*, so nothing carried a per-user stamp, which is what proves the match runs on the bare `tenant_code` and not on `tenant:{code}:{uid}`. A member of another tenant sees only their own; a cross-tenant record returns 403; a session without a `wst_` Bearer returns 401; `fields.verified.hide[]` columns stay out of the response; and `pagination.total` is scoped too (2 and 1, versus 3 as owner), so the count runs through the same gate. **Error shape — read this before writing browser code.** Only what the controller itself rejects (no session, missing CSRF) returns a real HTTP status. Everything the integration refuses — 403, 404, 422, 409 — arrives as **HTTP 200** with `success: false` and an `upstream_status`. Branching on `res.ok` turns a permission denial into a silent empty render. Check `success`. ## HTTP contract `deny` → 404 · `own`-mismatch → 403 · state guard failed → 409. Why those two differ, and how that squares with the IDOR rule, is in the dev skill under *404 or 403 — this is not a contradiction*. Not repeated here: one explanation, one place. ## `public_read` interaction The implied public-read shortcut fires **only when there is no policy block** (`policy === null && public_read`). Once an entity carries any `policy_json`, `public_read` is **ignored entirely** — grants come purely from `rules`. Sensitive entities: set the policy and keep `public_read: false`. ## SSR and governed data `` server-side rendering **does not check `public_read`** (`MapiSsrInjector` r.732, r.760-765, verified on h17 22-09). That was a deliberate change: `public_read` governs the public API, not server-side rendering, and forcing SSR through the public resolver made owners open `/mapi/public` just to show data on their own site. A governed entity therefore renders without error. The constraint that remains is the cache. The key is `mapi:ssr:{website_id}:{entity}` (r.740) — website and entity, **no session dimension**. One cache serves every visitor of that page, so SSR can never carry data that differs per person or per tenant, whatever the policy says. Records do pass through `sanitizePublicRecord`, which strips internal fields, but that is field hygiene, not per-viewer scoping. So: SSR for catalogues, blogs and public listings. For anything a policy governs, fetch client-side from a verified session via the SAPI execute route. **The difference from before is that nothing stops you any more** — the `public_read` check used to fail closed and no longer does. An empty `wps-mapi` block now means the entity name does not resolve, not that the entity is private (supersedes the earlier #1311 note). ## Enforcement boundary Owner/admin (`wsa_`) sessions run at `project` tier (`all`), so admin panels and the data grid work on governed entities with no extra wiring. **Always test a policy activation on a sandbox entity in a sandbox project before flipping `policy_json` on a live table** — the server validates JSON shape, not rule correctness, so a wrong `owner_field` or over-permissive rule is accepted. > **Testing as owner does not exercise the policy at all.** > An owner runs at tier `project` with grant `all`, and `all` performs no ownership matching — so > `owner_field` is never read. A misspelled or non-existent `owner_field` therefore returns rows > normally in an owner test, and only fails (fail-closed, HTTP 500) once a real `verified` or tenant > caller hits the `own` path. > > Verified live on 2026-09-01: setting `owner_field: "kolom_bestaat_niet"` on a governed sandbox entity > returned **all three rows to an owner, with no error**. The same policy would have broken for every > member. > > So an owner test proves only that the entity is governed — never that the scoping is correct. Verify > every policy from an actual member or visitor session before trusting it. ## Worked examples Per-user "My Account" (each verified visitor sees/edits only their own row) — live on Lenshouse: ```json { "owner_field": "email", "rules": { "read": {"verified":"own","project":"all"}, "list": {"verified":"own","project":"all"}, "update": {"verified":"own","project":"all"}, "create": {"project":"all"}, "delete": {"project":"all"} }, "fields": {"verified": {"hide": [""]}} } ``` Org-wide tenant isolation (all users of a tenant share the org's rows; SAPI/`wst_` sessions only): ```json { "owner_field": "tenant_code", "owner_scope": "tenant", "rules": { "read": {"verified":"own","project":"all"}, "list": {"verified":"own","project":"all"}, "update": {"verified":"own","project":"all"}, "create": {"verified":"self","project":"all"}, "delete": {"verified":"deny","project":"all"} } } ``` `create:self` auto-stamps `tenant_code` for the creating tenant; a cross-tenant row → 403; a non-tenant/visitor caller → 404. Verified 12/12 at the gate level (#942, 2026-07-29). **Reachable from a browser via the `records` bridge (#1310)** — read the delivery-path section above for its verification status before you build on this example. ## Triaging a data-access capability request Most "we need a feature for X" requests about member data are **configuration, not code**. Two worked cases: 24451 (`update-entity-policy-json`) turned out to be a live feature the requester could not find, and 27203 asked for a new match mode that already existed under another name (`owner_scope`). In both, the platform answer was a policy plus a pointer. Before scoping any build, separate the two questions: - **"Which rows may this person see?"** → `policy_json`. Almost always configuration. `account.sources` covers per-user reads across arbitrary entities; `records` + `owner_scope: "tenant"` covers org-wide sharing; `gated-files` covers files. - **"Can any service reach this data under the caller's identity at all?"** → that is the question worth building for. Non-owner identity exists only on the SAPI execute route, so a genuine gap is always a **missing route**, never a missing feature. #1310 is the example: one read verb on a route that already carried the identity. If a request does not survive the first question, answer it with configuration and close it — and check whether the customer-facing skill made it findable, because a request that should have been self-service is usually a documentation failure rather than a user error. ## Before you ship a policy The negative cross-tenant test is **required** for every policy change. It lives in the dev skill under *Negative cross-tenant test*, next to the IDOR rule it belongs to — not duplicated here. The short version: you cannot validate a policy as owner. Read *Enforcement boundary* above before you trust anything you tested. --- *Companion reference to the WebsitePublisher.ai dev skill.* *Last updated: 22 september 2026*