commit 0864f031a13c7f40fa58506aaa6c8bf89ce49ab9 Author: Rene Nochebuena Date: Wed Mar 18 13:25:43 2026 -0600 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3c1c1b2 --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dca678e --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d0bfce --- /dev/null +++ b/README.md @@ -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 diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..2360d40 --- /dev/null +++ b/compliance_test.go @@ -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) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..cff8297 --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-bitset-permissions.md b/docs/adr/ADR-001-bitset-permissions.md new file mode 100644 index 0000000..a9f4ea7 --- /dev/null +++ b/docs/adr/ADR-001-bitset-permissions.md @@ -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. diff --git a/docs/adr/ADR-002-identity-value-type.md b/docs/adr/ADR-002-identity-value-type.md new file mode 100644 index 0000000..65c408e --- /dev/null +++ b/docs/adr/ADR-002-identity-value-type.md @@ -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. diff --git a/docs/adr/ADR-003-identity-context-ownership.md b/docs/adr/ADR-003-identity-context-ownership.md new file mode 100644 index 0000000..ad0e96b --- /dev/null +++ b/docs/adr/ADR-003-identity-context-ownership.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e97804c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.nochebuena.dev/go/rbac + +go 1.25 diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..d114eb0 --- /dev/null +++ b/identity.go @@ -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 +} diff --git a/identity_test.go b/identity_test.go new file mode 100644 index 0000000..c57c9df --- /dev/null +++ b/identity_test.go @@ -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) + } +} diff --git a/permission.go b/permission.go new file mode 100644 index 0000000..e7d509b --- /dev/null +++ b/permission.go @@ -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))) +} diff --git a/permission_test.go b/permission_test.go new file mode 100644 index 0000000..1ccb93d --- /dev/null +++ b/permission_test.go @@ -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") + } +} diff --git a/provider.go b/provider.go new file mode 100644 index 0000000..65d1f15 --- /dev/null +++ b/provider.go @@ -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) +}