63 lines
2.2 KiB
Python

"""Principal builder — the single sanctioned way to construct a Principal.
Both Layer 1 (tool assembly) and Layer 2 (GuardrailAuthorizationAdapter) must
use this builder so identity semantics stay consistent. It is a pure function:
no global config reads, no caching, no input mutation.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from deerflow.authz.provider import Principal
def normalize_authz_attributes(raw: Any) -> dict[str, Any]:
"""Validate and copy ``authz_attributes`` into a fresh dict.
This is the single normalization point shared by the Principal builder and
all propagation sites (middleware, executor, task_tool). Keeping it in one
place ensures every in-process consumption boundary raises ``TypeError``
for non-Mapping values rather than silently coercing.
Raises:
TypeError: If *raw* is not ``None`` and not a ``Mapping``.
"""
if raw is None:
return {}
if isinstance(raw, Mapping):
return dict(raw)
raise TypeError(f"authz_attributes must be a Mapping, got {type(raw).__name__}")
def build_principal_from_context(
context: Mapping[str, Any],
*,
default_role: str,
) -> Principal:
"""Build a :class:`Principal` from a runtime context mapping.
Args:
context: The runtime context (``config["context"]`` or a dict assembled
from a :class:`~deerflow.guardrails.provider.GuardrailRequest`).
default_role: Role used when ``user_role`` is ``None`` or empty string.
Unknown but non-empty roles are **not** replaced — only missing ones.
Raises:
TypeError: If ``authz_attributes`` is present but not a ``Mapping``.
"""
resolved_role = context.get("user_role")
if resolved_role is None or resolved_role == "":
resolved_role = default_role
return Principal(
user_id=context.get("user_id"),
role=resolved_role,
oauth_provider=context.get("oauth_provider"),
oauth_id=context.get("oauth_id"),
channel_user_id=context.get("channel_user_id"),
is_internal=context.get("is_internal") is True,
attributes=normalize_authz_attributes(context.get("authz_attributes")),
)