authz

package module
v1.10.29 Latest Latest
Warning

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

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

Documentation

Overview

Package authz is the decision: who may do what, where. It is a stateless leaf — no store, no init, no config, no network on any path — so importing it costs a caller nothing and every subsystem can ask the same question the same way (HIP-0519).

LOCATION and ACCESS are different questions, and conflating them is the bug this package exists to prevent.

LOCATION is a PATH: org/workspace/project, and below it apps, sites, everything else — acme/prod/web, acme/prod/web/site/landing. It is total and unambiguous. It is what a resource IS: a human-meaningful name scoped by its parent, and the axis billing and quota roll up along.

ACCESS is a GRANT AT A PREFIX: (subject, scope, role), where scope is a prefix of the path. Can reports true iff some grant's scope prefixes the target and its role admits the verb. ONE predicate covers orgs, workspaces, projects, apps and sites, with no special case — every arrangement falls out of prefix matching:

org-wide member        a grant at acme
workspace member       a grant at acme/prod
invite-only project    a grant at acme/prod/web with NO ancestor grant
agent delegation       a grant NARROWER than the subject's own — a prefix
                       restriction, not a second mechanism
ephemeral credential   the same grant with an expiry

A TREE IS WRONG. Deriving access from containment cannot express an invite-only project, because containment would hand every ancestor's members the child. Containment NAMES; grants AUTHORIZE. The two are not re-braided here.

Grants do not all ride in a token. The edge resolves the requested scope ONCE and writes the resolved path and role, so the token is constant-size and no decision behind the edge performs I/O. The grant SET stays in IAM, which signs it; the resolved DECISION travels.

Index

Constants

View Source
const (
	HeaderOrg            = "X-Org-Id"
	HeaderWorkspace      = "X-Workspace-Id"
	HeaderProject        = "X-Project-Id"
	HeaderUser           = "X-User-Id"
	HeaderUserName       = "X-User-Name"
	HeaderUserEmail      = "X-User-Email"
	HeaderUserOwner      = "X-User-Owner"
	HeaderUserAdmin      = "X-User-IsAdmin"
	HeaderUserOrgAdmin   = "X-User-IsOrgAdmin"
	HeaderBillingAccount = "X-Billing-Account-Id"
	HeaderRequestID      = "X-Request-Id"

	// HeaderScope carries the resolved LOCATION a request acts at, printed as a path
	// (acme/prod/web), and HeaderScopeRole the role held there. This pair is why a
	// token stays constant-size: the edge resolves the requested scope against the
	// grant set ONCE, writes the outcome here, and no decision behind the edge performs
	// I/O. The grant SET stays in IAM, which signs it; the resolved DECISION travels.
	HeaderScope     = "X-Scope"
	HeaderScopeRole = "X-Scope-Role"

	// HeaderUserPermissions carries the money authority as a bit-field: commerce
	// gates every credit-creating and card-charging endpoint on it, with
	// intersection semantics, so whoever carries the admin bit satisfies them all.
	//
	// It is named HERE, and stripped with the rest, because it is written at the edge
	// and a forged copy would be believed. Its VALUE is not computed here: the bit
	// vocabulary is a deployment's commerce contract, not part of what an identity
	// IS, so the edge that holds that contract fills it in. Naming it without owning
	// its value is the point — the strip list has to be complete even where the write
	// is someone else's.
	HeaderUserPermissions = "X-User-Permissions"

	// HeaderApp is the application a request is attributed to — a caller LABEL, not
	// an isolation boundary: nothing scopes access by it, and the org header bounds
	// any mislabel to the caller's own subtree.
	//
	// It is named here anyway, because it is WRITTEN at an edge and the strip list has
	// to be complete: a name an edge writes and ingress does not delete is a name a
	// client can set. Being a label rather than a boundary is why forging it is cheap,
	// not a reason to leave it forgeable.
	HeaderApp = "X-App-Id"
)

The identity headers, named once by the party that writes their values.

These are STRINGS, so naming them costs a consumer nothing: reading a header is the whole of what a service behind the edge does with identity. Minting them is the edge's job and lives in authz/edge, which links a transport — this package deliberately does not.

View Source
const AdminOrg = "admin"

AdminOrg is the reserved org slug IAM seeds PLATFORM (sudo) admins into.

The org IS the capability. There is no separate superadmin boolean, because a flag beside the org is a second source of truth for one fact, and the two can disagree.

Variables

View Source
var (
	// CapKeyMint gates minting, rotating, or revoking a credential on another
	// principal's behalf — the service-account administration boundary, since a
	// minted key is an org-billing credential.
	CapKeyMint = Cap{Name: "key-mint", Env: "IAM_KEY_MINT_ALLOWED_APPS"}

	// CapUserAdmin gates cross-user account mutation (owner, isAdmin, email, type,
	// credentials) — cloud moves an onboarding user into the org it just created
	// through this.
	CapUserAdmin = Cap{Name: "user", Env: "IAM_USER_ADMIN_APPS"}

	// CapOrgAdmin gates organization create/read/update/delete. Unlike the
	// signing-material capabilities this one is populated in every environment: the
	// brand consoles legitimately create customer orgs during onboarding.
	CapOrgAdmin = Cap{Name: "organization", Env: "IAM_ORG_ADMIN_APPS"}

	// CapServiceAccountRead gates LISTING an org's service accounts — names and
	// metadata only, never secrets. It is the read-only counterpart to CapKeyMint (a
	// read cap can never mint, rotate, or delete a credential) and is additionally
	// tenant-bound by BoundTo.
	CapServiceAccountRead = Cap{Name: "service-account-read", Env: "IAM_SA_LIST_ALLOWED_APPS"}

	// CapKeyResolve gates resolving an opaque SECRET API key (hk-/sk-) to its owning
	// principal. It is a CREDENTIAL-DISCLOSURE boundary: the caller presents a secret
	// key and learns WHO it authenticates, so it must never be an arbitrary
	// authenticated caller. A public pk- is NOT resolved here — it is write-only, and
	// its own narrower CapPublishableResolve turns it into an org, never a principal.
	// The intended sole holder is cloud's identity boundary, which turns a keyed
	// request into the same principal a JWT yields. A human holds it vacuously, so
	// the handler also requires an app principal to keep this a service-only door.
	CapKeyResolve = Cap{Name: "key-resolve", Env: "IAM_KEY_RESOLVE_APPS"}

	// CapPublishableResolve gates resolving a WRITE-ONLY publishable pk- to just the
	// ORG that holds it, for cloud's ingest boundary. It is strictly NARROWER than
	// CapKeyResolve and deliberately a separate authority: this door discloses only
	// an org (a pk- is public, shipped in client JS), NEVER a principal, so a client
	// granted org-resolve must never thereby be able to disclose WHO a secret key
	// authenticates.
	CapPublishableResolve = Cap{Name: "publishable-resolve", Env: "IAM_PUBLISHABLE_RESOLVE_APPS"}
)

The capability set, matching the live allowlists byte for byte (universe infra/k8s/operator/crs/iam.yaml). Every one is fail-secure: an unset or empty allowlist denies EVERY app.

Headers is every header the edge writes, and therefore every header it must strip. A header that is written but not stripped is forgeable; the two lists being one list is what makes that impossible.

View Source
var Retired = []string{
	"X-User-IsGlobalAdmin", "X-Roles", "X-User-Role", "X-User-Roles",
	"X-Tenant-Id", "X-Phone-Number", "X-Org",
}

Retired are header names an older edge wrote and no edge writes now. They are still STRIPPED, and exported so an edge can strip them: a name some consumer may still read is a name a client must not be able to set.

Functions

func Can added in v1.10.15

func Can(subject string, v Verb, target Path, grants []Grant) bool

Can reports whether subject may v on target under grants — the ONE predicate.

True iff some grant held by subject has a scope covering target and a role admitting v. Fails closed: no subject, no target, or no covering grant is a denial, so an empty grant set authorizes nothing.

func HasUnsafeRune added in v1.10.15

func HasUnsafeRune(s string) bool

HasUnsafeRune reports whether an identifier carries whitespace, a control rune or a format rune.

An org, and every path segment below it, is a TENANCY key and must be injective: "acme " and "acme" are two strings a trim would fold into one — anywhere in the stack, including transport header handling — and folding two tenants together is a cross-tenant read. Such an identifier grants NOTHING; it is refused rather than cleaned, because cleaning IS the fold.

func IsAPIKey added in v1.10.15

func IsAPIKey(token string) bool

