adminauth

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package adminauth holds the admin principals and scoped API tokens that authenticate callers of the reference server's admin API.

Why

Phase 5 shipped the admin API behind one static bearer token, which was proportionate to the four routes it then had (decision record 0025). Phase 8 gives that API enrollment inventory, command enqueue, push certificate upload, and enrollment export, at which point a single credential both erases fleets and exfiltrates FileVault escrow. This package is the least-privilege answer: named principals, Cedar policies over the per-route actions the server declares, and tokens that are checksummed, stored only as a digest, and revocable without a restart.

It has one deliberate exception, and it is worth stating plainly because it suspends everything above. An empty principal store authenticates nobody, and the route that creates the first principal is itself authorized, so there has to be a way in. That way is the reference server's static MDM_ADMIN_TOKEN, which authenticates as root and bypasses policy, and which keeps working beside a configured store rather than being superseded by it. It has no expiry and cannot be revoked without restarting the process, so while it is set none of this package's guarantees hold for whoever holds it. A deployment sets it to create real principals and then unsets it; requests that used it are audited under the actor "break-glass" precisely so that using it afterwards is something an operator can alert on. The bootstrap and removal sequence is in docs/operations/deployment.md.

It is deliberately not an identity system. There are no users, sessions, passwords, or federation here, and there is no policy language. Those are product concerns, and the library refuses them the same way record 0011 refused Vault and KMS clients and record 0027 refused SAML: the reference server ships this implementation, and an integrator who needs mTLS, OIDC, or a policy engine supplies their own authorizer instead. Every reference MDM server surveyed for record 0034 has one shared secret with no principal, no scope, and no revocation; the two that model authorization properly, Fleet and Zentral, are whole products rather than libraries.

References

  • Decision record: docs/research/decisions/0034-admin-api-and-authorization.md
  • Decision record: docs/research/decisions/0035-mdmctl-structure-and-credentials.md
  • Plan of record: docs/research/implementation_plan.md (phase 8)
  • Threat model: docs/security/threat-model.md (admin API, repudiation)
  • E2E scenarios: docs/testing/e2e-scenarios.md (E2E-024)
  • Apple documents nothing about administering an MDM server; the device-facing protocol is elsewhere. The prior art is catalogued in docs/research/reference_projects.md and read in record 0034.
  • RFC 6750: bearer token usage, including the WWW-Authenticate challenge
  • RFC 9110 section 11: the 401 and 403 distinction the API relies on

Index

Constants

View Source
const (
	// EntityPrincipal is an admin caller, `MDM::Principal::"<name>"`.
	EntityPrincipal types.EntityType = "MDM::Principal"
	// EntityRole is a named group a principal belongs to,
	// `MDM::Role::"<name>"`, expressed as a Cedar entity parent so a policy
	// can say `principal in MDM::Role::"reader"`.
	EntityRole types.EntityType = "MDM::Role"
	// EntityAction is an operation a route declares, `MDM::Action::"<id>"`.
	EntityAction types.EntityType = "MDM::Action"
	// EntitySystem is the resource for routes that act on the deployment
	// rather than on one object: `MDM::System::"any"`.
	EntitySystem types.EntityType = "MDM::System"
	// EntityEnrollment is one enrollment as a resource.
	EntityEnrollment types.EntityType = "MDM::Enrollment"
	// EntityDeclaration is one declaration as a resource.
	EntityDeclaration types.EntityType = "MDM::Declaration"
	// EntityDEPAccount is one device enrollment service account as a resource.
	EntityDEPAccount types.EntityType = "MDM::DEPAccount"
)

Cedar entity types. Every admin authorization decision is a Cedar request over these: a principal (which may be a member of roles), an action, and a resource. Namespacing them under MDM keeps action ids unique, which Cedar requires globally.

View Source
const (
	// Prefix precedes every admin token.
	Prefix = "mdmt_"

	// TokenLen is the total length of a well-formed token.
	TokenLen = len(Prefix) + bodyLen + checksumLen
)

Token format. A token is a type prefix, a random body, and a checksum:

mdmt_<30 base62 characters><6 base62 checksum characters>

The prefix makes the credential recognisable to a secret scanner, and the checksum lets the server reject a mistyped or truncated value before it touches the database. The body carries 30*log2(62) is about 178 bits of entropy, so the stored digest needs no salt or key derivation: there is no dictionary to attack. Zentral's ztlu_/ztls_ tokens are the model here (record 0034); Fleet, by contrast, stores its bearer tokens in plaintext.

View Source
const DefaultPageSize = 100

DefaultPageSize applies when Page.Limit is not positive.

Variables

View Source
var (
	// ErrNotFound is an unknown principal or policy, or a token matching none.
	ErrNotFound = errors.New("adminauth: not found")
	// ErrConflict is a name that already exists.
	ErrConflict = errors.New("adminauth: conflict")
	// ErrInvalid is a malformed name, token, or policy.
	ErrInvalid = errors.New("adminauth: invalid")
	// ErrRevoked is a principal whose token has been revoked.
	ErrRevoked = errors.New("adminauth: token revoked")
	// ErrExpired is a principal whose token has passed its expiry.
	ErrExpired = errors.New("adminauth: token expired")
	// ErrDenied is an authorization decision of deny.
	ErrDenied = errors.New("adminauth: denied")
	// ErrLastRoot guards the last root principal against deletion, demotion,
	// or revocation, so an operator cannot lock themselves out of policy
	// administration. step-ca protects its last super admin the same way.
	ErrLastRoot = errors.New("adminauth: last root principal")
	// ErrEscalation is an attempt to issue a credential for a principal whose
	// authority the caller does not already hold.
	ErrEscalation = errors.New("adminauth: cannot issue credentials for a more privileged principal")
	// ErrUnknownAction is a policy naming an action no route serves. Cedar
	// parses such a policy happily and it then silently never grants, so the
	// check is ours to make.
	ErrUnknownAction = errors.New("adminauth: unknown action")
)

Errors this package and its stores return.

View Source
var Root = Principal{Name: "root", Root: true}

Root is the implicit actor for bootstrap, before any principal exists.

View Source
var SystemResource = types.NewEntityUID(EntitySystem, "any")

SystemResource is the resource for deployment-wide routes.

Functions

func ActionUID

func ActionUID(id string) types.EntityUID

ActionUID is the Cedar entity for an action id.

func Digest

func Digest(t Token) string

Digest is the SHA-256 hex digest a store persists in place of the token. The token is high-entropy random, so a plain hash is the right primitive; a password KDF would only add cost.

func ParseRoles

func ParseRoles(s string) ([]string, error)

ParseRoles validates a comma-separated role list, sorted and deduplicated.

func PrincipalUID

func PrincipalUID(name string) types.EntityUID

PrincipalUID is the Cedar entity for a principal name.

func Redact

func Redact(t Token) string

Redact renders a token for a log line or an error: the prefix, the first four body characters, and an ellipsis. Never log a whole token.

func RoleUID

func RoleUID(name string) types.EntityUID

RoleUID is the Cedar entity for a role name.

func Valid

func Valid(t Token) bool

Valid reports whether t is well formed: the right prefix, the right length, only alphabet characters, and a checksum that matches the body. It says nothing about whether the token was ever issued.

Checking this before a store lookup means a malformed value never becomes a database round trip, which is the point of carrying a checksum at all.

func ValidName

func ValidName(name string) bool

ValidName reports whether a principal or role name is usable: 1 to 64 characters of letters, digits, hyphen, underscore, or dot. Names appear in policies and audit lines, so they stay boring on purpose, and the character set keeps them safe to render inside a Cedar entity literal.

func Validate

func Validate(reg *Registry, doc Policy) error

Validate parses one document and checks its action ids without adding it to a set, for the write path.

Types

type Action

