Skip to content

RFC: Fine-Grained Permissions Model #508

@lakhansamani

Description

@lakhansamani

RFC: Fine-Grained Permissions Model

Phase: 2 — Authorization & M2M
Priority: P1 — Critical
Estimated Effort: High
Depends on: Audit Logs (#505)


Problem Statement

Authorizer only supports basic RBAC with comma-separated role strings on the User schema (Roles field). There is no permissions model, no resource-level access control, and no way to check "can user X perform action Y on resource Z?" WorkOS FGA, Keycloak Authorization Services, and Clerk all go beyond simple roles. This is the most requested enterprise feature for any auth platform.


Current Architecture Context

  • User schema has Roles string — comma-separated (e.g., "user,admin")
  • Roles configured via --roles and --default-roles CLI flags
  • --protected-roles prevents self-assignment
  • JWT claims include roles (string array) and allowed_roles
  • --jwt-role-claim configures the claim name (default: "role")
  • No Permission, Resource, or RolePermission schemas exist
  • GraphQL admin mutations _update_user can change roles
  • Custom access token script (--custom-access-token-script) via Otto JS VM can add custom claims

Proposed Solution

1. Data Model

New schemas in internal/storage/schemas/:

// Permission represents a named permission (e.g., "documents:read")
type Permission struct {
    ID          string `json:"id" gorm:"primaryKey;type:char(36)"`
    Name        string `json:"name" gorm:"type:varchar(100);uniqueIndex"`   // e.g., "documents:read"
    Description string `json:"description" gorm:"type:text"`
    CreatedAt   int64  `json:"created_at" gorm:"autoCreateTime"`
    UpdatedAt   int64  `json:"updated_at" gorm:"autoUpdateTime"`
}

// RolePermission maps roles to permissions (many-to-many)
type RolePermission struct {
    ID           string `json:"id" gorm:"primaryKey;type:char(36)"`
    Role         string `json:"role" gorm:"type:varchar(100);index:idx_rp_role;uniqueIndex:idx_rp_unique"`
    PermissionID string `json:"permission_id" gorm:"type:char(36);index:idx_rp_permission;uniqueIndex:idx_rp_unique"`
    CreatedAt    int64  `json:"created_at" gorm:"autoCreateTime"`
}

// Resource represents a protected resource instance
type Resource struct {
    ID             string `json:"id" gorm:"primaryKey;type:char(36)"`
    Type           string `json:"type" gorm:"type:varchar(100);index:idx_resource_type"`   // e.g., "document", "project"
    Name           string `json:"name" gorm:"type:varchar(256)"`
    OrganizationID string `json:"organization_id" gorm:"type:char(36);index"`
    CreatedAt      int64  `json:"created_at" gorm:"autoCreateTime"`
    UpdatedAt      int64  `json:"updated_at" gorm:"autoUpdateTime"`
}

// ResourcePermission grants a role a specific permission on a specific resource
type ResourcePermission struct {
    ID           string `json:"id" gorm:"primaryKey;type:char(36)"`
    ResourceID   string `json:"resource_id" gorm:"type:char(36);index;uniqueIndex:idx_resperms_unique"`
    PermissionID string `json:"permission_id" gorm:"type:char(36);index;uniqueIndex:idx_resperms_unique"`
    Role         string `json:"role" gorm:"type:varchar(100);index;uniqueIndex:idx_resperms_unique"`
    CreatedAt    int64  `json:"created_at" gorm:"autoCreateTime"`
}

Permission naming convention: resource_type:action

  • documents:read, documents:write, documents:delete
  • invoices:create, invoices:read, invoices:approve
  • users:manage, settings:update
  • Wildcard: documents:* (all actions on documents), *:* (superadmin)

2. Permission Resolution Logic

New package: internal/authorization/

type Provider interface {
    // CheckPermission checks if a user has a specific permission
    // If resourceID is empty, checks global permission via role
    // If resourceID is set, checks resource-level permission first, then falls back to global
    CheckPermission(ctx context.Context, userID string, permission string, resourceID string) (bool, error)
    
    // GetUserPermissions returns all permissions for a user (via their roles)
    GetUserPermissions(ctx context.Context, userID string) ([]string, error)
    
    // GetRolePermissions returns permissions for a specific role
    GetRolePermissions(ctx context.Context, role string) ([]string, error)
}

Resolution order:

  1. Get user's roles from User schema
  2. For each role, get role-level permissions from RolePermission table
  3. If resourceID provided, also check ResourcePermission table
  4. Wildcard matching: documents:* matches documents:read
  5. Superadmin: *:* matches everything

Caching — Permission lookups happen on every authorized request. Cache in memory store:

  • Key: permissions:{role}["documents:read", "documents:write"]
  • TTL: 60 seconds
  • Invalidate on permission assignment/removal

3. Storage Interface Methods

// Permissions CRUD
AddPermission(ctx context.Context, permission *schemas.Permission) (*schemas.Permission, error)
UpdatePermission(ctx context.Context, permission *schemas.Permission) (*schemas.Permission, error)
DeletePermission(ctx context.Context, id string) error
GetPermissionByID(ctx context.Context, id string) (*schemas.Permission, error)
GetPermissionByName(ctx context.Context, name string) (*schemas.Permission, error)
ListPermissions(ctx context.Context, pagination *model.Pagination) ([]*schemas.Permission, *model.Pagination, error)

// Role-Permission mapping
AddRolePermission(ctx context.Context, rp *schemas.RolePermission) (*schemas.RolePermission, error)
DeleteRolePermission(ctx context.Context, role string, permissionID string) error
GetRolePermissions(ctx context.Context, role string) ([]*schemas.RolePermission, error)
GetPermissionRoles(ctx context.Context, permissionID string) ([]*schemas.RolePermission, error)

// Resource CRUD
AddResource(ctx context.Context, resource *schemas.Resource) (*schemas.Resource, error)
DeleteResource(ctx context.Context, id string) error
ListResources(ctx context.Context, resourceType string, pagination *model.Pagination) ([]*schemas.Resource, *model.Pagination, error)

// Resource-Permission mapping
AddResourcePermission(ctx context.Context, rp *schemas.ResourcePermission) (*schemas.ResourcePermission, error)
DeleteResourcePermission(ctx context.Context, id string) error
GetResourcePermissions(ctx context.Context, resourceID string) ([]*schemas.ResourcePermission, error)
CheckResourcePermission(ctx context.Context, resourceID string, role string, permissionID string) (bool, error)

4. GraphQL API

type Permission {
    id: ID!
    name: String!
    description: String
    created_at: Int64!
}

type RolePermissions {
    role: String!
    permissions: [Permission!]!
}

# Admin mutations
type Mutation {
    _add_permission(params: AddPermissionInput!): Permission!
    _update_permission(params: UpdatePermissionInput!): Permission!
    _delete_permission(id: ID!): Response!
    _assign_permission_to_role(params: RolePermissionInput!): Response!
    _remove_permission_from_role(params: RolePermissionInput!): Response!
}

# Admin queries
type Query {
    _permissions(params: PaginatedInput): Permissions!
    _role_permissions(role: String!): RolePermissions!
}

# User-facing query (check own permissions)
type Query {
    check_permission(permission: String!, resource_id: String): Boolean!
}

input AddPermissionInput {
    name: String!           # e.g., "documents:read"
    description: String
}

input UpdatePermissionInput {
    id: ID!
    description: String
}

input RolePermissionInput {
    role: String!
    permission_id: ID!
}

5. Permissions in JWT Claims

Configurable via --include-permissions-in-token=false:

When enabled, access tokens include permissions:

{
    "sub": "user_123",
    "roles": ["admin", "editor"],
    "permissions": ["documents:read", "documents:write", "users:manage"],
    "iat": 1711800000,
    "exp": 1711801800
}

Trade-off: Including permissions in JWT makes tokens larger but enables stateless authorization checks downstream. When disabled, downstream services call check_permission query.

Alternative claim format — role-permission map (for services that need to know which role grants which permission):

{
    "roles_permissions": {
        "admin": ["*:*"],
        "editor": ["documents:read", "documents:write"]
    }
}

Configurable via --token-permission-format=flat (flat = array, grouped = map).

6. Organization-Scoped Permissions

Permissions can be global or scoped to an organization:

  • Global permissions: apply everywhere (e.g., settings:update for platform admins)
  • Org-scoped: a user might be admin in Org A (with *:*) but only viewer in Org B (with documents:read)

This builds on the Organization enhancements (Phase 2.4). The ResourcePermission table's OrganizationID field enables this scoping.


Backward Compatibility

  • Existing Roles field on User schema remains unchanged
  • Existing role-based checks continue to work
  • Permissions are additive — if no permissions are defined for a role, the role still functions as before
  • --include-permissions-in-token=false by default — no JWT format change unless opted in

CLI Configuration Flags

--include-permissions-in-token=false       # Include permissions array in JWT
--token-permission-format=flat             # flat (array) or grouped (role-permission map)
--permission-cache-ttl=60s                 # Cache TTL for permission lookups

Migration Strategy

  1. Create permissions, role_permissions, resources, resource_permissions tables across all DB providers
  2. Add storage interface methods to all providers
  3. Add authorization package with caching
  4. Add GraphQL types, queries, mutations
  5. Optionally inject permissions into JWT claims
  6. No changes to existing role behavior

Testing Plan

  • Unit tests for permission resolution (wildcard matching, role hierarchy)
  • Integration tests for CRUD operations on permissions
  • Integration tests for role-permission assignment
  • Integration tests for check_permission query
  • Test permission caching and invalidation
  • Test JWT claims with permissions enabled
  • Test backward compatibility (existing role-only auth still works)

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