Source code for loom.core.sql.roles

"""Identity-bound role resolution of the SQL pillar.

The effective roles of a query are derived from the VERIFIED identity the
authentication middleware published, never from the request body.  The body may
only narrow the resulting set.  Resolution is fail-closed: an anonymous caller
or one holding no allowlisted role is denied, and ``default_role`` is never a
fallback once a connection binds roles to identity.

This module knows nothing about tokens or claims: whichever mechanism
authenticated the caller, it sees the same
:class:`~loom.core.identity.identity.Identity`.

Public API of the SQL pillar: any layer that executes caller-scoped queries
(the REST endpoint, the agent compiler's SQL capability at run time) resolves
the effective roles through this single function.
"""

from __future__ import annotations

import logging
from collections.abc import Sequence
from typing import NoReturn

from loom.core.identity import Identity
from loom.core.sql.abc import RoleNotAllowedError, RolesNotBoundError

_logger = logging.getLogger(__name__)


[docs] def resolve_query_roles( identity: Identity, *, connection: str, roles_bound: bool, allowed_roles: frozenset[str], requested_roles: Sequence[str] | None, ) -> tuple[str, ...]: """Resolve the roles one query may use. Args: identity: Verified caller published by the authentication middleware. connection: Name of the SQL connection being queried. roles_bound: Whether the configured authentication mechanism binds roles to the identity. ``False`` means the connection declares no binding and is single-role by config. allowed_roles: Connection allowlist, the ceiling of the intersection. requested_roles: Roles asked for in the body; they may only narrow. Returns: The effective roles for this single request. Raises: RolesNotBoundError: When no allowed role can be derived from the verified identity. RoleNotAllowedError: When the body asks for a role the identity does not hold. """ if not roles_bound: # No binding for this connection: its allowlist is empty — the binder # guard refuses to mount a non-empty one without a binding — so the # service rejects every caller-supplied role and applies default_role. return tuple(requested_roles or ()) authorized = _authorized_roles(identity, connection=connection, allowed_roles=allowed_roles) return _narrow(authorized, requested_roles, connection)
def _authorized_roles( identity: Identity, *, connection: str, allowed_roles: frozenset[str], ) -> tuple[str, ...]: """Intersect the roles the identity holds with the allowlist, fail-closed.""" if not identity.is_authenticated: _deny(connection, identity, "the request carries no verified identity") if not identity.roles: _deny(connection, identity, "the verified identity carries no role") authorized = tuple(role for role in identity.roles if role in allowed_roles) if not authorized: _deny(connection, identity, "no role held by the identity is allowlisted") return authorized def _narrow( authorized: tuple[str, ...], requested: Sequence[str] | None, connection: str, ) -> tuple[str, ...]: """Apply the body narrowing: a subset of *authorized*, never a widening.""" if not requested: return authorized for role in requested: if role not in authorized: raise RoleNotAllowedError(role, connection=connection) return tuple(dict.fromkeys(requested)) def _deny(connection: str, identity: Identity, reason: str) -> NoReturn: """Log the audit trail of a denial and refuse the request. The response message stays generic on purpose (no oracle about which part of the credentials failed); the precise reason is only recorded server-side. """ _logger.warning( "SQL role authorization denied: connection=%s subject=%s mechanism=%s reason=%s", connection, identity.subject, identity.mechanism, reason, ) raise RolesNotBoundError(connection)