feat(contracts): initial implementation (v1.0.0)

Introduces code.nochebuena.dev/einherjar/contracts — the zero-dependency
foundation of the Einherjar framework. Defines the interfaces and minimal
types consumed by every starter. Zero external dependencies. Zero Einherjar
dependencies. Nothing is above it in the dependency graph.

lifecycle:
- Component — OnInit, OnStart, OnStop three-phase lifecycle hooks

observability:
- Level (LevelCritical=0, LevelDegraded); zero value is the safe default
- Checkable — HealthCheck, Name, Priority
- Identifiable — ModulePath, ModuleVersion; implemented by all starters to
  surface module identity and version in the startup banner

logging:
- Logger — Debug, Info, Warn, Error, With, WithContext

errs:
- CodedError — ErrorCode() string; satisfied by core/xerrors.Err
- ContextualError — ErrorContext() map[string]any; satisfied by core/xerrors.Err

security:
- Identity value type — UID, TenantID, DisplayName, Email; NewIdentity, WithTenant
- Permission (int64), MaxPermission=62, PermissionMask — Has, Grant
- PermissionProvider — ResolveMask(ctx, uid, resource) (PermissionMask, error)
- SecurityBag value type — immutable request-scoped security context; carries
  Identity and arbitrary typed attributes (hardware IDs, grant codes, etc.);
  With copies the attribute map on every call to preserve receiver-invariant behaviour
- NewSecurityBag, Identity, WithIdentity, Get, With
- SetBagInContext / BagFromContext — full bag context storage
- SetInContext / FromContext — backed by SecurityBag; all four cross-function
  combinations (SetInContext+BagFromContext, SetBagInContext+FromContext) are valid

One file per type; CT-6 enforced by compliance test AST walk.
This commit is contained in:
2026-05-29 15:43:08 +00:00
commit 098a2098f8
31 changed files with 2230 additions and 0 deletions

4
security/doc.go Normal file
View File

@@ -0,0 +1,4 @@
// Package security defines the Identity type representing an authenticated
// principal, the PermissionProvider interface for resolving access masks, and
// the Permission and PermissionMask types for capability modelling.
package security

75
security/identity.go Normal file
View File

@@ -0,0 +1,75 @@
package security
import "context"
// Identity represents the authenticated principal for a request.
//
// Identity is a value type — always copied, never a pointer — to prevent
// nil-check burden and accidental mutation of a shared context value.
// Construction follows a two-step pattern: NewIdentity populates authentication
// data from the token (uid, name, email); 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 the unexported context key used to store Identity values.
// 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 left empty — populate it later with WithTenant once the enrichment
// middleware has resolved it from the request context.
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 id in ctx as a [SecurityBag] and returns the enriched context.
// Callers that need to attach additional request-level attributes should use
// [SetBagInContext] directly. [FromContext] continues to work unchanged.
func SetInContext(ctx context.Context, id Identity) context.Context {
return SetBagInContext(ctx, NewSecurityBag(id))
}
// FromContext retrieves the Identity stored by [SetInContext] or [SetBagInContext].
// Returns the zero-value Identity and false if no identity is present in ctx.
func FromContext(ctx context.Context) (Identity, bool) {
bag, ok := BagFromContext(ctx)
if !ok {
return Identity{}, false
}
return bag.Identity(), true
}
// SetBagInContext stores bag in ctx and returns the enriched context.
// Use this when you need to attach request-level attributes beyond the Identity
// (hardware IDs, grant codes, etc.) via [SecurityBag.With].
func SetBagInContext(ctx context.Context, bag SecurityBag) context.Context {
return context.WithValue(ctx, authKey, bag)
}
// BagFromContext retrieves the [SecurityBag] stored by [SetBagInContext] or [SetInContext].
// Returns an empty SecurityBag and false if no bag is present in ctx.
// Permission providers use this to access both the Identity and any extra attributes
// injected during enrichment.
func BagFromContext(ctx context.Context) (SecurityBag, bool) {
bag, ok := ctx.Value(authKey).(SecurityBag)
return bag, ok
}

19
security/permission.go Normal file
View File

@@ -0,0 +1,19 @@
package security
// Permission is a named bit position (062) representing a single capability.
//
// Applications define their own permission constants using this type:
//
// const (
// Read security.Permission = 0
// Write security.Permission = 1
// Delete security.Permission = 2
// )
//
// Valid positions are 0 through MaxPermission (62). Values outside that range
// are silently ignored by PermissionMask.Has and PermissionMask.Grant.
type Permission int64
// MaxPermission is the highest valid bit position for a Permission constant.
// Bit 63 is reserved for the sign bit of the underlying int64.
const MaxPermission Permission = 62

View File

@@ -0,0 +1,26 @@
package security
// PermissionMask is a resolved bit-mask for a user on a specific resource.
// It is returned by PermissionProvider.ResolveMask and inspected with Has.
// A zero value means no permissions are granted.
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 > MaxPermission).
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 — safe to use in builder chains:
//
// mask := security.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)))
}

View File

@@ -0,0 +1,18 @@
package security
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 domain.
type PermissionProvider interface {
// ResolveMask returns the PermissionMask for uid on resource.
// A zero mask means no permissions are granted. Callers check individual
// bits with PermissionMask.Has using domain-defined Permission constants.
ResolveMask(ctx context.Context, uid, resource string) (PermissionMask, error)
}

55
security/security_bag.go Normal file
View File

@@ -0,0 +1,55 @@
package security
// SecurityBag is the request-scoped security context for a single request.
// It carries the authenticated [Identity] alongside any additional attributes
// injected during the enrichment phase (hardware IDs, grant codes, etc.).
//
// SecurityBag is a value type — all mutation methods return a new value without
// modifying the receiver. The attribute map is copied on every [SecurityBag.With]
// call to preserve this guarantee.
//
// The framework defines no string key constants for bag attributes — all
// framework-known fields live as typed fields on [Identity]. Callers define
// their own constants to avoid collisions:
//
// const KeyHardwareID = "hardware_id"
//
// bag.With(KeyHardwareID, hwID)
// val, ok := bag.Get(KeyHardwareID)
type SecurityBag struct {
identity Identity
attributes map[string]any
}
// NewSecurityBag creates a SecurityBag wrapping id with an empty attribute map.
func NewSecurityBag(id Identity) SecurityBag {
return SecurityBag{identity: id}
}
// Identity returns the authenticated Identity stored in the bag.
func (b SecurityBag) Identity() Identity {
return b.identity
}
// WithIdentity returns a copy of the bag with the Identity replaced by id.
// Used by bag enrichers that modify the Identity (e.g., [authmw.WithTenantHeader]).
func (b SecurityBag) WithIdentity(id Identity) SecurityBag {
return SecurityBag{identity: id, attributes: b.attributes}
}
// Get returns the attribute stored under key and true, or (nil, false) if absent.
func (b SecurityBag) Get(key string) (any, bool) {
v, ok := b.attributes[key]
return v, ok
}
// With returns a copy of the bag with key set to value.
// The receiver is not modified — safe to call from concurrent middleware chains.
func (b SecurityBag) With(key string, value any) SecurityBag {
attrs := make(map[string]any, len(b.attributes)+1)
for k, v := range b.attributes {
attrs[k] = v
}
attrs[key] = value
return SecurityBag{identity: b.identity, attributes: attrs}
}