Skip to content

feat(rbac): add zero-allocation RBAC core engine - #22

Merged
Saxy merged 3 commits into
feat/rbacfrom
feat/rbac-phase-1
Jul 31, 2026
Merged

feat(rbac): add zero-allocation RBAC core engine#22
Saxy merged 3 commits into
feat/rbacfrom
feat/rbac-phase-1

Conversation

@Saxy

@Saxy Saxy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements Phase 1 of the RBAC design (issue #16): command ID catalog with bulk categories, dynamic zero-allocation Bitset, role whitelist rules, rule parser, SessionContext hot-path check, and an atomically hot-swappable PolicyStore.

IsAllowed benchmarks at 0 allocs/op, ~2.8 ns/op.

Description

Adds the Phase 1 core RBAC engine as a new self-contained internal/rbac/
package — the authorization primitive that later phases (ROLE commands + config
file, AUTH integration, API keys/OIDC mapping) build on. No production paths are
wired yet; this lands the model and its hot path in isolation so the
allocation/performance guarantees are proven before integration.

Component: Security / Access Control (internal/rbac/)

Type of Change:

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Performance optimization (no change in behavior, improved speed/memory)
  • Refactoring (no functional changes, code cleanup)
  • Build / CI / Documentation

Related Issue

#16


Technical Deep Dive & Context

What was built, per the issue's Phase 1 checklist:

  1. Command catalog (cmd.go) — 17 commands mapped to iota + 1 IDs
    (CmdGet=1 … CmdRevoke=16; 0 is reserved as invalid, so the zero-value
    bitset fails closed instead of accidentally granting CmdGet). Bulk
    categories (read, write, readwrite, operator, maintenance, admin,
    all, none, login) are declared as flat command lists and expanded at
    build time. login is the category of CmdAuth, kept separate so a
    data-plane role like readwrite can grant AUTH without granting admin.
    admin (identity management) is deliberately split from maintenance
    (FLUSH/SHUTDOWN/CONFIG) for least privilege.
  2. Dynamic Bitset (rbac.go) — []uint64, pre-sized via NewBitset,
    Set/Clear/Has. Has is one word load + bit test with an out-of-range
    guard that returns false (fail-closed). The zero value denies everything, so
    a partially-built role can never be accidentally permissive.
  3. Role (role.go) — name + permissions + namespace whitelist. Flat, no
    inheritance in v1 (per issue). Immutable after construction.
  4. SessionContext (session.go) — pinned at handshake, holds the role's
    resolved permissions and namespace rules (not a pointer to the live role),
    matching the issue's "copy, not reference" design. IsAllowed is the hot
    path: command bit test, then prefix scan with default-deny.
  5. PolicyStore + atomic hot-swap (policy.go) — a full immutable snapshot
    (Roles + Users + Default) behind atomic.Pointer[PolicyStore]. Updates
    build a complete replacement and swap in one store: readers never block, never
    observe partial state, and new connections pick up the new policy at
    handshake while existing sessions stay pinned.
  6. Rule parser (parser.go) — +GET, +@read, -SET, -@admin, ~prefix,
    ~*. Deny always overrides allow regardless of rule order (Redis precedence).
    Category expansion happens at parse time, so the hot path never expands.

Design trade-offs worth calling out:

  • Default-deny namespaces. Any ~ rule makes the role a whitelist; keys not
    matching a prefix are denied even for granted commands. This is the explicit
    guardrail against "cache-manager can read users:*" in the issue. ~* and
    empty rules mean all keys — the two cases collapse to "no restriction", so no
    separate wildcard flag is needed.
  • Literal prefix matching, not globs. The Redis-style trailing * in
    ~users:* is stripped at parse time because matching is bytes.HasPrefix.
    This keeps the hot path allocation-free (no regex/glob compile or match) at the
    cost of only supporting prefix scoping — which is all the issue requires.
  • Slices shared, not copied. SessionContext.Namespaces aliases the role's
    byte slices; safe because roles are immutable once published. Copying would
    allocate per handshake and buy nothing.

Performance & Benchmarks

Microbenchmarks of the IsAllowed hot path on AMD Ryzen 9 9950X, -benchmem:

Workload: single role, one ~ prefix rule, 3 runs each

Metric Before After Delta
IsAllowed allowed n/a (new pkg) 2.69–2.74 ns/op
IsAllowed denied-by-prefix n/a (new pkg) 2.76–2.84 ns/op
allocations n/a (new pkg) 0 B/op, 0 allocs/op
go test ./internal/rbac/ -bench=. -benchmem -run='^$'
BenchmarkIsAllowedAllowed-32           441528438   2.695 ns/op   0 B/op   0 allocs/op
BenchmarkIsAllowedAllowed-32           427467008   2.687 ns/op   0 B/op   0 allocs/op
BenchmarkIsAllowedAllowed-32           440097133   2.735 ns/op   0 B/op   0 allocs/op
BenchmarkIsAllowedDeniedByPrefix-32    425571968   2.763 ns/op   0 B/op   0 allocs/op
BenchmarkIsAllowedDeniedByPrefix-32    409098697   2.813 ns/op   0 B/op   0 allocs/op
BenchmarkIsAllowedDeniedByPrefix-32    436544012   2.841 ns/op   0 B/op   0 allocs/op

Meets the issue's targets: 0 allocs/op and well under 10 ns/op. There is
no before/after comparison — the package is new and not yet on any execution
path, which is precisely why the numbers are published now rather than after
integration.


How Has This Been Tested?

  • go test ./internal/rbac/20 tests covering: bitset set/has/clear and
    fail-closed zero value, category expansion, deny-overrides-allow in both rule
    orders, ~* wildcard, namespace default-deny (allowed + denied prefixes),
    parser errors (unknown command/category, malformed rules), role build
    validation, session fail-closed with nil role, user→role resolution, default
    role fallback, atomic hot-swap visibility, and session pinning across swaps
  • go test -race ./internal/rbac/ — passes (atomic pointer swap exercised)
  • go vet ./internal/rbac/ — clean
  • gofmt -l internal/rbac/ — clean

Checklist

  • My code follows the existing code style of this project
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally (go test ./... and go test -race ./...)
  • I have updated the documentation (README, comments, or any relevant docs)
  • My changes generate no new go vet warnings
  • Any breaking changes are documented and communicated

Summary by CodeRabbit

  • New Features

    • Added role-based access control for commands and key namespaces.
    • Added configurable roles, user assignments, default roles, and policy updates.
    • Added command categories, grants, denies, wildcard access, and validation.
    • Added session-level authorization with fail-closed behavior and stable permissions.
  • Tests

    • Added comprehensive coverage for permissions, namespaces, parsing, policy updates, and authorization performance.

Implements Phase 1 of the RBAC design (issue #16): command ID
catalog with bulk categories, dynamic zero-allocation Bitset,
role whitelist rules, rule parser, SessionContext hot-path check,
and an atomically hot-swappable PolicyStore.

IsAllowed benchmarks at 0 allocs/op, ~2.8 ns/op.

Signed-off-by: Maximilian Hagen <git@saxy.dev>
@Saxy Saxy added the enhancement New feature or request label Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f98cb7ec-c070-40da-a685-9517b7206e7e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Saxy Saxy linked an issue Jul 31, 2026 that may be closed by this pull request

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/rbac/cmd.go (1)

20-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a consistency check across the three command registries.

AllCommands (Lines 42-46) and commandNames (Lines 50-56) are maintained by hand, separately from the CmdXxx const block (Lines 20-38). Nothing enforces that every constant appears in both lists. If a future command is added to the const block but forgotten in AllCommands or commandNames, LookupCommand and the "all" category silently diverge with no test failure, since TestCategoryAllCoversEveryRegisteredCommand only checks Category("all") against AllCommands itself, not against the const block.

Add a small test (or a go:generate-based single source of truth) that verifies len(commandNames) == len(AllCommands) and that every ID in AllCommands has a matching entry in commandNames.

♻️ Suggested consistency test
func TestCommandRegistriesStayInSync(t *testing.T) {
	if len(commandNames) != len(AllCommands) {
		t.Fatalf("commandNames has %d entries, AllCommands has %d", len(commandNames), len(AllCommands))
	}
	for _, id := range AllCommands {
		found := false
		for _, cmd := range commandNames {
			if cmd == id {
				found = true
				break
			}
		}
		if !found {
			t.Errorf("command id %d missing from commandNames", id)
		}
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/rbac/cmd.go` around lines 20 - 56, Add a test for the command
registries, such as TestCommandRegistriesStayInSync, that asserts commandNames
and AllCommands have equal lengths and verifies every ID in AllCommands appears
among commandNames values. Keep the existing CmdXxx constants and registry
definitions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/rbac/parser.go`:
- Around line 64-71: Update the "~" rule handling in ParseRole to trim the
prefix, reject an empty result with the parser’s existing error behavior, and
append only non-empty namespace prefixes; preserve the special "~*" all-keys
behavior. Add a regression test in parser_test.go verifying ParseRole("x",
"~cache:", "~") returns an error.

In `@internal/rbac/session.go`:
- Around line 14-20: Fix the indentation in the SessionContext doc comment by
removing the leading tab from the continuation line so the full sentence renders
as one normal paragraph in Go documentation.

---

Nitpick comments:
In `@internal/rbac/cmd.go`:
- Around line 20-56: Add a test for the command registries, such as
TestCommandRegistriesStayInSync, that asserts commandNames and AllCommands have
equal lengths and verifies every ID in AllCommands appears among commandNames
values. Keep the existing CmdXxx constants and registry definitions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 117cf357-dca4-481c-8247-f8e6d206ee7a

📥 Commits

Reviewing files that changed from the base of the PR and between d51e187 and 54ed199.

📒 Files selected for processing (12)
  • internal/rbac/bench_test.go
  • internal/rbac/cmd.go
  • internal/rbac/parser.go
  • internal/rbac/parser_test.go
  • internal/rbac/policy.go
  • internal/rbac/policy_test.go
  • internal/rbac/rbac.go
  • internal/rbac/rbac_test.go
  • internal/rbac/role.go
  • internal/rbac/role_test.go
  • internal/rbac/session.go
  • internal/rbac/session_test.go

Comment thread internal/rbac/parser.go
Comment thread internal/rbac/session.go
@Saxy
Saxy merged commit a554a9f into feat/rbac Jul 31, 2026
5 checks passed
@Saxy
Saxy deleted the feat/rbac-phase-1 branch July 31, 2026 10:31
Saxy added a commit that referenced this pull request Aug 1, 2026
* feat(rbac): add zero-allocation RBAC core engine (#22)

* feat(rbac): add zero-allocation RBAC core engine
Implements Phase 1 of the RBAC design (issue #16): command ID
catalog with bulk categories, dynamic zero-allocation Bitset,
role whitelist rules, rule parser, SessionContext hot-path check,
and an atomically hot-swappable PolicyStore.

IsAllowed benchmarks at 0 allocs/op, ~2.8 ns/op.

Signed-off-by: Maximilian Hagen <git@saxy.dev>

fix(pr): resolve comments

chore(rbac): added test to ensure cmdList is always up2date

Signed-off-by: Maximilian Hagen <git@saxy.dev>

---------

Signed-off-by: Maximilian Hagen <git@saxy.dev>

* feat(rbac): wire RBAC into RESP and binary servers (#23)

* feat(rbac): wire RBAC into RESP and binary servers
Add the ROLE command family and per-user bcrypt auth over both protocols,
replacing --require-pass when an RBAC policy is loaded:
- internal/rbac: Role/User policy store, rule parser (+cmd, +@cat, ~prefix),
  atomic hot-swap holder, and zero-alloc session gating
-  RESP: AUTH <user> <pass>, ROLE CREATE/SETUSER/DELUSER/DELETE/LIST/GETUSER,
  -NOAUTH / -NOPERM replies; nopass default-user semantics
- binary: OpRoleCreate/SetUser/DelUser/Delete/List/GetUser wire codecs,
  MsgAuth username+password frame, ERR NOT_AUTHORIZED gating on both
  plaintext and TLS loops
- server: --rbac-config / TELLSTONE_RBAC_CONFIG, SIGHUP hot-reload
- client: AuthUser + Role* methods in the network and public client packages
- example: cmd/example/role drives the full flow over the binary protocol

Signed-off-by: Maximilian Hagen <git@saxy.dev>

* feat(rbac): add metrics auth and per-role command counters

Signed-off-by: Maximilian Hagen <git@saxy.dev>

* fix(pr): resolve comments

* fix(pr): resolve comments

* fix(startup): reordered inits for graceful shutdown on error

Signed-off-by: Maximilian Hagen <git@saxy.dev>

* fix(rbac): extend documentation and client logger

Signed-off-by: Maximilian Hagen <git@saxy.dev>

---------

Signed-off-by: Maximilian Hagen <git@saxy.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zero-Allocation RBAC with Hot-Swap Policy Store

1 participant