IsAPIKey reports whether a credential is an opaque key rather than a JWT. It carries no claims and no signature to check, so it is resolved by IAM (which holds the key) and never parsed here.

func IsReservedOrg added in v1.10.15

func IsReservedOrg(owner string) bool

IsReservedOrg reports whether owner is a SYSTEM organization a self-service, federated, or otherwise customer-driven flow may NEVER land a principal in. It is the ONE predicate that boundary shares, so the reserved set is defined in exactly one place and can never drift between those surfaces.

The set is the platform-sudo/signing trust boundary — IsSigningOwner, composed so a newly-reserved signing owner is covered here for free — plus the service-principal org. A user created under any of these is a platform identity, not a customer. Fail-closed by construction: an unknown org is NOT reserved, so legitimate tenants are unaffected while every reserved org is refused.

func IsSigningOwner added in v1.10.15

func IsSigningOwner(owner string) bool

IsSigningOwner reports whether owner is a reserved platform signing-cert owner — the trust boundary the JWKS and token verification enforce, and the owner-pin a capability is granted under.

Types

type App added in v1.10.15

type App struct {
	// Name is the application name — the key a capability allowlist is written in.
	Name string

	// Owner is the organization that OWNS the application row: a reserved platform
	// org for a platform console, the tenant's own org for a customer app. It is not
	// the org the application SERVES. A capability is granted only when this is a
	// reserved signing owner, so a tenant that registers an app whose name collides
	// with a platform console inherits none of its authority.
	Owner string

	// Cert is the name of the signing cert the application row references. It is
	// carried here so the self-read clause can admit an app exactly one cert — its
	// own — without the decision reaching into a store.
	Cert string
}

App is the confidential client a request authenticated as, resolved by IAM from its own application row. It is NOT a wire claim: a token cannot assert it, which is what keeps the capability gate below out of a caller's reach.

An app principal is never a platform admin and never an org admin — its whole authority is the capability allowlist (entity.go), so a leaked client credential can neither read another tenant nor touch signing material.

type Cap added in v1.10.15

type Cap struct {
	Name string
	Env  string
}

Cap is one capability a confidential client may hold: a Name for diagnostics and the Env variable holding its comma-separated allowlist of application names.

A Cap is the ONLY thing an app principal's authority is made of — an app is never a platform admin and never an org admin — so a leaked client credential grants exactly the capabilities its NAME was allowlisted for and nothing more.

type Claims added in v1.10.15

type Claims struct {
	jwt.RegisteredClaims

	Scope        string `json:"scope,omitempty"`
	Organization string `json:"organization,omitempty"`
	Email        string `json:"email,omitempty"`
	Name         string `json:"name,omitempty"`

	// Owner is the org of the APPLICATION the token was minted through, NOT the
	// subject's own. IAM's Sign stamps `Owner: app.Organization` for every
	// authorization_code and refresh token (internal/oidc/jwt.go), so it follows
	// whichever app a person signed in through.
	//
	// IT IS THEREFORE NOT AUTHORITY, and reading it as the home org makes platform
	// sudo a property of the APPLICATION: anyone signing in through an app owned by
	// the reserved org would arrive as a platform admin. Use [Claims.Home], which
	// reads the subject's own membership.
	//
	// It is still worth carrying: for a client_credentials token the app IS the
	// subject, so this is that machine's own org, and Home says so.
	Owner string `json:"owner,omitempty"`

	// PreferredUsername is the IAM USERNAME — the `<name>` half of
	// `<owner>/<name>`, e.g. "z" — not a display name. A consumer addressing a
	// wallet needs it: cloud's money path addresses `<org>/<username>`, and with
	// no username claim it fell back to `name` (a DisplayName) and addressed
	// `hanzo/Zach Kelling`, a wallet no funding path can name, while the balance
	// sat in `hanzo/z`. Every signed-in completion then 402'd on a funded account.
	PreferredUsername string `json:"preferred_username,omitempty"`

	// BillingAccount names WHICH LEDGER this token spends from. It is SIGNED, so
	// a caller cannot name its own payer — this is IAM stating who is entitled to
	// spend what. Empty is meaningful rather than missing: no explicit
	// entitlement, and the consumer keeps the behaviour it already had.
	BillingAccount string `json:"billing_account,omitempty"`

	// IsAdmin is IAM's ORG-level role bit: an org owner carries it within their
	// own org.
	//
	// IT IS NOT PLATFORM AUTHORITY, and reading it as such is a privilege
	// escalation. Gating the money/admin permission on it let any org owner
	// satisfy commerce's admin gates — unlimited free balance. Use PlatformSudo
	// for platform authority and OrgAdmin for this one; never this field directly.
	IsAdmin bool `json:"isAdmin,omitempty"`

	// Orgs is the membership set — every org the subject may act in, home org
	// first. A resource server authorizes an org-switch against it with no
	// round-trip. A machine token has no membership and omits the claim.
	Orgs []Membership `json:"orgs,omitempty"`

	// Workspace and Project narrow the org along the location path
	// (org/workspace/project). Both are SUB-SCOPES, never authority: they say
	// WHERE a request acts, and Can says whether it may. Absent means the whole
	// scope above — a token with no project acts across the workspace, one with
	// neither acts across the org.
	//
	// They are separate claims rather than one printed path because IAM signs the
	// segments it resolved; joining them is Location's job, and a consumer that
	// wants one segment should not have to parse a path to get it.
	Workspace string `json:"workspace,omitempty"`
	Project   string `json:"project,omitempty"`

	Nonce     string `json:"nonce,omitempty"`
	Azp       string `json:"azp,omitempty"`
	TokenType string `json:"tokenType,omitempty"`

	// App is the confidential client the request authenticated as, or nil for a
	// human. IAM resolves it from its own application row and sets it after
	// verification; it is never decoded from the token, so `json:"-"`.
	App *App `json:"-"`
}

