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