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/
This commit is contained in:
2026-03-18 13:25:43 -06:00
commit 0864f031a1
17 changed files with 940 additions and 0 deletions

186
README.md Normal file
View File

@@ -0,0 +1,186 @@
# `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`](#identity) (the authenticated principal), [`Permission`](#permissions) and [`PermissionMask`](#permissions) (typed bit-mask access control), and [`PermissionProvider`](#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`](../firebase) or any `httpauth.TokenVerifier` implementation. Route enforcement belongs to [`httpauth`](../httpauth).
## Installation
```sh
go get code.nochebuena.dev/go/rbac
```
## Quick start
```go
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.
```go
type Identity struct {
UID string
TenantID string
DisplayName string
Email string
}
```
**Construction follows a two-step pattern** that mirrors the middleware pipeline:
```go
// 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
```go
// 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:
```go
// 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`:
```go
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`:
```go
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.
```go
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:
```go
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:**
```go
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 `Identity`**`Identity` 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