-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
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
--rolesand--default-rolesCLI flags --protected-rolesprevents self-assignment- JWT claims include
roles(string array) andallowed_roles --jwt-role-claimconfigures the claim name (default:"role")- No Permission, Resource, or RolePermission schemas exist
- GraphQL admin mutations
_update_usercan 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:deleteinvoices:create,invoices:read,invoices:approveusers: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:
- Get user's roles from User schema
- For each role, get role-level permissions from
RolePermissiontable - If
resourceIDprovided, also checkResourcePermissiontable - Wildcard matching:
documents:*matchesdocuments:read - 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:updatefor platform admins) - Org-scoped: a user might be
adminin Org A (with*:*) but onlyviewerin Org B (withdocuments:read)
This builds on the Organization enhancements (Phase 2.4). The ResourcePermission table's OrganizationID field enables this scoping.
Backward Compatibility
- Existing
Rolesfield 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=falseby 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
- Create
permissions,role_permissions,resources,resource_permissionstables across all DB providers - Add storage interface methods to all providers
- Add authorization package with caching
- Add GraphQL types, queries, mutations
- Optionally inject permissions into JWT claims
- 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_permissionquery - Test permission caching and invalidation
- Test JWT claims with permissions enabled
- Test backward compatibility (existing role-only auth still works)