Skip to content

RFC: Organization & Multi-Tenancy Enhancements #511

@lakhansamani

Description

@lakhansamani

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_id field
  • Single ClientID/ClientSecret per Authorizer instance
  • Roles are global (comma-separated on User schema)
  • JWT tokens have no org_id claim
  • 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 Domain field

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_id parameter
  • 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) error

6. 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_id claim are valid (global context)
  • Existing User schema and role behavior unchanged
  • Login without organization_id parameter 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

  1. Create organizations, organization_members, organization_invitations tables across all DB providers
  2. Add storage interface methods
  3. Add GraphQL types, queries, mutations
  4. Update login flow to accept optional organization_id
  5. Update token generation to include org claims when in org context
  6. 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

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions