See the security payoff
Before you wire persistence into your own posts, see where the
framework is heading. The reference Publish workspace ships auth
and api fully built on users and orgs. In four curls you watch one
JWT drive row-level scoping and field masking — no check written in any
handler. By the end of this page an admin sees every column, a member
sees the same rows with email gone, and a cross-tenant read returns
403.
Run the reference workspace
Section titled “Run the reference workspace”The tutorial blog app has no auth yet — that is deliberate. To live the
payoff now, drive the reference api app over users and
orgs. It already wires the guards, the ability, and the masker you
build by hand later in
Authenticate and authorize.
-
From the Publish repo, load the demo tenants and start both apps.
Terminal window $ nestrs run db resetseed: 12 row(s) inserted$ nestrs run dev auth # token issuer on :3001$ nestrs run dev api # resource server on :3002db resetseeds two orgs — Acme and Globex — each with an admin and members. Every demo account shares the passwordpublish-demo. -
Sign in as the Acme admin and read the org’s users.
Terminal window $ ADMIN=$(curl -s localhost:3001/login -H 'content-type: application/json' \-d '{"email":"admin@acme.test","password":"publish-demo"}' | jq -r .access_token)$ curl -s localhost:3002/users -H "authorization: Bearer $ADMIN" | jq -c '.[0]'{"id":"018f…","name":"Acme Admin","email":"admin@acme.test"}The admin sees every field —
emailincluded — and only Acme rows. -
Sign in as a plain member and run the identical query.
Terminal window $ MEMBER=$(curl -s localhost:3001/login -H 'content-type: application/json' \-d '{"email":"acme-user-1@example.test","password":"publish-demo"}' | jq -r .access_token)$ curl -s localhost:3002/users -H "authorization: Bearer $MEMBER" | jq -c '.[0]'{"id":"018f…","name":"Acme Admin"}Same rows, same route — but
emailis gone from every object. The member’s ability grants onlyidandname. -
Read a user in the other tenant with the member’s token.
Terminal window $ curl -s -o /dev/null -w '%{http_code}\n' \localhost:3002/users/00000000-0000-7000-8000-000000006101 \-H "authorization: Bearer $MEMBER"403That row belongs to Globex, so the member’s org-scoped ability refuses it — a
403, decided by the framework, not the handler.
What just happened
Section titled “What just happened”One policy — the caller’s Ability — drove three outcomes with no
authorization code in the controller. The row-level .when(...)
condition scoped both listings to the caller’s org; the field-level
.fields([...]) grant dropped email for the member; and the by-id bind
turned a cross-tenant load into a 403. The UsersController that
produced all three is the same thin CRUD shape you are about to write for
posts — it never reads the tenant or the role.
You build this yourself — the guards, the AppAbility, the Bind
extractor — in Authenticate and authorize, once
your posts feature persists and is tested.
Going further
Section titled “Going further”- Expose over HTTP — the next step: back in
blog, write the service and mount the CRUD routes.