Claims are the claims Hanzo IAM mints. internal/oidc signs THIS type, so the issuer and every reader share one definition and cannot disagree about a field's name, presence or meaning.

func Verify added in v1.10.15

func Verify(token string, keys Keys, issuers []string) (*Claims, error)

Verify parses token, verifies its signature against the keys its `kid` names, and validates issuer and expiry. It returns the claims IAM signed.

issuers is an ALLOWLIST, because one deployment fronts several brands and each signs under its own issuer (hanzo.id, lux.id, zoo.id, plus whatever a white-label deployment adds). A single expected issuer cannot express that, and the service that needed it wrote its own reader rather than go without.

Verified: the algorithm (against algs, before any signature check), the `kid` (required — a token with no key id is refused rather than tried against every key in the set, which is how a reader ends up accepting a signature from a key the token never named), the signature, the issuer, and expiry with leeway.

FAIL-SECURE IN BOTH DIRECTIONS on the issuer. An EMPTY allowlist matches nothing and refuses every token — a misconfiguration that empties it must reject rather than silently disable the check, which is what happens when a comparison is made conditional on having something to compare against. A non-empty allowlist refuses any issuer outside it, and the comparison is VERBATIM.

The AUDIENCE is deliberately NOT verified. IAM sets aud per RFC 8707 to the requesting CLIENT (audienceFor: the client id, or "<clientId>-org-<org>" for a shared application, or an explicit resource indicator). It therefore names which client asked for the token, not which resource server may accept it — so a resource server checking it must enumerate every client id in the estate, and each new console 401s every user until someone adds it to a list. That list is exactly the drift this package exists to remove. The claim is returned in RegisteredClaims.Audience, so a service that IS a named audience (a per-org KMS identity checking "<owner>-platform-kms", which Machine already does) checks the one value it owns rather than maintaining a registry of everyone else's.

func (*Claims) BoundTo added in v1.10.15

func (c *Claims) BoundTo(org string) bool

BoundTo reports whether an app principal is bound to org by the <org>-<app> naming convention — app/hanzo-team may act on organization=hanzo and on no other tenant's. The org is derived from the (allowlist-reserved) application NAME, so the binding holds regardless of the app row's owner, and it is the same prefix rule the service-account names it reads obey. A human is never bound by it, and so is never admitted by it.

func (*Claims) Can added in v1.10.15

func (c *Claims) Can(v Verb, target Path, grants []Grant) bool

Can reports whether these claims may v at target, given the grant set IAM holds for this subject. The caller supplies that set — this leaf holds no store.

The signed membership set is folded in via Grants, so an org-wide member is authorized without a round-trip; grants adds everything narrower than an org (workspace, project, an invite-only project, a delegation to an agent).

