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
- Variables
- func ActionUID(id string) types.EntityUID
- func Digest(t Token) string
- func ParseRoles(s string) ([]string, error)
- func PrincipalUID(name string) types.EntityUID
- func Redact(t Token) string
- func RoleUID(name string) types.EntityUID
- func Valid(t Token) bool
- func ValidName(name string) bool
- func Validate(reg *Registry, doc Policy) error
- type Action
- type Decision
- type Manager
- func (m *Manager) Authenticate(ctx context.Context, t Token) (Principal, error)
- func (m *Manager) Authorize(ctx context.Context, p Principal, action string, resource types.EntityUID, ...) (Decision, error)
- func (m *Manager) CreatePrincipal(ctx context.Context, actor Principal, p Principal, expires time.Time) (Principal, Token, error)
- func (m *Manager) DeletePolicy(ctx context.Context, actor Principal, name string) error
- func (m *Manager) DeletePrincipal(ctx context.Context, actor Principal, name string) error
- func (m *Manager) GetPolicy(ctx context.Context, actor Principal, name string) (Policy, error)
- func (m *Manager) Policies(ctx context.Context, actor Principal) ([]Policy, error)
- func (m *Manager) Principal(ctx context.Context, name string) (Principal, error)
- func (m *Manager) Principals(ctx context.Context, p Page) (Result[Principal], error)
- func (m *Manager) PutPolicy(ctx context.Context, actor Principal, doc Policy) (Policy, error)
- func (m *Manager) Registry() *Registry
- func (m *Manager) Revoke(ctx context.Context, actor Principal, name string) error
- func (m *Manager) Rotate(ctx context.Context, actor Principal, name string, expires time.Time) (Principal, Token, error)
- func (m *Manager) UpdatePrincipal(ctx context.Context, actor Principal, name string, roles []string, root bool) (Principal, error)
- type Option
- type Page
- type Policy
- type PolicySet
- type Principal
- type Registry
- type Result
- type Store
- type Token
Constants ¶
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.
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.
const DefaultPageSize = 100
DefaultPageSize applies when Page.Limit is not positive.
Variables ¶
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.
var Root = Principal{Name: "root", Root: true}
Root is the implicit actor for bootstrap, before any principal exists.
var SystemResource = types.NewEntityUID(EntitySystem, "any")
SystemResource is the resource for deployment-wide routes.
Functions ¶
func Digest ¶
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 ¶
ParseRoles validates a comma-separated role list, sorted and deduplicated.
func PrincipalUID ¶
PrincipalUID is the Cedar entity for a principal name.
func Redact ¶
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 Valid ¶
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.
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 (*Manager) Authenticate ¶
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 ¶
DeletePolicy removes a policy document.
func (*Manager) DeletePrincipal ¶
DeletePrincipal removes a principal.
func (*Manager) Principals ¶
Principals pages principals by name.
func (*Manager) PutPolicy ¶
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) Revoke ¶
Revoke clears a principal's token, leaving the principal in place so its name still resolves in audit history.
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 ¶
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.
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 ¶
Active reports whether p has a credential that is neither revoked nor expired at now.
func (Principal) Covers ¶
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.
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 ¶
NewRegistry returns a registry over actions, refusing duplicates and malformed ids.
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.
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. |