187 lines
6.5 KiB
Markdown
187 lines
6.5 KiB
Markdown
|
|
# `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
|