This document describes the multi-tenancy implementation used in the Go Clean Boilerplate. We use a "Global User, Local Member" strategy combined with Row-Level Security (RLS) via GORM scopes and Domain-scoped RBAC via Casbin to ensure strict isolation.
The architecture distinguishes between a user's identity (who they are) and their membership (what they can access).
-
User (Global):
- Represents the human identity (Email, Password, Name).
- Existing in the
userstable. - Can belong to multiple organizations.
- Authentication validates the User.
-
Organization (Tenant):
- Represents the tenant/workspace.
- Has a unique
slugfor URL-friendly identification (e.g.,/orgs/acme-corp). - Owned by a specific User (Owner).
-
Member (Link):
- The association between a User and an Organization.
- Contains the Role specific to that organization (e.g.,
Admin,Member,Viewer). - Authorization checks the Member status and Casbin domain-scoped permissions.
erDiagram
User ||--o{ Member : has
Organization ||--o{ Member : has
Organization ||--o{ Project : owns
User {
uuid id PK
string email
string password
}
Organization {
uuid id PK
string name
string slug
uuid owner_id
}
Member {
uuid id PK
uuid user_id FK
uuid organization_id FK
string role_id FK
}
Project {
uuid id PK
uuid organization_id FK
string name
}
We rely on Middleware, Database Scopes, and Casbin Domains to enforce isolation.
The TenantMiddleware runs on every request to a tenant-specific route (e.g., /api/v1/organizations/:org_id/*).
Responsibilities:
- Extract Context: Reads
X-Organization-IDorX-Organization-Slugheaders. - Validate Membership: Checks if the authenticated
Useris aMemberof the targetOrganization.- Uses Redis Caching (
org:member:{org}:{user}) to minimize DB lookups.
- Uses Redis Caching (
- Inject Context: Sets the
organization_idin the request context for downstream controllers.
To streamline development, the Frontend ApiClient (web/src/lib/api/client.ts) automatically handles organization context:
- Client-Side: Retrieves the active organization from
useOrganizationStoreand injectsX-Organization-IDandX-Organization-Sluginto every outgoing request. - Server-Side (Next.js): Reads organization context from cookies (
organization_id,organization_slug) during Server Component rendering or Server Actions to ensure consistent headers are sent to the Backend.
To prevent accidental data leaks, every database query within a tenant context must apply a scope.
Implementation:
// Scope to filter by Organization ID
func ScopeOrganization(orgID string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("organization_id = ?", orgID)
}
}We utilize Casbin's Domain feature to scope permissions to specific Organizations.
- Grouping Policy:
g, {user_id}, {role_id}, {organization_id} - Policy:
p, {role_id}, {organization_id}, {resource}, {action}
Enhancements:
- Batch Pengecekan:
BatchCheckPermissionnow supports an optionaldomainfield per item. This allows checking permissions across different organizations in a single request. - Dynamic Domain Fallback: If a domain is not provided in permission requests, the system defaults to
"global"to maintain backward compatibility while allowing granular tenant-scoped overrides.
When modifying permissions within a business transaction (e.g., creating an org and assigning the owner role), use the TransactionalEnforcer. It ensures that Casbin policy changes are committed or rolled back atomically with your database changes.
Because Casbin might use an in-memory cache, after performing "out-of-transaction" policy updates (like accepting an invitation), the system should call Enforcer.LoadPolicy() to ensure the latest rules are visible to the authorization middleware.
Member roles are retrieved dynamically from the database/cache rather than being hardcoded.
- Cache Key:
org:role:{org_id}:{user_id} - TTL: 5 Minutes (Invalidated on role update).
We use specific patterns to verify multi-tenancy security:
- Header Spoofing: Verify that providing a valid
X-Org-IDwithout actual membership returns403 Forbidden. - Cross-Tenant Access: Verify that a valid member of Org A cannot access resources of Org B, even if they guess the ID.
- Casbin Domain Check: Verify that permissions granted in
Org Ado not leak intoOrg B.
This architecture ensures that while users are global (for ease of login), their data and access are strictly compartmentalized by Organization.