authz

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 2, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

Documentation

Overview

Package authz authorizes explicit caller, action and resource facts using immutable rules. It does not authenticate, evaluate AWS IAM policies, resolve application grants automatically or write HTTP responses.

Construct rules once and share them across requests. Custom checks must be concurrency-safe, honor their context and return false, nil for ordinary denial. Every callback error becomes ErrUnavailable. All and Any evaluate left to right and stop on an observed error; keep mandatory application guards outside alternative permission branches.

Authorize requires a nonanonymous caller even for a permissive custom check. Identity constructors validate facts but do not authenticate them: establish the caller through a trusted producer before authorization. Complete selection and authorization before executing the same action/resource or committing an HTTP response, including a stream.

Example (Dispatcher)
package main

import (
	"context"
	"encoding/json/v2"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/asteroid-computing/go-lambda-edge/actionheader"
	"github.com/asteroid-computing/go-lambda-edge/authn"
	"github.com/asteroid-computing/go-lambda-edge/authz"
	"github.com/asteroid-computing/go-lambda-edge/iamproof"
	"github.com/asteroid-computing/go-lambda-edge/identity"
)

const orderIssuer = "https://issuer.example/pool"

// grantStore is an application interface, not a universal edge principal model.
// Its implementation chooses the authority and enrollment keys, resolves tenant membership/suspension and checks the exact action/resource in one operation.
type grantStore interface {
	Allowed(context.Context, identity.Caller, string, string) (bool, error)
}

// orderHandler owns an immutable registry.
// This example has one fixed resource;
// a real application validates/resolves its target before calling Authorize and keeps that same object/version for execution, using a transaction if needed.
func orderHandler(authentication *authn.Authenticator, grants grantStore, issuer string) (http.Handler, error) {
	selector, err := actionheader.NewSelector("Action")
	if err != nil {
		return nil, err
	}
	scopes, err := authz.JWTScopes(issuer, "orders.read")
	if err != nil {
		return nil, err
	}
	iam, err := authz.IAMRoleSessions("aws", "123456789012", "OrderReader")
	if err != nil {
		return nil, err
	}
	permission, err := authz.Any(scopes, iam)
	if err != nil {
		return nil, err
	}
	applicationGrant, err := authz.Check(func(ctx context.Context, req authz.Request) (bool, error) {
		if req.Resource == "" {
			return false, nil
		}
		return grants.Allowed(ctx, req.Caller, req.Action, req.Resource)
	})
	if err != nil {
		return nil, err
	}
	policy, err := authz.All(permission, applicationGrant)
	if err != nil {
		return nil, err
	}
	type entry struct {
		policy   authz.Rule
		resource string
		execute  func(http.ResponseWriter, *http.Request, authz.Request)
	}
	registry := map[string]entry{
		"orders.read": {
			policy:   policy,
			resource: "tenant/42/order/7",
			execute: func(w http.ResponseWriter, _ *http.Request, facts authz.Request) {
				w.Header().Set("Content-Type", "application/json")

				_ = json.MarshalWrite(w, struct {
					Resource string `json:"resource"`
				}{facts.Resource})
			},
		},
	}
	dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		action, err := selector.Parse(r.Header)
		if err != nil {
			writePolicyError(w, 400, "invalid_action")
			return
		}
		selected, ok := registry[action]
		if !ok {
			writePolicyError(w, 404, "unknown_action")
			return
		}
		facts := authz.Request{Caller: identity.FromContext(r.Context()), Action: action, Resource: selected.resource}
		if err := selected.policy.Authorize(r.Context(), facts); err != nil {
			switch {
			case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, authz.ErrUnavailable):
				writePolicyError(w, 503, "unavailable")
			case errors.Is(err, authz.ErrDenied):

				w.Header().Set("WWW-Authenticate", `Bearer realm="orders"`)
				writePolicyError(w, 403, "forbidden")
			case errors.Is(err, authz.ErrUnauthenticated):
				w.Header().Add("WWW-Authenticate", `Bearer realm="orders"`)
				w.Header().Add("WWW-Authenticate", `EdgeIAM realm="orders"`)
				writePolicyError(w, 401, "unauthenticated")
			default:
				writePolicyError(w, 500, "internal_error")
			}
			return
		}
		selected.execute(w, r, facts)
	})
	return authentication.Handler(dispatch)
}

func writePolicyError(w http.ResponseWriter, status int, code string) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	_ = json.MarshalWrite(w, struct {
		Error string `json:"error"`
	}{code})
}

// exampleGrants is a server-owned, read-only fixture.
// A database implementation can return false,nil for suspended/unregistered principals and an error when its dependency cannot decide.
// These grants are never put in identity.Claims.
type exampleGrants struct{}

func (exampleGrants) Allowed(ctx context.Context, caller identity.Caller, action, resource string) (bool, error) {
	if err := ctx.Err(); err != nil {
		return false, err
	}
	if action != "orders.read" || resource != "tenant/42/order/7" {
		return false, nil
	}
	if j, ok := caller.JWT(); ok {
		subject, present := j.Subject()
		return present && j.Issuer() == orderIssuer && subject == "alice", nil
	}
	if i, ok := caller.IAM(); ok {

		return i.PrincipalARN() == "arn:aws:sts::123456789012:assumed-role/OrderReader/session-one", nil
	}
	return false, nil
}

func main() {
	// Construction performs no network I/O.
	// Configure real pool/client/resource and STS audience values for deployment.
	// The example sends no credentials, so neither verifier contacts AWS when run.
	bearer, err := authn.NewCognitoVerifier(authn.CognitoConfig{
		Issuer:  orderIssuer,
		Clients: []authn.CognitoClient{{ClientID: "orders-client", Audience: "https://orders.example"}},
	})
	if err != nil {
		panic(err)
	}
	proof, err := iamproof.NewVerifier("eu-west-2", "orders.production")
	if err != nil {
		panic(err)
	}
	authentication, err := authn.New(authn.Config{
		Realm:  "orders",
		Bearer: bearer.Verify,
		IAMProof: func(ctx context.Context, token string) (identity.Caller, error) {
			caller, err := proof.Verify(ctx, token)
			if errors.Is(err, iamproof.ErrInvalidProof) {
				return identity.Caller{}, authn.ErrInvalidCredentials
			}
			return caller, err
		},
	})
	if err != nil {
		panic(err)
	}
	h, err := orderHandler(authentication, exampleGrants{}, orderIssuer)
	if err != nil {
		panic(err)
	}
	// h is an ordinary http.Handler.
	// For Lambda, edge.New(h) uses the same dispatcher, with gateway identity left disabled for these local verifiers.
	r := httptest.NewRequest(http.MethodGet, "/", nil)
	r.Header.Set("Action", "orders.read")
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	fmt.Println(w.Code)
	fmt.Println(w.Header().Values("WWW-Authenticate"))
}
Output:
401
[Bearer realm="orders" EdgeIAM realm="orders"]

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidConfiguration means a rule constructor rejected its arguments, or [Rule.Authorize] was called on a zero Rule or with a nil context.
	ErrInvalidConfiguration = errors.New("authz: invalid configuration")

	// ErrInvalidRequest means the [Request] has an empty or non-UTF-8 action or a non-UTF-8 resource.
	// No check runs.
	ErrInvalidRequest = errors.New("authz: invalid request")

	// ErrUnauthenticated means the request caller is anonymous.
	// No check runs, even a permissive custom one.
	ErrUnauthenticated = errors.New("authz: unauthenticated")

	// ErrDenied means the rule evaluated without error and did not match.
	ErrDenied = errors.New("authz: denied")

	// ErrUnavailable means a [CheckFunc] returned an error, whatever its boolean result.
	// Evaluation stops at that node rather than trying another branch.
	ErrUnavailable = errors.New("authz: unavailable")
)

Authorization errors contain no request facts or callback diagnostics. Match them with errors.Is.

Functions

This section is empty.

Types

type CheckFunc

type CheckFunc func(ctx context.Context, request Request) (bool, error)

CheckFunc decides whether a request matches. Return false, nil for denial; any error becomes ErrUnavailable, even when accompanied by true. Checks run synchronously, must honor ctx and must be safe for concurrent use when shared. The caller owns dependency deadlines. Panics propagate.

type Request

type Request struct {
	Caller   identity.Caller
	Action   string
	Resource string
}

Request supplies facts passed by value to each check. Action must be nonempty UTF-8; Resource is optional UTF-8. Both compare exactly without normalization and have application-defined meaning. A resource-aware check must reject an absent Resource. Caller is explicit, not read from context.

type Rule

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

Rule holds immutable private configuration. Copies may be shared concurrently. The zero value is unconfigured and never allows. Construction performs no I/O and never invokes callbacks. A rule is limited to depth 32 (a leaf has depth 1) and 1,024 expanded nodes, counting each occurrence of a shared subtree.

func All

func All(rules ...Rule) (Rule, error)

All requires every child to match, evaluating left to right. It stops on the first nonmatch or error. Empty, unconfigured or oversized trees return ErrInvalidConfiguration. The input slice is copied.

func Any

func Any(rules ...Rule) (Rule, error)

Any requires one child to match, evaluating left to right. It stops on the first match or error; an error never falls through to another permission path. Empty, unconfigured or oversized trees return ErrInvalidConfiguration. The input slice is copied. Mandatory guards belong in an outer All.

func Check

func Check(check CheckFunc) (Rule, error)

Check constructs a rule from a nonnil callback. A nil callback returns ErrInvalidConfiguration. Reusing a check does not deduplicate its evaluation.

Example
package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/asteroid-computing/go-lambda-edge/authz"
	"github.com/asteroid-computing/go-lambda-edge/identity"
)

const orderIssuer = "https://issuer.example/pool"

// exampleGrants is a server-owned, read-only fixture.
// A database implementation can return false,nil for suspended/unregistered principals and an error when its dependency cannot decide.
// These grants are never put in identity.Claims.
type exampleGrants struct{}

func (exampleGrants) Allowed(ctx context.Context, caller identity.Caller, action, resource string) (bool, error) {
	if err := ctx.Err(); err != nil {
		return false, err
	}
	if action != "orders.read" || resource != "tenant/42/order/7" {
		return false, nil
	}
	if j, ok := caller.JWT(); ok {
		subject, present := j.Subject()
		return present && j.Issuer() == orderIssuer && subject == "alice", nil
	}
	if i, ok := caller.IAM(); ok {

		return i.PrincipalARN() == "arn:aws:sts::123456789012:assumed-role/OrderReader/session-one", nil
	}
	return false, nil
}

func main() {
	// These are validated fixture facts, not credential verification.
	// A request handler obtains the caller from its configured authentication producer.
	claims, err := identity.NewClaims(map[string]any{"iss": orderIssuer, "sub": "alice"})
	if err != nil {
		panic(err)
	}
	caller, err := identity.NewJWT(claims, identity.SourceCustomAssertion)
	if err != nil {
		panic(err)
	}
	grants := exampleGrants{}
	policy, err := authz.Check(func(ctx context.Context, req authz.Request) (bool, error) {
		return grants.Allowed(ctx, req.Caller, req.Action, req.Resource)
	})
	if err != nil {
		panic(err)
	}
	request := authz.Request{Caller: caller, Action: "orders.read", Resource: "tenant/42/order/7"}
	fmt.Println(policy.Authorize(context.Background(), request))
	request.Resource = "tenant/99/order/8"
	fmt.Println(errors.Is(policy.Authorize(context.Background(), request), authz.ErrDenied))
}
Output:
<nil>
true

func CognitoGroups

func CognitoGroups(issuer string, required ...string) (Rule, error)

CognitoGroups requires all supplied exact cognito:groups values from the exact issuer. Flattened gateway strings remain unavailable and never match. Groups are distinct from scopes and application grants. Issuer and each required value must be nonempty UTF-8; an empty list or invalid value returns ErrInvalidConfiguration. The input slice is copied.

func IAMPrincipal

func IAMPrincipal(principalARN string) (Rule, error)

IAMPrincipal matches the full caller ARN literally, including path and session. A root ARN matches only that root caller, not the account. Valid literal * and ? in user paths retain their meaning as characters. Unsupported caller forms (including bare role ARNs) return ErrInvalidConfiguration, following NewIAM's grammar. This does not evaluate AWS IAM permissions or bind a unique identity across deletion and recreation of the principal.

func IAMRoleSessions

func IAMRoleSessions(partition, accountID, roleName string) (Rule, error)

IAMRoleSessions matches assumed-role callers with the exact partition, account and role name, allowing any valid session name. Invalid components, wildcards and role paths return ErrInvalidConfiguration. This is a role-name policy: recreating the same role name can match again. It does not bind a unique role ID, identify the original actor, fabricate a role ARN or evaluate IAM policies.

func JWTClient

func JWTClient(issuer, clientID string) (Rule, error)

JWTClient matches an exact issuer and client_id, without interpreting audience or subject as a client ID or inferring a machine grant. Both arguments must be nonempty UTF-8 or construction returns ErrInvalidConfiguration.

func JWTScopes

func JWTScopes(issuer string, required ...string) (Rule, error)

JWTScopes requires all supplied exact OAuth scope tokens from the exact issuer. It does not interpret wildcards. Missing or unavailable scopes do not match. Issuer must be nonempty UTF-8 and required must contain at least one valid RFC 6749 scope token, otherwise construction returns ErrInvalidConfiguration. The input slice is copied. Use Any for alternative scope requirements.

func JWTSubject

func JWTSubject(issuer, subject string) (Rule, error)

JWTSubject matches an exact issuer and subject. Both must be nonempty UTF-8 or construction returns ErrInvalidConfiguration. A subject does not prove a human identity. Wrong kind or missing subject does not match.

func Sources

func Sources(allowed ...identity.Source) (Rule, error)

Sources matches any supplied nonanonymous producer. Empty lists, SourceNone and unknown sources return ErrInvalidConfiguration. Source is trusted-code attribution, not an unforgeable credential. Combine this broad predicate with permission checks. The input slice is copied.

func (Rule) Authorize

func (r Rule) Authorize(ctx context.Context, request Request) error

Authorize returns nil only for an explicit allow. It checks rule/context configuration, cancellation, request validity and a nonanonymous caller in that order. No check runs for an anonymous or invalid request. Nonmatches return ErrDenied; all callback errors are sanitized to ErrUnavailable. Cancellation is checked between nodes and before returning, and returns ctx.Err(), never context.Cause. No results are cached or installed in context.

Jump to

Keyboard shortcuts

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