The platform operator is admitted anywhere, and only as a HUMAN: an admin-org machine identity is not a cross-tenant principal.

func (*Claims) CanEntity added in v1.10.15

func (c *Claims) CanEntity(v Verb, e Entity, env Env) bool

CanEntity reports whether these claims may v on the addressed registry row.

Three scopes, never conflated (conflation is privilege escalation):

  • PLATFORM SUDO — the home org is the reserved admin org. The only cross-tenant scope, and the only one that may write a platform-owned row: the signing-cert poisoning gate, admin-scoped application and provider registration, every reserved surface.
  • ORG ADMIN — IsAdmin, scoped to its OWN org. Manages every row its org owns; never another org's, never a platform-owned one.
  • REGULAR USER — self-service only: reading its own user record. The users kind serves reads as GET and writes as POST, so gating the self clause to a read keeps a regular user from writing its own record, which would otherwise let it carry isAdmin and self-promote.

A confidential client sits outside all three: its authority is its capability allowlist plus the self-read clauses, and nothing else.

func (*Claims) EffectiveOrg added in v1.10.15

func (c *Claims) EffectiveOrg(selected string) (org string, switched bool)

EffectiveOrg resolves WHICH ORG a request acts in: the org the client selected when the signed membership set admits it (or the operator may masquerade), otherwise the home org.

It fails closed to home. A selected org outside the membership set is not an error to surface, it is simply not granted.

func (*Claims) Grants added in v1.10.15

func (c *Claims) Grants() []Grant

Grants projects the signed membership set onto the grant model: a membership in an org IS a grant at that org's path. An org-wide member therefore needs no separate grant row, and the two representations cannot disagree.

A membership whose org is not an injective identifier yields no grant — the same refusal EffectiveOrg makes, for the same reason.

func (*Claims) Holds added in v1.10.15

func (c *Claims) Holds(cap Cap, env Env) bool

Holds reports whether these claims hold capability cap.

A non-app principal holds every capability vacuously: this gate concerns confidential clients ONLY, and a human's authority is decided by CanEntity. Conflating the two would either lock every human out or hand every app a human's scope. Nil claims hold nothing.

Fail-secure: an app whose allowlist is unset, empty, or does not name it holds nothing.

The key is the application NAME, not its (owner, name) row. That alone would let ANY owner's app claim a listed name, so Holds ALSO pins the app's OWNING org to a reserved platform signing owner: the name is thereby reserved to the platform's admin-owned app, and a tenant that registers <theirOrg>/hanzo-console — same name, its own owner — inherits none of its grants. The pin is what ENFORCES that reservation; the name match alone was the escalation.

func (*Claims) Home added in v1.10.26

func (c *Claims) Home() string

Home is the org the SUBJECT belongs to — the identity and billing anchor, and the only org that confers authority.

It is the first entry of the membership set, which store.MemberOrgRefs builds home-first from the user row: `refs := []OrgRef{{Org: user.Owner, …}}` before any other membership. That is the subject's OWN org, resolved from the user record rather than from whichever application minted the token.

It deliberately does NOT fall back to the `owner` claim. Falling back is precisely the app-selected value this exists to stop trusting, and empty means empty: a token with no membership set resolves NO home org and every gate above must fail closed.

For a MACHINE the app IS the subject, so its own org is the `owner` claim and there is no user to mis-attribute — but a machine holds no admin scope either way (see Claims.Machine), so this returns empty for one and the callers that need a machine's org read it from the credential that authenticated it.

func (*Claims) LedgerOrg added in v1.10.17

func (c *Claims) LedgerOrg(selected string) string

LedgerOrg resolves WHICH ORG PAYS for a request, from the same selection EffectiveOrg reads.

It is a SECOND function with its own branch rather than a reuse of the first, because acting and paying are different questions and one answer cannot serve both. A platform operator inspecting a customer's org must not spend the customer's money, so the ledger stays on the operator's HOME org while the data scope moves to the customer. Everyone else pays from the org they act in — that IS the product: a person belongs to several orgs, picks one, and that org is the payer of record.

It takes the RAW selection, exactly like EffectiveOrg, so the two can never be mis-threaded by a caller passing one's output into the other.

func (*Claims) Location added in v1.10.17

