Field privacy
Mark a field private or secret on the model, and every API inherits it — REST, GraphQL, the admin, and whatever you write next.
Field privacy
Some columns should not leave the process. Your wholesale cost. An API signing key. A password hash.
The tempting place to enforce that is the API layer — RestPlugin::hide("product", "cost") — and for presentation that's right. But it means each API re-derives what is confidential, and you don't get three independent policies. You get one policy with three chances to forget it, where forgetting is a breach. That's exactly how umbral's own GraphQL plugin shipped able to serve password hashes: REST had guarded them for a year, and GraphQL, written later, inherited none of it.
So secrecy is declared on the model, because it's a property of the data rather than of the door someone walks through to reach it.
#[derive(Model)]pub struct Product { pub id: i64, pub name: String, #[umbral(private)] // confidential — but staff may see it pub cost: String, #[umbral(secret)] // nobody sees it. ever. pub signing_key: String, pub api_token: Masked<String>, // secret automatically — no annotation needed}Every serialized read now omits those columns — REST payloads, GraphQL schemas, the admin, and any plugin written in future — without any of them being asked to cooperate. They aren't stripped from the response; they are never SELECTed, so the value doesn't cross the database boundary at all.
The two tiers
#[umbral(private)] | Hidden by default. Unlockable by a read that explicitly asks. |
#[umbral(secret)] | Never serialized. No unlock exists, anywhere. |
private is for data that some callers legitimately see: wholesale cost, internal notes, another user's email. secret is for data that no client should ever receive, whoever they are — and it deliberately has no escape hatch, because the value of a tier you can't reach for is that nobody reaches for it at 2am under a deadline. An admin that needs to show whether a password is set shows "set / not set", never the hash.
password_hash is denied by name, on every table, even if you never annotate anything. Annotations only protect the people who remember to write them, and this exists for the people who don't.
private is a read policy. It does not guard writes.
This is the part worth reading twice, because the name invites the wrong assumption.
private answers "who may SEE this?" It says nothing about who may SET it, and it must not, because the two questions have different answers in real applications. A storefront takes cost on the create form and never shows it back. A support tool lets an agent file an internal_note nobody can read through the API afterwards. That column is write-only, and it is a perfectly ordinary thing to want.
So a caller who is allowed to write the resource may write a private column, and still cannot read it back:
POST /api/product/ {"name": "Widget", "cost": "0.01"} → 201, and the response has no `cost`GET /api/product/1 → still no `cost`GET /api/product/1 (as staff, with an unlock) → "cost": "0.01"To stop a column being written from an untrusted body, reach for a different attribute:
| Want | Use |
|---|---|
| Hide it from responses | #[umbral(private)] (+ allow_private_if to unlock for some callers) |
| Stop it being set by a client | #[umbral(privileged)] — the mass-assignment guard |
| Neither readable nor writable, ever | #[umbral(secret)] |
| Not a form field at all | #[umbral(noform)] / #[umbral(noedit)] |
Your OpenAPI spec describes a never-readable-but-settable column with OpenAPI's own word for it, writeOnly: true, rather than omitting it and leaving a client unable to discover a field it is allowed to send.
Unlocking a private field
Over REST, on the resource:
ResourceConfig::new("product") .allow_private_if("cost", |id| id.is_some_and(|i| i.is_staff))GET /api/product/1 now returns cost to staff and omits it for everyone else — same URL, same handler. The unlock governs reads, and only reads: see above. Anyone who may write the resource may set cost; #[umbral(privileged)] is what stops them.
One path cannot describe two response shapes, so a conditionally-visible column is emitted in your OpenAPI spec as optional — cost?: string — with a description of who gets it. That is the honest answer and it is correct for both audiences: the field genuinely may or may not be there, and a generated TypeScript client will make the consumer check.
Over GraphQL, on the plugin:
GraphqlPlugin::new() .expose("product") .allow_private_if("product", "cost", |id| id.is_some_and(|i| i.is_staff))The schema is one document for everyone, so the field exists and is nullable, and who gets a value is decided per request. Writes are a separate question: the field is in the input type for everyone who may write the model, because private governs reads. GraphQL expresses that natively — an input field need not exist on the object type, which is exactly a write-only column.
From your own handlers, the same unlock is a call on the queryset:
DynQuerySet::for_meta(&meta) .allow_private(&["cost"]) .fetch_as_json() .await?Per-field, at the call site, on purpose. The verbosity is the audit trail: grep -rn allow_private gives you a complete inventory of every place in your codebase where confidential data is permitted to leave. A per-audience switch ("this whole surface is trusted") would be one line — and then adding a new private field a year later would silently widen it.
Naming a secret column here does nothing. That's not an oversight; it's the tier.
This mirrors the write path, which has worked this way all along: #[umbral(privileged)] marks fields like is_staff that an untrusted JSON body may not set, and allow_privileged is how a caller who has checked opts them back in. private/allow_private is the same shape, pointed at disclosure instead of mass assignment.
Backups are not clients
DynQuerySet::for_meta(&meta).unredacted_for_backup()dumpdata reads through this, and it has to: a fixture without password_hash restores a database where nobody can log in, and one without the Masked ciphertext restores empty encrypted columns.
Two consequences. A dump file contains your secrets — treat it like one. And grep -rn unredacted_for_backup should only ever return backup code. If it appears on a path that answers HTTP, that's the bug.
What stays in the plugins
Field privacy is on the model. Field selection is not: RestPlugin::hide and GraphqlPlugin::hide are still per-plugin, because "which fields this endpoint returns" is a property of the endpoint, and REST and GraphQL are swappable. Hiding a field in one does not hide it in the other.
The line: if forgetting it means data leaks, it belongs on the model. If forgetting it means the API is uglier than you'd like, it belongs in the plugin.
See planning/gaps3.md #70–#71 for the design discussion.