# Authorizing reads & writes

The wire carries only `(name, args)` — identity never crosses it. The API server derives the principal from its own session, resolves each named query to a scoped AST the client can't widen, and drives every mutator with the authenticated `ctx.user`. Read scope is encoded into the query; write scope is a guard in the mutator that `throw`s and snaps back.

Authorization in a Rindle app rests on one fact: **the wire carries only `{ name, args }`** — never the
actor. A client names a query and a mutation; the [API server](/docs/api-server) supplies the identity
from its *own* trusted session. So there's no owner arg to tamper with, and three seams to get right:

1. **Authenticate** — derive the principal server-side, ignoring anything the client claims.
2. **Gate reads** — resolve each named query to an AST *scoped to that principal*, which the client can't
   widen.
3. **Gate writes** — drive each mutator with the authenticated `ctx.user`, and `throw` to refuse.

## Authenticate: the principal is off-wire

The client predicts under *its* session user so its optimistic rows match, but the authority re-derives
the identity from its session cookie and never trusts a header. `ctx.user` is that authenticated value.

## Gate reads: scope the query, don't add an ACL layer

A [named query](/docs/api-server) resolves on the server to a **byte-identical** AST — a client can't
smuggle a different shape. To scope it, encode the principal *into* the AST. A **context-scoped** query
takes the authenticated `ctx` and filters by it:

```ts
// defined ONCE, runs on both tiers — the OWNER is not an arg
export const myIssuesQuery = defineQuery(
  "myIssues",
  validateMyIssuesArgs,                                  // → { limit }
  ({ limit }, ctx: { user: string | undefined }) =>
    q.issue.where.ownerId(ctx.user ?? "").orderBy("createdAt", "desc").limit(limit),
);
// wire carries { name: "myIssues", args: { limit } } — the user is re-derived, never sent
```

When the server must add a filter the client shouldn't even see — a tenancy or sharing rule — register a
**server-only** resolver under the same name; it wins, and the client never learns the extra clause:

```ts
import { defineApiQueries, registerQueries } from "@rindle/api-server";

const serverOnly = defineApiQueries({
  issuesPage: (ctx, args) => issuesPageQuery.resolve(args).where.ownerId(requireUser(ctx.user)),
});
const apiQueries = { ...registerQueries([issuesPageQuery]), ...serverOnly };  // later entry wins
```

A blanket `authorizeQuery(ctx)` gate can also refuse a caller outright (e.g. no session at all) before
any query resolves.

## Gate writes: the mutator runs as `ctx.user`, and a `throw` refuses

`sharedApiMutators` drives your [isomorphic mutators](/docs/mutators) on the server with the
**authenticated** principal injected as `ctx.user` — never a client-supplied author. A precondition that
fails just `throw`s: on the server that rolls the transaction back, and the client's optimistic write
[snaps back on its own](/docs/rejected-writes).

```ts
// a server-authoritative guard: read the row, refuse if the caller isn't the owner
const user = requireUser(ctx.user);              // throws if unauthenticated → rejected
const deck = await tx.query(q.deck.where.id(a.id).where(owned.owner_id(user)).one());
if (deck == null) throw new RindleApiError("forbidden", "not your deck", 403);
```

Ownership can also be expressed *inside* the shared body as a no-op — a write that simply does nothing
for a non-owner, identically on both tiers. Either way, identity comes from `ctx.user`, so it's the same
on the optimistic prediction and the authoritative run.

## Defense in depth, not UI trust

Hiding an edit button is a UX nicety; it is **not** the authorization. Compute a `canEdit` in the client
to hide affordances, *and* enforce it on the server — the server rejects a forbidden write even if the
client sent it anyway.

## In the wild — Strut

[Strut](https://github.com/tantaman/strut) authorizes a multi-tenant, shareable deck editor exactly this
way. The principal is derived from the session and the client's `x-user` header is ignored — this is the
trust boundary:

```ts
// server/session.ts
export async function resolveSessionUser(request: Request): Promise<string> {
  const auth = await getAuth(request)
  const session = await auth.api.getSession({ headers: request.headers })
  return session?.user.id ?? ''
}
```

*[`server/session.ts` L12–24](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/server/session.ts#L12-L24) · Strut*

Reads are gated by a **server twin** of each query. The client's `decksQuery` is un-scoped
([`shared/queries.ts` L36–45](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/shared/queries.ts#L36-L45)); the server registers the
same name with an access predicate — *owner OR shared-with* — that the client can't remove:

```ts
// server/queries.ts — the authoritative twin
function deckAccess(user: string) {
  return or(deck.owner_id(user), existsNoSync(rels.deckShares, (s) => s.where.user_id(user)))
}
const decksQuery = defineQuery('decks', /* validate */, ({ limit }, ctx: Ctx) =>
  q.deck.where(deckAccess(ctx.user)).include(DeckFragment).orderBy('modified', 'desc')
    .limit(limit).countAs('slideCount', rels.deckSlides),
)
```

*[`server/queries.ts` L40–56](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/server/queries.ts#L40-L56) · Strut*

The API server wires the principal in and gates every query/mutation on a present session:

```ts
// server/rindle-api.ts
const ctx = { user: await resolveSessionUser(request), request }
// …
authorizeQuery:    ({ user }) => typeof user === 'string' && user.length > 0,
authorizeMutation: ({ user }) => typeof user === 'string' && user.length > 0,
```

*[`server/rindle-api.ts` L405–423](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/server/rindle-api.ts#L405-L423) · Strut* — write guards like `setDeckVisibilityGuarded` add per-mutation ownership + entitlement checks that `throw` a `403` (see [handling rejected writes](/docs/rejected-writes)).

On the read side, the editor computes `canEdit` (owner, or an `editor`-role share) to hide editing
chrome — *defense in depth*, since the server rejects a disallowed write regardless:

```tsx
// src/routes/deck.$deckId.tsx
const canEdit =
  !!deck &&
  (deck.owner_id === me || shares.some((s) => s.user_id === me && s.role === 'editor'))
```

*[`src/routes/deck.$deckId.tsx` L60–63](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/routes/deck.$deckId.tsx#L60-L63) · Strut*

## See also

- [The API server](/docs/api-server) — named-query resolution, `defineApiQueries`, `sharedApiMutators`,
  and the `authorize*` hooks in full.
- [Isomorphic mutators](/docs/mutators) — `ctx.user`, the off-wire principal, and no-op-for-non-owner.
- [Handling rejected writes](/docs/rejected-writes) — what the client sees when a server guard throws.

---

[View this page on Rindle](https://rindle.sh/docs/authorization)
