Authorization
Yayaw RBAC, scoped group-role bindings, policy evaluation, and dashboard authorization operations.
Overview
Yayaw uses Better Auth for identity, sessions, organizations, and membership records. Application authorization is handled by Yayaw's own RBAC engine on top of those identities.
The model is group-based:
Users belong to groups.
Groups receive roles through explicit bindings.
Roles contain policies.
Policies grant or deny actions on resources.
Bindings and checks can be scoped globally, by organization, or by resource.
The boolean can(...) helper is the facade most route loaders, server actions,
and services use. The detailed evaluator records the decision chain for
debugging and audit workflows.
Core Concepts
Resources are declared in src/lib/server/authz/contracts.ts. Current resource
families include:
organizations and members
platform users
API keys
billing plans, products, entitlements, webhook events, and code access
control-plane audit events
authorization administration resources
media, pages, sections, components, dynamic data, global data, design tokens, and themes
system, organization, and user-preference settings
Actions are:
readlistcreateupdatedeleteinvitemanagepublish
manage is the broad administrative action for a resource. Prefer narrower
actions when a UI or service only needs read/list/update behavior.
Tables
Authorization tables:
groupsgroup_membershipsrolesrole_policiesgroup_role_bindingsauthorization_audit_eventsauthorization_decision_logs
group_role_bindings is the key scope table. A group can receive a role with:
globalscopeorganizationscoperesourcescope
This is why organization access is modeled as roles applied to groups, not as direct user-to-permission rows.
Seeded Roles
The seed creates the platform roles:
Super AdminOrganization AdminOrganization ManagerOrganization MemberAuthenticated UserScreen Viewer,Screen EditorandScreen Publisher, the levels of access to one dashboard screen (see Access to one screen)
Super Admin receives manage coverage for every declared resource and is bound
to the superadmins group.
Platform user administration uses the user:manage resource/action pair. Do
not use Better Auth's user.role as Yayaw authorization authority; Yayaw
permissions remain exclusively users -> groups -> roles -> policies.
When an organization is created, Yayaw creates organization-scoped groups:
<organizationSlug>-admin<organizationSlug>-manager<organizationSlug>-member
Each group is bound to the matching organization role with
scopeType = "organization" and scopeId = <organizationId>.
The organization creator is added to <organizationSlug>-admin, because
organization administration is represented by Yayaw group membership and scoped
role bindings rather than by reading Better Auth role strings directly.
Organization Sync
Better Auth organization hooks keep Yayaw authorization groups synchronized:
organization creation creates default groups and assigns the creator to admin
invitation acceptance adds members to the right organization group
member role changes move memberships between organization groups
a member who leaves, whichever way, leaves every group the organization holds for them (below)
Key services:
src/lib/server/services/authz/organization-setup-drizzle.tssrc/lib/server/services/authz/better-auth-sync-drizzle.tssrc/lib/server/services/authz/organization-departure.tssrc/config/better-auth.config.ts
Do not create Better Auth organization members through a path that bypasses the sync service unless the code also repairs the corresponding group membership. Do not remove one through a path that skips the departure below.
Leaving an organization
Every way out takes the same memberships, found by one function
(organization-departure.ts) on the caller's transaction:
| Path | When the memberships go |
|---|---|
Removing a member (/organization/remove-member) | In one transaction, from Better Auth's afterRemoveMember hook, right after the removal |
Leaving on one's own (/organization/leave) | The same step, run after the endpoint by createOrganizationLeavePlugin: Better Auth runs no member hook for it |
| Platform user administration | In the transaction that deletes the membership, with its Better Auth teams |
| Deleting the organization | Read before the deletion, since its screens and records cascade with it, and removed once it is done |
What goes:
every group the organization scopes: its admin, manager and member groups, and any other group scoped to it;
its teams' groups;
a role group created before groups carried their organization's scope, by its exact slug (
<organizationSlug>-admin,-manageror-member), never by a prefix another organization's slug could share;the own groups of its screens, and of the records a product's kind assigns to it (the kind's
organizationField, or a model stored per organization), however that access was given, expired memberships included.
What stays: a group also scoped to another organization the member still belongs to, the global groups, and the own groups of a resource of another organization or of none. A kind whose records cannot be read keeps its access and never stops the rest. SCIM never removes an organization membership: Yayaw configures no SCIM projection. Deleting an account removes all of its memberships with it.
Policy Evaluation
Main files:
src/lib/server/authz/can.tssrc/lib/server/authz/policy-loader.tssrc/lib/server/authz/drizzle-group-membership-delegate.ts
Evaluation rules:
unknown resources/actions deny by default
matching deny rules win over allows
binding scope must match the requested check scope
organization-scoped checks require the organization id in scope
resource-scoped checks require the resource id in scope
authorizeDetailed(...)returns a decision chain for explainabilitycan(...)returns a boolean for route and action guards
Server-side authorization must remain in the mutation/query path. UI navigation visibility is only a convenience and must never be the sole access control.
Dashboard Admin
Authorization administration lives at:
/dashboard/admin/authorization/dashboard/admin/authorization/groups/[groupId]/dashboard/admin/users
The admin UI supports:
role and policy inspection
group list/detail views
group membership management
group-role binding management
platform user listing, organization membership maintenance, and default organization updates
quick permission evaluation
decision/audit diagnostics
The authorization screen
/dashboard/admin/authorization (route permission group:manage) is a global
dashboard screen, dashboard.admin.authorization: one copy for the whole
platform, which only Super Admins customize (see Dashboard). A
customization can change its layout, but the groups directory always stays.
Who sees the groups: holders of a global
group:list(group:manageallows it). Agrouppolicy bound to an organization never opens it: the check is made without any organization. Whether a person may manage groups is a globalgroup:manage, and every group action checkscan()again on the server.Numbers: groups, group memberships, role bindings, SCIM groups and read-only groups, counted on every group whatever the table's search, and read live after a change.
Groups directory: name, slug, source, members, bindings and the last update; read-only, nested groups, scopes and the link can be shown. Clicking a row opens the group's page (
/dashboard/admin/authorization/groups/[groupId]). The counts include every stored membership, binding and nested group, expired ones too. No external or SCIM id is ever read.Views: the screen's own view (every group by name, 20 a page), then Local groups and Provisioned groups (SCIM and synced groups), then saved views. Any of them can be a person's favorite, and saved views can be shared with the organization.
Modes: table, list, a kanban by source (cards cannot be dragged) and charts.
Search and order: the search reads the name and the slug; names sort as people read them, case-insensitive ("Group 2" before "group 10"); a page holds at most 100 rows.
Actions: Create asks for a name, a slug, a policy mode and a description, then creates an organization group through the generated group action, which checks
group:createon the server. A selection, or every group the view matches, can be exported.Quick evaluator: explains one decision (a resource, an action, an optional organization and a person) and records it, for holders of a global
group:list, which its options need. Explaining another person's decision needs a globalauthorization-decision-log:manage; the server checks both again.
The users surface lets superadmins add a user to another organization without
removing existing memberships, change a Better Auth organization membership
role, remove memberships, and start a Yayaw RBAC-checked impersonation session.
The impersonation endpoints intentionally wrap Better Auth's session mechanics
behind user:manage checks and block impersonating another user who also has
user:manage. When the original admin session cookie is no longer available,
stopping impersonation safely ends the impersonated session and requires the
administrator to sign in again instead of leaving the user trapped in the
impersonated account.
Use this page to inspect why a user can or cannot access a resource before changing seed policies.
Route and Action Contracts
Routes declare permission intent in the navigation/route config. Generated database actions also declare resource/action pairs. Contract tests ensure declared resources stay inside the canonical authz contract.
Important test:
bun test src/lib/server/authz/contracts.test.tsThis guards:
navigation resources
generated DB action resources
Super Admin seed coverage
user admin repair coverage
section authz repair coverage
navigable dashboard roots
Common Resource Expectations
Organization defaults:
| Resource | Member | Manager/Admin |
|---|---|---|
page | list/read | manage |
sections | list/read | manage |
media | list/read | manage |
code-access | list/read | manage |
dynamic-data | list/read | manage |
Super Admin gets platform-global manage access, including billing products,
feature flags, system settings, registry templates, platform users, and
authorization resources.
Dynamic data model checks should pass scope.orgId for organization models and
scope.resourceId when operating on a specific model, so global, organization,
and resource-scoped bindings and deny rules remain effective.
Code access still requires billing eligibility. code-access:read allows a user
to reach the code-access surface, but the billing service decides whether paid
deliverables are unlocked.
Access to one screen
Organization Admins manage every dashboard screen of their organization
(screen:manage). From Organization › Screens, Manage access gives
other people a level on one screen, or on every screen of its menu section:
| Level | Role | screen actions |
|---|---|---|
| View | Screen Viewer | read |
| Edit | Screen Editor | read, update: review the pending draft, save drafts |
| Publish | Screen Publisher | read, update, publish: also publish or discard the draft, reset to the default |
These roles grant nothing alone. They are held only through a binding scoped
to one screen (scopeType = "resource", the screen's dashboard_screens id)
or to one menu section of one organization (scopeType = "parent",
screen-section:<organizationId>:<section>), never to an organization or
globally:
a member joins the screen's own group for the level, one group per level, as for any resource's access groups (a system screen gets an empty row first, so it has an id);
one of the organization's teams, or its member group (everyone in the organization, its admins and managers included), is bound to the screen at the level;
a team or everyone is bound at the level to the screen's section, which covers every screen in it, present and future.
Only the organization's current members, its teams and its member group can
be named, and a person holds one level on a screen or a section. Every check
names the screen as stored: resourceId, resourceOrganizationId and its
section as parents. It also requires the screen-customization flag and
membership of the active organization; a change asks afresh and is refused
while impersonating. A person who leaves the organization leaves the
screens' own groups, and the teams and member group they belonged to.
A dynamic data model's screen (its records page,
dynamic-data.records.<model id>) is delegated the same way: its section is the model's menu section
(Admin by default, Content, Organization or the model's own), so access on
that section covers every model screen in it, and access on the screen
covers that model's alone. Screen access never opens the model itself:
whoever may not list the model (dynamic-data:list or manage on it, or
over its scope) gets no page, whatever their level on its screen.
MCP never changes who has access: no screen tool reaches it, and the
yayaw_resource_access_* tools administer the resource kinds registered
products declare, which can never be screen. A screen grants no data: each
widget still reads its source through that source's own check.
Adding a Resource
When adding a resource:
Add it to
RESOURCESinsrc/lib/server/authz/contracts.ts.Add seed policies for Super Admin.
Add organization-role policies when the resource is organization scoped.
Add migrations or repair SQL for existing installs when needed.
Add or update route config permissions.
Add generated action mappings if the resource has database actions.
Update English docs and
content/llm/llm-source.mdwhen the resource changes architecture or assistant guidance.Run authz contract tests.
Debugging Access
When a user cannot access a page:
Confirm the active organization is correct.
Confirm Better Auth membership exists.
Confirm the corresponding Yayaw group membership exists.
Confirm the group has a scoped role binding.
Confirm the role has a policy for the requested resource/action.
Use the quick evaluator in
/dashboard/admin/authorization.Check
authorization_decision_logswhen detailed logging is enabled.
Validation
Useful checks after authz changes:
bun test src/lib/server/authz/policy-loader.test.ts
bun test src/lib/server/authz/contracts.test.ts
bun test src/lib/server/services/authz/authz-service.test.ts
bun run check
bunx tsc --noEmit