Skip to main content

Tenancy and ACL (workspace-scoped authorization)

Tier: Public
Status: v1.0 (as-built + intended policy; role wiring in progress)
Audience: contributors, module authors, self-hosting operators.

Not authentication. Login, sessions, cookie vs bearer, and the webview bridge are in AUTH-STRATEGY.md.
Not data routing. Where Prisma lives vs where clients call HTTP is in DATA-ACCESS-BOUNDARIES.md.

This document is the single contributor-facing guide for workspace tenancy and access control on the API platform.


1. Summary

ConceptImplementation
Tenancy unitWorkspace — users belong via WorkspaceMember
Active scopeSession activeWorkspaceId (explicit; never implicit)
Workspace rolesbrewery_admin, member, viewer (Prisma enum WorkspaceRole)
Membership gateWorkspacesService.assertMembershipwidely enforced today
Role gateAclService.requireRolecentral API; wiring in progress
Platform adminUser.isPlatformAdmin + requirePlatformAdminseparate from workspace roles
Enforcement layerApplication-level (Fastify + service methods); RLS deferred

2. Models and terminology

  • Workspace — the tenant boundary (club, brewery, operator org). Domain tables carry workspaceId (or FK to Workspace).
  • WorkspaceMember — links UserWorkspace with a role.
  • Active workspace — stored on Session.activeWorkspaceId. Workspace-scoped routes require it via requireActiveWorkspace().

Obsolete terms: older docs may say account / account_id / /accounts. The platform uses workspace / workspaceId / /workspaces.

Roles (database identifiers)

enum WorkspaceRole {
brewery_admin
member
viewer
}

User-facing copy may show brewery-admin (hyphen); persistence uses brewery_admin (underscore). Docs sometimes mention owner as a product label — there is no separate owner enum value today; workspace creators receive brewery_admin.


3. Request flow

Client (web / native)
→ Nginx
→ Fastify route
requireSession() → 401 if not authenticated
requireActiveWorkspace() → 401 if no active workspace selected
(optional) requirePlatformAdmin() → 403; global admin only
→ Service method
assertMembership() → 403 if not a workspace member (today)
requireRole([...]) → 403 if role insufficient (intended default)
Prisma queries scoped by workspaceId

Code entry points

ConcernFile
Session + active workspaceservices/api/src/plugins/sessionAuth.ts
Request context helpersservices/api/src/plugins/requestContext.ts
Membershipservices/api/src/services/workspacesService.ts
Role checks (central)services/api/src/services/acl.ts
Platform adminservices/api/src/plugins/requirePlatformAdmin.ts

4. Three authorization gates

4.1 Authentication (who are you?)

Handled by sessionAuthPlugin and requireSession(). See AUTH-STRATEGY.md.

4.2 Tenancy / membership (are you in this workspace?)

WorkspacesService.assertMembership(userId, workspaceId) throws 403 not_a_member if the user has no WorkspaceMember row.

This is the baseline enforced across brewery, PIM, MRP, CRP, automation, AI services, and most module routes today.

Every workspace-scoped service method should:

  1. Derive workspaceId from session context (activeWorkspaceId), not from unvalidated client input alone.
  2. Call assertMembership before reads or writes.
  3. Include workspaceId in every Prisma where clause (for get-by-id, prefer findFirst({ where: { id, workspaceId } }) over bare findUnique({ where: { id } })).

4.3 Role-based ACL (what may you do in this workspace?)

AclService.requireRole(userId, workspaceId, allowedRoles) is the platform-owned role gate (RFC-0001 §8.2).

await acl.requireRole(userId, workspaceId, ["member", "brewery_admin"]);

As-built (v0): AclService exists but is not yet invoked from most routes. Some paths use ad-hoc checks (e.g. role !== "brewery_admin" in AI settings). See TESTING.md (Phase 4d deferred).

Intended baseline policy (to be applied consistently when wiring lands):

RoleBaseline intent
viewerRead-only
memberCreate / update domain data (recipes, brew sessions, inventory, profiles, …)
brewery_adminWorkspace settings and admin-only operations (branding, AI BYOK settings, ingredient sync, …)

Modules must not invent parallel permission tables or custom role enums. Declare required roles through platform conventions; future SDK slots may register module-specific role extensions (RFC-0001 §8.2).

4.4 Platform admin (global, not workspace-scoped)

/platform/* routes use User.isPlatformAdmin via requirePlatformAdmin(). This is independent of WorkspaceRole — a workspace brewery_admin is not automatically a platform admin.


5. AI and ACL inheritance

AI tools run in the API with the same { userId, workspaceId } context as HTTP handlers. They call existing services — no raw DB access, no parallel ACL layer.


6. Module author checklist

Before shipping a workspace-scoped route or service:

  • Route uses requireActiveWorkspace() (or equivalent) — not user-supplied workspace id alone.
  • Service calls assertMembership (minimum) or AclService.requireRole (when role policy is defined).
  • Every Prisma query filters by workspaceId.
  • ID-based mutations use workspaceId + id together.
  • No module-private role / permission tables.
  • L2 test includes cross-workspace isolation (second workspace gets 404/403, not data leak). See TESTING.md.

7. Testing and follow-on work

ItemStatusDoc
Cross-workspace isolation (L2)Largely covered (Phase 4b)TESTING.md
Role-based 403 tests (Phase 4d)Deferred until requireRole is wiredTESTING.md, FOUNDATION-HARDENING.md
AclService route wiringOpenrfcs/0001-modules-tiers-governance-and-automation-placement.md §9.2

8. Cross-references