authz

package
v0.26.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 3 Imported by: 0

README

authz

Package authz declares the SDK's authorization port: Policy — may this subject perform this action on this resource — the three-valued Decision it answers with, the immutable RequestValue that carries the question, the typed AttrValue facts attached to it, and the Condition an attribute rule is written as.

Policy and Condition are function ports rather than interfaces, so neither can grow a method and break a downstream implementer (ADR 0039).

Four things are decided here, and all four are security properties:

  • A Decision has three states. Allow, Deny and Abstain — because an evaluator that has no opinion must be able to say so. Its zero value is Abstain, never Allow.
  • Nothing in this package turns an abstention into a grant. The closure lives in internal/service/security/authz.Check, and it closes toward refusal.
  • An absent attribute is not a false one. RequestValue.Attr returns (AttrValue, bool) and every AttrValue accessor reports a kind mismatch, so "the subject has no department" and "nobody said" stay different facts.
  • A refusal explains nothing to the caller. All four sentinels carry the same wire-safe sentence; the diagnosis lives in Private and the fields.

It answers "may this", never "who is this" — identity comes from internal/core/security/session or internal/core/security/token. There is no policy language, no wildcard and no relationship model. The RBAC and ABAC evaluators, the combiner and the built-in conditions live in internal/service/security/authz; facade: pkg/v1/security/authz. ADR 0057. See CLAUDE.md.

Documentation

Overview

Package authz — hosts AttrValue, one typed fact about a request.

Package authz declares the SDK's authorization port: the Policy that answers "may this subject perform this action on this resource", the three-valued Decision it answers with, the RequestValue that carries the question, and the Condition an attribute rule is written as. A core sibling admitted by ADR 0057.

The port is a FUNCTION type rather than an interface — the shape internal/core/CLAUDE.md already admits for resilience.Operation, scheduler.Job, lifecycle.Start and validation.Constraint. ADR 0039's rule is that a published port must not grow a method, because pkg/v1 aliases publish the shape and Go interfaces are structural; a func type satisfies that rule structurally, since it cannot grow a method at all.

The SDK ships the evaluation, not the vocabulary

"Who is an admin", "what a role is called", "which routes are protected" and "what happens to a denied request" are questions only the application can answer, and every library in this domain that tried to answer them grew a policy language to do it. This package therefore ships NO policy DSL, no expression parser, no role catalogue and no relationship tuple store: a Condition is an ordinary Go func, a grant table is data the caller writes, and composition is function composition. The engine evaluates; the vocabulary is the caller's. See ADR 0057 §D1.

It answers "may this", never "who is this"

Identity arrives from internal/core/security/session (a server-side session, opaque and revocable) or internal/core/security/token (a self-contained signed claim set). This package takes the SUBJECT those produce and nothing else — it authenticates nobody, mints nothing, and reads no header. Handing it a subject the caller has not authenticated produces a perfectly valid decision about a principal that does not exist.

Three states, and the default is refusal

A Decision is Allow, Deny or Abstain. Abstention is a real answer — "this policy has no opinion about this request" — and it is deliberately not spelled as either of the other two. An evaluator that returned Deny where it merely had no grant would veto every other policy composed with it; one that returned Allow would authorize by silence. Both mistakes are unwritable here because the third state exists.

Nothing in this package converts an abstention into a grant. The closure — the single place where "nobody said Allow" becomes a refusal — lives in internal/service/security/authz.Check, and it closes toward Deny. See ADR 0057 §D3.

The concrete evaluators (RBAC over a grant table, ABAC over conditions), the deny-overrides combiner and the built-in conditions live in internal/service/security/authz; this package owns the contract, the domain values and the typed sentinels.

Package authz — ranges 0.2.26.* (the domain's verdicts) and 0.3.56.* (the engine's construction refusals) — ADR 0057, declared here since ADR 0160.

Package authz — hosts Decision, the three-valued verdict a Policy returns.

Package authz — declares the sentinel *errs.Error outcomes: the four verdicts of the port, and the three construction refusals of the engine in internal/service/security/authz (ADR 0160). Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Package authz — hosts RequestValue, the question a Policy answers.

Index

Constants

View Source
const CodeAttributeKindMismatch errs.Code = 0x00_02_1A_03 // 0.2.26.3

CodeAttributeKindMismatch identifies an attribute that is present but not the kind the rule compares — a text rule against a number, a flag rule against a set. Like an absent attribute, it means the rule did not run.

View Source
const CodeAttributeMissing errs.Code = 0x00_02_1A_02 // 0.2.26.2

CodeAttributeMissing identifies a rule that named an attribute the request does not carry. It is an evaluation FAILURE, never a false comparison: the rule did not run, so nothing about it was satisfied.

View Source
const CodeConditionInvalid errs.Code = 0x00_03_38_03 // 0.3.56.3

CodeConditionInvalid identifies a condition constructor refused at construction: an empty attribute name, an empty set member, a nil inner condition, or a combinator over no conditions at all.

View Source
const CodeGrantInvalid errs.Code = 0x00_03_38_01 // 0.3.56.1

CodeGrantInvalid identifies an RBAC grant table refused at construction: no roles attribute named, no grants, an unnamed role, a role that confers nothing, or a permission with an empty half.

View Source
const CodePermissionDenied errs.Code = 0x00_02_1A_01 // 0.2.26.1

CodePermissionDenied identifies the single refusal this domain produces. It is the code every non-Allow outcome converts to — an explicit Deny, an all-Abstain evaluation, a policy that could not be evaluated — because a caller who can tell those apart from the outside learns the shape of the policy set.

View Source
const CodePolicyMisconfigured errs.Code = 0x00_02_1A_04 // 0.2.26.4

CodePolicyMisconfigured identifies a policy that cannot be evaluated because of how it was assembled: a nil Policy in a composition, or a Policy that returned a Decision outside the three named states.

View Source
const CodeRuleInvalid errs.Code = 0x00_03_38_02 // 0.3.56.2

CodeRuleInvalid identifies an ABAC rule set refused at construction: no rules, an unnamed rule, an empty action or resource, a nil condition, or an effect that is neither Allow nor Deny.

Variables

View Source
var (
	// PermissionDenied is the refusal. It is what internal/service/security/authz.Check
	// returns for an explicit Deny, for an evaluation in which every policy
	// abstained, and for one that could not be completed — three causes, one
	// error, one code, one sentence.
	//
	// The fields distinguish them for the operator: "outcome" is "deny",
	// "abstain" or "unevaluable", and an unevaluable outcome also carries the
	// underlying code and reason. errs.HasCode(err, CodePermissionDenied)
	// therefore answers true for every refusal, which is what a framework
	// routes on.
	PermissionDenied = errs.Static(CodePermissionDenied, "PERMISSION_DENIED",
		"Access to the requested resource is denied",
		"core/security/authz: the request was refused; the fields carry the outcome, the subject, the action, the resource and — when the evaluation failed — the underlying code",
		403,
		0)

	// AttributeMissing is returned by a [Condition] whose rule names an
	// attribute the request does not carry.
	//
	// It exists as its own code because the alternative is the defect this
	// domain was written to prevent: reporting an absent attribute as a failed
	// comparison. `department == "finance"` on a subject with no department is
	// not false — it is unanswerable, and answering false lets a request that
	// carries NO attributes at all walk past every rule of that shape. Every
	// combinator treats it as absorbing, so it cannot be laundered into a
	// grant by a Not, an AnyOf, or a rule whose effect happens to be Deny.
	AttributeMissing = errs.Static(CodeAttributeMissing, "ATTRIBUTE_MISSING",
		"Access to the requested resource is denied",
		"core/security/authz: a rule named an attribute the request does not carry; the fields name the attribute and the rule",
		403,
		0)

	// AttributeKindMismatch is returned by a [Condition] whose attribute is
	// present but carries another kind — a number where the rule compares
	// text, a set where it reads a flag.
	//
	// It is the same failure as an absent attribute wearing a different hat:
	// the comparison did not happen. A kind mismatch is normally a wiring bug
	// on the producing side, so it is reported rather than coerced; coercing
	// would make "42" and 42 the same attribute in one direction and not the
	// other, which is how a rule starts matching requests it was never
	// written for.
	AttributeKindMismatch = errs.Static(CodeAttributeKindMismatch, "ATTRIBUTE_KIND_MISMATCH",
		"Access to the requested resource is denied",
		"core/security/authz: an attribute is present but not the kind the rule compares; the fields name the attribute, the wanted kind and the kind found",
		403,
		0)

	// PolicyMisconfigured is returned when a composition cannot be evaluated
	// because of how it was built: a nil [Policy] among its members, or a
	// member that returned a [Decision] outside the three named states.
	//
	// Both are treated as [Deny] and reported, never skipped. Skipping a nil
	// policy would silently shrink the policy set — the one edit an attacker
	// would most like to make — and treating an out-of-contract Decision as
	// anything but a refusal would let a numeric conversion authorize.
	PolicyMisconfigured = errs.Static(CodePolicyMisconfigured, "POLICY_MISCONFIGURED",
		"Access to the requested resource is denied",
		"core/security/authz: a policy could not be evaluated as assembled — a nil member, or a decision outside allow/deny/abstain; the fields carry the position and the value",
		403,
		78)

	// GrantInvalid is returned by NewRBAC for a grant table that could never
	// grant correctly. The fields name the offending row and role.
	GrantInvalid = errs.Static(CodeGrantInvalid, "GRANT_INVALID",
		"The role grant table is not usable and was refused",
		"service/security/authz: NewRBAC received a grant table it cannot honour; the fields carry the detail, the row index and the role",
		0,
		78)

	// RuleInvalid is returned by NewABAC for a rule set that could never
	// decide correctly. The fields name the offending rule and its position.
	RuleInvalid = errs.Static(CodeRuleInvalid, "RULE_INVALID",
		"The attribute rule set is not usable and was refused",
		"service/security/authz: NewABAC received a rule it cannot honour; the fields carry the detail, the rule index and the rule name",
		0,
		78)

	// ConditionInvalid is returned by a condition constructor whose arguments
	// it cannot honour. It is never an evaluation outcome: an attribute that
	// is absent or of the wrong kind is AttributeMissing or
	// AttributeKindMismatch, and both of those are refusals of a REQUEST,
	// while this is a refusal of the RULE.
	ConditionInvalid = errs.Static(CodeConditionInvalid, "CONDITION_INVALID",
		"The condition cannot be built from the given arguments",
		"service/security/authz: a condition constructor received an empty key, an empty member, a nil condition or an empty set; the fields carry the detail",
		0,
		78)
)

