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:
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.
|
||||
Reference in New Issue
Block a user