Documentation
¶
Overview ¶
Example (Engine) ¶
Example_engine shows the canonical pipeline: extractor -> authenticator orchestrated by the Engine, ending with an AccessDecisionManager.
package main
import (
"context"
"fmt"
"github.com/hyperscale-stack/security"
)
// userAuth is an example concrete Authentication produced by a fictional
// authenticator. The point of this example is the Engine wiring, so the type
// is kept minimal.
type userAuth struct {
sub string
roles []string
credentials string
verified bool
}
func (u userAuth) Principal() security.Principal { return userPrincipal{sub: u.sub} }
func (u userAuth) Credentials() any { return u.credentials }
func (u userAuth) Authorities() []string { return u.roles }
func (u userAuth) IsAuthenticated() bool { return u.verified }
func (u userAuth) Name() string { return u.sub }
type userPrincipal struct{ sub string }
func (p userPrincipal) Subject() string { return p.sub }
// staticExtractor returns a fixed userAuth when the "X-Demo-User" header is
// set, and (nil, nil) otherwise.
type staticExtractor struct{}
func (staticExtractor) Extract(_ context.Context, c security.Carrier) (security.Authentication, error) {
sub := c.Get("X-Demo-User")
if sub == "" {
return nil, nil
}
return userAuth{sub: sub, credentials: "password"}, nil
}
// staticAuthenticator accepts only "alice" / "password".
type staticAuthenticator struct{}
func (staticAuthenticator) AuthenticatorName() string { return "static" }
func (staticAuthenticator) Supports(_ security.Authentication) bool { return true }
func (staticAuthenticator) Authenticate(_ context.Context, a security.Authentication) (security.Authentication, error) {
u, ok := a.(userAuth)
if !ok {
return a, security.ErrUnsupportedCredential
}
if u.sub != "alice" || u.credentials != "password" {
return a, security.ErrInvalidCredentials
}
u.roles = []string{"ROLE_USER"}
u.verified = true
return u, nil
}
// demoCarrier is a tiny Carrier used to drive the example without depending
// on the http sub-module.
type demoCarrier struct{ headers map[string]string }
func (c *demoCarrier) Get(k string) string { return c.headers[k] }
func (c *demoCarrier) Values(k string) []string { return []string{c.headers[k]} }
func (c *demoCarrier) Set(k, v string) { c.headers[k] = v }
func (c *demoCarrier) Add(k, v string) { c.headers[k] = v }
// roleVoter implements [security.Voter] for the example. It supports any
// attribute string starting with "role:" and grants when the principal has
// the matching role.
type roleVoter struct{}
func (roleVoter) Supports(a security.Attribute) bool {
if a == nil {
return false
}
const prefix = "role:"
if len(a.String()) < len(prefix) {
return false
}
return a.String()[:len(prefix)] == prefix
}
func (roleVoter) Vote(_ context.Context, auth security.Authentication, attrs []security.Attribute) security.Decision {
for _, a := range attrs {
const prefix = "role:"
if len(a.String()) < len(prefix) || a.String()[:len(prefix)] != prefix {
continue
}
want := a.String()[len(prefix):]
for _, r := range auth.Authorities() {
if r == want {
return security.DecisionGrant
}
}
}
return security.DecisionDeny
}
type roleAttr string
func (r roleAttr) String() string { return "role:" + string(r) }
func main() {
engine := security.NewEngine(
security.NewManager(staticAuthenticator{}),
staticExtractor{},
)
carrier := &demoCarrier{headers: map[string]string{"X-Demo-User": "alice"}}
ctx, auth, err := engine.Process(context.Background(), carrier)
if err != nil {
fmt.Println("auth error:", err)
return
}
fmt.Printf("authenticated=%t subject=%s\n", auth.IsAuthenticated(), auth.Principal().Subject())
adm := security.NewAffirmativeDecisionManager(roleVoter{})
if err := adm.Decide(ctx, auth, []security.Attribute{roleAttr("ROLE_USER")}); err != nil {
fmt.Println("denied:", err)
return
}
fmt.Println("granted")
}
Output: authenticated=true subject=alice granted
Index ¶
- Constants
- Variables
- func WithAbstainFallback(d Decision) admOption
- func WithAuthentication(ctx context.Context, auth Authentication) context.Context
- func WithTieBreak(d Decision) admOption
- type AccessDecisionManager
- type Attribute
- type Authentication
- type Authenticator
- type AuthenticatorFunc
- type AuthorityAttribute
- type Carrier
- type Clock
- type Decision
- type Engine
- type Extractor
- type Manager
- type NamedAuthenticator
- type PermissionAttribute
- type Principal
- type RoleAttribute
- type ScopeAttribute
- type SecurityError
- type SystemClock
- type Voter
Examples ¶
Constants ¶
const ( // AttrAuthenticated reports whether the resulting Authentication is // authenticated. Value: bool. AttrAuthenticated = attribute.Key("security.authenticated") // AttrPrincipalSubject is the principal subject. Emission is gated by the // subject-redaction policy (see SetSubjectAttributeMode) to avoid leaking // personal data into trace backends; the default is a hashed prefix. AttrPrincipalSubject = attribute.Key("security.principal.subject") // AttrExtractorsCount counts the extractors tried by an Engine call. // Value: int. AttrExtractorsCount = attribute.Key("security.extractors.count") // AttrAuthenticatorsCount counts the authenticators tried by a Manager. // Value: int. AttrAuthenticatorsCount = attribute.Key("security.authenticators.count") // AttrAuthenticatorName names the authenticator that produced the final // authenticated value, when known. Value: string. AttrAuthenticatorName = attribute.Key("security.authenticator.name") // AttrStrategy names the AccessDecisionManager strategy that took the // final decision. Value: "affirmative" | "consensus" | "unanimous". AttrStrategy = attribute.Key("security.strategy") // AttrDecision is the final authorization decision. // Value: "permit" | "deny" | "abstain". AttrDecision = attribute.Key("security.decision") // AttrAttributes is the joined String() form of the Attributes considered // for an authorization decision. Value: string. AttrAttributes = attribute.Key("security.attributes") )
Span attribute keys used across the core. They are kept here as typed constants so that documentation in docs/observability.md can be diffed against the source of truth.
Variables ¶
var ( // ErrInvalidCredentials indicates that the supplied credentials could not // be validated (bad password, unknown user, malformed token). Maps to // HTTP 401 / gRPC Unauthenticated. ErrInvalidCredentials = newSentinel("security: invalid credentials") // ErrClientSecretMismatch indicates that an OAuth2 client presented a // secret that did not match the registered value. Maps to HTTP 401. ErrClientSecretMismatch = newSentinel("security: oauth2 client secret mismatch") // ErrTokenExpired indicates that a valid token has passed its expiry. // Maps to HTTP 401. ErrTokenExpired = newSentinel("security: token expired") // ErrTokenNotFound indicates that the presented token does not exist in // the configured storage. Maps to HTTP 401. ErrTokenNotFound = newSentinel("security: token not found") // ErrUnsupportedCredential indicates that no provider recognized the // credential type. Maps to HTTP 400. ErrUnsupportedCredential = newSentinel("security: unsupported credential type") // ErrNoExtractor indicates that the [Engine] was configured without any // [Extractor]. The Engine returns the anonymous authentication and this // error so that the caller can distinguish "no extractor" from "all // extractors found nothing". ErrNoExtractor = newSentinel("security: no extractor configured") // ErrAuthenticatorRefused is the umbrella error returned by [Manager] // when every supporting [Authenticator] rejected the credential. The // individual errors are joined via errors.Join and reachable through // errors.Is / errors.As. ErrAuthenticatorRefused = newSentinel("security: every authenticator refused the credential") // ErrAccessDenied indicates that authorisation voting denied access. // Maps to HTTP 403 / gRPC PermissionDenied. ErrAccessDenied = newSentinel("security: access denied") // ErrInsufficientScope indicates that the principal is authenticated but // does not carry the OAuth2 scope required for the resource. Maps to // HTTP 403 with the "insufficient_scope" WWW-Authenticate parameter. ErrInsufficientScope = newSentinel("security: insufficient scope") )
Sentinel errors. Wrap them via fmt.Errorf("...: %w", ErrXxx) when adding contextual information so errors.Is keeps working.
Functions ¶
func WithAbstainFallback ¶
func WithAbstainFallback(d Decision) admOption
WithAbstainFallback controls the verdict when every unanimous voter abstains. Default: DecisionDeny.
func WithAuthentication ¶
func WithAuthentication(ctx context.Context, auth Authentication) context.Context
WithAuthentication returns a copy of ctx with auth attached. Subsequent calls overwrite the previous value; this is the expected behavior when an authenticator promotes an unauthenticated value to an authenticated one.
Passing a nil Authentication clears the slot — useful for "logout" middlewares.
func WithTieBreak ¶
func WithTieBreak(d Decision) admOption
WithTieBreak controls the consensus strategy when grant and deny votes are equal in number. Default: DecisionDeny.
Types ¶
type AccessDecisionManager ¶
type AccessDecisionManager interface {
// Decide returns nil on grant, [ErrAccessDenied] on deny.
// Wrapping callers add a short message indicating the strategy used.
Decide(ctx context.Context, auth Authentication, attrs []Attribute) error
}
AccessDecisionManager combines the verdicts of multiple [Voter]s into a single decision. Three strategies are provided, mirroring Spring Security:
- Affirmative — a single DecisionGrant grants access; everything else denies. Abstentions are ignored. The strictest "fail closed by default" policy.
- Consensus — the majority wins. Ties default to deny; pass WithTieBreak(DecisionGrant) to flip the policy.
- Unanimous — every voter that does not abstain MUST grant. A single deny refuses; if every voter abstains, the result depends on WithAbstainFallback.
Implementations are safe for concurrent use.
func NewAffirmativeDecisionManager ¶
func NewAffirmativeDecisionManager(voters ...Voter) AccessDecisionManager
NewAffirmativeDecisionManager returns an AccessDecisionManager that grants access as soon as one voter does, and denies otherwise.
func NewConsensusDecisionManager ¶
func NewConsensusDecisionManager(voters []Voter, opts ...admOption) AccessDecisionManager
NewConsensusDecisionManager returns an AccessDecisionManager that follows majority rule. Pass WithTieBreak to override the default (deny-on-tie) behavior.
func NewUnanimousDecisionManager ¶
func NewUnanimousDecisionManager(voters []Voter, opts ...admOption) AccessDecisionManager
NewUnanimousDecisionManager returns an AccessDecisionManager that refuses on a single deny and otherwise grants when at least one voter grants. Pass WithAbstainFallback to control the all-abstain case.
type Attribute ¶
type Attribute interface {
// String returns a stable, log-friendly form of the attribute. It is used
// by [AccessDecisionManager] for OTel attributes; it MUST NOT include
// any secret or PII.
String() string
}
Attribute is an opaque authorization predicate carried alongside a request. Voters opt-in via Voter.Supports and inspect the concrete type through type switches. Four concrete attributes are shipped below; applications can define their own (they just need to implement String()).
func Permission ¶
func Permission(name string, predicate func(ctx context.Context, auth Authentication) bool) Attribute
Permission constructs a PermissionAttribute in one call.
type Authentication ¶
type Authentication interface {
// Principal returns the identity carried by this authentication.
// MUST return [AnonymousPrincipal] for unauthenticated values and
// MUST NOT return nil.
Principal() Principal
// Credentials returns the raw credentials presented by the principal.
// For a token-based authentication this is typically the token string;
// for username/password it is the cleartext password. Implementations
// SHOULD zero or omit secret material once authentication has succeeded
// to limit accidental leakage through logging or panics.
//
// The return type is intentionally any: typed accessors are provided by
// each scheme module (basic.Password(), bearer.Token(), ...).
Credentials() any
// Authorities returns the authorities (roles, scopes, permissions) the
// system has granted to this principal. The slice is read-only;
// implementations SHOULD return the same backing slice across calls.
Authorities() []string
// IsAuthenticated reports whether the credentials have been validated by
// an [Authenticator]. Voters use this to short-circuit denials before
// inspecting authorities.
IsAuthenticated() bool
// Name returns a stable, log-friendly identifier for this authentication.
// It is typically the principal subject; for client_credentials flows it
// can be the client ID. It MUST be safe to include in structured logs
// (no secrets, no high-cardinality values that are not the subject).
Name() string
}
Authentication is an immutable snapshot of a security context: who is acting (the Principal), what proof was presented (the credentials), what authorities the system has granted them, and whether the proof has been verified.
Authentication values flow through three logical stages during a request:
- An Extractor reads raw credentials from a Carrier and constructs an unauthenticated value (IsAuthenticated() == false).
- A matching Authenticator validates the credentials and returns a NEW authenticated value (IsAuthenticated() == true). The original value MUST NOT be mutated.
- Authorisation [Voter]s inspect the value to grant or deny access.
Implementations MUST be safe for concurrent reads. Because every state change goes through a fresh value, no synchronization is required for callers.
func Anonymous ¶
func Anonymous() Authentication
Anonymous returns the singleton Authentication used when no credential could be extracted from a Carrier. It is safe to call from any goroutine; the returned value is shared and immutable.
Voters that opt-in to anonymous access (see the voter package's Anonymous) match this value; the default policy of AccessDecisionManager is to deny when no voter grants, so anonymous calls fail closed by default.
func FromContext ¶
func FromContext(ctx context.Context) (Authentication, bool)
FromContext returns the Authentication stored in ctx and a boolean indicating whether one was present. When the slot is empty, it returns the anonymous authentication (see Anonymous) so callers can rely on a non-nil value without a nil check.
type Authenticator ¶
type Authenticator interface {
Supports(auth Authentication) bool
Authenticate(ctx context.Context, auth Authentication) (Authentication, error)
}
Authenticator validates an Authentication produced by an Extractor and returns a NEW authenticated value. It MUST NOT mutate its input — the Authentication is treated as immutable everywhere in the core.
Two-step contract:
- Supports reports whether the authenticator recognizes the credential type. Implementations MUST be cheap (a type switch); they MUST NOT perform I/O.
- Authenticate validates the credential and either returns the new, authenticated value or an error wrapping a security sentinel (ErrInvalidCredentials, ErrTokenExpired, ...). Returning (ErrUnsupportedCredential) is the canonical way to bail out at runtime when Supports returned true but the value was nonetheless out of scope.
Implementations MUST be safe for concurrent use.
type AuthenticatorFunc ¶
type AuthenticatorFunc func(ctx context.Context, auth Authentication) (Authentication, error)
AuthenticatorFunc adapts a function to the Authenticator interface. It reports Supports == true for every input; callers wanting selectivity should write a concrete type instead.
func (AuthenticatorFunc) Authenticate ¶
func (f AuthenticatorFunc) Authenticate(ctx context.Context, auth Authentication) (Authentication, error)
Authenticate implements Authenticator.
func (AuthenticatorFunc) Supports ¶
func (AuthenticatorFunc) Supports(Authentication) bool
Supports implements Authenticator.
type AuthorityAttribute ¶
type AuthorityAttribute string
AuthorityAttribute names a free-form authority string. Unlike RoleAttribute it carries no convention — the configured voter compares the value verbatim against Authentication.Authorities.
func (AuthorityAttribute) String ¶
func (a AuthorityAttribute) String() string
String implements Attribute. Output is the bare authority name.
type Carrier ¶
type Carrier interface {
// Get returns the first value associated with the given key, or the
// empty string if absent. Keys are case-insensitive in the HTTP sense.
Get(key string) string
// Values returns all values associated with the given key, or a nil
// slice if absent. The caller MUST NOT mutate the returned slice.
Values(key string) []string
// Set replaces all values associated with the given key.
Set(key, value string)
// Add appends a value to the list associated with the given key.
Add(key, value string)
}
Carrier abstracts a transport-level message (an HTTP request, gRPC metadata, a queue envelope) from which credentials can be read and security artifacts (challenges, cookies, headers) can be written.
The interface mimics http.Header semantics so that the HTTP adapter is a thin wrapper. For transports that do not naturally support multi-valued keys (e.g. websocket frames), implementations MAY collapse Values() to a single-element slice and treat Add() as Set().
Implementations MUST be safe for concurrent reads but MAY require external synchronization for writes — adapters are expected to wrap a request scope, which is serial by construction.
type Clock ¶
Clock abstracts time.Now to make time-sensitive code (expiry checks, TTLs, token rotation windows) deterministic in tests. Implementations MUST be safe for concurrent use.
var DefaultClock Clock = SystemClock{}
DefaultClock is the package-level Clock used when none is supplied via configuration. It is a value, not a pointer, so it is safe to copy.
type Decision ¶
type Decision int
Decision is the verdict returned by a Voter for a given authentication and attribute set. Three values are defined:
- DecisionGrant — the voter explicitly grants access.
- DecisionDeny — the voter explicitly denies access.
- DecisionAbstain — the voter has no opinion (e.g. it does not support any of the attributes presented). The AccessDecisionManager strategy decides what to do when every voter abstains.
Voting verdicts. The numeric layout (-1/0/1) is deliberate so that algorithms summing decisions remain readable.
type Engine ¶
type Engine interface {
// Process runs extractors in order and consults the manager on the first
// non-empty result. The returned context always carries an Authentication
// (the anonymous one when nothing was extracted).
Process(ctx context.Context, c Carrier) (context.Context, Authentication, error)
}
Engine is the high-level entry point: it drives a chain of [Extractor]s against a Carrier, hands the produced Authentication to its Manager, and returns a context enriched with the result so downstream handlers can call FromContext.
Engine is safe for concurrent use.
type Extractor ¶
type Extractor interface {
Extract(ctx context.Context, c Carrier) (Authentication, error)
}
Extractor pulls raw, unauthenticated credentials from a Carrier and returns an Authentication that captures them. The returned value MUST have IsAuthenticated() == false: validation is the Authenticator's job.
Sentinel conventions:
- Return (nil, nil) when no credentials of the supported scheme are present. The Engine treats this as "this extractor does not apply" and consults the next one.
- Return (nil, err) wrapping a security sentinel when credentials were present but malformed (e.g. invalid base64 in Basic). The Engine surfaces err to the caller and stops; downstream authenticators are not invoked.
Implementations MUST be safe for concurrent use.
type Manager ¶
type Manager interface {
Authenticate(ctx context.Context, auth Authentication) (Authentication, error)
}
Manager orchestrates a chain of [Authenticator]s with first-success-wins semantics:
- Authenticators are consulted in registration order.
- The first authenticator whose Supports() returns true is invoked.
- On success, the resulting Authentication is returned immediately; subsequent authenticators are NOT consulted.
- On error, the next supporting authenticator is tried; if every one fails, the joined error is wrapped in ErrAuthenticatorRefused.
- If no authenticator supports the credential, ErrUnsupportedCredential is returned. The Engine then surfaces it as a 400 in the HTTP adapter.
Manager is safe for concurrent use.
func NewManager ¶
func NewManager(authenticators ...Authenticator) Manager
NewManager returns a Manager consulting the given authenticators in order. Passing zero authenticators is allowed; the returned manager will always return ErrUnsupportedCredential.
Example ¶
ExampleNewManager illustrates first-success-wins semantics.
package main
import (
"context"
"errors"
"fmt"
"github.com/hyperscale-stack/security"
)
// userAuth is an example concrete Authentication produced by a fictional
// authenticator. The point of this example is the Engine wiring, so the type
// is kept minimal.
type userAuth struct {
sub string
roles []string
credentials string
verified bool
}
func (u userAuth) Principal() security.Principal { return userPrincipal{sub: u.sub} }
func (u userAuth) Credentials() any { return u.credentials }
func (u userAuth) Authorities() []string { return u.roles }
func (u userAuth) IsAuthenticated() bool { return u.verified }
func (u userAuth) Name() string { return u.sub }
type userPrincipal struct{ sub string }
func (p userPrincipal) Subject() string { return p.sub }
func main() {
first := security.AuthenticatorFunc(func(_ context.Context, a security.Authentication) (security.Authentication, error) {
return a, errors.New("first refuses")
})
second := security.AuthenticatorFunc(func(_ context.Context, a security.Authentication) (security.Authentication, error) {
return userAuth{sub: "bob", verified: true}, nil
})
m := security.NewManager(first, second)
auth, err := m.Authenticate(context.Background(), userAuth{sub: "bob"})
fmt.Println(auth.Name(), err)
}
Output: bob <nil>
type NamedAuthenticator ¶
type NamedAuthenticator interface {
AuthenticatorName() string
}
NamedAuthenticator is an optional capability: when an Authenticator implements it, the Manager records the name in the OTel span so observability backends can attribute decisions per provider.
type PermissionAttribute ¶
type PermissionAttribute struct {
// Name is the human-readable label of the permission. It populates the
// OTel attributes; keep it stable across deployments.
Name string
// Predicate is invoked by the permission voter with the live
// authentication. A nil predicate is treated as DecisionDeny.
Predicate func(ctx context.Context, auth Authentication) bool
}
PermissionAttribute carries an arbitrary predicate evaluated by the permission voter. It is the escape hatch for application-specific authorization (ABAC, ownership checks, time-of-day windows, ...). The predicate MUST be pure (no I/O) and safe for concurrent use.
func (PermissionAttribute) String ¶
func (p PermissionAttribute) String() string
String implements Attribute. Output is "permission:<Name>".
type Principal ¶
type Principal interface {
// Subject returns the stable, unique identifier of the principal. It is
// the value that authorisation checks key off (`sub` claim, user ID,
// client ID, ...). Implementations MUST return the same value across
// calls for the lifetime of a request.
Subject() string
}
Principal identifies the subject of an Authentication. Implementations represent end users, service clients, devices, or any other authenticatable entity.
The interface is intentionally minimal: any authorisation-specific data (roles, scopes, claims, ...) is carried by Authentication.Authorities or by attaching a concrete implementation via [Authentication.Attribute]. This keeps the core decoupled from any user store schema.
type RoleAttribute ¶
type RoleAttribute string
RoleAttribute names a role expected on the authenticated principal. Roles use the Spring Security "ROLE_" prefix at the wire level (in OTel attributes and in custom Authorities() slices) but the constructor accepts the bare name to keep usage idiomatic.
func (RoleAttribute) Name ¶
func (r RoleAttribute) Name() string
Name returns the bare role name (without the ROLE_ prefix).
func (RoleAttribute) String ¶
func (r RoleAttribute) String() string
String implements Attribute. Output is "ROLE_<name>" — Spring-compatible for ops tooling that already keys off that convention.
type ScopeAttribute ¶
type ScopeAttribute string
ScopeAttribute names an OAuth2 scope expected on the authenticated principal. Scope names follow the RFC 6749 §3.3 grammar but this type stays format-agnostic.
func (ScopeAttribute) Name ¶
func (s ScopeAttribute) Name() string
Name returns the bare scope name.
func (ScopeAttribute) String ¶
func (s ScopeAttribute) String() string
String implements Attribute. Output is "scope:<name>".
type SecurityError ¶
type SecurityError interface {
error
// contains filtered or unexported methods
}
SecurityError is the marker interface implemented by every error returned by this module's public API. Callers SHOULD use errors.Is/errors.As against the sentinel values exported here rather than relying on string matching.
The unexported method securityError() prevents foreign types from accidentally satisfying the interface.
type SystemClock ¶
type SystemClock struct{}
SystemClock is the default Clock returning time.Now().
func (SystemClock) Now ¶
func (SystemClock) Now() time.Time
Now returns the current wall-clock time.
type Voter ¶
type Voter interface {
Supports(attr Attribute) bool
Vote(ctx context.Context, auth Authentication, attrs []Attribute) Decision
}
Voter is the unit of authorisation logic. It inspects an Authentication against a set of [Attribute]s and returns a Decision. Voters MUST be pure (no I/O) and safe for concurrent use.
Supports is a fast-path filter: a voter that does not recognize any of the passed attributes SHOULD return false to short-circuit the call. When Supports returns false, the AccessDecisionManager records an abstention for the voter without invoking Vote.