Permissions

RBAC statements, route authority, v-access, and useAccess in a Taman app.

Permissions have two layers: better-auth access control (API) and frontend access (routes, menus, v-access).

API RBAC (@taman/rbac)

sharedStatements lists resource/action pairs once so admin and org role sets cannot drift:

export const sharedStatements = {
  todo: ['create', 'read', 'update', 'delete'],
} as const;

Platform roles (USER_ROLES)

RoleTodo
admincreate, read, update, delete
user(none from sharedStatements)

Built in rbac.admin.ts with better-auth admin plugin statements plus sharedStatements.

Organization roles (ORGANIZATION_ROLES)

RoleTodo
ownerfull CRUD
memberfull CRUD (todos are collaborative)

better-auth's default organization admin role is omitted. Passing roles replaces plugin defaults; a member.role value that is not owner or member fails closed in resolveMember.

CoreService exposes can(permission). It answers only "does this role allow this action": a platform admin passes on the admin statements, and everyone else needs an organization role that allows it. It does not check row scope.

Each feature service adds its own private authorize method on top. TodoService shows the pattern: require a membership, check the role with can, and return the organization ID that every query must be scoped to:

private authorize(action: TodoAction): string {
  if (!this.member) {
    throw new ORPCError('FORBIDDEN', {
      message: 'Organization required',
      data: { code: ORG_REQUIRED },
    });
  }

  if (!this.can({ todo: [action] })) {
    throw new ORPCError('FORBIDDEN', { message: `You may not ${action} todos.` });
  }

  return this.member.organizationId;
}

Row ownership stays as explicit checks in the service and in the repo's scope filter.

Route authority

On feature routes, meta.authority is a list of TamanRoleName or a callback:

meta: {
  authority: ['admin'],
  // or: authority: (roles) => roles.includes('admin'),
}

TamanRoleName is string until you augment the registry:

declare module '@vinicunca/taman-core/typings' {
  interface TamanRoleRegistry {
    role: 'admin' | 'user';
  }
}

The web app already maps this to AuthRoleNames in apps/web/src/types/taman-core.d.ts.

Access modes

preferences.app.accessMode (frontend | backend | mixed), default frontend.

generateAccessible (@taman/access):

  • frontend — generateRoutesByFrontend(routes, roles, forbiddenComponent) using meta.authority
  • backend — generateRoutesByBackend(options) via fetchMenuListAsync
  • mixed — merge both by route name (backend wins on collision)

apps/web/src/router/access.ts still has // TODO: Implement fetching menu from backend. Until that exists, backend/mixed modes cannot load a server menu. Forbidden access uses views/_core/fallback/forbidden.vue. meta.menuVisibleWithForbidden keeps the item visible while the page is 403.

v-access

Registered in bootstrap: registerAccessDirective(app, () => useSessionStore().roles).

<button v-access:role="'admin'">
Admin only
</button>

<button v-access:code="'todo:create'">
If access codes include this
</button>
  • v-access:role in frontend mode matches session roles.
  • Otherwise (or v-access:code) matches accessStore.accessCodes.
  • Failing elements are removed from the DOM on mounted (not hidden).

useAccess()

Must run after registerAccessDirective (it injects the roles).

const { hasAccessByRoles, hasAccessByCodes, accessMode, toggleAccessMode } = useAccess();

hasAccessByRoles(['admin']);
hasAccessByCodes(['todo:create']);

toggleAccessMode flips frontend ↔ backend in preferences (does not implement a server menu by itself).