func (c *Claims) Location(selected string) Path

Location is the path a request acts AT: the effective org, then the workspace and project the token names, stopping at the first absent segment.

Stopping is what keeps a path a location. A project under no workspace would print acme/web and collide with a WORKSPACE named web — two different places with one name, which is the fold every identifier rule here exists to prevent. A segment that is not an injective identifier ends the path for the same reason.

func (*Claims) Machine added in v1.10.15

func (c *Claims) Machine() bool

Machine reports whether these claims belong to a machine rather than a person.

The signal is the MEMBERSHIP SET, because that is the one IAM guarantees. A person's token always carries at least their home org: store.MemberOrgRefs opens with {user.Owner, HomeRole(user)} before it appends anything else, so every user token IAM signs has a non-empty `orgs`. A client_credentials token carries none — IAM's own token.go says why, at the call that mints it: "a machine token has no user and therefore no membership set", so "an app token can never carry a tenancy it did not earn".

This is not an inference about token shape, it is the semantics: authority here IS membership, and an identity with no memberships has none to hold.

It fails closed. A user token whose membership set did not resolve reads as a machine and loses the two admin scopes; it does not gain them.

An App principal is a confidential client by construction — IAM resolves it from its own application row after verification, so it is a statement rather than a claim, and it stands on its own.

WHAT THIS REPLACES: `tokenType == "application"` and an owner-bound "<owner>-platform-kms" audience. IAM assigns `tokenType` exactly two values, "access-token" and "id-token" (internal/oidc/jwt.go), and never "application" — so the check could not fire, and every machine fell through to the audience clause, which only matches one identity in the estate. An admin-org client_credentials token therefore read as a HUMAN and PlatformSudo admitted it: cross-tenant reads plus the platform header the money gates trust. The tests covering it built their machine with the value IAM never mints, so they passed.

func (*Claims) OrgAdmin added in v1.10.15

func (c *Claims) OrgAdmin(org string) bool

OrgAdmin reports whether these claims administer the named org — admin OF ONE'S OWN org, which is org-scoped self-service and NEVER platform authority.

The org is compared VERBATIM. "acme" and "acme " are distinct identifiers to IAM, so a trim here would fold two tenants into one. The ROLE is folded by Role.Admits, which also folds owner into admin.

A machine never administers an org through this predicate: a client_credentials app is issued for a purpose, not handed an org's self-service surface.

func (*Claims) PlatformSudo added in v1.10.15

func (c *Claims) PlatformSudo() bool

PlatformSudo reports platform authority: a HUMAN whose HOME org is the reserved admin org. It is the only cross-tenant scope — the one predicate every subsystem asks, so platform authority cannot mean two things in two places.

The narrowing to a human is load-bearing in both directions. A machine token for admin/<anything> holds NO authority (IAM resolves platform sudo from a live user record in the admin org, never from a subject that merely names one), and a confidential client holds none either — its whole authority is its capability allowlist (entity.go). Without that, any admin-org client_credentials identity — the KMS sync app, say — could name a victim org and the edge would write it, handing every backend that trusts that header a cross-tenant read.

The org is compared VERBATIM, like every other org comparison here: folding case or space would make an org someone can self-serve ("Admin", "admin ") the reserved one, which is the whole escalation in a single strings call.

func (*Claims) UserID added in v1.10.15

func (c *Claims) UserID() string

UserID resolves the subject's stable identifier: `sub`, then the username, then the display name. IAM may leave `sub` empty on some token shapes.

func (*Claims) Username added in v1.10.15

func (c *Claims) Username() string

Username is the IAM username — the half a wallet address is built from. It never falls back to the display name, because addressing a wallet by a display name names a wallet nothing can fund.

type Entity added in v1.10.15

type Entity struct {
	Kind  string
	Owner string
	Name  string
}

Entity addresses one row in IAM's identity registry: its Kind (the plural entity noun — users, certs, applications, organizations, projects) and the (Owner, Name) pair identifying it.

type Env added in v1.10.15

type Env func(name string) string

Env resolves a capability allowlist by variable name. os.Getenv satisfies it in a server; a map literal satisfies it in a test.

The allowlist is per-environment DATA, so the decision takes the lookup as an INPUT and never reads the process environment itself — that is what keeps this leaf free of config. A nil Env denies every capability.

