How AxonHub Implements Fine-Grained RBAC Permission System with Scope-Based Access Control
AxonHub’s RBAC permission system uses a scope-based model where every operation is guarded by a ScopeSlug that defines the exact action and target resource, evaluated against a user's aggregated policy of system roles, project memberships, and resource ownership.
The looplj/axonhub repository implements a sophisticated RBAC permission system that replaces coarse-grained "admin vs. user" checks with granular, resource-specific access controls. Instead of boolean flags, the system treats every permission as a distinct scope that can be assigned at the system, project, or individual resource level.
Core Components of the RBAC Permission System
ScopeSlug and Scope Definitions
At the heart of the RBAC permission system lies the ScopeSlug type, defined in internal/scopes/scopes.go. This enumerated type represents every possible permission in the system as a string constant, eliminating magic strings and enabling compile-time safety.
// internal/scopes/scopes.go
const (
ScopeReadAnalytics ScopeSlug = "read_analytics"
ScopeWriteAnalytics ScopeSlug = "write_analytics"
ScopeReadProjects ScopeSlug = "read_projects"
ScopeReadDashboard ScopeSlug = "read_dashboard"
)
Each Scope struct pairs a ScopeSlug with an optional ScopeLevel, determining whether the permission applies system-wide, to a specific project, or to an owned resource.
Policy Aggregation
The internal/scopes/policy.go file implements the policy engine that aggregates a user's effective permissions from multiple sources. Rather than checking a single role, the RBAC permission system builds a comprehensive policy by combining:
- System role scopes: Pre-defined permissions assigned to system-wide roles (admin, manager, etc.)
- Direct user scopes: Explicitly assigned individual permissions
- Project membership scopes: Permissions inherited from project roles
- Ownership scopes: Automatic write access to resources the user owns
Rule Evaluation Engine
The rule implementations in internal/scopes/rule.go and related files convert required ScopeSlug values into executable privacy rules. The core helper UserHasScope(ctx, requiredSlug) performs the actual authorization check by traversing the policy hierarchy:
- Checks
hasSystemRoleScopefor system-level role assignments - Falls back to
userHasSystemScopefor direct user permissions - Evaluates
userHasProjectScope(fromrule_user_project_scope.go) when the request targets a specific project - Applies
ownerScopelogic (fromrule_owner.go) if the user owns the target resource
How Permission Checks Work in AxonHub
When a request hits a GraphQL or REST endpoint, the RBAC permission system executes a multi-stage validation process:
1. Endpoint Registration
Each endpoint declares its required scope using the privacy middleware. In internal/scopes/rule_user_scope.go, factory functions generate rules for specific access patterns:
// Protecting a REST endpoint with project-specific read scope
router.GET("/projects/:id/analytics",
privacy.Require(scopes.UserProjectScopeReadRule(scopes.ScopeReadAnalytics)),
analyticsHandler,
)
2. Context Injection
Middleware extracts the authenticated user from the JWT token and injects a privacy.Context carrying the evaluated policy. This context propagates through the request lifecycle, making authorization data available to all downstream rules.
3. Rule Evaluation
When the handler executes, the privacy engine invokes the registered rule. The UserHasScope function in internal/scopes/rule.go coordinates the check:
// internal/scopes/rule.go
func UserHasScope(ctx context.Context, slug ScopeSlug) bool {
// 1. Check system roles first (fast path for admins)
if hasSystemRoleScope(ctx, slug) {
return true
}
// 2. Check direct user scopes
if userHasSystemScope(ctx, slug) {
return true
}
// 3. Check project-specific scopes
if userHasProjectScope(ctx, slug) {
return true
}
return false
}
4. Denial or Proceed
If any layer grants the scope, the request proceeds. If all layers deny access, the rule returns privacy.ErrDenied, triggering a 403 Forbidden response.
Fine-Grained Access Control Patterns
The RBAC permission system supports three distinct granularity levels, allowing developers to model complex authorization scenarios without hardcoding role checks.
System-Wide Scopes
System scopes apply globally across the AxonHub instance. These are typically granted to administrative roles or service accounts. The UserReadScopeRule and UserWriteScopeRule factories in internal/scopes/rule_user_scope.go handle these checks by verifying the user's policy against system-level assignments.
// Granting system-wide dashboard read access
privacy.Require(scopes.UserReadScopeRule(scopes.ScopeReadDashboard))
Project-Specific Scopes
Most operations in AxonHub target a specific project. The internal/scopes/rule_user_project_scope.go file implements UserProjectScopeReadRule and UserProjectScopeWriteRule, which extract the project ID from the request context and verify the user has the required scope within that specific project boundary.
// internal/scopes/rule_user_project_scope.go
func UserProjectScopeReadRule(slug ScopeSlug) privacy.QueryRule {
return func(ctx context.Context, q privacy.Query) error {
projectID := GetProjectIDFromContext(ctx)
if userHasProjectScope(ctx, slug, projectID) {
return nil
}
return privacy.ErrDenied
}
}
Owner-Resource Scopes
The finest granularity involves resource ownership. In internal/scopes/rule_owner.go, the OwnerScopeRule grants implicit write permissions to users who own the specific resource being modified. This eliminates the need to explicitly assign scopes for every resource a user creates.
// internal/scopes/rule_owner.go
func OwnerScopeRule(requiredScope ScopeSlug) privacy.MutationRule {
return func(ctx context.Context, m privacy.Mutation) error {
if ownerID, ok := m.GetOwnerID(); ok && ownerID == privacy.GetUserID(ctx) {
// Owners implicitly have write scope for their resources
return nil
}
// Fall back to standard scope check
if UserHasScope(ctx, requiredScope) {
return nil
}
return privacy.ErrDenied
}
}
Implementing Custom RBAC Rules
Extending the RBAC permission system requires understanding the rule factory pattern used throughout the codebase. Rules are functions that return privacy.QueryRule or privacy.MutationRule types, allowing composition and reuse.
To add a new permission check for analytics data:
// Step 1: Define the scope constant in internal/scopes/scopes.go
const ScopeExportAnalytics ScopeSlug = "export_analytics"
// Step 2: Create a rule factory in internal/scopes/rule_analytics.go
func UserAnalyticsExportRule() privacy.QueryRule {
return func(ctx context.Context, q privacy.Query) error {
// Check system-wide permission
if UserHasScope(ctx, ScopeExportAnalytics) {
return nil
}
// Check if user is project admin for the analytics project
projectID := GetProjectIDFromContext(ctx)
if userHasProjectScope(ctx, ScopeWriteAnalytics, projectID) {
return nil
}
return privacy.ErrDenied
}
}
// Step 3: Apply to endpoint
router.GET("/analytics/export",
privacy.Require(scopes.UserAnalyticsExportRule()),
exportHandler,
)
The internal/scopes/rule_apikey_scope.go file demonstrates how to implement alternative authentication schemes, checking API key permissions against the same scope definitions used for user sessions.
Summary
AxonHub’s RBAC permission system delivers fine-grained access control through these key mechanisms:
- Scope-based authorization: Every operation is protected by a specific
ScopeSlugdefined ininternal/scopes/scopes.go, eliminating coarse role checks. - Hierarchical policy evaluation: The system aggregates permissions from system roles, direct user assignments, project memberships, and resource ownership via
internal/scopes/policy.go. - Composable rule factories: Reusable rule functions in
internal/scopes/rule_user_scope.goandinternal/scopes/rule_user_project_scope.goallow precise endpoint protection. - Multi-level granularity: Supports system-wide, project-specific, and owner-resource scopes through specialized rules in
internal/scopes/rule_owner.go.
Frequently Asked Questions
How does AxonHub differ from traditional RBAC systems?
Traditional RBAC relies on coarse roles like "admin" or "user" that grant broad permissions. AxonHub’s RBAC permission system uses scope-based access control where each permission is a discrete capability (e.g., read_dashboard, write_projects). This allows users to have, for example, read access to analytics but write access only to specific projects, without needing a custom role for every permutation.
What is the performance impact of checking multiple scope levels?
The RBAC permission system optimizes for the common case by checking system roles first in UserHasScope (defined in internal/scopes/rule.go). Since administrative users hit this fast path immediately, they incur minimal overhead. For non-admin users, the system evaluates project scopes and ownership only when necessary, with the policy context cached in the request lifecycle via privacy.Context to avoid redundant database queries.
Can API keys use the same scope definitions as user accounts?
Yes. The internal/scopes/rule_apikey_scope.go file implements parallel rule factories for API key authentication. API keys are assigned scopes from the same ScopeSlug enumeration used for users, ensuring consistent authorization logic across both session-based and key-based access patterns. The rules check apikey.HasScope(slug) using the same evaluation logic as UserHasScope.
How do I add a new permission to the system?
Adding a new permission requires three steps: First, define a new ScopeSlug constant in internal/scopes/scopes.go. Second, create a rule factory function (e.g., in internal/scopes/rule_custom.go) that returns a privacy.QueryRule or privacy.MutationRule checking UserHasScope(ctx, yourNewSlug). Third, apply the rule to your GraphQL field or REST endpoint using privacy.Require(). The policy engine automatically includes the new scope in permission evaluations without modifying existing role logic.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →