Roles
can, Gate and useSession().can — asking a role question inside the current organization, where the roles in it are read from, and why the predicate has no hierarchy.
import { Gate, can, organizationOf, useSession } from "@kanzo-tech/auth";Everything on this page decides what to draw, and none of it decides what is allowed — see the one rule.
Asking the question
can(session, "warden"); // inside session.organization, the hall this request is in
can(session, "warden", "amber"); // inside the hall named
can(session, "gold"); // with no current hall: a realm or client role — Ravenna's rankThe organization defaults to session.organization, the current tenant
the server resolved for this request, so can(session, "editor") asks
the question a page means: here. Clerk's auth().has() binds to the active organization the same
way. Named explicitly, the question moves to that organization. With no current tenant at all, the
realm and client roles answer.
Inside an organization the question is asked against the person's roles there, which is a
different set from their realm and client roles and is deliberately not merged with them. A
role held in one organization says nothing about another, and the day those two sets are unioned
for convenience is the day one organization's owner is every organization's owner. A realm role is
still on session.roles for the page that needs it while a tenant is current.
In a component, useSession().can(role, organization?) is the same predicate bound to the session
the provider holds.
It is closed by default. No session, or no membership of the organization — current or named — is
false rather than a throw — a predicate that raises is a predicate every call site has to guard, and a guard
somebody forgets is a control drawn for a stranger.
organizationOf(session, alias) is the lookup underneath, for when you want the membership itself
rather than a yes or no.
Gate
Ravenna is a warden in her own hall and an archivist or a scout in the two she visits, so the
control appears and disappears without her memberships changing at all. That is the current
organization, drawn — and the predicate is on the screen beside the thing it decides, with the roles the
token carries for that hall above it, because why a gate is shut is the part worth reading. Two of
the five halls are ones she holds nothing in: those answer false because there is no membership at
all, which is a different sentence from being a member and not a warden.
<Gate fallback={<p>Only a warden signs for a party.</p>} role="warden">
<Button>Post a contract</Button>
</Gate>Gate asks through useSession().can, so it asks inside the current organization unless its
organization prop names another.
While the session is loading, neither branch is drawn. Neither answer is known to be true yet, and both of the alternatives are worse: rendering the fallback flashes you cannot do this at somebody who can, for as long as the session endpoint takes, and rendering the children offers a control and then retracts it.
No hierarchy, and no way to configure one
That admin outranks editor is a fact about a product, not about the predicate, so can never
ranks: it asks whether a role is in a set, and nothing else. A hierarchy wired into the library would
apply to every consumer at once, including the ones with three roles that order differently or not
at all.
The product declares its hierarchy once, as Keycloak composite roles, in its own client
registration — admin containing editor, editor containing reader. Keycloak expands a
composite into every token, so an admin's token says admin, editor and reader, and the
question a product asks is the one it means:
can(session, "editor", "amber"); // true for an editor and for an admin, because the token says soThe order then lives in one place both sides read — the realm — rather than in a predicate the
browser runs and a second copy the server runs, which is the one deciding. services/auth/modules/app
is where an application declares its roles and their composites; the Keycloak
page has the rest of what the realm owes.
Roles inside an organization are read from its entry
Each entry of the organization claim carries what the person holds there, keyed by
application exactly as the top-level claim is:
"organization": {
"amber": {
"id": "hall-amber",
"groups": ["/Wardens"],
"resource_access": { "board": { "roles": ["warden"] }, "ledger": { "roles": ["reader"] } }
}
}organization.<alias>.resource_access.<clientId>.roles is the set can(session, role, alias) asks
against. Keycloak writes it from the roles an organization's administrator mapped onto the groups
the person is in, with composites already expanded — so the organization decides who is a warden,
the application decides what a warden contains, and neither has to know how the other arranged it.
Reading under board sees warden and nothing of the ledger's reader, which is another
application's role sitting inside the very same organization; without the client id, a role granted
in the ledger would authorise its holder on the board.
Group names are never read. /Wardens is how the Amber Hall arranged its people, and it is the
organization's own data: it may be renamed, split or called something else in the next hall, and a
role inferred from it would change with it. An application learns its roles and never a group's
name — what grants anything is a role mapped onto a group, not the group.
They are not merged into session.roles either, for the reason above: a
role in one organization says nothing about another. Keycloak keeps them out of the top-level
resource_access too, and the reader does not undo that.
What the token actually carries is a recorded token in this shape, and the live reading under it lets you switch the asking client and watch the same claim set change hands.
Which organization a page is in
The server decides, per request, with kanzoAuth's organization
resolver — a hostname's first label, a path segment, a cookie — and the
answer arrives as session.organization.
That answer is an address and never an authority. Anyone can send Host: amber.kanzo.tech;
what makes an organization real for a session is the claim in a token Keycloak signed. So can
resolves the current organization against session.organizations, and a miss is false — never a
throw, and never the first organization instead. Falling back is how somebody ends up reading
another customer's data believing it is their own. A product that wants to say you are not a
member of this hall checks organizationOf(session, session.organization).
API Reference
can
can(session: Session | null | undefined, role: string, organization?: string): boolean| Argument | Type | Description |
|---|---|---|
session | Session | null | undefined | null is false, not an error |
role | string | The role name, as the token carries it |
organization | string? | Defaults to session.organization; with neither, asks against session.roles |
organizationOf
organizationOf(session: Session | null | undefined, alias: string): Organization | undefinedGate
| Prop | Type | Default |
|---|---|---|
role | string | — |
organization | string? | session.organization, as for can |
fallback | React.ReactNode | null |
children | React.ReactNode | — |
Session
One session shape in the browser, bffAuth behind it, how a session is kept alive and how it ends, and why the current organization is resolved per request rather than stored.
Keycloak
The realm this package expects — role claims on both tokens, an audience mapper naming your API, the organization scope with the star, an application's roles inside each organization, back-channel logout, and passkeys.