Docs

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:

  1. Users belong to groups.

  2. Groups receive roles through explicit bindings.

  3. Roles contain policies.

  4. Policies grant or deny actions on resources.

  5. 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:

  • read

  • list

  • create

  • update

  • delete

  • invite

  • manage

  • publish

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:

  • groups

  • group_memberships

  • roles

  • role_policies

  • group_role_bindings

  • authorization_audit_events

  • authorization_decision_logs

group_role_bindings is the key scope table. A group can receive a role with:

  • global scope

  • organization scope

  • resource scope

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 Admin

  • Organization Admin

  • Organization Manager

  • Organization Member

  • Authenticated User

  • Screen Viewer, Screen Editor and Screen 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.ts

  • src/lib/server/services/authz/better-auth-sync-drizzle.ts

  • src/lib/server/services/authz/organization-departure.ts

  • src/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:

PathWhen 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 administrationIn the transaction that deletes the membership, with its Better Auth teams
Deleting the organizationRead 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, -manager or -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.ts

  • src/lib/server/authz/policy-loader.ts

  • src/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 explainability

  • can(...) 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:manage allows it). A group policy bound to an organization never opens it: the check is made without any organization. Whether a person may manage groups is a global group:manage, and every group action checks can() 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:create on 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 global authorization-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.ts

This 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:

ResourceMemberManager/Admin
pagelist/readmanage
sectionslist/readmanage
medialist/readmanage
code-accesslist/readmanage
dynamic-datalist/readmanage

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:

LevelRolescreen actions
ViewScreen Viewerread
EditScreen Editorread, update: review the pending draft, save drafts
PublishScreen Publisherread, 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:

  1. Add it to RESOURCES in src/lib/server/authz/contracts.ts.

  2. Add seed policies for Super Admin.

  3. Add organization-role policies when the resource is organization scoped.

  4. Add migrations or repair SQL for existing installs when needed.

  5. Add or update route config permissions.

  6. Add generated action mappings if the resource has database actions.

  7. Update English docs and content/llm/llm-source.md when the resource changes architecture or assistant guidance.

  8. Run authz contract tests.

Debugging Access

When a user cannot access a page:

  1. Confirm the active organization is correct.

  2. Confirm Better Auth membership exists.

  3. Confirm the corresponding Yayaw group membership exists.

  4. Confirm the group has a scoped role binding.

  5. Confirm the role has a policy for the requested resource/action.

  6. Use the quick evaluator in /dashboard/admin/authorization.

  7. Check authorization_decision_logs when 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