Skip to content

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.

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:

  1. Class-level gate. On extraction, refuses with 403 unless the ability’s can_class(Read, TypeId::of::<user::Entity>()) is true.
  2. Shaper trigger. The RouteResponseShaper impl wraps the handler in with_ability(ability, inner) (so Repo::scoped reads the right ability) and runs mask_response::<user::Entity> on the way out.

The GraphQL analog is #[authorize(Action, Entity)] on a #[query]/#[mutation] — same declaration, same automatic mask.

  • 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); the Ability decides what may ship to this caller. A column on neither list never leaves — even an unrestricted grant cannot leak an unexposed column like password_hash.
  • Fail-closed. A successful body that cannot be reconciled with the entity’s Model yields 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.

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 body

Each step has a defined failure mode:

  • Parse fails (the body was not JSON) → original bytes ship.
  • wire_to_model fails (object shape does not match Model) → 500. Fail closed — and the same for a failure on any array element, or a re-serialize failure.
  • Ability::mask runs — any row the caller cannot read drops out (collection responses) or every restricted field becomes null.
  • retain_wire_keys — strips back any field the wire DTO never carried.
pub fn mask<E>(&self, action: Action, model: &E::Model) -> serde_json::Value

Serializes 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.

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.

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 Default impl.
  • Columns whose Default is 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.