Source code for loom.core.identity.identity
"""The authenticated caller of one execution, as a domain value object.
``Identity`` is deliberately transport-agnostic: it says *who* is calling and
*what they hold*, never *how* they proved it beyond a free-form ``mechanism``
label. A JWT authenticator, a mutual-TLS one, or a signed-header one all
produce the same shape, so business policies never learn what a token is.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from loom.core.errors import Forbidden, Unauthenticated
_EMPTY_ATTRIBUTES: Mapping[str, str] = MappingProxyType({})
_NO_SUBJECT_MESSAGE = "Authentication required: the request carries no verified identity."
[docs]
@dataclass(frozen=True, slots=True)
class Identity:
"""Verified caller of a single execution.
Instances are immutable and safe to share across an async context. Only
the transport layer builds them, from credentials it has already verified;
every other layer consumes them read-only.
Attributes:
subject: Stable identifier of the caller. Empty means anonymous.
roles: Roles the caller holds, in the order the mechanism reported
them and without duplicates.
attributes: Verified string-valued facts about the caller (e-mail,
tenant, department, ...). Copied on construction, so a later
mutation of the source mapping cannot rewrite the identity.
mechanism: Label of the mechanism that authenticated the caller (e.g.
``"jwt"``). Used for the audit trail, never for authorization.
Example::
identity = Identity(
subject="user-1",
roles=("role_viz_reader",),
attributes={"email": "ada@example.com"},
mechanism="jwt",
)
if identity.has_role("role_viz_reader"):
...
"""
subject: str
roles: tuple[str, ...] = ()
# default_factory, not default: on Python 3.11 dataclasses reject an
# unhashable default, and a mappingproxy is unhashable. The factory hands
# back the same frozen empty mapping, so nothing is allocated per instance.
attributes: Mapping[str, str] = field(default_factory=lambda: _EMPTY_ATTRIBUTES)
mechanism: str = ""
def __post_init__(self) -> None:
"""Freeze the attribute mapping so the identity cannot be rewritten.
The copy is the point: without it, whoever built the identity keeps a
live handle on the data an authorization decision is taken from.
"""
object.__setattr__(self, "attributes", MappingProxyType(dict(self.attributes)))
@property
def is_authenticated(self) -> bool:
"""Whether the caller was identified by an authentication mechanism.
Returns:
``True`` when a non-empty subject is present.
"""
return bool(self.subject)
[docs]
def has_role(self, role: str) -> bool:
"""Report whether the caller holds *role*.
Matching is exact: no case folding and no prefix matching, so a role
name can never be widened by accident.
Args:
role: Role name to look for.
Returns:
``True`` when the caller holds exactly that role.
"""
return role in self.roles
[docs]
def attribute(self, name: str) -> str | None:
"""Return the verified attribute *name*, or ``None`` when absent.
Args:
name: Attribute key as published by the authenticator.
Returns:
The attribute value, or ``None`` when the caller does not carry it.
"""
return self.attributes.get(name)
[docs]
def require_subject(self) -> str:
"""Return the subject, refusing anonymous callers.
Returns:
The caller subject.
Raises:
Unauthenticated: When the identity is anonymous.
"""
if not self.subject:
raise Unauthenticated(_NO_SUBJECT_MESSAGE)
return self.subject
[docs]
def require_attribute(self, name: str) -> str:
"""Return a mandatory verified attribute.
The two failure modes are distinct on purpose: an anonymous caller can
fix the request by authenticating (401), while an authenticated caller
missing the attribute cannot (403).
Args:
name: Attribute key the caller must carry.
Returns:
The attribute value.
Raises:
Unauthenticated: When the identity is anonymous.
Forbidden: When the authenticated caller does not carry *name*.
"""
self.require_subject()
value = self.attributes.get(name)
if value is None:
raise Forbidden(f"The caller identity carries no {name!r} attribute.")
return value
def __repr__(self) -> str:
"""Render the identity without ever echoing an attribute value.
Attribute *names* are needed to debug a policy reading the wrong key;
their values are personal data and must not reach a log through a
stray repr.
"""
names = ", ".join(sorted(self.attributes))
return (
f"Identity(subject={self.subject!r}, roles={self.roles!r}, "
f"mechanism={self.mechanism!r}, attributes=[{names}])"
)
ANONYMOUS = Identity(subject="")
"""The absence of an identity, as an explicit value instead of ``None``."""