Public reads
A resource anyone may read is declared in AbilityFactory::define_visitor — the anonymous branch of the same policy, granting nothing until you write it.
A blog needs published posts readable without a login. The rules for
that caller live in the same AbilityFactory as everything else, in a
second branch: define_visitor. It receives no actor because there is
none, and it grants nothing until you write a rule into it.
use nest_rs::authz::{AbilityBuilder, AbilityFactory, Action};
impl AbilityFactory for AuthzAbility { type Actor = Claims;
fn define(&self, actor: &Claims, ab: &mut AbilityBuilder) { /* … */ }
fn define_visitor(&self, ab: &mut AbilityBuilder) { ab.can(Action::Read, post::Entity) .when(|p| p.eq(post::Column::Status, PostStatus::Published)); }}Everything downstream is the machinery you already have: the grant
becomes the WHERE clause on the visitor’s query, the class-level gate
on the route, and the field set the response mask keeps.
Wire it in
Section titled “Wire it in”The route declares two things, and it needs both.
#[get("/")]#[public]async fn list(&self, _authz: Authorize<Read, post::Entity>) -> Result<Json<Vec<Post>>> { Ok(Json(self.svc.feed().await?))}feed is the service method, and it is where the boundary work belongs:
CrudService::list yields the entity’s Model and a DbErr, neither of which
crosses the wire, so the service returns Result<Vec<Post>, ServiceError> and
the handler stays a delegation.
pub async fn feed(&self) -> Result<Vec<Post>, ServiceError> { Ok(self.list().await?.iter().map(Post::from).collect())}#[public] is the posture. It tells AuthnGuard to admit a caller
with no credential, and it tells AuthzGuard to build the ability from
define_visitor instead of define.
Authorize<Read, post::Entity> is the enforcement plumbing that
posture needs: it installs the ambient ability the data layer reads and
arms the response mask. This is the one route shape where you write the
extractor by hand — #[authorize(...)] and #[public] declare opposite
postures, so the decorator form is a compile error here.
Delete that parameter and the route answers 200 []. It is prefixed _
and looks unused, so it is what a cleanup pass removes — and the symptom is the
one symptom that does not point back at it. The visitor’s ability still reaches
the request; nothing installs it as the ambient one, so Repo falls back to
its no-ability behaviour and renders 1 = 0. Fail-closed, and not silent: the
warn on nest_rs::orm names the entity, the action and the remedy.
WARN nest_rs::orm: no ambient Ability outside a worker job — denying all rows entity=features::posts::entity::Entity action=Read scope=Some(Request) hint="bind #[use_guards(AuthnGuard, AuthzGuard)], or — on a #[public] route — take the `Authorize<A, E>` parameter that arms the shaper"Grep that target the first time a route returns an empty collection you did not
expect. On a detail route reach for Bind<Read, PostsService> instead; it is a
shaper too.
A token still takes the authenticated branch
Section titled “A token still takes the authenticated branch”define_visitor answers for the anonymous caller only. On a #[public]
route reached with a valid token, AuthnGuard attaches the principal
and AbilityGuard calls define — the visitor rules are not a floor
added under every caller.
That is what makes one route serve both audiences without a branch in
the handler: a visitor sees published posts because define_visitor
grants them, and a signed-in author sees their drafts too because
define grants those. The handler calls self.svc.feed() either way.
Reviewing a #[public] route
Section titled “Reviewing a #[public] route”The reach of #[public] grows with this feature, and that is the
point of it. Before, the marker could only widen authentication:
AuthzGuard built an empty ability for the anonymous caller, so no
#[public] route could return a row of a guarded entity, however it was
written. Now the marker also selects which half of the policy runs.
So reading the diff that adds #[public] no longer tells you what the
route exposes. The pair does:
- the
#[public]marker on the route, and - what
define_visitorgrants for that entity.
Both are greppable, both stay in one file each, and neither is inferred.
A route that turns #[public] while define_visitor stays empty
exposes nothing — it answers 403. A grant added to define_visitor
reaches only the routes marked #[public]. Review the two together, and
treat an edit to define_visitor with the weight of a route-exposure
change, because that is what it is.
Limits
Section titled “Limits”The default grants nothing, on every transport. An app that never
implements define_visitor behaves exactly as it did before the method
existed: an anonymous caller on a #[public] route gets an ability with
no rules, and the class gate answers 403.
One correction covers HTTP, GraphQL and WebSockets. The GraphQL
bridge and the WS upgrade both run AbilityGuard::check_http, so the
visitor branch reaches a #[public] query and a guest socket the same
way. On WebSockets the visitor ability is captured at the upgrade and
frozen for the connection, like any other.
On GraphQL the operation’s posture, not the endpoint, holds the
line. /graphql is one #[public]-marked endpoint, so the anonymous
caller always reaches the resolvers carrying the visitor ability. An
operation declared #[authorize(...)] refuses it for want of a
principal (UNAUTHENTICATED) before consulting a single grant — which
is what keeps the sentence above true on that transport: a visitor grant
opens the #[public] operations on that entity and no others.
MCP refuses anonymous callers by design. The /mcp endpoint carries
no Public marker, so an unauthenticated tool call is denied before the
factory is consulted. define_visitor does not open it, and adding the
marker there is not the supported path.
A malformed visitor rule denies the request. A rule whose relation
predicate cannot be lowered fails ability construction, and the guard
answers 500 rather than installing a degraded ability — the same
fail-closed behavior the authenticated branch has.
Going further
Section titled “Going further”- Policies — the builder API both branches share.
- Row-level filtering — what the visitor’s grant becomes in SQL.
- Response masking — how
.fields([...])narrows a visitor’s response.