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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Go",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
|
||||
},
|
||||
"forwardPorts": [],
|
||||
"postCreateCommand": "go version",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"files.autoSave": "afterDelay",
|
||||
"files.autoSaveDelay": 1000,
|
||||
"explorer.compactFolders": false,
|
||||
"explorer.showEmptyFolders": true
|
||||
},
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"eamodio.golang-postfix-completion",
|
||||
"quicktype.quicktype",
|
||||
"usernamehw.errorlens"
|
||||
]
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with go test -c
|
||||
*.test
|
||||
|
||||
# Output of go build
|
||||
*.out
|
||||
|
||||
# Dependency directory
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# VCS files
|
||||
COMMIT.md
|
||||
RELEASE.md
|
||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this module will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
- `Permission` — `int64` type representing a named bit position (0–62) for a single capability; applications define their own constants using this type
|
||||
- `PermissionMask` — `int64` type representing a resolved bit-set of capabilities for a user on a resource
|
||||
- `PermissionMask.Has(p Permission) bool` — O(1) check whether a permission bit is set; returns false for out-of-range values (p < 0 or p >= 63)
|
||||
- `PermissionMask.Grant(p Permission) PermissionMask` — returns a new mask with the given bit set without mutating the receiver; silently ignores out-of-range values
|
||||
- `Identity` — value type (not a pointer) carrying `UID`, `TenantID`, `DisplayName`, and `Email` for an authenticated principal
|
||||
- `NewIdentity(uid, displayName, email string) Identity` — constructs an Identity from token authentication data; `TenantID` is intentionally left empty for later enrichment
|
||||
- `Identity.WithTenant(id string) Identity` — returns a copy of the Identity with `TenantID` set; does not mutate the receiver, safe for concurrent middleware use
|
||||
- `SetInContext(ctx context.Context, id Identity) context.Context` — stores an Identity in a context using a private unexported key type to prevent collisions
|
||||
- `FromContext(ctx context.Context) (Identity, bool)` — retrieves the Identity stored by `SetInContext`; returns the zero-value Identity and false if no identity is present
|
||||
- `PermissionProvider` interface — `ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)` for DB-backed or in-memory permission resolution
|
||||
|
||||
### Design Notes
|
||||
|
||||
- `Identity` is a value type throughout — every enrichment call (e.g. `WithTenant`) returns a new copy, eliminating nil-pointer bugs and preventing accidental mutation of a shared context value across concurrent middleware.
|
||||
- Permissions are bit positions (0–62) packed into an `int64` mask; applications define their own named `Permission` constants — none are prescribed by this package — keeping the bit-set model flat and free of role-hierarchy complexity.
|
||||
- This package owns the context key for `Identity` via an unexported `authContextKey{}` struct, so any module that needs to carry an authenticated identity imports only `rbac`; zero micro-lib dependencies (stdlib only).
|
||||
|
||||
[0.9.0]: https://code.nochebuena.dev/go/rbac/releases/tag/v0.9.0
|
||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# rbac
|
||||
|
||||
Foundational identity and permission types for role-based access control.
|
||||
|
||||
## Purpose
|
||||
|
||||
`rbac` defines the `Identity` value type (authenticated principal), the
|
||||
`PermissionMask` bit-set type (resolved capabilities), and the `PermissionProvider`
|
||||
interface (DB-backed permission resolution). Every other module that needs to carry
|
||||
or inspect an authenticated identity imports this package; it has no dependencies of
|
||||
its own.
|
||||
|
||||
## Tier & Dependencies
|
||||
|
||||
**Tier:** 0
|
||||
**Imports:** `context` (stdlib only)
|
||||
**Must NOT import:** `logz`, `xerrors`, `launcher`, or any other micro-lib module.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- Permissions are bit positions (0–62) packed into an `int64` mask for O(1) checks
|
||||
and compact storage. See `docs/adr/ADR-001-bitset-permissions.md`.
|
||||
- `Identity` is a value type — copied on every enrichment, never a pointer — to
|
||||
prevent nil bugs and accidental mutation from concurrent middleware. See
|
||||
`docs/adr/ADR-002-identity-value-type.md`.
|
||||
- `rbac` owns the context key for `Identity` (`authContextKey{}`). Any package that
|
||||
needs an identity imports only `rbac`, not an HTTP package. See
|
||||
`docs/adr/ADR-003-identity-context-ownership.md`.
|
||||
- `TenantID` is an optional enrichment field on `Identity`. Multi-tenancy is not
|
||||
modelled in the core; `PermissionProvider` implementations retrieve the tenant from
|
||||
`rbac.FromContext(ctx)` when needed.
|
||||
|
||||
## Patterns
|
||||
|
||||
**Identity lifecycle:**
|
||||
|
||||
```go
|
||||
// Step 1: create from auth token (in httpauth middleware)
|
||||
id := rbac.NewIdentity(uid, displayName, email)
|
||||
|
||||
// Step 2: optionally enrich with tenant (in tenant middleware)
|
||||
id = id.WithTenant(tenantID)
|
||||
|
||||
// Store in context
|
||||
ctx = rbac.SetInContext(ctx, id)
|
||||
|
||||
// Retrieve anywhere downstream
|
||||
id, ok := rbac.FromContext(ctx)
|
||||
if !ok {
|
||||
// no authenticated user in context
|
||||
}
|
||||
```
|
||||
|
||||
**Defining application permissions:**
|
||||
|
||||
```go
|
||||
// In your domain package — define once, use everywhere
|
||||
const (
|
||||
PermRead rbac.Permission = 0
|
||||
PermWrite rbac.Permission = 1
|
||||
PermDelete rbac.Permission = 2
|
||||
)
|
||||
```
|
||||
|
||||
**Checking permissions:**
|
||||
|
||||
```go
|
||||
mask, err := provider.ResolveMask(ctx, id.UID, "orders")
|
||||
if err != nil { ... }
|
||||
if !mask.Has(PermWrite) {
|
||||
return xerrors.New(xerrors.ErrPermissionDenied, "write access required")
|
||||
}
|
||||
```
|
||||
|
||||
**Building masks in tests:**
|
||||
|
||||
```go
|
||||
mask := rbac.PermissionMask(0).Grant(PermRead).Grant(PermWrite)
|
||||
```
|
||||
|
||||
**In-memory PermissionProvider for tests:**
|
||||
|
||||
```go
|
||||
type staticProvider struct{ mask rbac.PermissionMask }
|
||||
|
||||
func (p *staticProvider) ResolveMask(_ context.Context, _, _ string) (rbac.PermissionMask, error) {
|
||||
return p.mask, nil
|
||||
}
|
||||
```
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- Do not store `*Identity` in context — always store the value type. The type
|
||||
assertion in `FromContext` relies on `Identity` being stored as a value.
|
||||
- Do not define permission constants in this package. Domain permissions belong in
|
||||
the domain package that owns the resource.
|
||||
- Do not add role-string logic here. The bit-set model deliberately avoids the
|
||||
role-to-permission mapping table problem.
|
||||
- Do not call `rbac.FromContext` from within `rbac` itself — the context helpers are
|
||||
for consumers, not for internal use.
|
||||
- Permissions 63 and above are silently ignored by `Has` and `Grant`. Keep constants
|
||||
in the range 0–62.
|
||||
|
||||
## Testing Notes
|
||||
|
||||
- `compliance_test.go` enforces at compile time that `Identity.WithTenant` returns
|
||||
`Identity` (not `*Identity`) and that `PermissionMask` exposes `Has` and `Grant`
|
||||
with the correct typed signatures.
|
||||
- `identity_test.go` covers `NewIdentity`, `WithTenant` immutability,
|
||||
`SetInContext`/`FromContext` round-trips, and the zero-value absent case.
|
||||
- `permission_test.go` covers `Has`, `Grant`, and boundary conditions (negative
|
||||
positions, position >= 63).
|
||||
- No external dependencies — run with plain `go test`.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NOCHEBUENADEV
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
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
|
||||
19
compliance_test.go
Normal file
19
compliance_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package rbac_test
|
||||
|
||||
import "code.nochebuena.dev/go/rbac"
|
||||
|
||||
// Compile-time contract verification.
|
||||
//
|
||||
// These assertions are zero-cost at runtime. A build failure here means a
|
||||
// method was removed or its signature changed — a breaking change.
|
||||
|
||||
// Identity must support immutable enrichment returning a value (not pointer).
|
||||
var _ interface {
|
||||
WithTenant(string) rbac.Identity
|
||||
} = rbac.Identity{}
|
||||
|
||||
// PermissionMask must expose Has and Grant with the correct typed signatures.
|
||||
var _ interface {
|
||||
Has(rbac.Permission) bool
|
||||
Grant(rbac.Permission) rbac.PermissionMask
|
||||
} = rbac.PermissionMask(0)
|
||||
50
doc.go
Normal file
50
doc.go
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Package rbac provides the foundational types and helpers for identity and
|
||||
role-based access control across the micro-lib ecosystem.
|
||||
|
||||
It is Tier 0: zero external dependencies, stdlib only. Every other module
|
||||
that needs to carry or inspect an authenticated identity imports this package.
|
||||
|
||||
# Identity
|
||||
|
||||
[Identity] represents the authenticated principal. It is a value type — never
|
||||
a pointer — to eliminate nil-check burden and prevent accidental mutation of
|
||||
a shared context value.
|
||||
|
||||
id := rbac.NewIdentity(uid, displayName, email)
|
||||
|
||||
// Enrichment (e.g. from a database lookup) returns a new value
|
||||
id = id.WithTenant(tenantID)
|
||||
|
||||
// Thread it through the request context
|
||||
ctx = rbac.SetInContext(ctx, id)
|
||||
|
||||
// Retrieve it anywhere downstream
|
||||
id, ok := rbac.FromContext(ctx)
|
||||
|
||||
# Permissions
|
||||
|
||||
[Permission] is a typed bit position (0–62). Applications define their own
|
||||
named constants using this type:
|
||||
|
||||
const (
|
||||
Read rbac.Permission = 0
|
||||
Write rbac.Permission = 1
|
||||
Delete rbac.Permission = 2
|
||||
)
|
||||
|
||||
[PermissionMask] is the resolved bit-mask returned by a [PermissionProvider].
|
||||
Use [PermissionMask.Has] to check whether a permission is granted:
|
||||
|
||||
mask, err := provider.ResolveMask(ctx, uid, "orders")
|
||||
if !mask.Has(Read) {
|
||||
return rbac.ErrPermissionDenied
|
||||
}
|
||||
|
||||
# PermissionProvider
|
||||
|
||||
[PermissionProvider] is the interface that authorization backends implement.
|
||||
The httpauth module calls it from its AuthzMiddleware without knowing the
|
||||
concrete implementation.
|
||||
*/
|
||||
package rbac
|
||||
51
docs/adr/ADR-001-bitset-permissions.md
Normal file
51
docs/adr/ADR-001-bitset-permissions.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# ADR-001: Bit-Set Permissions
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
Applications need to represent and check whether a user holds a specific capability
|
||||
on a resource. Common approaches include: role strings (e.g. `"admin"`, `"editor"`),
|
||||
permission string lists, or bit-set integers. Role strings require the application to
|
||||
know which capabilities each role implies, making capability checks indirect and
|
||||
requiring either a lookup table or a case statement everywhere. Permission string
|
||||
lists are flexible but expensive to check (linear scan) and verbose to store.
|
||||
|
||||
## Decision
|
||||
|
||||
`Permission` is typed as `int64` and represents a named bit position (0–62).
|
||||
Applications define their own constants using this type:
|
||||
|
||||
```go
|
||||
const (
|
||||
Read rbac.Permission = 0
|
||||
Write rbac.Permission = 1
|
||||
Delete rbac.Permission = 2
|
||||
)
|
||||
```
|
||||
|
||||
`PermissionMask` is also typed as `int64` and holds the resolved OR-combination of
|
||||
granted permission bits. It is returned by `PermissionProvider.ResolveMask` and
|
||||
checked with `PermissionMask.Has(p Permission) bool`.
|
||||
|
||||
The upper bound is 62 (not 63) to keep the value within the positive range of a
|
||||
signed 64-bit integer, avoiding sign-bit ambiguity. `Has` and `Grant` both return
|
||||
false/no-op for out-of-range values (`p < 0 || p >= 63`).
|
||||
|
||||
`Grant(p Permission) PermissionMask` is provided for constructing masks in tests and
|
||||
in-memory provider implementations, returning a new mask with the bit set without
|
||||
mutating the receiver.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Permission checks are O(1) bitwise operations — no map lookup, no string comparison.
|
||||
- A single `int64` column stores up to 62 independent permission bits per user-resource
|
||||
pair in the database.
|
||||
- Applications must define their own named constants; this package does not enumerate
|
||||
domain permissions. This keeps `rbac` domain-agnostic.
|
||||
- The 62-bit limit is sufficient for all foreseeable use cases; if an application
|
||||
needs more than 62 orthogonal permissions on a single resource, a structural
|
||||
refactor (multiple resources or resource hierarchies) is the appropriate response.
|
||||
- `PermissionProvider` is the extension point for DB-backed resolution; the bit-set
|
||||
design does not constrain the storage schema.
|
||||
52
docs/adr/ADR-002-identity-value-type.md
Normal file
52
docs/adr/ADR-002-identity-value-type.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR-002: Identity as a Value Type
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
`Identity` travels through the request context from authentication middleware to
|
||||
handler and downstream service calls. When a type travels via `context.WithValue`,
|
||||
callers retrieve it with a type assertion. If the stored type were a pointer
|
||||
(`*Identity`), any middleware could mutate the struct fields after the value was
|
||||
placed in context, causing non-obvious bugs in concurrent or pipelined middleware
|
||||
chains. Additionally, pointer types retrieved from context require nil checks at
|
||||
every retrieval site.
|
||||
|
||||
## Decision
|
||||
|
||||
`Identity` is declared as a plain struct, not a pointer:
|
||||
|
||||
```go
|
||||
type Identity struct {
|
||||
UID string
|
||||
TenantID string
|
||||
DisplayName string
|
||||
Email string
|
||||
}
|
||||
```
|
||||
|
||||
`NewIdentity` returns `Identity` (value), not `*Identity`. `SetInContext` stores the
|
||||
value directly. `FromContext` retrieves it as a value and returns `(Identity, bool)`;
|
||||
the boolean indicates absence without requiring a nil pointer check.
|
||||
|
||||
Enrichment methods return new values rather than mutating the receiver.
|
||||
`WithTenant(id string) Identity` copies the receiver, sets `TenantID` on the copy,
|
||||
and returns the copy. The original is unchanged, making it safe to call from
|
||||
concurrent middleware.
|
||||
|
||||
The two-step construction pattern is intentional:
|
||||
1. `NewIdentity(uid, displayName, email)` — populated from the auth token.
|
||||
2. `id.WithTenant(tenantID)` — optionally enriched by a tenant-resolution middleware.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No nil checks are needed at retrieval sites. A zero-value `Identity{}` is returned
|
||||
when no identity is in context; the `bool` return of `FromContext` distinguishes
|
||||
"not authenticated" from "authenticated with empty fields".
|
||||
- Concurrent middleware cannot accidentally mutate an `Identity` already stored in
|
||||
context — each enrichment step produces a new value.
|
||||
- Copying `Identity` on every `WithTenant` call is negligible: four string fields,
|
||||
each a pointer-and-length pair internally.
|
||||
- The compliance test enforces that `WithTenant` returns `Identity` (not `*Identity`)
|
||||
at compile time, preventing a future regression to a pointer receiver.
|
||||
45
docs/adr/ADR-003-identity-context-ownership.md
Normal file
45
docs/adr/ADR-003-identity-context-ownership.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# ADR-003: Identity Context Ownership
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
Global ADR-003 establishes that context helpers must live with their data owners.
|
||||
`Identity` is defined in the `rbac` package. Its context key, storage function, and
|
||||
retrieval function must also live here to avoid requiring any other package to know
|
||||
the key type.
|
||||
|
||||
If the context key for `Identity` were defined elsewhere (e.g. in an `httpauth`
|
||||
module), any package that wanted to retrieve an identity from context would need to
|
||||
import `httpauth` — creating a coupling that does not make sense for packages that
|
||||
have nothing to do with HTTP authentication.
|
||||
|
||||
## Decision
|
||||
|
||||
The unexported type `authContextKey struct{}` and the package-level variable
|
||||
`authKey = authContextKey{}` are defined in `identity.go` within the `rbac` package.
|
||||
Two exported functions manage the context lifecycle:
|
||||
|
||||
- `SetInContext(ctx context.Context, id Identity) context.Context` — stores the
|
||||
identity value under `authKey`.
|
||||
- `FromContext(ctx context.Context) (Identity, bool)` — retrieves it, returning
|
||||
`false` when absent.
|
||||
|
||||
The key type is unexported (`authContextKey`), which prevents any external package
|
||||
from constructing or comparing the key directly — only `rbac` can write or read the
|
||||
identity in context. This eliminates the risk of key collisions with other packages
|
||||
that might also use an empty struct as a context key.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Any package that needs to read the authenticated identity imports only `rbac` —
|
||||
not `httpauth`, not any HTTP package.
|
||||
- `httpauth` middleware stores the identity via `rbac.SetInContext`; domain handlers
|
||||
retrieve it via `rbac.FromContext`. The two call sites share a single, well-known
|
||||
contract.
|
||||
- The unexported key type guarantees that no external package can accidentally shadow
|
||||
or overwrite the identity value by using the same key.
|
||||
- `PermissionProvider.ResolveMask` receives `ctx` explicitly; implementations that
|
||||
need the `TenantID` call `rbac.FromContext(ctx)` to obtain it — no need to thread
|
||||
tenant ID as a separate parameter.
|
||||
55
identity.go
Normal file
55
identity.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package rbac
|
||||
|
||||
import "context"
|
||||
|
||||
// Identity represents the authenticated principal for a request.
|
||||
//
|
||||
// It is a value type — always copied, never a pointer — to prevent nil-check
|
||||
// burden and avoid accidental mutation of a shared context value.
|
||||
//
|
||||
// Construction follows a two-step pattern:
|
||||
// 1. [NewIdentity] populates authentication data from the token (uid, name, email).
|
||||
// 2. [Identity.WithTenant] optionally enriches with a tenant ID in a later
|
||||
// middleware step, returning a new value without mutating the original.
|
||||
type Identity struct {
|
||||
UID string
|
||||
TenantID string
|
||||
DisplayName string
|
||||
Email string
|
||||
}
|
||||
|
||||
// authContextKey is an unexported type used as the context key for Identity.
|
||||
// Using a private type prevents collisions with keys from other packages.
|
||||
type authContextKey struct{}
|
||||
|
||||
var authKey = authContextKey{}
|
||||
|
||||
// NewIdentity creates an Identity from token authentication data.
|
||||
// TenantID is intentionally left empty — populate it later with [Identity.WithTenant]
|
||||
// once the enrichment middleware has resolved it.
|
||||
func NewIdentity(uid, displayName, email string) Identity {
|
||||
return Identity{
|
||||
UID: uid,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
}
|
||||
}
|
||||
|
||||
// WithTenant returns a copy of the Identity with TenantID set to id.
|
||||
// The receiver is not mutated — safe to call from concurrent middleware.
|
||||
func (i Identity) WithTenant(id string) Identity {
|
||||
i.TenantID = id
|
||||
return i
|
||||
}
|
||||
|
||||
// SetInContext stores the Identity in ctx and returns the enriched context.
|
||||
func SetInContext(ctx context.Context, id Identity) context.Context {
|
||||
return context.WithValue(ctx, authKey, id)
|
||||
}
|
||||
|
||||
// FromContext retrieves the Identity stored by [SetInContext].
|
||||
// Returns the zero-value Identity and false if no identity is present.
|
||||
func FromContext(ctx context.Context) (Identity, bool) {
|
||||
id, ok := ctx.Value(authKey).(Identity)
|
||||
return id, ok
|
||||
}
|
||||
93
identity_test.go
Normal file
93
identity_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIdentity(t *testing.T) {
|
||||
id := NewIdentity("uid-1", "Jane Doe", "jane@example.com")
|
||||
|
||||
if id.UID != "uid-1" {
|
||||
t.Errorf("UID = %q, want uid-1", id.UID)
|
||||
}
|
||||
if id.DisplayName != "Jane Doe" {
|
||||
t.Errorf("DisplayName = %q, want Jane Doe", id.DisplayName)
|
||||
}
|
||||
if id.Email != "jane@example.com" {
|
||||
t.Errorf("Email = %q, want jane@example.com", id.Email)
|
||||
}
|
||||
if id.TenantID != "" {
|
||||
t.Errorf("TenantID must be empty at construction, got %q", id.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentity_WithTenant(t *testing.T) {
|
||||
original := NewIdentity("uid-1", "Jane Doe", "jane@example.com")
|
||||
enriched := original.WithTenant("tenant-abc")
|
||||
|
||||
if enriched.TenantID != "tenant-abc" {
|
||||
t.Errorf("enriched.TenantID = %q, want tenant-abc", enriched.TenantID)
|
||||
}
|
||||
// WithTenant must not mutate the original.
|
||||
if original.TenantID != "" {
|
||||
t.Errorf("original.TenantID mutated to %q, must remain empty", original.TenantID)
|
||||
}
|
||||
// Other fields must be preserved.
|
||||
if enriched.UID != original.UID {
|
||||
t.Errorf("WithTenant changed UID: got %q, want %q", enriched.UID, original.UID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpers_SetAndGet(t *testing.T) {
|
||||
id := NewIdentity("uid-1", "Jane Doe", "jane@example.com")
|
||||
ctx := SetInContext(context.Background(), id)
|
||||
|
||||
retrieved, ok := FromContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("FromContext returned ok=false, expected to find identity")
|
||||
}
|
||||
if retrieved.UID != id.UID {
|
||||
t.Errorf("retrieved UID = %q, want %q", retrieved.UID, id.UID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpers_MissingReturnsZeroValue(t *testing.T) {
|
||||
id, ok := FromContext(context.Background())
|
||||
if ok {
|
||||
t.Error("FromContext on empty context returned ok=true")
|
||||
}
|
||||
if id != (Identity{}) {
|
||||
t.Errorf("FromContext on empty context returned non-zero Identity: %+v", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpers_ValueSemanticsOnSet(t *testing.T) {
|
||||
id := NewIdentity("uid-1", "Jane Doe", "jane@example.com")
|
||||
ctx := SetInContext(context.Background(), id)
|
||||
|
||||
// Mutating the original struct after storing must not affect the context value
|
||||
// because Identity is a value type.
|
||||
id.UID = "mutated"
|
||||
|
||||
retrieved, _ := FromContext(ctx)
|
||||
if retrieved.UID == "mutated" {
|
||||
t.Error("context value was mutated after SetInContext; Identity must be a value type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpers_OverwriteInContext(t *testing.T) {
|
||||
first := NewIdentity("uid-1", "Jane", "jane@example.com")
|
||||
second := NewIdentity("uid-2", "John", "john@example.com")
|
||||
|
||||
ctx := SetInContext(context.Background(), first)
|
||||
ctx = SetInContext(ctx, second)
|
||||
|
||||
retrieved, ok := FromContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("expected identity in context")
|
||||
}
|
||||
if retrieved.UID != "uid-2" {
|
||||
t.Errorf("expected overwritten identity uid-2, got %s", retrieved.UID)
|
||||
}
|
||||
}
|
||||
39
permission.go
Normal file
39
permission.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package rbac
|
||||
|
||||
// Permission is a named bit position (0–62) representing a single capability.
|
||||
//
|
||||
// Applications define their own constants using this type:
|
||||
//
|
||||
// const (
|
||||
// Read rbac.Permission = 0
|
||||
// Write rbac.Permission = 1
|
||||
// Delete rbac.Permission = 2
|
||||
// )
|
||||
//
|
||||
// The zero value (0) is a valid permission representing the first bit.
|
||||
type Permission int64
|
||||
|
||||
// PermissionMask is a resolved bit-mask for a user on a specific resource.
|
||||
// It is returned by [PermissionProvider.ResolveMask] and checked with [PermissionMask.Has].
|
||||
type PermissionMask int64
|
||||
|
||||
// Has reports whether the given permission bit is set in the mask.
|
||||
// Returns false for out-of-range values (p < 0 or p >= 63).
|
||||
func (m PermissionMask) Has(p Permission) bool {
|
||||
if p < 0 || p >= 63 {
|
||||
return false
|
||||
}
|
||||
return (int64(m) & (1 << uint(p))) != 0
|
||||
}
|
||||
|
||||
// Grant returns a new mask with the bit for p set.
|
||||
// The receiver is not modified.
|
||||
// Useful for building masks in tests and in-memory [PermissionProvider] implementations:
|
||||
//
|
||||
// mask := rbac.PermissionMask(0).Grant(Read).Grant(Write)
|
||||
func (m PermissionMask) Grant(p Permission) PermissionMask {
|
||||
if p < 0 || p >= 63 {
|
||||
return m
|
||||
}
|
||||
return PermissionMask(int64(m) | (1 << uint(p)))
|
||||
}
|
||||
95
permission_test.go
Normal file
95
permission_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package rbac
|
||||
|
||||
import "testing"
|
||||
|
||||
// App-level permission constants used only within this test file.
|
||||
const (
|
||||
read Permission = 0
|
||||
write Permission = 1
|
||||
del Permission = 2
|
||||
admin Permission = 62 // highest valid bit
|
||||
)
|
||||
|
||||
func TestPermissionMask_Has(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mask PermissionMask
|
||||
perm Permission
|
||||
want bool
|
||||
}{
|
||||
{"bit 0 set", 1, read, true},
|
||||
{"bit 4 set", 16, Permission(4), true},
|
||||
{"multi-bit, check bit 0", 17, read, true},
|
||||
{"multi-bit, check bit 4", 17, Permission(4), true},
|
||||
{"bit not set", 16, read, false},
|
||||
{"out of range low", 1, Permission(-1), false},
|
||||
{"out of range high", 1, Permission(63), false},
|
||||
{"max valid bit (62)", PermissionMask(1 << 62), admin, true},
|
||||
{"zero mask", 0, read, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.mask.Has(tt.perm); got != tt.want {
|
||||
t.Errorf("PermissionMask(%d).Has(%d) = %v, want %v", tt.mask, tt.perm, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionMask_Grant(t *testing.T) {
|
||||
mask := PermissionMask(0)
|
||||
|
||||
mask = mask.Grant(read)
|
||||
if !mask.Has(read) {
|
||||
t.Error("Grant(read): read not set")
|
||||
}
|
||||
if mask.Has(write) {
|
||||
t.Error("Grant(read): write must not be set")
|
||||
}
|
||||
|
||||
mask = mask.Grant(write)
|
||||
if !mask.Has(read) {
|
||||
t.Error("Grant(write): read must still be set")
|
||||
}
|
||||
if !mask.Has(write) {
|
||||
t.Error("Grant(write): write not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionMask_Grant_Chain(t *testing.T) {
|
||||
mask := PermissionMask(0).Grant(read).Grant(write).Grant(del)
|
||||
|
||||
if !mask.Has(read) {
|
||||
t.Error("chained Grant: read not set")
|
||||
}
|
||||
if !mask.Has(write) {
|
||||
t.Error("chained Grant: write not set")
|
||||
}
|
||||
if !mask.Has(del) {
|
||||
t.Error("chained Grant: del not set")
|
||||
}
|
||||
if mask.Has(admin) {
|
||||
t.Error("chained Grant: admin must not be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionMask_Grant_OutOfRange(t *testing.T) {
|
||||
mask := PermissionMask(0)
|
||||
result := mask.Grant(Permission(-1))
|
||||
if result != mask {
|
||||
t.Error("Grant with out-of-range permission must return the original mask unchanged")
|
||||
}
|
||||
result = mask.Grant(Permission(63))
|
||||
if result != mask {
|
||||
t.Error("Grant with out-of-range permission (63) must return the original mask unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionMask_Grant_DoesNotMutateReceiver(t *testing.T) {
|
||||
original := PermissionMask(0)
|
||||
_ = original.Grant(read)
|
||||
if original.Has(read) {
|
||||
t.Error("Grant mutated the receiver; PermissionMask must be a value type")
|
||||
}
|
||||
}
|
||||
25
provider.go
Normal file
25
provider.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package rbac
|
||||
|
||||
import "context"
|
||||
|
||||
// PermissionProvider resolves the permission mask for a user on a given resource.
|
||||
//
|
||||
// Implementations may call [FromContext] to retrieve the [Identity] (and its
|
||||
// TenantID) when multi-tenancy is required — there is no need to thread tenantID
|
||||
// as an explicit parameter since it is already in the context.
|
||||
//
|
||||
// The resource string identifies what is being accessed (e.g. "orders",
|
||||
// "invoices"). Its meaning is defined by the application.
|
||||
//
|
||||
// Example 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
|
||||
// }
|
||||
type PermissionProvider interface {
|
||||
ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)
|
||||
}
|
||||
Reference in New Issue
Block a user