Documentation
¶
Index ¶
- Constants
- Variables
- func AssertPolicySatisfied(ctx context.Context) error
- func BeeFromContext(ctx context.Context) (*service.BeeSession, bool)
- func ClientIPFromContext(ctx context.Context) string
- func OrgRoleError(err error) error
- func PlatformRoleError(err error) error
- func RequireAdminAuth(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))
- func RequireAuthenticated(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))
- func RequireBeeAuth(api huma.API, fleet *service.FleetService) func(huma.Context, func(huma.Context))
- func RequireFirstFactorEnrolment(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))
- func RequirePlatformAdmin(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))
- func RequirePlatformAdminHTTP(deps *app.Deps) func(http.Handler) http.Handler
- func RequireUserAuth(api huma.API, deps *app.Deps) func(huma.Context, func(huma.Context))
- func WithBee(ctx context.Context, s *service.BeeSession) context.Context
- func WithPrincipal(ctx context.Context, p *Principal) context.Context
- type AssertOrgRoleOption
- type AuthMethod
- type FactorInventory
- type Principal
Constants ¶
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.
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 ¶
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") )
var ( ErrNotPlatformStaff = errors.New("caller is not platform staff") ErrNestedImpersonate = errors.New("an impersonated session may not act as platform staff") )
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.
var ErrAccessDenied = errors.New("access denied")
Functions ¶
func AssertPolicySatisfied ¶
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 OrgRoleError ¶
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 ¶
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 ¶
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 ¶
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 RequireUserAuth ¶
RequireUserAuth is RequireAuthenticated plus the policy gate.
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 (*Principal) ActingUser ¶
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.