Skip to content

ontolith.identity

ontolith.identity

Identity and authorization layer.

This module contains: - Principal model (humans, AI agents, services) - AuthProvider port (authentication abstraction) - Capability management - Delegation support

TokenAuthProvider/hash_token (identity/token_auth.py, the concrete AuthProvider implementation, ADR-0014 — the one every create_rest_app/ create_graphql_app/create_mcp_server docstring example actually uses) are exported below, but the import order matters: token_auth.py imports StorageBackend from store.base, and store.base imports Principal from this package — reaching back into ontolith.identity while it's still initializing. That reverse import only succeeds if Principal has already been bound in this module's namespace by the time it's reached, which requires the principal import below to run before the token_auth one — reproduced directly: swapping the two order raises ImportError: cannot import name 'Principal' from partially initialized module. This isn't a silent footgun: the required order (token_auth alphabetically last) is exactly what ruff's own isort rule (I001, already CI-blocking) enforces, so any reordering that broke this would also fail ruff check before merge. Round-1 review of this ADR-0019 Update found and corrected an earlier, inaccurate version of this docstring claiming the import was circular unconditionally — verified false; it depends on order, and the enforced order happens to be the working one.

AdminAction module-attribute

AdminAction = Literal[
    "create_principal", "apply_schema", "register_plugin"
]

The admin-capability-gated action kinds an AdminEvent records.

AdminEvent

Bases: BaseModel

A single admin-tier action, recorded for forensic traceability.

Deliberately does NOT cover token issuance/revocation — those are attributed directly on PrincipalCredential.issued_by/.revoked_by instead, since every credential row already has a natural home for its own attribution and doesn't need a separate event row to point back to.

target is free text, not a foreign key: unlike assertion_event/ proposal_event (each referencing exactly one row type), the three actions here target a principal id, a namespace:version schema descriptor, and a plugin name respectively — no single column type could reference all three, so this follows ProposalEvent.type's own precedent of trusting Pydantic's Literal at construction rather than a DB-level CHECK/FOREIGN KEY.

Attributes:

Name Type Description
id str

Unique event ID (ULID)

actor str

Principal ID who performed the action (admin capability required for apply_schema/register_plugin, checked by the caller before this event is ever constructed; create_principal itself is deliberately ungated per ADR-0022, so actor there is whatever the caller optionally supplied, unverified)

action AdminAction

Which admin action this event records

target str

Free-text identifier of what was acted on — a principal id, a namespace:vN schema descriptor, or a plugin name

at datetime

When the action occurred

detail str | None

Optional free-text detail

PrincipalCredential

Bases: BaseModel

A single issued API-key credential for a principal.

Attributes:

Name Type Description
id str

Unique identifier (ULID) — a stable handle for revocation, independent of the (never-stored) raw token

principal_id str

Principal this credential authenticates as

token_hash str

SHA-256 hex digest of the raw token

created_at datetime

When this credential was issued

revoked_at datetime | None

When this credential was revoked, if it has been

issued_by str | None

Principal ID of the admin who issued this credential (KI-060) — None only for a credential row persisted before this field existed; every Ontology.issue_token() call supplies it going forward, since issue_token already requires and validates author

revoked_by str | None

Principal ID of the admin who revoked this credential (KI-060), or None if not yet revoked (or revoked before this field existed)

AuthProvider

Bases: Protocol

Abstract port for resolving a caller's credential to a Principal.

AuthProviders resolve a bearer credential (e.g. an API-key token) to the principal it authenticates as — the principal is never taken as a caller-asserted ID. See ADR-0014 for the interim per-principal API-key model implemented by the concrete TokenAuthProvider; full OIDC/ workload-identity support remains future work.

resolve

resolve(token: str) -> Principal

Resolve a raw bearer token to its Principal.

Parameters:

Name Type Description Default
token str

Raw bearer token supplied by the caller

required

Returns:

Type Description
Principal

The Principal the token authenticates as

Raises:

Type Description
AuthError

token is invalid, unknown, or revoked

Source code in src/ontolith/identity/ports.py
def resolve(self, token: str) -> Principal:
    """Resolve a raw bearer token to its Principal.

    Args:
        token: Raw bearer token supplied by the caller

    Returns:
        The Principal the token authenticates as

    Raises:
        AuthError: token is invalid, unknown, or revoked
    """
    ...

Principal

Bases: BaseModel

Identified actor in the system.

Attributes:

Name Type Description
id str

Email (human) or slug (ai/service)

kind Literal['human', 'ai', 'service']

Type of principal

owner str | None

Required for AI principals - accountable human/team

auth_method Literal['oidc', 'workload', 'apikey']

How this principal authenticates

default_capability Literal['read', 'propose', 'write', 'review', 'admin']

Default permission level

trust_level int

Base trust score

created_at datetime

When this principal was created

metadata dict[str, Any]

Open JSON blob for future extension

ai_must_have_owner

ai_must_have_owner() -> Principal

AI principals must declare an accountable owner (SPEC §8.1, ADR-0003).

Source code in src/ontolith/identity/principal.py
@model_validator(mode="after")
def ai_must_have_owner(self) -> "Principal":
    """AI principals must declare an accountable owner (SPEC §8.1, ADR-0003)."""
    if self.kind == "ai" and self.owner is None:
        raise ValueError("AI principals must have an owner (SPEC §8.1)")
    return self

TokenAuthProvider

TokenAuthProvider(backend: StorageBackend)

AuthProvider that resolves API-key tokens via the abstract StorageBackend port.

Depends only on StorageBackend (the port), the same pattern Ontology itself uses (backend: StorageBackend) — keeps identity/ from importing a concrete adapter, satisfying the dependency rule.

Source code in src/ontolith/identity/token_auth.py
def __init__(self, backend: StorageBackend) -> None:
    self._backend = backend

resolve

resolve(token: str) -> Principal

Resolve a raw bearer token to its Principal.

Raises:

Type Description
AuthError

token is invalid, unknown, or revoked

Source code in src/ontolith/identity/token_auth.py
def resolve(self, token: str) -> Principal:
    """Resolve a raw bearer token to its Principal.

    Raises:
        AuthError: token is invalid, unknown, or revoked
    """
    principal = self._backend.get_principal_by_token_hash(hash_token(token))
    if principal is None:
        raise AuthError("Invalid or revoked token")
    return principal

min_capability

min_capability(a: str, b: str) -> str

Return the lower of two capability levels (SPEC §8.3 ordering).

Ordering: read < propose < write < review < admin.

Source code in src/ontolith/identity/principal.py
def min_capability(a: str, b: str) -> str:
    """Return the lower of two capability levels (SPEC §8.3 ordering).

    Ordering: read < propose < write < review < admin.
    """
    return a if _CAPABILITY_ORDER[a] <= _CAPABILITY_ORDER[b] else b

hash_token

hash_token(raw_token: str) -> str

Hash a raw bearer token for storage/lookup.

SHA-256, not a slow password hash (bcrypt/scrypt/argon2): the input is already a high-entropy random secret (secrets.token_urlsafe(32)), not a low-entropy human password, so brute-force resistance from a slow hash buys nothing here — it would only add latency to every authenticated call.

Source code in src/ontolith/identity/token_auth.py
def hash_token(raw_token: str) -> str:
    """Hash a raw bearer token for storage/lookup.

    SHA-256, not a slow password hash (bcrypt/scrypt/argon2): the input is
    already a high-entropy random secret (`secrets.token_urlsafe(32)`), not a
    low-entropy human password, so brute-force resistance from a slow hash
    buys nothing here — it would only add latency to every authenticated call.
    """
    return hashlib.sha256(raw_token.encode()).hexdigest()