Module Access Control
An interactive, zoomable version of this diagram is available at module-access-control.html.
This document explains how a signed-in user is matched to a module. Authentication Flow covers how you sign in and how tokens are minted. This document covers the next question: once signed in, what can you reach?

The core fact

The Better Auth users 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

users.role holds one value only. An account cannot be both landlord and admin. This is a constraint of Better Auth’s admin plugin, and several guards exist purely to protect it.

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:
Ordering is a contract: element 0 is always the org role, the platform role follows. The Go backend’s 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.
buildRoles is re-implemented on both sides — once in Bun (services/auth/api.ts) and once in Go (internal/api/http/middleware/better_auth_validator.go). They sit across a trust boundary and cannot share code, so the behaviour is pinned by a shared fixture at contracts/auth/role-revocation-vectors.json.Change one side without the other and TestAuthGoldenVectors (Go) or auth-golden.test.ts (Bun) will fail. This is intentional.

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: adminOnlyMiddlewareapps/monolith/src/api-gateway/middleware/admin.middleware.ts
  • Go: RequirePlatformRole (DB-backed) or RequireJWTRole("admin","superadmin") (trusts the token)
  • Frontend: isPlatformAdmin in apps/panel/src/app.tsx

2. Leave management

  • No module gate and no feature flag. internal/modules/leave/module.go mounts every route behind plain AuthMiddleware.
  • Differentiation comes entirely from permissions derived from members.role, resolved in internal/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_managers row OR org owner/admin, via CanAccessLearning in application/usecase/learning_authz.go. A plain member or field_team is 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 by RequireLandlord().
  • Tenant = any authenticated user, with no role at all.
  • Admin review queue = admin / superadmin only; 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. From apps/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?”

Applied to exactly two groups in internal/modules/rental/module.go:
Everything else — contacts, viewing requests, offers, agreements, tenancies, payments, handover — mounts on the bare 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:
And the use case derives your role from which column your id sits in:
Your role is per row, not per account. For one-sided landlord rows (properties, listings) the guard is 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

Tenant identity is created by the act of requesting a viewing — no role assignment, no approval. Self-booking is blocked by comparing the two ids (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 request path is:
Notes:
  • 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.
Verification does exist, but one level lower — on publishing a listing, not on the account:
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

  1. users.role is single-valued. A user cannot be both landlord and platform admin. PromoteToLandlord explicitly refuses accounts holding a staff role.
  2. 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 gets roles: ['member', 'landlord'] and both work.
  3. The rental role is named landlord, not owner, on purpose. RequireOrgRole already uses owner for organization owners, and buildRoles flattens org and platform roles into one array — reusing owner would make the two meanings indistinguishable inside a token. See internal/modules/rental/domain/entity/role.go.
  4. Role changes are not instant for JWT-trust gates. RequireJWTRole does no DB lookup. A promotion needs a token refresh; a demotion stays effective until the token expires or is revoked. Server-side revocation exists in backend/bun/apps/monolith/src/services/auth/revocation.ts.
  5. ?role=owner on GET /rental/requests is a view selector, not authorization. It picks ListOwnerRequests vs ListTenantRequests; both filter by your own user id. Passing it grants nothing.
  6. 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.
  7. The rental party check is duplicated. isLandlord/isTenant appears roughly seven times in handover.go plus variants in payment.go. Unlike EnsureOwner, there is no shared EnsureParty helper — 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.