The core fact
The Better Authusers table has no module column. Nothing on a user row says
“this person belongs to leave management” or “this person belongs to rental”.
Module access is decided at request time by combining four independent mechanisms.
Each module picks a different combination.
The four mechanisms
How roles reach the backends
The Bun backend mints the app-JWT and flattens the org role and the platform role into one flat array:AuthMiddleware puts that array into the Echo context as user_roles
(backend/go/internal/api/http/middleware/auth_middleware.go), and
RequireJWTRole matches against it.
Two styles of authorization check
The codebase deliberately uses two different seams. Know which one you are reaching for.
Use
RequireJWTRole for coarse gates. Use BetterAuthRBAC when a stale role would be
unacceptable.
Per-module breakdown
1. Platform admin
- Gate:
users.role∈ . Not organization-scoped. - Bun:
adminOnlyMiddleware—apps/monolith/src/api-gateway/middleware/admin.middleware.ts - Go:
RequirePlatformRole(DB-backed) orRequireJWTRole("admin","superadmin")(trusts the token) - Frontend:
isPlatformAdmininapps/panel/src/app.tsx
2. Leave management
- No module gate and no feature flag.
internal/modules/leave/module.gomounts every route behind plainAuthMiddleware. - Differentiation comes entirely from permissions derived from
members.role, resolved ininternal/modules/organizations/application/usecase/permission_service.go:
Any organization member automatically has leave access. Leave is the default module.
3. Fieldforce
- Org-scoped: routes are
/api/v1/organizations/:org_id/fieldforce(internal/modules/fieldforce/module.go) - Feature flag
fieldforce, two-tier and fail-open — a missing row means enabled, so a database hiccup never takes fieldforce down (adapter/inbound/http/feature_flag_middleware.go) - The field worker is a dedicated org role
field_team, checked inline in handlers (briefing_handler.go,risk_intervention_handler.go). It can see tasks but is blocked from briefings and risk data.
4. Digital workers
- Org-scoped + feature flag
digital_worker. This one returns 404, not 403 — the feature is not acknowledged at all until enabled — and the global flag is seeded OFF (internal/modules/digitalworker/adapter/inbound/http/feature_flag_middleware.go). - Org membership is not enough. Access requires a
dw_agent_managersrow OR org owner/admin, viaCanAccessLearninginapplication/usecase/learning_authz.go. A plainmemberorfield_teamis excluded. - Sensitive approvals stay owner/admin-only even when an Agent Manager exists.
5. Rental platform
The odd one out — see Rental: landlord vs tenant below.- Not organization-scoped at all. Routes are
/api/rental/...with no:org_id. Landlords have no organization ID in context. - No feature flag.
- Landlord = the platform role token
users.role = 'landlord', gated byRequireLandlord(). - Tenant = any authenticated user, with no role at all.
- Admin review queue =
admin/superadminonly; even the owning landlord gets 403.
Summary table
Rental: landlord vs tenant
Rental is worth its own section because it does not follow the organization model.The design is asymmetric
There is no tenant role. Only the landlord has a role. A tenant is identified purely by row data. Fromapps/panel/src/routes/tenant.tsx:
No role is checked here because the backend has no tenant role — the backend’s row-level
tenant_user_id check is the real boundary.
Layer 1 — route gate: “is a landlord calling?”
internal/modules/rental/module.go:
protected group and is reachable by any signed-in user.
Layer 2 — row-level party check: “is it this landlord / this tenant?”
The tables carry both ids side by side:EnsureOwner in
internal/modules/rental/application/ownership.go. It maps to 404, never 403, so a
cross-landlord probe cannot confirm that a row exists.
How the ids get planted
ErrSelfBooking).
Same row, different rights
The stored
recorded_by_role and actor_role columns (CHECK IN ('tenant','landlord'))
are records of who acted, not permissions.
Becoming a landlord
Promotion is self-service and instant — there is no verification step.apps/panel/src/routes/owner/sign-up.tsx:
- The endpoint takes no user id. You can only promote yourself; an admin cannot promote someone else through it.
- The
NOT IN ('admin','superadmin')clause is the only guard. It exists to stop a promotion from destroying a staff role, not to verify identity. - The response returns
token_refresh_required: true. The role lands in Postgres immediately, but the caller’s current JWT still carries the old role, so landlord routes keep refusing until a new token is minted.
Submit refuses unless an ownership proof document exists for the property
(ErrNoOwnershipProof) and the declaration is ticked (ErrDeclarationNotAccepted). A
human admin / superadmin then reviews it. So the role is cheap, but a fake landlord can
never get a listing in front of a tenant.
There is currently no UI path for an existing user to become a landlord.
/owner/sign-up redirects away anyone who already has a session, and /owner then shows
“Access Denied”. The API itself works fine for any signed-in non-staff account — this is a
missing button, not a missing capability. Adding it requires forcing a session refresh
after promotion so the new role reaches the token.Gotchas for developers
-
users.roleis single-valued. A user cannot be bothlandlordand platformadmin.PromoteToLandlordexplicitly refuses accounts holding a staff role. -
A landlord can be an organization member. Org role lives in
members.role, a different column, so a landlord who is also a leave-management employee getsroles: ['member', 'landlord']and both work. -
The rental role is named
landlord, notowner, on purpose.RequireOrgRolealready usesownerfor organization owners, andbuildRolesflattens org and platform roles into one array — reusingownerwould make the two meanings indistinguishable inside a token. Seeinternal/modules/rental/domain/entity/role.go. -
Role changes are not instant for JWT-trust gates.
RequireJWTRoledoes no DB lookup. A promotion needs a token refresh; a demotion stays effective until the token expires or is revoked. Server-side revocation exists inbackend/bun/apps/monolith/src/services/auth/revocation.ts. -
?role=owneronGET /rental/requestsis a view selector, not authorization. It picksListOwnerRequestsvsListTenantRequests; both filter by your own user id. Passing it grants nothing. - Feature-flag failure policy differs by module and is deliberate. Fieldforce fails open (a DB hiccup must not stop field work). Digital workers fails closed with a 404 (the feature is not acknowledged until enabled). Do not “normalize” these.
-
The rental party check is duplicated.
isLandlord/isTenantappears roughly seven times inhandover.goplus variants inpayment.go. UnlikeEnsureOwner, there is no sharedEnsurePartyhelper — a future rule change must be applied to every copy.
Adding a new module
Pick your combination deliberately:
Always keep the coarse gate in middleware and the row-level check in the use case.
Middleware proves a valid caller; only the use case can prove it is this caller.
Related files
Related documents
- Authentication Flow — sign-up, sign-in, token minting
- CLI Auth Architecture — device flow and API keys
- Organization Permissions v3 — custom roles and teams
- Permission Service Integration — using
PermissionServicein Go - Role Assignment — assigning roles in practice
- ADR-0006 — Better Auth read-only consumer (Go never writes roles)