182 lines
6.2 KiB
Python
182 lines
6.2 KiB
Python
"""Trusted TaskPlane scope model and SQL authorization helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Mapping
|
|
|
|
from .errors import ForbiddenError, ValidationError
|
|
|
|
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
|
PRIVILEGED_CAPABILITIES = frozenset(
|
|
{"*", "admin", "system.admin", "task.admin", "task.read.all"}
|
|
)
|
|
|
|
|
|
def _value(value: Any, field: str) -> Any:
|
|
if hasattr(value, "task_plan"):
|
|
value = value.task_plan
|
|
if isinstance(value, Mapping):
|
|
return value.get(field)
|
|
return getattr(value, field, None)
|
|
|
|
|
|
def _text(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
result = str(value).strip()
|
|
return result or None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TaskScope:
|
|
scope_type: str
|
|
tenant_id: str | None = None
|
|
user_id: str | None = None
|
|
project_id: str | None = None
|
|
conversation_id: str | None = None
|
|
run_id: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TaskActorContext:
|
|
identity: str
|
|
scope_type: str = "legacy"
|
|
tenant_id: str | None = None
|
|
user_id: str | None = None
|
|
project_id: str | None = None
|
|
conversation_id: str | None = None
|
|
run_id: str | None = None
|
|
capabilities: frozenset[str] = frozenset()
|
|
|
|
|
|
def normalize_task_scope(value: Any) -> TaskScope:
|
|
fields = {field: _text(_value(value, field)) for field in SCOPE_FIELDS}
|
|
declared = _text(_value(value, "scope_type"))
|
|
if declared is None:
|
|
declared = "tenant" if fields["tenant_id"] else "legacy"
|
|
if declared == "tenant":
|
|
if fields["tenant_id"] is None:
|
|
raise ValidationError(
|
|
"tenant scope requires tenant_id", code="TASK_SCOPE_TENANT_ID_REQUIRED"
|
|
)
|
|
elif declared == "legacy":
|
|
if any(fields.values()):
|
|
raise ValidationError(
|
|
"legacy scope cannot contain scoped ownership",
|
|
code="TASK_SCOPE_LEGACY_WITH_SCOPED_FIELDS",
|
|
)
|
|
elif declared == "system":
|
|
if any(fields[field] for field in ("tenant_id", "user_id", "project_id", "conversation_id")):
|
|
raise ValidationError(
|
|
"system scope cannot contain tenant ownership",
|
|
code="TASK_SCOPE_SYSTEM_WITH_TENANT_FIELDS",
|
|
)
|
|
else:
|
|
raise ValidationError("invalid task scope type", code="TASK_SCOPE_TYPE_INVALID")
|
|
return TaskScope(declared, **fields)
|
|
|
|
|
|
def actor_is_privileged(actor: TaskActorContext) -> bool:
|
|
return bool(actor.capabilities & PRIVILEGED_CAPABILITIES)
|
|
|
|
|
|
def actor_context(
|
|
identity: str,
|
|
configured: TaskActorContext | Mapping[str, Any] | None,
|
|
capabilities: set[str] | frozenset[str],
|
|
) -> TaskActorContext:
|
|
if isinstance(configured, TaskActorContext):
|
|
context = configured
|
|
else:
|
|
data = dict(configured or {})
|
|
data.pop("identity", None)
|
|
data.pop("capabilities", None)
|
|
context = TaskActorContext(identity=str(identity), **data)
|
|
scope = normalize_task_scope(context)
|
|
return replace(
|
|
context,
|
|
identity=str(identity),
|
|
scope_type=scope.scope_type,
|
|
tenant_id=scope.tenant_id,
|
|
user_id=scope.user_id,
|
|
project_id=scope.project_id,
|
|
conversation_id=scope.conversation_id,
|
|
run_id=scope.run_id,
|
|
capabilities=frozenset(str(item) for item in capabilities),
|
|
)
|
|
|
|
|
|
def bind_task_scope(plan: Any, actor: TaskActorContext, *, public_create: bool) -> Any:
|
|
requested = normalize_task_scope(plan)
|
|
if actor_is_privileged(actor):
|
|
trusted = normalize_task_scope(actor)
|
|
if requested.scope_type == "legacy" and trusted.scope_type != "legacy":
|
|
plan.scope_type = trusted.scope_type
|
|
for field in SCOPE_FIELDS:
|
|
setattr(plan, field, getattr(actor, field))
|
|
return plan
|
|
trusted = normalize_task_scope(actor)
|
|
if public_create and trusted.scope_type == "legacy":
|
|
raise ForbiddenError(
|
|
"authenticated identity has no tenant scope",
|
|
code="TASK_ACTOR_SCOPE_UNAVAILABLE",
|
|
)
|
|
if trusted.scope_type != "tenant":
|
|
if requested.scope_type == "legacy":
|
|
plan.scope_type = trusted.scope_type
|
|
for field in SCOPE_FIELDS:
|
|
setattr(plan, field, getattr(actor, field))
|
|
return plan
|
|
if requested.scope_type != trusted.scope_type:
|
|
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
|
return plan
|
|
if requested.scope_type not in {"legacy", "tenant"}:
|
|
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
|
updates: dict[str, Any] = {"scope_type": "tenant"}
|
|
for field in SCOPE_FIELDS:
|
|
declared = getattr(requested, field)
|
|
authoritative = getattr(actor, field)
|
|
if declared is not None and declared != authoritative:
|
|
raise ForbiddenError(
|
|
"task scope mismatch",
|
|
code="TASK_SCOPE_MISMATCH_DENY",
|
|
details={"field": field},
|
|
)
|
|
updates[field] = authoritative
|
|
for field, item in updates.items():
|
|
setattr(plan, field, item)
|
|
return plan
|
|
|
|
|
|
def actor_scope_sql(actor: TaskActorContext | None) -> tuple[str, tuple[Any, ...]]:
|
|
if actor is None or actor_is_privileged(actor):
|
|
return "", ()
|
|
scope = normalize_task_scope(actor)
|
|
if scope.scope_type == "legacy":
|
|
return "scope_type = 'legacy'", ()
|
|
if scope.scope_type == "system":
|
|
return "scope_type = 'system'", ()
|
|
clauses = ["scope_type = 'tenant'", "tenant_id = ?"]
|
|
params: list[Any] = [scope.tenant_id]
|
|
for field in ("user_id", "project_id", "conversation_id", "run_id"):
|
|
value = getattr(scope, field)
|
|
if value is None:
|
|
clauses.append(f"{field} IS NULL")
|
|
else:
|
|
clauses.append(f"({field} IS NULL OR {field} = ?)")
|
|
params.append(value)
|
|
return " AND ".join(clauses), tuple(params)
|
|
|
|
|
|
def task_scope_values(value: Any) -> tuple[str | None, ...]:
|
|
scope = normalize_task_scope(value)
|
|
return (
|
|
scope.scope_type,
|
|
scope.tenant_id,
|
|
scope.user_id,
|
|
scope.project_id,
|
|
scope.conversation_id,
|
|
scope.run_id,
|
|
)
|