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:

  1. Checks hasSystemRoleScope for system-level role assignments
  2. Falls back to userHasSystemScope for direct user permissions
  3. Evaluates userHasProjectScope (from rule_user_project_scope.go) when the request targets a specific project
  4. Applies ownerScope logic (from rule_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:

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →