Help us shape the future of Go! Take the 2026 Go Developer Survey

apimiddleware

package
v0.1.5 Latest Latest
Warning

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

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

Documentation

Index

Constants

View Source
const (
	UserKeyHeader   = "X-API-Key"
	OrgKeyHeader    = "X-ORG-Key"
	OrgSecretHeader = "X-ORG-Secret"

	// OrgIDHeader selects which organization a USER credential is acting in.
	//
	// Only a user credential needs it: a person may belong to several
	// organizations, so their key identifies who they are and not which tenant
	// they mean. An org key is already bound to one and ignores this entirely —
	// letting a header redirect it would be handing a tenant-bound credential a
	// way to name a different tenant.
	OrgIDHeader = "X-Org-Id"
)

Headers carrying a non-bearer credential. They match the security schemes the served spec declares, so what the documentation says and what the middleware reads cannot drift apart.

View Source
const (
	AgentKeyHeader    = "X-AGENT-Key"
	AgentSecretHeader = "X-AGENT-Secret"
)

AgentKeyHeader and AgentSecretHeader carry the two halves of an agent credential.

Two explicit headers, matching the shape org keys already use (X-ORG-Key / X-ORG-Secret) rather than HTTP Basic. Basic makes a browser that wanders onto the endpoint pop a login dialog, reads as a second and different auth system beside everything else here, and hides the credential inside a base64 blob that logs and request dumps show as opaque.

Its own pair rather than the org one: presenting a credential on the wrong header means the wrong middleware never reads it at all. Scope exclusion is what enforces the boundary — this makes crossing it hard to do by accident.

Variables

View Source
var (
	ErrNoMembership = errors.New("caller holds no membership in this organization")

	// ErrNoOrganization is distinct from ErrNoMembership on purpose. A user API
	// key identifies a person, not a tenant, and a person may belong to several —
	// so the organization arrives in X-Org-Id and may simply be absent. "You
	// forgot a header" and "you are not a member there" need different answers,
	// or the first reads as the second and sends someone to ask for access they
	// already have.
	ErrNoOrganization   = errors.New("no organization selected for this request")
	ErrInsufficientRole = errors.New("caller's role is below the required role")

	// ErrNoActingUser is what Principal.ActingUser returns for an org key.
	//
	// Not an authorization failure — the key may hold every role the operation
	// requires. It is a failure to identify a PERSON, which these operations need
	// because they record who acted, so the message names the credential to use
	// rather than the permission to acquire.
	ErrNoActingUser = errors.New(
		"this action records who performed it, and an organization API key identifies " +
			"the organization rather than a person; use a user session or a user API key")
	ErrImpersonationRefused = errors.New("impersonated sessions may not perform this action")
)
View Source
var (
	ErrNotPlatformStaff  = errors.New("caller is not platform staff")
	ErrNestedImpersonate = errors.New("an impersonated session may not act as platform staff")
)
View Source
var (
	// SecurityUserOrKey is what RequireUserAuth admits: a session token, a user
	// API key, or an organization key/secret pair.
	//
	// The agent pair is deliberately absent. Scope is the surface boundary and
	// the exclusion runs both ways — a compromised hypervisor must not be able to
	// turn its credential on the rest of the tenant's API.
	SecurityUserOrKey = []map[string][]string{
		{"BearerAuth": {}},
		{"ApiKeyAuth": {}},
		{"OrgKeyAuth": {}, "OrgKeySecret": {}},
	}

	// SecurityUserOnly is for the platform surface, which is gated on a USER
	// role. An organization credential has no user behind it and can never
	// satisfy it, so offering it here would document a path that always 401s.
	SecurityUserOnly = []map[string][]string{
		{"BearerAuth": {}},
		{"ApiKeyAuth": {}},
	}

	// SecuritySessionOnly is for managing the session and its second factor:
	// logout, TOTP enrolment, passkeys.
	//
	// A long-lived key is refused here deliberately. An API key that can disable
	// MFA defeats MFA — the factor exists to make a stolen bearer insufficient,
	// and a credential that can remove it is a credential that can remove the
	// protection over itself. Proving you hold a live session is the point of
	// these endpoints, and a key does not prove that.
	SecuritySessionOnly = []map[string][]string{
		{"BearerAuth": {}},
	}

	// SecurityAgent is the hypervisor agent surface, and nothing else reaches it.
	SecurityAgent = []map[string][]string{
		{"AgentKeyAuth": {}, "AgentKeySecret": {}},
	}
)

Security requirement sets, declared once so that what the documentation advertises and what the middleware accepts cannot drift apart.

Each entry in the outer slice is an ALTERNATIVE (OpenAPI reads the list as OR); the map inside one entry is a conjunction (AND). So the org pair below means "both headers together", not "either header".

Referencing these from operations rather than spelling out {"BearerAuth": {}} is the point: a scheme declared in components but named by no operation still appears in Scalar's auth panel, so the docs offer a credential type every endpoint rejects. Someone follows them, mints a key, and gets a bare 401.

View Source
var ErrAccessDenied = errors.New("access denied")

Functions

func AssertPolicySatisfied

func AssertPolicySatisfied(ctx context.Context) error

AssertPolicySatisfied is for handlers that must not act on a pre-authz session even though their route is reachable from one.

Nothing should need it if routes are mounted correctly; it exists so that a handler which cannot be split by route can still refuse.

func BeeFromContext

func BeeFromContext(ctx context.Context) (*service.BeeSession, bool)

BeeFromContext returns the authenticated bee session, if there is one.

It carries the organization alongside the bee. A tenant row has no organization column — the database is the organization — so a handler holding only the bee could not reopen the database it came from.

func ClientIPFromContext

func ClientIPFromContext(ctx context.Context) string

func OrgRoleError

func OrgRoleError(err error) error

OrgRoleError maps an assertion failure onto an HTTP response without distinguishing "you are not a member" from "you are the wrong role" — both are 403 with the same body, so the endpoint cannot be used to probe another tenant's membership list.

func PlatformRoleError

func PlatformRoleError(err error) error

PlatformRoleError maps the assertion onto a response that does not reveal whether the caller merely lacks the role or is not staff at all.

func RequireAdminAuth

func RequireAdminAuth(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))

RequireAdminAuth admits only platform administrators.

It is a MIDDLEWARE rather than a check inside each handler because the route group is the honest place for an authorization boundary: an endpoint added to the admin group is protected by existing, and an endpoint that must not be admin-only cannot drift into being so by someone copying a neighbouring handler's first three lines. The previous arrangement — AssertPlatformRole called at the top of every handler — protects exactly the handlers whose author remembered.

It must be layered AFTER RequireUserAuth: it reads the principal that establishes, and a group carrying only this middleware would admit anyone, because there would be no principal to reject.

The three platform roles are not interchangeable, and this gate is the narrowest of them:

admin    the platform operator. Everything, including policy that binds
         every tenant. This gate.
support  may impersonate a member who has consented (membership
         support_enabled), and nothing else. NOT admitted here — a support
         engineer helping one customer must not be able to change the
         security floor for all of them.
customer the tenant's own people, authorized through their membership.

func RequireAuthenticated

func RequireAuthenticated(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))

RequireUserAuth admits the three HUMAN-or-automation credential shapes: a session bearer token, a user API key, and an organization key/secret pair.

It deliberately does not admit the fourth. An agent credential is refused here because Authenticate is asked for a specific scope, and the exclusion runs both ways: a compromised hypervisor must not be able to turn its credential on the rest of the tenant's API, and a human or an automation must not be able to forge an agent. RequireAuthenticated establishes WHO is calling and stops there.

It is the pre-authz half of the split: the token must be a valid, live access token naming a real session, but the policy gate is NOT applied. A principal it admits may carry PolicySatisfied=false.

Mount only remediation endpoints behind it — the ones whose whole purpose is to let someone comply with the policy refusing them. Anything else mounted here is reachable by a caller the policy has already declined, which is the bypass this exists to avoid creating.

Everything except the gate still applies. The signature, the token type, the issuer, session liveness and the impersonation shape are all identity questions, and relaxing authorization must not relax authentication.

func RequireBeeAuth

func RequireBeeAuth(api huma.API, fleet *service.FleetService) func(huma.Context, func(huma.Context))

RequireBeeAuth authenticates a fleet agent by its agent-scoped API key.

Deliberately NOT the user middleware. A bee presents a key/secret pair on the AGENT surface, not a JWT: it has no membership, no MFA, and no user behind it. Running it through RequireUserAuth would mean inventing a principal for it, and a principal that exists only to satisfy a middleware is exactly the sort of thing that later gets accepted somewhere it should not be.

It IS tenant-scoped, though — the key is org-scoped, and every fleet row it reaches lives in that organization's database. That is the whole reason the key is verified in control first: the organization it yields is what decides which database is opened next, and a bee that authenticated has by construction no way to name a different one.

The surfaces exclude each other in both directions. An org key cannot reach these endpoints (a human or an automation forging an agent), and an agent key cannot reach the org endpoints (a compromised hypervisor turning its credential on the rest of the tenant's API).

func RequireFirstFactorEnrolment

func RequireFirstFactorEnrolment(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))

RequireFirstFactorEnrolment guards the endpoints reachable from a pre-authz session, and it is what stops the split from becoming an MFA bypass.

The danger is specific. Once enrolment is reachable without satisfying policy, someone holding only a stolen PASSWORD can register their own authenticator on an account that already has one — and then satisfy the policy with it. The victim's second factor never comes into it. That is strictly worse than the lockout the split fixes.

So the relaxation is bounded to the case that motivated it: a session that has NOT satisfied policy may enrol only when the account holds no verified factor at all. Someone who already has one is not locked out — they have something to present, and presenting it yields a satisfied session that reaches these endpoints by the ordinary route.

Cross-TYPE is the part worth stating, because it is the hole a per-endpoint check leaves. SetupTOTP already refuses a second TOTP, but nothing stopped a passkey being added to an account whose factor was a TOTP. The inventory is therefore about factors, not about the factor this endpoint happens to create.

Layer it AFTER RequireAuthenticated: it reads the principal that establishes.

func RequirePlatformAdmin

func RequirePlatformAdmin(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))

func RequirePlatformAdminHTTP

func RequirePlatformAdminHTTP(deps *app.Deps) func(http.Handler) http.Handler

func RequireUserAuth

func RequireUserAuth(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))

RequireUserAuth is RequireAuthenticated plus the policy gate.

func WithBee

WithBee puts an authenticated bee session on the context.

func WithPrincipal

func WithPrincipal(ctx context.Context, p *Principal) context.Context

Types

type AssertOrgRoleOption

type AssertOrgRoleOption func(*assertOrgRoleOpts)

AssertOrgRoleOption adjusts the assertion.

func RefuseImpersonation

func RefuseImpersonation() AssertOrgRoleOption

RefuseImpersonation rejects a session created by a platform admin acting as the user. Apply it to anything that grants standing access rather than reading state — inviting a member, changing a role, or registering a credential. An impersonated operator troubleshooting an account should not be able to leave themselves a way back in.

func RequireActingUser

func RequireActingUser() AssertOrgRoleOption

RequireActingUser rejects a credential that names no person. Apply it to anything that records WHO acted — an audit row, an inviter, the author of a policy change.

An organization key is tenant-bound by design and carries no UserID, and this gate admits one as an admin without a membership row. The handler behind it then has an actor column it cannot fill. Declared here rather than checked mid-handler so that the requirement reads off the route, beside the role and the impersonation rule, instead of being something a reader has to find.

type AuthMethod

type AuthMethod string
const (
	AuthMethodBearer    AuthMethod = "bearer"
	AuthMethodUserKey   AuthMethod = "user_api_key"
	AuthMethodOrgSecret AuthMethod = "org_key_secret"
)

type FactorInventory

type FactorInventory interface {
	HasVerifiedFactor(ctx context.Context, userID uuid.UUID) (bool, error)
}

FactorInventory answers one question: does this user already hold a verified authentication factor?

Declared here, on the consumer side, so the rule below can be tested without standing up either repository.

type Principal

type Principal struct {
	Method         AuthMethod
	UserID         *uuid.UUID
	OrganizationID uuid.UUID
	APIKeyID       *uuid.UUID
	SessionJTI     string
	ActorID        *uuid.UUID
	Impersonating  bool

	// PolicySatisfied separates AUTHN from AUTHZ.
	//
	// Authentication answers who this is. Authorization answers whether they may
	// act, and policy — require_mfa, require_passkey — sits between the two. The
	// traditional shortcut is to let a successful authentication produce
	// authority, which works right up to the moment policy refuses someone who
	// has not yet enrolled: they hold a valid token, and the endpoint that would
	// let them comply is behind the gate refusing them.
	//
	// So a principal can exist and be genuine while not yet being allowed to do
	// anything but satisfy the policy. False means exactly that. Every principal
	// admitted by RequireUserAuth has this true, because passing that gate is
	// what it records.
	//
	// A key credential is satisfied by construction: policy of this kind applies
	// to interactive sessions, and a key has no second factor to present.
	PolicySatisfied bool
}

func AssertOrgRole

func AssertOrgRole(
	ctx context.Context,
	memberships repo.MembershipRepo,
	minRole control.MemberRole,
	opts ...AssertOrgRoleOption,
) (*Principal, error)

AssertOrgRole resolves the caller's membership in the organization their token names, and requires at least minRole.

The organization comes from the PRINCIPAL, never from the request. Reading it from a path or body parameter is the obvious IDOR here, and in this system it would grant a stranger a membership-shaped view of another tenant's cloud credentials.

It returns the principal so a handler can use it without fetching twice.

func AssertPlatformRole

func AssertPlatformRole(ctx context.Context, users repo.UserRepo, allowed ...control.UserRole) (*Principal, *control.User, error)

AssertPlatformRole requires the caller to hold one of the given PLATFORM roles — the role on the user record, not a membership role in some organization.

The two are unrelated ladders and conflating them is the obvious escalation here: anyone can be an owner of an organization they created, and owner outranks admin on the membership ladder, so a check that accepted a membership role would let any customer reach staff-only endpoints.

An impersonated session is refused unconditionally, whatever role it carries. Impersonation issues a session whose SUBJECT is the target user, so the role read here is the target's; a support engineer impersonating a customer who happens to be staff would otherwise be able to start a second impersonation from inside the first, and the audit trail would name the wrong actor.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) (*Principal, bool)

func (*Principal) ActingUser

func (p *Principal) ActingUser() (uuid.UUID, error)

ActingUser returns the person this principal is, and refuses when there is none.

An ORG key authenticates a tenant, not a human: it carries no UserID by design, and every SecurityUserOrKey endpoint admits one. An operation that records who acted — an audit row, an inviter, the author of a policy change — therefore has a principal it cannot describe, and `*p.UserID` on that path is a nil dereference that takes the process down.

Refusing is the honest answer rather than the conservative one. The alternatives are to panic, or to write the nil uuid into the actor column, and an audit trail that names nobody is worse than one that has no row: it looks like an answer. What these operations need is a human, and an org key is not one — so it is told so, in terms that name the fix.

func (*Principal) IsImpersonating

func (p *Principal) IsImpersonating() bool

Jump to

Keyboard shortcuts

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