-
-
Notifications
You must be signed in to change notification settings - Fork 203
Description
RFC: Organization & Multi-Tenancy Enhancements
Phase: 2 — Authorization & M2M
Priority: P1 — High
Estimated Effort: Medium
Depends on: Fine-Grained Permissions (#508)
Problem Statement
Authorizer has no built-in multi-tenancy or organization support. B2B SaaS applications need to group users into organizations, manage per-org roles, and configure org-specific auth policies. WorkOS Organizations, Clerk Organizations, and Keycloak Realms/Organizations all provide this. Without it, B2B customers must build their own tenancy layer on top of Authorizer.
Current Architecture Context
- No Organization schema exists
- User schema has no
organization_idfield - Single
ClientID/ClientSecretper Authorizer instance - Roles are global (comma-separated on User schema)
- JWT tokens have no
org_idclaim - Config settings (MFA policy, password policy, auth methods) are global — not per-org
Proposed Solution
1. Organization Schema
New schemas in internal/storage/schemas/:
type Organization struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
Name string `json:"name" gorm:"type:varchar(256)"`
Slug string `json:"slug" gorm:"type:varchar(100);uniqueIndex"` // URL-friendly identifier
Domain string `json:"domain" gorm:"type:varchar(256);index"` // e.g., "company.com" for domain-based routing
LogoURL string `json:"logo_url" gorm:"type:text"`
Metadata string `json:"metadata" gorm:"type:text"` // JSON for custom fields
Settings string `json:"settings" gorm:"type:text"` // JSON for org-level auth config
IsActive bool `json:"is_active" gorm:"type:bool;default:true"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"`
}
type OrganizationMember struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
OrganizationID string `json:"organization_id" gorm:"type:char(36);uniqueIndex:idx_orgmember_unique;index:idx_orgmember_org"`
UserID string `json:"user_id" gorm:"type:char(36);uniqueIndex:idx_orgmember_unique;index:idx_orgmember_user"`
Role string `json:"role" gorm:"type:varchar(100)"` // org-specific role (can differ from global role)
JoinedAt int64 `json:"joined_at" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"`
}
type OrganizationInvitation struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
OrganizationID string `json:"organization_id" gorm:"type:char(36);index"`
Email string `json:"email" gorm:"type:varchar(256)"`
Role string `json:"role" gorm:"type:varchar(100)"`
Token string `json:"token" gorm:"type:varchar(256);uniqueIndex"`
InvitedBy string `json:"invited_by" gorm:"type:char(36)"` // user ID of inviter
Status string `json:"status" gorm:"type:varchar(20);default:pending"` // pending | accepted | expired
ExpiresAt int64 `json:"expires_at"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
}2. Organization Settings (Per-Org Auth Policy)
The Settings JSON field on Organization allows per-org configuration:
type OrganizationSettings struct {
// Auth methods allowed for this org
EnableBasicAuth *bool `json:"enable_basic_auth,omitempty"`
EnableMagicLink *bool `json:"enable_magic_link,omitempty"`
EnableSocialLogin *bool `json:"enable_social_login,omitempty"`
// MFA policy
EnforceMFA *bool `json:"enforce_mfa,omitempty"`
// Password policy
EnableStrongPassword *bool `json:"enable_strong_password,omitempty"`
MinPasswordLength *int `json:"min_password_length,omitempty"`
// Session policy
MaxSessionsPerUser *int `json:"max_sessions_per_user,omitempty"`
SessionTimeoutMinutes *int `json:"session_timeout_minutes,omitempty"`
// Allowed roles for this org
AllowedRoles []string `json:"allowed_roles,omitempty"`
DefaultRole *string `json:"default_role,omitempty"`
}Resolution: Org settings override global config. If an org setting is nil, fall back to global. This is checked in auth handlers:
func resolveConfig(globalCfg *config.Config, org *schemas.Organization) *config.Config {
resolved := *globalCfg // copy
if org != nil && org.Settings != "" {
var orgSettings OrganizationSettings
json.Unmarshal([]byte(org.Settings), &orgSettings)
if orgSettings.EnforceMFA != nil {
resolved.EnforceMFA = *orgSettings.EnforceMFA
}
// ... merge other fields
}
return &resolved
}3. Domain-Based Routing
When a user with @company.com email signs up or logs in:
func resolveOrganizationByEmail(ctx context.Context, email string) (*schemas.Organization, error) {
domain := strings.Split(email, "@")[1]
org, err := store.GetOrganizationByDomain(ctx, domain)
if err != nil || org == nil {
return nil, nil // No org for this domain — proceed with global config
}
return org, nil
}- If domain matches an org → auto-associate user with that org on signup
- If org has SAML configured (Phase 3) → redirect to org's SSO IdP
- Multiple domains per org supported via comma-separated
Domainfield
4. Organization-Scoped JWT Claims
When a user authenticates in the context of an organization, tokens include:
{
"sub": "user_123",
"org_id": "org_456",
"org_slug": "acme-corp",
"org_role": "admin",
"roles": ["user"],
"permissions": ["documents:read", "documents:write"]
}How org context is selected:
- Login request includes optional
organization_idparameter - If user belongs to multiple orgs, they select which one at login (or via org switcher)
- If user belongs to only one org, it's auto-selected
- No org context → global token (backward compatible)
5. Storage Interface Methods
// Organization CRUD
AddOrganization(ctx context.Context, org *schemas.Organization) (*schemas.Organization, error)
UpdateOrganization(ctx context.Context, org *schemas.Organization) (*schemas.Organization, error)
DeleteOrganization(ctx context.Context, id string) error
GetOrganizationByID(ctx context.Context, id string) (*schemas.Organization, error)
GetOrganizationBySlug(ctx context.Context, slug string) (*schemas.Organization, error)
GetOrganizationByDomain(ctx context.Context, domain string) (*schemas.Organization, error)
ListOrganizations(ctx context.Context, pagination *model.Pagination) ([]*schemas.Organization, *model.Pagination, error)
// Membership
AddOrganizationMember(ctx context.Context, member *schemas.OrganizationMember) (*schemas.OrganizationMember, error)
UpdateOrganizationMember(ctx context.Context, member *schemas.OrganizationMember) (*schemas.OrganizationMember, error)
RemoveOrganizationMember(ctx context.Context, orgID string, userID string) error
GetOrganizationMember(ctx context.Context, orgID string, userID string) (*schemas.OrganizationMember, error)
ListOrganizationMembers(ctx context.Context, orgID string, pagination *model.Pagination) ([]*schemas.OrganizationMember, *model.Pagination, error)
ListUserOrganizations(ctx context.Context, userID string) ([]*schemas.Organization, error)
// Invitations
AddOrganizationInvitation(ctx context.Context, invitation *schemas.OrganizationInvitation) (*schemas.OrganizationInvitation, error)
GetOrganizationInvitationByToken(ctx context.Context, token string) (*schemas.OrganizationInvitation, error)
ListOrganizationInvitations(ctx context.Context, orgID string, pagination *model.Pagination) ([]*schemas.OrganizationInvitation, *model.Pagination, error)
UpdateOrganizationInvitation(ctx context.Context, invitation *schemas.OrganizationInvitation) error
DeleteOrganizationInvitation(ctx context.Context, id string) error6. GraphQL API
type Organization {
id: ID!
name: String!
slug: String!
domain: String
logo_url: String
metadata: Map
settings: Map
is_active: Boolean!
members_count: Int!
created_at: Int64!
}
type OrganizationMember {
id: ID!
user: User!
role: String!
joined_at: Int64!
}
type OrganizationInvitation {
id: ID!
email: String!
role: String!
status: String!
invited_by: User
expires_at: Int64!
created_at: Int64!
}
# Admin mutations
type Mutation {
_create_organization(params: CreateOrganizationInput!): Organization!
_update_organization(params: UpdateOrganizationInput!): Organization!
_delete_organization(id: ID!): Response!
_invite_to_organization(params: InviteToOrganizationInput!): OrganizationInvitation!
_add_organization_member(params: AddMemberInput!): OrganizationMember!
_update_member_role(params: UpdateMemberRoleInput!): OrganizationMember!
_remove_organization_member(organization_id: ID!, user_id: ID!): Response!
}
# Admin queries
type Query {
_organizations(params: PaginatedInput): Organizations!
_organization(id: ID!): Organization!
_organization_members(organization_id: ID!, params: PaginatedInput): OrganizationMembers!
_organization_invitations(organization_id: ID!, params: PaginatedInput): OrganizationInvitations!
}
# User-facing
type Query {
organizations: [Organization!]! # List user's organizations
}
type Mutation {
accept_organization_invitation(token: String!): Response!
switch_organization(organization_id: ID!): AuthResponse! # Get new tokens for different org context
}Backward Compatibility
- Organizations are opt-in — existing instances without organizations continue to work exactly as before
- Tokens without
org_idclaim are valid (global context) - Existing User schema and role behavior unchanged
- Login without
organization_idparameter works as before
CLI Configuration Flags
--enable-organizations=false # Enable multi-tenancy features
--allow-org-signup=false # Allow users to create their own organizations
--org-invitation-expiry=72h # Invitation link expiry
Migration Strategy
- Create
organizations,organization_members,organization_invitationstables across all DB providers - Add storage interface methods
- Add GraphQL types, queries, mutations
- Update login flow to accept optional
organization_id - Update token generation to include org claims when in org context
- No changes to existing non-org behavior
Testing Plan
- Integration tests for org CRUD operations
- Test domain-based routing (email → org mapping)
- Test org-scoped tokens include correct claims
- Test org settings override global config
- Test invitation flow (invite → accept → membership created)
- Test org switcher (switch context → new tokens)
- Test backward compatibility (no orgs configured → everything works as before)
- Test member role enforcement within org context