Functions

This section is empty.

Types

type AttrKind

type AttrKind uint8

AttrKind names the type an AttrValue carries. The kind is part of the attribute's identity, not a hint: a rule comparing strings and an attribute holding an integer do not compare — they MISMATCH, and the SDK says so rather than quietly answering false (see AttributeKindMismatch).

const (
	// KindInvalid is the zero AttrKind — FIRST in the iota run for exactly
	// that reason. It belongs to the zero [AttrValue] and to nothing else:
	// [NewRequestValue] drops an attribute carrying it, so a kind-less
	// attribute is ABSENT rather than present and unusable.
	KindInvalid AttrKind = iota
	// KindString is a single text value — a department, a tenant, a region.
	KindString
	// KindInt64 is a whole number — an age, a level, a Unix timestamp.
	KindInt64
	// KindBool is a flag — mfa_satisfied, account_locked, is_internal.
	KindBool
	// KindStrings is an unordered set of text values — the roles a subject
	// holds, the groups it belongs to, the scopes a token carries.
	KindStrings
)

type AttrValue

type AttrValue struct {
	// contains filtered or unexported fields
}

AttrValue is one typed, named fact the caller attaches to a RequestValue: the subject's department, whether MFA was satisfied, the owner recorded on the resource. It is immutable once built, and its accessors report the kind mismatch rather than a zero value.

Why it is typed

The obvious shape for an attribute bag is map[string]string, and it has two defects that this domain cannot afford. It cannot express a flag that is present and FALSE — "mfa_satisfied=false" and "mfa_satisfied absent" become the same empty string — and it makes every numeric comparison a string comparison, in which "9" is greater than "10". Both failures resolve toward permission often enough to be worth four kinds and an explicit accessor.

func AttrBool

func AttrBool(key string, value bool) AttrValue

AttrBool builds a flag attribute. A flag that is present and false is a different fact from a flag that is absent, and this is the constructor that lets a caller state the first one.

func AttrInt64

func AttrInt64(key string, value int64) AttrValue

AttrInt64 builds a whole-number attribute. Timestamps travel here as Unix seconds: the domain deliberately has no time kind, because an attribute carrying a time zone would make two requests with the same instant compare unequal.

func AttrString

func AttrString(key, value string) AttrValue

AttrString builds a text attribute.

func AttrStrings

func AttrStrings(key string, values ...string) AttrValue

AttrStrings builds a set attribute — roles, groups, scopes. The slice is cloned, so the caller keeps ownership of the one it passed.

AttrStrings(key) with no values is a legitimate attribute: it states that the subject holds NO roles, which is a different fact from the caller not having said. Rules distinguish the two, and only the second is an error.

func (AttrValue) BoolValue

func (a AttrValue) BoolValue() (value, ok bool)

BoolValue returns the flag payload. ok is false when the attribute is not KindBool — and this is the accessor where ignoring ok is most expensive, since false is both "the flag is off" and "there is no flag".

func (AttrValue) Contains

func (a AttrValue) Contains(want string) (found, ok bool)

Contains reports whether a KindStrings attribute holds want. ok is false when the attribute is not KindStrings, which the caller MUST distinguish from a set that simply does not contain want.

It exists so membership does not have to allocate: AttrValue.StringsValue clones, and membership is the single hottest attribute read in the domain — every RBAC evaluation performs one per role.

func (AttrValue) Int64Value

func (a AttrValue) Int64Value() (value int64, ok bool)

Int64Value returns the whole-number payload. ok is false when the attribute is not KindInt64.

func (AttrValue) Key

func (a AttrValue) Key() string

Key returns the attribute's name.

func (AttrValue) Kind

func (a AttrValue) Kind() AttrKind

Kind returns the type the attribute carries.

func (AttrValue) StringValue

func (a AttrValue) StringValue() (value string, ok bool)

StringValue returns the text payload. ok is false when the attribute is not KindString — the caller MUST check it: the zero string is a legitimate text value, so the returned value alone cannot report a mismatch.

func (AttrValue) StringsValue

func (a AttrValue) StringsValue() (values []string, ok bool)

StringsValue returns a copy of the set payload. ok is false when the attribute is not KindStrings. The copy is what makes an AttrValue safe to share across goroutines: a caller that sorts or appends to the result cannot reach the attribute the next request will evaluate.

type Condition

type Condition func(request RequestValue) (bool, error)

Condition reports whether an attribute rule holds for request. It is the unit an ABAC rule is written in, and it is an ordinary Go predicate rather than a sentence in a policy language: the caller already has a language, and a second one would have to grow comparison, negation, arithmetic and short circuits before it could express what an `if` expresses today.

Implementations MUST be pure and safe for concurrent use, and MUST NOT perform I/O — a Condition is evaluated on the request path, possibly several times per request, with no context to cancel it. A rule that needs a lookup is a Policy, which has one.

false and "cannot tell" are different answers

Returning (false, nil) means the condition was evaluated and does not hold; the rule carrying it declines to fire, and the decision is left to the other policies. Returning a non-nil error means the condition could NOT be evaluated — most often because the request does not carry the attribute the rule names (AttributeMissing), or carries it as another kind (AttributeKindMismatch).

Those two MUST NOT be collapsed. An absent attribute compared to a wanted value is not a comparison that failed: it is a comparison that never happened, and reporting it as false would let a request carrying no attributes at all slip past every "deny if X" rule in the system. The built-in conditions in internal/service/security/authz report absence as an error, and every combinator over them treats an unevaluable branch as absorbing.

type Decision

type Decision uint8

Decision is what a Policy answers: it permits, it refuses, or it has no opinion. Three states, not two.

Why the third state exists

Most policies only know about part of the system. A policy that grants "orders:write" to a role has nothing to say about a request to read a profile — and it must be able to SAY that. Folding "no opinion" into either of the other two breaks something:

  • Folded into Allow, a policy authorizes every request it does not recognise. That is the hole.
  • Folded into Deny, a policy vetoes every request the OTHER policies were meant to grant, because refusal is absorbing (see ADR 0057 §D2). That is the outage, and the usual repair for it is to make the combiner permissive — which reintroduces the hole one layer up.

Abstain costs one enum value and removes both.

The zero value

The zero Decision is Abstain, never Allow. A Policy that forgets to set its result therefore says "I have no opinion", and the request is refused by the closure in internal/service/security/authz.Check — not permitted (which would be a vulnerability) and not made to veto every other policy (which would be an outage from a forgotten assignment). This is ADR 0031 applied to a FUNC port: a func type has no constructor to refuse in, so the safe value has to be the zero one.

A Decision outside the three named constants is a defect in whoever produced it. The SDK treats it as Deny and reports PolicyMisconfigured; it is never treated as permission. Use Decision.Valid to check, and Decision.Granted — never `!= Deny` — to read a verdict.

const (
	// Abstain means this policy has no opinion about this request. It is the
	// zero value — FIRST in the iota run for exactly that reason — and it
	// grants nothing: composition preserves it, and the closure refuses it.
	Abstain Decision = iota
	// Allow means this policy permits the request. It is the ONLY value that
	// grants, and under deny-overrides it can still be defeated by a [Deny]
	// from any other policy in the composition.
	Allow
	// Deny means this policy refuses the request. It is absorbing: no [Allow]
	// anywhere in a composition can outvote it (ADR 0057 §D2).
	Deny
)

func (Decision) Granted

func (d Decision) Granted() bool

Granted reports whether d permits the request. It is true for Allow and for nothing else.

