agilentics / boiler
"""Who is asking, and what they are allowed to ask for.

`Principal` is the seam that multi-tenancy and authentication plug into. It is
resolved once by a façade (from a session cookie, an API token, an OIDC callback)
and threaded through the core, so there is one answer to "may this caller do
this" regardless of which door the request came through.

Nothing here is HTTP-flavoured: the core raises domain errors and each façade
maps them to whatever its transport calls a failure (see endpoints/crud.STATUS).
"""
from dataclasses import dataclass, field


class CoreError(Exception):
    """Base for anything the core refuses to do."""


class NotFound(CoreError):
    """The thing asked for does not exist, or is not visible to this principal.

    One error for both cases on purpose: telling an unauthorised caller that a
    resource exists but is not theirs leaks the roster of every other tenant.
    """


class Invalid(CoreError):
    """The request is malformed or violates a rule."""


class Conflict(CoreError):
    """The request collides with something already stored."""


class Forbidden(CoreError):
    """The principal is known but not permitted to do this."""


class Scope:
    """Coarse capability names a principal can hold. These gate *kinds* of
    action; row visibility is tenancy's job (scope_to_org)."""

    READ = "read"
    WRITE = "write"
    # Separate from WRITE so an ordinary write credential cannot perform an
    # administrative act - inviting a member, changing a role, deleting a tenant.
    ADMIN = "admin"


ALL_SCOPES = frozenset({Scope.READ, Scope.WRITE, Scope.ADMIN})


# The surfaces allowed to hold no organisation, and so to read across every
# tenant. One, and named out loud: scripts, seeds and migrations, which answer to
# whoever ran them. Add to this set only with the same deliberation - a staff
# console belongs here, an ordinary feature never does.
SPANNING_SURFACES = frozenset({"system"})


@dataclass(frozen=True)
class Principal:
    """The caller, resolved from whatever the façade used to authenticate."""

    subject: str
    # "ui" | "api" | "system" - how this caller arrived. Add surfaces as you add
    # façades; the name is what appears in audit lines and in Forbidden messages.
    surface: str
    scopes: frozenset = field(default_factory=frozenset)

    # Which customer's data this caller sees. None is permitted only for the
    # surfaces in SPANNING_SURFACES, which legitimately cross organisations.
    org_id: int | None = None

    # Which person/account, when there is one.
    user_id: int | None = None

    def __post_init__(self):
        """A customer-facing principal without an organisation is the bug that
        leaks one tenant's data to another, so it is rejected at construction
        rather than relied on to be filtered downstream."""
        if self.surface not in SPANNING_SURFACES and self.org_id is None:
            raise Invalid(
                f"A {self.surface} principal must be scoped to an organisation. "
                f"Only {' and '.join(sorted(SPANNING_SURFACES))} callers may span "
                "organisations."
            )

    @property
    def spans_orgs(self) -> bool:
        return self.org_id is None

    def may(self, scope: str) -> bool:
        return scope in self.scopes

    def require(self, scope: str) -> None:
        if not self.may(scope):
            raise Forbidden(
                f"This action needs the {scope!r} permission, which a {self.surface} "
                f"caller does not hold."
            )

    @classmethod
    def for_ui(cls, org_id: int, subject: str = "dashboard",
               user_id: int | None = None) -> "Principal":
        """A fully-scoped dashboard caller. For tests and bootstrap; real
        sign-ins go through auth.principal_for_user, which derives scopes from
        the membership role."""
        return cls(subject=subject, surface="ui", scopes=ALL_SCOPES,
                   org_id=org_id, user_id=user_id)

    @classmethod
    def system(cls, subject: str = "cli", org_id: int | None = None) -> "Principal":
        """Scripts, seeds and migrations - the only principal allowed to span
        organisations, and named rather than implied so that crossing tenants has
        to be said out loud."""
        return cls(subject=subject, surface="system", scopes=ALL_SCOPES,
                   org_id=org_id)


def scope_to_org(query, model, principal: Principal):
    """Narrow a query to the principal's organisation - the single place the
    tenancy rule is written down. A system caller spans organisations and the
    query comes back untouched."""
    if principal.spans_orgs:
        return query
    return query.filter(model.org_id == principal.org_id)