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:
186
README.md
Normal file
186
README.md
Normal 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 (0–62). `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 0–62. `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
|
||||
Reference in New Issue
Block a user