type Grant added in v1.10.15

type Grant struct {
	Subject string
	Scope   Path
	Role    Role

	// Expiry is when the grant stops authorizing. The zero time means it does not
	// expire; an expiry that has passed authorizes nothing.
	Expiry time.Time
}

Grant is one access fact: Subject holds Role everywhere at or below Scope, until Expiry.

A delegation to an agent, and a credential that expires, are this same value — a narrower Scope, and a set Expiry. Neither is a separate mechanism.

type Keys added in v1.10.15

type Keys func(kid string) []crypto.PublicKey

Keys resolves the public keys a token's `kid` may be verified against.

Key material is an INPUT. Fetching a JWKS is I/O and must not live in a stateless leaf, or "stateless" quietly acquires a network dependency on the one path every request takes: the edge owns the fetch and the cache, and hands the result here. A nil Keys, or one that resolves nothing, verifies nothing.

type Membership added in v1.10.15

type Membership struct {
	Org  string `json:"org"`
	Role Role   `json:"role,omitempty"`
}

Membership is one org membership: the org, and the coarse role held in it. The JSON shape is the fixed claim contract — a token IAM mints and a consumer reads round-trips byte for byte.

type Path added in v1.10.15

type Path []string

Path is a location: the segments of org/workspace/project and anything below it. Segments are compared VERBATIM — an org is a tenancy key and must be injective, so folding case or whitespace here would fold two tenants into one.

func ParsePath added in v1.10.15

func ParsePath(s string) (Path, error)

ParsePath reads a location from its printed form, "acme/prod/web".

It is total: a path with no segments, an empty segment, or a segment carrying a rune that would make the identifier non-injective is not a location, and is refused rather than cleaned — cleaning IS the fold that merges two tenants.

func (Path) Covers added in v1.10.15

func (p Path) Covers(q Path) bool

Covers reports whether p is an ancestor of, or equal to, q — the prefix test the whole grant model rests on.

The test is SEGMENT-WISE, not textual: acme/prod does NOT cover acme/production. A string-prefix test would, and that is a cross-tenant read.

An EMPTY path covers nothing. A zero-value scope must never be the grant that authorizes everything: a missing or malformed scope has to deny, or the failure mode of this package is a silent allow.

func (Path) String added in v1.10.15

func (p Path) String() string

String prints the path in the form ParsePath reads.

type Role

type Role string

Role is the authority a grant carries. The vocabulary is IAM's coarse membership role and is exactly three values (internal/schema/membership.go): owner, admin, member. No fourth role is defined here, because nothing in the estate assigns one.

const (
	Member Role = "member"
	Admin  Role = "admin"
	Owner  Role = "owner"
)

func (Role) Admits added in v1.10.15

func (r Role) Admits(v Verb) bool

Admits reports whether r admits v.

owner and admin are FOLDED: IAM assigns `owner` to whoever creates an org, so a table matching only `admin` refuses every self-serve founder their own org's admin surface. The fold is stated once, here.

A member reads. Write authority inside a narrower location is expressed by a narrower grant — an invited collaborator holds admin AT acme/prod/web — not by a new role, which is why three roles are enough for the invite-only case.

The role is folded for case and surrounding space because it is a closed vocabulary IAM controls, unlike an org, which is a tenant-chosen identifier and is therefore compared verbatim. An unknown role admits nothing.

type Verb added in v1.10.15

type Verb string

Verb is what a subject wants to do at a path. Two verbs are what the estate distinguishes: IAM authorizes reads apart from writes (a GET or HEAD addresses its target in the query string; everything else carries a body), and cloud gates writes on org-admin authority while admitting members to read.

const (
	Read  Verb = "read"
	Write Verb = "write"
)

func VerbOf added in v1.10.15

func VerbOf(method string) Verb

VerbOf maps an HTTP method onto the verb it performs. It is the ONE mapping, so a caller can never disagree with IAM about whether a method writes.

Directories

Path Synopsis
Package edge is the identity boundary: it strips what a client claimed, verifies the credential, and writes the headers everything behind it reads.
Package edge is the identity boundary: it strips what a client claimed, verifies the credential, and writes the headers everything behind it reads.
Package serve is the network surface over the decision, and nothing else.
Package serve is the network surface over the decision, and nothing else.

Jump to

Keyboard shortcuts

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