The principal model
A Principal in axess is the answer to "who is making this request?"
The unusual choice, and the one this chapter explains, is that the
same type answers the question for human users and for service-to-service
workloads. A signed-in employee opening a page and a CI job calling
an API are both principals, with different variants but the same trait
surface, the same authorisation contract, and the same place in the
audit trail.
This chapter covers the type, where each variant comes from, how the unified shape lets a Cedar policy treat humans and workloads with one set of rules, and why the alternative (two parallel authentication stacks) was rejected.
The type
Principal lives in axess-identity:
pub enum Principal {
Human(HumanPrincipal),
Workload(WorkloadPrincipal),
}
pub struct HumanPrincipal {
pub user_id: UserId,
pub tenant_id: TenantId,
pub session_id: Option<SessionId>,
pub attributes: BTreeMap<String, serde_json::Value>,
}
pub struct WorkloadPrincipal {
pub workload_id: WorkloadId,
pub trust_domain: TrustDomain,
pub issuer: Issuer,
pub tenant_id: TenantId,
pub tenant_slug: String,
pub service_name: String,
pub attributes: BTreeMap<String, serde_json::Value>,
}
The two variants are intentionally not symmetric. They carry the data
each principal kind actually has. A human has a user_id and is
optionally inside a session (some flows act on behalf of a user without
a live HTTP session, which is why the field is Option). A workload
has a workload_id (a SPIFFE-format URI), a trust domain, and an
issuer that says how the principal was authenticated (which OIDC
provider, which JWKS, which SPIFFE control plane).
Both variants carry a tenant_id (because every request happens in
the context of a tenant, whether the caller is human or not) and an
open attributes map (because policies need to ask questions that the
fixed fields cannot answer). The attribute map is JSON-valued so that
custom attributes (a hardware-key serial, a CI build hash, a regulator
classification) can be carried without changing the type.
Where each variant comes from
The two variants are constructed by two different resolvers. The split is what keeps the human and workload sides from contaminating each other.
A HumanPrincipal is constructed by a SessionResolver from an
AuthSession. The resolver reads the session's AuthState, returns
None if the state is not Authenticated, and otherwise reads
user_id, tenant_id, and the session id off the variant. The
attributes map is populated from the resolved user's stored profile
data (which fields depend on the application's identity store).
Construction is synchronous and cheap because everything the resolver
needs is already on the session.
A WorkloadPrincipal is constructed by a PrincipalResolver from
an inbound credential (a bearer JWT, an mTLS client certificate, a
projected Kubernetes service-account token, a GitHub Actions OIDC
token). The resolver does the verification work (signature, audience,
expiry, sometimes a token-exchange against a control plane) and on
success returns a WorkloadPrincipal with the validated identity. The
work is async because verifying tokens typically involves a JWKS
fetch or an STS round-trip. The chapter Workload identity overview
covers the resolver landscape end-to-end.
The two resolvers are independent. An application that has no
workloads (a customer-facing SaaS, say) never wires a
PrincipalResolver and never sees a Workload variant. An
application that has only workloads (an internal data-pipeline API,
say) never wires a SessionResolver and never sees a Human variant.
An application that mixes both wires both resolvers and a small piece
of glue that decides which to consult given the incoming request shape.
Why one type
The natural alternative is two types and two stacks: a User for
humans, a Service for workloads, a different middleware for each,
a different authorisation contract for each, two parallel audit trails.
That shape is what most libraries ship, and it is what axess
deliberately rejects.
The argument for one type is straightforward when you start to write
the authorisation policy. A request to a billing endpoint might be
made by a finance staff member during office hours, or by a scheduled
job running the monthly invoicing batch. The policy that decides
whether the request is allowed is the same in both cases: this caller,
in this tenant, has the right to read this resource. With one
Principal type, the policy is one rule. With two types, the policy
either duplicates the rule (and the duplicates drift) or branches on
the caller kind (and the branches obscure the intent).
The same applies to the audit trail. A regulatory audit log that records "principal X performed action Y against resource Z at time T" works uniformly across human and workload callers when the principal type is unified. The downstream SIEM rules ("alert on any principal making more than N requests per minute to the high-sensitivity endpoint") fire on both human attacks and runaway workloads, without separate detection logic.
The unification has a cost. The Principal enum must accommodate
both variants, which makes its memory footprint larger than either
variant alone, and pattern-matching code has to handle both arms even
when the application only uses one. The cost is paid mostly in code
that loads the principal (one match per request), and not in policy
evaluation or audit emission (which see the trait surface). On
balance, the unification pays for itself by simplifying the policy
layer.
SPIFFE shape for workloads
The WorkloadPrincipal is shaped after SPIFFE because SPIFFE is the
right shape for workload identity even when the underlying credential
is not literally a SVID.
A SPIFFE identity is a URI of the form
spiffe://<trust_domain>/<path>. The trust domain is the federation's
namespace (prod.example.com, say), and the path identifies a
specific workload within that domain (/svc/billing/tenant-acme).
The combination uniquely names the workload, the trust domain
parameterises the verification (each domain has its own signing keys),
and the path is structured enough for policies to match on patterns
("any workload under /svc/billing/*") without inventing parallel
identity stacks.
Axess's workload identity layer uses this shape even when the inbound
credential is a Kubernetes service-account token (which is an OIDC
token, not a SVID) or a GitHub Actions OIDC token (which is also not
a SVID). The relevant resolver constructs a SPIFFE-format WorkloadId
from the inbound claims; downstream code sees a uniform identity.
Workload identity overview covers the construction rules for each
resolver.
The trust domain and issuer fields on WorkloadPrincipal are the part
that policies can use to discriminate between identity sources. A
policy that says "only workloads issued by our production control
plane may write to the production database" reads the issuer and
matches against a fixed list. A policy that says "any workload in the
finance trust domain may read the audit log" reads the trust domain.
The Cedar bridge
Cedar policies take principals as entities. Axess implements
ToCedarEntity for both HumanPrincipal and WorkloadPrincipal,
producing entities with the canonical shape Cedar expects.
A HumanPrincipal becomes a Cedar entity with UID
User::"<user_id>", attributes including tenant_id,
factors_completed, and authn_time, and parent entities for the
tenant and any groups the user belongs to (which the application
provides through AuthzEntityProvider, covered in Entity providers
and request context).
A WorkloadPrincipal becomes a Cedar entity with UID
Workload::"<spiffe-uri>", attributes including trust_domain,
issuer, and tenant_id, and parent entities for the trust domain
and the tenant. Policies that want to match all workloads in a trust
domain write principal in TrustDomain::"prod.example.com";
policies that want to match a specific workload pattern write
principal.workload_id like "spiffe://prod.example.com/svc/billing/*".
The bridge is what makes one type into one policy. A Cedar policy that says
permit (
principal,
action == Action::"read",
resource in TenantData::"acme"
) when {
principal.tenant_id == "acme"
};
allows both a human user in tenant acme and a workload bound to tenant acme. The principal type does not appear in the rule because it does not need to. If the policy later needs to discriminate (say, to require MFA for humans but not for workloads), the rule that expresses the discrimination is local and readable.
When the type is empty
Some flows operate without a principal: a health check, a metrics
endpoint, the login page itself. Axess models this by representing
the request as Option<Principal>. The resolver returns None, the
authorisation layer either short-circuits (for unauthenticated
endpoints) or evaluates against principal == Principal::None (for
endpoints that take a deny-by-default position toward unauthenticated
callers).
The pattern matters for one specific reason. A misconfigured resolver
that returns a stub principal for unauthenticated requests, instead
of None, silently widens the authorisation surface. The Cedar policy
evaluates against the stub and may allow actions that should require
authentication. Treating "no principal" as the absence of a value,
rather than as a kind of value, makes the policy author's life harder
in the short term and easier in the long term: a policy that does not
explicitly admit None denies it by default.
What this enables
The unified principal type is what makes the rest of the workload
identity story (Part VII) and the Cedar authorisation story (Part IV)
short. A handler reads Principal, the authorisation layer evaluates
policies against it, and the audit pipeline emits events keyed by
it. None of these layers need to know whether the caller is a human
or a workload, because the type carries both possibilities and the
policy author resolves the discrimination where it actually matters.
Further reading
Workload identity overview covers the resolvers that produce
WorkloadPrincipal values: SPIFFE JWT-SVID, SPIFFE mTLS, Kubernetes
ServiceAccount tokens, GitHub Actions OIDC, generic OAuth-RS, and
cloud STS exchange. Cedar policy fundamentals covers the
AuthzSession::require and AuthzSession::decide calls that take a
Principal and return an AuthzDecision. Audit events covers the
log emitted for each authentication and authorisation decision,
including the principal serialisation.