Rene Nochebuena 0864f031a1 feat(rbac): initial stable release v0.9.0
Foundational identity and permission types for role-based access control — bit-set PermissionMask, immutable Identity value type, and PermissionProvider interface.

What's included:
- `Identity` value type with NewIdentity / WithTenant constructors and SetInContext / FromContext context helpers
- `Permission` (int64 bit position) and `PermissionMask` (int64 bit-set) with O(1) Has and non-mutating Grant
- `PermissionProvider` interface for DB-backed ResolveMask(ctx, uid, resource) resolution

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
2026-03-18 13:25:43 -06:00

rbac

Identity and role-based access control primitives for the request lifecycle.

Module: code.nochebuena.dev/go/rbac Tier: 0 — zero external dependencies, stdlib only Go: 1.25+ Dependencies: none


Overview

rbac provides the foundational types that flow through every authenticated request: Identity (the authenticated principal), Permission and PermissionMask (typed bit-mask access control), and PermissionProvider (the interface authorization backends implement).

These types are Tier 0 — every module that needs to read or carry identity data imports this package. Higher-level modules (httpauth, httpserver) build on top of it without this package knowing about them.

This package does not verify tokens, issue JWTs, query a database, or enforce permissions on HTTP routes. Token verification belongs to firebase or any httpauth.TokenVerifier implementation. Route enforcement belongs to httpauth.

Installation

go get code.nochebuena.dev/go/rbac

Quick start

import "code.nochebuena.dev/go/rbac"

// 1. Construct an identity from token claims (e.g. in auth middleware)
id := rbac.NewIdentity(uid, displayName, email)

// 2. Optionally enrich with a tenant ID (e.g. in enrichment middleware)
id = id.WithTenant(tenantID)

// 3. Store it in the request context
ctx = rbac.SetInContext(ctx, id)

// 4. Retrieve it anywhere downstream (handler, service, repository)
id, ok := rbac.FromContext(ctx)
if !ok {
    // no authenticated identity in context
}

Usage

Identity

Identity is a value type — it is always copied, never a pointer. This eliminates nil-check burden and prevents accidental mutation of a shared context value.

type Identity struct {
    UID         string
    TenantID    string
    DisplayName string
    Email       string
}

Construction follows a two-step pattern that mirrors the middleware pipeline:

// Step 1 — auth middleware: populate from token claims
id := rbac.NewIdentity(uid, displayName, email)
// id.TenantID == "" at this point

// Step 2 — enrichment middleware: attach tenant (opt-in, returns a new value)
id = id.WithTenant(tenantID)
// original id is unchanged — WithTenant does not mutate

WithTenant is safe to call from concurrent middleware because it returns a new value instead of mutating the receiver.

Context helpers

// Store — typically called in auth middleware
ctx = rbac.SetInContext(ctx, id)

// Retrieve — typically called in handlers or services
id, ok := rbac.FromContext(ctx)
if !ok {
    // unauthenticated request
}

FromContext returns the zero-value Identity and false when no identity is present — no nil pointer to check.

Permissions

Define your application's permissions as typed constants:

// In your application code
const (
    Read   rbac.Permission = 0
    Write  rbac.Permission = 1
    Delete rbac.Permission = 2
    Admin  rbac.Permission = 3
)

Permission is a bit position (062). PermissionMask is the resolved bit-mask for a user on a resource. Check permissions with Has:

mask, err := provider.ResolveMask(ctx, id.UID, "orders")
if err != nil { ... }

if !mask.Has(Read) {
    return xerrors.New(xerrors.ErrPermissionDenied, "cannot read orders")
}

Build masks for tests or in-memory implementations with Grant:

mask := rbac.PermissionMask(0).Grant(Read).Grant(Write)
mask.Has(Read)   // true
mask.Has(Delete) // false

Grant returns a new mask — it does not mutate the receiver.

Valid bit positions are 062. Has and Grant both return the unmodified value for out-of-range inputs rather than panicking.

PermissionProvider

PermissionProvider is the interface that authorization backends implement. httpauth.AuthzMiddleware calls it — no knowledge of the concrete implementation is required.

type PermissionProvider interface {
    ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)
}

Multi-tenant implementations retrieve TenantID from the context rather than requiring it as a parameter:

func (p *myProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) {
    id, _ := rbac.FromContext(ctx)
    return p.db.QueryMask(ctx, id.TenantID, uid, resource)
}

Single-tenant implementations ignore TenantID entirely — the interface signature does not force them to handle it.

In-memory implementation for tests:

type staticProvider struct{ mask rbac.PermissionMask }

func (p *staticProvider) ResolveMask(_ context.Context, _, _ string) (rbac.PermissionMask, error) {
    return p.mask, nil
}

// Usage in tests
provider := &staticProvider{mask: rbac.PermissionMask(0).Grant(Read)}

Design decisions

Value type for Identity — the original authz package used *Identity everywhere. Pointer semantics forced every caller to check for nil and opened the door to accidental mutation of a context value shared across the request. Value semantics are safer and simpler at zero extra cost.

No JSON tags on IdentityIdentity is a runtime value threaded through context, not a data transfer object. If an application needs to serialize identity data (e.g. in an audit log), it maps to its own DTO with the fields it actually needs.

WithTenant instead of a mutable setter — middleware pipelines are concurrent. Returning a new value from WithTenant makes the enrichment step safe without synchronization.

tenantID removed from PermissionProvider.ResolveMask — it is already in the context via Identity. Passing it again as an explicit parameter forces every call site to extract and re-pass data that is already available, and leaks multi-tenancy concerns into the interface signature. Single-tenant implementations never needed it; multi-tenant implementations read it from context.

Permission and PermissionMask as distinct types — the original HasPermission(mask, bit int64) had two int64 parameters in the same position, making it trivial to swap them by accident. Distinct types make that a compile-time error.

Ecosystem

Tier 0:   rbac ← you are here
               ↑
Tier 4:   httpauth (builds auth + enrichment + authz middleware on top of rbac)

rbac.Identity flows from httpauth into every downstream handler and service via rbac.FromContext(ctx). No module between rbac and the handler needs to import httpauth.

License

MIT

Description
Bit-set permission masks and identity context helpers for role-based access control.
Readme 42 KiB
Release 0.9.0 Latest
2026-03-18 13:27:28 -06:00
Languages
Go 100%