type Action struct {
	// ID is the Cedar action id, `MDM::Action::"<ID>"`.
	ID string
	// Help is operator-facing prose naming the consequence, shown by
	// `mdmctl policy actions`. Zentral writes the same for its secret-reveal
	// actions, and it is the difference between an operator granting an
	// action knowingly and granting it by name.
	Help string
	// Resource is the entity type this action acts on, EntitySystem when the
	// action is deployment-wide.
	Resource types.EntityType
}

Action is an operation an admin route declares. The set is closed and owned by the route table, which is what lets a policy naming an action nobody serves be refused when it is written rather than silently never granting.

type Decision

type Decision struct {
	Allowed bool
	// Policy is the document and statement that decided, empty when nothing
	// matched and the default deny applied.
	Policy string
	// Errors are per-policy evaluation errors. A policy that errors neither
	// permits nor forbids, so these are surfaced rather than swallowed.
	Errors []string
}

Decision is the outcome of an authorization check, carrying the policy that decided so an audit line can name it.

type Manager

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

Manager authenticates admin callers, answers authorization decisions from the stored Cedar policies, and administers principals and policies.

Two things sit outside Cedar on purpose. Policy administration is gated by Principal.Root, because a policy that can edit policies can grant itself anything, so it cannot be the thing that bounds itself. And the last root principal cannot be removed, demoted, or revoked, so an operator cannot lock themselves out. Credential administration is an ordinary action a policy may grant, bounded by Covers.

func New

func New(store Store, reg *Registry, opts ...Option) (*Manager, error)

New returns a Manager over store, serving the actions in reg.

func (*Manager) Authenticate

func (m *Manager) Authenticate(ctx context.Context, t Token) (Principal, error)

Authenticate resolves a plaintext token to its principal.

A malformed token is rejected on its checksum before any query runs, so a scanner spraying the endpoint costs no database round trips. The principal carries TokenID, so an audit line names the credential that acted without naming the secret.

func (*Manager) Authorize

func (m *Manager) Authorize(ctx context.Context, p Principal, action string, resource types.EntityUID, reqCtx map[string]types.Value) (Decision, error)

Authorize answers one request against the current policies, recompiling them when the store's policy version has moved.

The default is deny: an unknown action, an empty policy set, and a policy that errors all produce a denial rather than an allow.

func (*Manager) CreatePrincipal

func (m *Manager) CreatePrincipal(ctx context.Context, actor Principal, p Principal, expires time.Time) (Principal, Token, error)

CreatePrincipal adds a principal and mints its first token.

actor must already hold every role it grants, so a credential can never mint one more privileged than itself. Whether the caller may administer principals at all is the ActionManagePrincipals decision, made by the caller before it gets here.

func (*Manager) DeletePolicy

func (m *Manager) DeletePolicy(ctx context.Context, actor Principal, name string) error

DeletePolicy removes a policy document.

func (*Manager) DeletePrincipal

func (m *Manager) DeletePrincipal(ctx context.Context, actor Principal, name string) error

DeletePrincipal removes a principal.

func (*Manager) GetPolicy

func (m *Manager) GetPolicy(ctx context.Context, actor Principal, name string) (Policy, error)

GetPolicy returns one policy document.

func (*Manager) Policies

func (m *Manager) Policies(ctx context.Context, actor Principal) ([]Policy, error)

Policies returns every policy document, ordered by name.

func (*Manager) Principal

func (m *Manager) Principal(ctx context.Context, name string) (Principal, error)

Principal returns one principal.

func (*Manager) Principals

func (m *Manager) Principals(ctx context.Context, p Page) (Result[Principal], error)

Principals pages principals by name.

func (*Manager) PutPolicy

func (m *Manager) PutPolicy(ctx context.Context, actor Principal, doc Policy) (Policy, error)

PutPolicy validates and stores a policy document. Validation is at write time on purpose: a policy naming an action nobody serves parses cleanly and then silently never grants, so refusing it here is the difference between an error an operator sees and a rule that quietly does nothing.

func (*Manager) Registry

func (m *Manager) Registry() *Registry

Registry returns the action registry this manager serves.

func (*Manager) Revoke

func (m *Manager) Revoke(ctx context.Context, actor Principal, name string) error

Revoke clears a principal's token, leaving the principal in place so its name still resolves in audit history.

func (*Manager) Rotate

func (m *Manager) Rotate(ctx context.Context, actor Principal, name string, expires time.Time) (Principal, Token, error)

Rotate mints a replacement token, invalidating the previous one at once.

func (*Manager) UpdatePrincipal

func (m *Manager) UpdatePrincipal(ctx context.Context, actor Principal, name string, roles []string, root bool) (Principal, error)

UpdatePrincipal replaces a principal's roles and root flag.

type Option

type Option func(*Manager)

Option configures a Manager.

func WithClock

func WithClock(c clock.Clock) Option

WithClock injects a clock, so token expiry is deterministic in tests.

type Page

type Page struct {
	Cursor string
	Limit  int
}

Page requests one page of principals or policies.

type Policy

type Policy struct {
	// Name identifies the document for editing and for audit lines.
	Name string
	// Source is the Cedar text, stored and served exactly as written so an
	// operator sees back what they wrote.
	Source string
	// Description is operator prose, never interpreted.
	Description string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

Policy is a stored Cedar policy document. One record may hold several statements; the whole document is parsed and validated as a unit.

type PolicySet

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

PolicySet is a compiled, immutable set of policies ready to answer decisions. Compile once per policy version and share it across requests.

func Compile

func Compile(reg *Registry, version int64, docs []Policy) (*PolicySet, error)

Compile parses and validates policy documents into a decision-ready set.

Validation is two steps. Cedar's parser rejects malformed syntax, which is the stable half. It does not reject a policy naming an action that does not exist -- such a policy compiles and then silently never grants, which is exactly the failure Zentral's schema validation exists to prevent -- so every action id referenced is additionally checked against the registry. The result is a typo refused at write time without depending on cedar-go's experimental schema package.

func (*PolicySet) Authorize

func (p *PolicySet) Authorize(principal Principal, action string, resource types.EntityUID, ctx map[string]types.Value) Decision

Authorize evaluates one request. The default is deny: with no policy set, no matching policy, or an unknown action, the answer is no.

func (*PolicySet) Version

func (p *PolicySet) Version() int64

Version is the store version this set was compiled from.

type Principal

type Principal struct {
	Name  string
	Roles []string
	// Root may administer principals and policies. It confers no other
	// authority: a root principal still needs a policy to enqueue a command.
	Root      bool
	CreatedAt time.Time
	UpdatedAt time.Time
	// TokenID names the current credential in audit lines, empty once
	// revoked. It is a fragment of the token, never enough to use.
	TokenID string
	// TokenAt is when the current token was minted (zero when revoked).
	TokenAt time.Time
	// ExpiresAt is when the current token stops being accepted; zero never
	// expires. Fleet makes every API-only token non-expiring with no way to
	// say otherwise, which is the failure this field exists to avoid.
	ExpiresAt time.Time
}

Principal is an admin caller.

Authority comes from policies that name the principal or one of its roles, evaluated by Cedar. The one exception is Root, which is deliberately outside the policy system: a principal that may edit policies can grant itself anything, so that capability cannot itself be policy-granted. Zentral draws the same line, excluding policy mutation from the permissions it bounds.

func (Principal) Active

func (p Principal) Active(now time.Time) error

Active reports whether p has a credential that is neither revoked nor expired at now.

func (Principal) Covers

func (p Principal) Covers(other Principal) bool

Covers reports whether p may issue a credential for other: it must hold every role other does, and be root if other is. This is the subset test that stops a principal issuing a credential more privileged than its own, ported from Zentral's can_issue_credentials_for.

A root principal covers everything, as Zentral's superuser does. Requiring root to hold every role it grants would make the first grant of a new role impossible, since a role exists only by being named on a principal or in a policy.

func (Principal) Entity

func (p Principal) Entity() types.Entity

Entity renders the principal as a Cedar entity whose parents are its roles, so `principal in MDM::Role::"reader"` resolves.

func (Principal) UID

func (p Principal) UID() types.EntityUID

UID is the principal's Cedar entity.

type Registry

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

Registry is the set of actions the server serves. It is built once from the route table and then read-only, so there is no global mutable state and no init-order dependence.

func NewRegistry

func NewRegistry(actions ...Action) (*Registry, error)

NewRegistry returns a registry over actions, refusing duplicates and malformed ids.

func (*Registry) Actions

func (r *Registry) Actions() []Action

Actions returns every action, sorted by id.

func (*Registry) IDs

func (r *Registry) IDs() []string

IDs returns every action id, sorted.

func (*Registry) Lookup

func (r *Registry) Lookup(id string) (Action, bool)

Lookup returns the action with id.

type Result

type Result[T any] struct {
	Items      []T
	NextCursor string
}

Result is one page with the cursor for the next ("" at the end).

type Store

type Store interface {
	// CreatePrincipal adds a principal with its first token digest. An
	// existing name is ErrConflict.
	CreatePrincipal(ctx context.Context, p Principal, digest string, now time.Time) (Principal, error)
	// Principal returns one principal by name.
	Principal(ctx context.Context, name string) (Principal, error)
	// PrincipalByDigest returns the principal whose current token digest
	// matches. This is the authentication path and must be one indexed lookup.
	PrincipalByDigest(ctx context.Context, digest string) (Principal, error)
	// Principals pages principals by name.
	Principals(ctx context.Context, p Page) (Result[Principal], error)
	// UpdatePrincipal replaces the roles and root flag of a principal.
	UpdatePrincipal(ctx context.Context, name string, roles []string, root bool, now time.Time) (Principal, error)
	// SetToken replaces the current token digest, invalidating the previous
	// one immediately.
	SetToken(ctx context.Context, name, digest, tokenID string, expires, now time.Time) (Principal, error)
	// RevokeToken clears the current token, leaving the principal in place.
	RevokeToken(ctx context.Context, name string, now time.Time) error
	// DeletePrincipal removes a principal entirely.
	DeletePrincipal(ctx context.Context, name string) error
	// CountRoot returns how many principals carry Root, for the anti-lockout
	// invariant.
	CountRoot(ctx context.Context) (int, error)

	// PutPolicy stores or replaces a policy document.
	PutPolicy(ctx context.Context, p Policy, now time.Time) (Policy, error)
	// GetPolicy returns one policy document by name.
	GetPolicy(ctx context.Context, name string) (Policy, error)
	// Policies returns every stored policy, ordered by name. The whole set is
	// compiled together, so this is not paged.
	Policies(ctx context.Context) ([]Policy, error)
	// DeletePolicy removes a policy document.
	DeletePolicy(ctx context.Context, name string) error
	// PolicyVersion changes whenever any policy changes, so a cached
	// compilation can tell it is stale without recompiling.
	PolicyVersion(ctx context.Context) (int64, error)
}

Store persists principals, the digests of their tokens, and the policy documents that grant them authority. Implementations are contract-tested by adminauth/adminauthtest, so the in-memory and the three SQL backends behave identically.

A store never sees a plaintext token: the caller mints one, hands the store its digest, and shows the value to the operator once.

type Token

type Token string

Token is a plaintext admin API token. It exists only between minting and handing it to the operator: the store keeps Digest(t) and never the value.

func Mint

func Mint() (Token, error)

Mint returns a new token. The caller stores Digest(token) and shows the token to the operator once.

Directories

Path Synopsis
Package adminauthtest is the contract suite every adminauth.Store must pass, plus a failing store for error-path tests.
Package adminauthtest is the contract suite every adminauth.Store must pass, plus a failing store for error-path tests.
Package inmem is an in-memory adminauth.Store for tests and for the reference server's development mode.
Package inmem is an in-memory adminauth.Store for tests and for the reference server's development mode.
Package sqlstore is the SQL-backed adminauth.Store for SQLite, PostgreSQL, and MySQL.
Package sqlstore is the SQL-backed adminauth.Store for SQLite, PostgreSQL, and MySQL.

Jump to

Keyboard shortcuts

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