Response masking
The third layer of authorization runs after the handler returns — and the
80% of this page is one sentence: declare Authorize<A, S> as a handler
parameter and masking runs automatically after every successful
response. Fields the caller cannot read are stripped, rows they cannot
see are dropped, and a body that cannot be verified fails closed (500)
rather than shipping unmasked. The route never calls mask itself.
Declare it — Authorize<A, S>
Section titled “Declare it — Authorize<A, S>”Authorize<A, S> is the handler-level extractor that arms the shaper.
Declare it as a parameter; the #[routes] orchestrator detects it and
installs the shaper:
use nest_rs_authz::{Read, http::Authorize};use sea_orm::EntityTrait;
#[get("/")]async fn list( &self, _authz: Authorize<Read, user::Entity>,) -> Result<Json<Vec<User>>> { Ok(Json(self.svc.list().await?.into_iter().map(User::from).collect()))}Authorize<Read, user::Entity> does two things:
- Class-level gate. On extraction, refuses with 403 unless the
ability’s
can_class(Read, TypeId::of::<user::Entity>())is true. - Shaper trigger. The
RouteResponseShaperimpl wraps the handler inwith_ability(ability, inner)(soRepo::scopedreads the right ability) and runsmask_response::<user::Entity>on the way out.
The GraphQL analog is #[authorize(Action, Entity)] on a
#[query]/#[mutation] — same declaration, same automatic mask.
What you get
Section titled “What you get”- Field masking. Restricted fields become
null(single objects) per the ability’s.fields([...])grants. - Row dropping. In collection responses, rows the caller cannot read drop out entirely.
- Two-layer defense. The wire DTO decides what may ever ship
(column-level allowlist — no
#[expose], no wire); theAbilitydecides what may ship to this caller. A column on neither list never leaves — even an unrestricted grant cannot leak an unexposed column likepassword_hash. - Fail-closed. A successful body that cannot be reconciled with the
entity’s
Modelyields 500 rather than shipping unmasked. A misconfigured route surfaces as a 500 in prod; the alternative — shipping unmasked data — is worse.
Non-2xx, non-JSON, and scalar responses pass through untouched.
Pair every masked route with a negative-path test that pins the masked body shape — a regression surfaces immediately, not after an audit.
How it works (internals)
Section titled “How it works (internals)”The pipeline after a 2xx
Section titled “The pipeline after a 2xx”Handler returns Json<UserWire> │ ▼ body in bytes │parse JSON → Value │wire_to_model::<S> ← fill unexposed columns via WireModelDefaults │ability.mask::<S> ← typed: filters by row, masks by field │retain_wire_keys ← drop anything not on the wire DTO │serialize → response bodyEach step has a defined failure mode:
- Parse fails (the body was not JSON) → original bytes ship.
wire_to_modelfails (object shape does not matchModel) → 500. Fail closed — and the same for a failure on any array element, or a re-serialize failure.Ability::maskruns — any row the caller cannot read drops out (collection responses) or every restricted field becomesnull.retain_wire_keys— strips back any field the wire DTO never carried.
Ability::mask — what it strips
Section titled “Ability::mask — what it strips”pub fn mask<E>(&self, action: Action, model: &E::Model) -> serde_json::ValueSerializes the model, then keeps only the fields permitted by the
matching grants. The rule shape (.fields([cols])) decides; the
union across matching grants wins.
pub fn mask_many<'m, E>( &self, action: Action, models: impl IntoIterator<Item = &'m E::Model>,) -> Vec<serde_json::Value>For a collection: drops the models the caller cannot see
(Ability::can returns false), masks the rest. The
retain_wire_keys step runs per item afterwards.
A grant with no .fields returns FieldSet::All — every column is
permitted. A grant with .fields([Id, Name]) returns
FieldSet::Only({"id", "name"}). The masker unions:
FieldSet::Only(union_of_grants) unless any matching grant is
FieldSet::All, which clears the restriction.
retain_wire_keys — the defense in depth
Section titled “retain_wire_keys — the defense in depth”After Ability::mask produces a serde_json::Value, the shaper
drops keys absent from the original wire body:
fn retain_wire_keys(masked: &mut Value, wire: &Value) { let (Some(masked_obj), Some(wire_obj)) = (masked.as_object_mut(), wire.as_object()) else { return; }; masked_obj.retain(|key, _| wire_obj.contains_key(key));}Why this step exists: an Ability grant with no .fields permits
every column on the Model. If the Model carries a
password_hash field (server-only, never carries #[expose] so it is
absent from the wire DTO), an unrestricted grant would have it pass Ability::mask
untouched. retain_wire_keys then drops it because it was never on
the wire DTO to begin with.
WireModelDefaults — filling the gaps
Section titled “WireModelDefaults — filling the gaps”To run Ability::mask the shaper needs a typed S::Model. The wire
body, by design, omits every unexposed column. WireModelDefaults
fills them with placeholder values so deserialization succeeds:
pub trait WireModelDefaults: EntityTrait { fn fill_wire_defaults(_map: &mut Map<String, Value>) {}}The #[expose] macro emits an impl per entity that fills every
unexposed scalar column with a JSON default ("", 0, null). The
defaults are discarded again by retain_wire_keys before the body
ships — they never reach the wire.
A few column types the macro cannot default automatically:
Decimal(no canonical zero in JSON).- Custom Rust enums with no
Defaultimpl. - Columns whose
Defaultis meaningful business state.
Hand-write a WireModelDefaults impl for those entities:
impl WireModelDefaults for invoice::Entity { fn fill_wire_defaults(map: &mut Map<String, Value>) { map.entry("amount").or_insert(serde_json::json!("0")); map.entry("currency").or_insert(serde_json::json!("USD")); }}A handler returning a body that does not deserialize even with
defaults yields 500 with "response masking failed: body did not match the authorized subject type". The test bench is the right
home for noticing this — a unit test that runs the shaper against
the handler’s Json<T> shape pins it.
Going further
Section titled “Going further”- Policies —
.fields([...]), which feedsAbility::mask. - Row-level filtering — the layer that filters rows, complementing the field mask.
- Authorization — engine overview.