It exists so that no call site has to spell the test itself. `d != Deny` is the same expression with an extra character and a security hole in it: it is true for Abstain, so it authorizes every request no policy recognised, and it is true for a corrupt Decision value as well.

func (Decision) String

func (d Decision) String() string

String renders the decision in lower case for logs and test failures: "abstain", "allow", "deny". An out-of-contract value renders as "invalid", which is deliberately not one of the three: a log line must not be able to show a corrupt decision as if it were a real verdict.

func (Decision) Valid

func (d Decision) Valid() bool

Valid reports whether d is one of the three named states. A Decision that is not is a defect in the policy that produced it — a numeric conversion, a zeroed struct field read as a Decision, a value from a future version of a caller's own code — and the SDK refuses on it rather than guessing.

type Policy

type Policy func(ctx context.Context, request RequestValue) (Decision, error)

Policy answers whether request is permitted. Implementations MUST be pure with respect to the decision — the same request yields the same decision — and safe for concurrent use, because one compiled Policy is shared by every goroutine serving a request.

A Policy returns Abstain when it has nothing to say about this request. That is the common case for a policy that only knows about one action or one resource, and it is NOT a refusal: refusing is Deny, and a policy that conflates the two silently vetoes every policy it is composed with.

A returned error is never an authorization

The error return means "this policy could not be evaluated" — a required attribute was absent, an attribute was not the kind the rule compares, a backing store was unreachable. A caller MUST treat that as a refusal. The SDK's own combiner and closure do exactly that: an error is folded to Deny and the decision returned alongside it is ignored. A Policy that cannot decide has not permitted anything.

Implementations SHOULD return Deny alongside a non-nil error rather than the zero Abstain, so that a caller who reads only the decision — a mistake this contract cannot prevent — still fails closed.

type RequestValue

type RequestValue struct {
	// contains filtered or unexported fields
}

RequestValue is the question: may Subject perform Action on Resource, given these attributes. It is immutable once built, so one request value can be handed to every policy in a composition without any of them being able to edit what the next one sees.

The three strings are the caller's vocabulary

The SDK attaches no meaning to them. "orders" and "urn:acme:orders" are equally valid resources, "write" and "POST" equally valid actions; nothing here parses, splits or pattern-matches them, and there is no wildcard — matching is byte equality, so a resource literally named "*" is a resource named "*" and nothing more. A hierarchy, if the application has one, is expressed by the strings it chooses and the rules it writes.

Resource is the type, not the row

A grant table answers about a KIND of thing — "an editor may publish an article" — and an instance rule answers about one of them — "…if they wrote it". Put the type in Resource and the instance's facts in the attributes (owner, tenant, classification), then let RBAC answer the first question and an ABAC condition answer the second. That split is why this domain ships no wildcard and needs none; see ADR 0057 §D5.

func NewRequestValue

func NewRequestValue(subject, action, resource string, attrs ...AttrValue) RequestValue

NewRequestValue builds the immutable question. Attributes are indexed by their key; a later attribute with the same key replaces an earlier one, so the caller can layer defaults and then override them.

An attribute with an empty key or the zero KindInvalid kind is DROPPED rather than stored. The invariant is worth the drop: every attribute a rule can find is one it can use, so "found but unusable" is not a state any rule has to handle. The dropped attribute is then simply absent, and absence is already a refusal in every rule that names it — the failure mode is closed.

An empty action or resource is accepted and matches no grant and no rule, so such a request is refused by the closure. It is not rejected here because doing so would put an error return on the constructor every request calls, to catch a mistake that already fails closed.

func (RequestValue) Action

func (r RequestValue) Action() string

Action returns the verb being attempted.

func (RequestValue) Attr

func (r RequestValue) Attr(key string) (attr AttrValue, ok bool)

Attr resolves an attribute by name. ok is false when the request does not carry it — which callers MUST NOT read as a zero-valued attribute.

This two-value shape is the whole reason the attribute bag is not exported as a map. "The subject has no department" and "nobody told us the subject's department" are different facts, and a rule that cannot tell them apart grants access to every request that simply omitted the attribute.

func (RequestValue) AttrCount

func (r RequestValue) AttrCount() int

AttrCount returns how many attributes the request carries. It exists for diagnostics and tests; no rule reads it, because "how many facts" never answers "which fact".

func (RequestValue) Resource

func (r RequestValue) Resource() string

Resource returns the kind of thing being acted on.

func (RequestValue) Subject

func (r RequestValue) Subject() string

Subject returns the authenticated principal, or "" for anonymous.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL