# How AxonHub Implements Fine-Grained RBAC Permission System with Scope-Based Access Control

> Explore AxonHubs fine-grained RBAC permission system. Learn how scope-based access control guards every operation with precise action and resource definitions.

- Repository: [Loop/axonhub](https://github.com/looplj/axonhub)
- Tags: deep-dive
- Published: 2026-03-06

---

**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`](https://github.com/looplj/axonhub/blob/main/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.

```go
// 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`](https://github.com/looplj/axonhub/blob/main/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`](https://github.com/looplj/axonhub/blob/main/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`](https://github.com/looplj/axonhub/blob/main/rule_user_project_scope.go)) when the request targets a specific project
4. Applies `ownerScope` logic (from [`rule_owner.go`](https://github.com/looplj/axonhub/blob/main/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`](https://github.com/looplj/axonhub/blob/main/internal/scopes/rule_user_scope.go), factory functions generate rules for specific access patterns:

```go
// 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`](https://github.com/looplj/axonhub/blob/main/internal/scopes/rule.go) coordinates the check:

```go
// 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`](https://github.com/looplj/axonhub/blob/main/internal/scopes/rule_user_scope.go) handle these checks by verifying the user's policy against system-level assignments.

```go
// 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`](https://github.com/looplj/axonhub/blob/main/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.

```go
// 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`](https://github.com/looplj/axonhub/blob/main/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.

```go
// 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:

```go
// 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`](https://github.com/looplj/axonhub/blob/main/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 `ScopeSlug` defined in [`internal/scopes/scopes.go`](https://github.com/looplj/axonhub/blob/main/internal/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`](https://github.com/looplj/axonhub/blob/main/internal/scopes/policy.go).
- **Composable rule factories**: Reusable rule functions in [`internal/scopes/rule_user_scope.go`](https://github.com/looplj/axonhub/blob/main/internal/scopes/rule_user_scope.go) and [`internal/scopes/rule_user_project_scope.go`](https://github.com/looplj/axonhub/blob/main/internal/scopes/rule_user_project_scope.go) allow precise endpoint protection.
- **Multi-level granularity**: Supports system-wide, project-specific, and owner-resource scopes through specialized rules in [`internal/scopes/rule_owner.go`](https://github.com/looplj/axonhub/blob/main/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`](https://github.com/looplj/axonhub/blob/main/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`](https://github.com/looplj/axonhub/blob/main/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`](https://github.com/looplj/axonhub/blob/main/internal/scopes/scopes.go). Second, create a rule factory function (e.g., in [`internal/scopes/rule_custom.go`](https://github.com/looplj/axonhub/blob/main/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.