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

View 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
View 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
View 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 (062) 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 (062) 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
View 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 (062) 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 062.
## 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
View 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
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

19
compliance_test.go Normal file
View 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
View 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 (062). 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

View 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 (062).
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.

View 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.

View 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.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module code.nochebuena.dev/go/rbac
go 1.25

55
identity.go Normal file
View 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
View 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
View File

@@ -0,0 +1,39 @@
package rbac
// Permission is a named bit position (062) 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
View 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
View 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)
}