Files
rbac/README.md
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

187 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `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