Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions backend/src/domus/application/household_administration_queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Permitted public presentation and exclusively current-owner private preference reads."""

from dataclasses import dataclass

from domus.application.authorization import HouseholdAccess
from domus.application.member_profile_access import preference_owner
from domus.application.ports import Transactions
from domus.domain.errors import NotFound, StorageFailure
from domus.domain.identity import HumanIdentity, Permission
from domus.domain.member_profiles import MemberPreferences, MemberPresentation
from domus.domain.storage import validate_identifier


@dataclass(frozen=True, slots=True)
class HouseholdAdministrationQueries:
transactions: Transactions
human: HumanIdentity
household_id: str = "home"

def __post_init__(self) -> None:
validate_identifier(self.household_id)

def presentation(self, member_id: str) -> MemberPresentation:
validate_identifier(member_id)
with self.transactions.read() as unit:
HouseholdAccess(self.human).require(unit, self.household_id, Permission.MEMBERS_READ)
value = unit.presentations.get(self.household_id, member_id)
if value is None:
raise NotFound("member_missing")
return value

def preferences(self) -> MemberPreferences:
with self.transactions.read() as unit:
member = preference_owner(unit, self.human, self.household_id)
value = unit.preferences.get(self.household_id, member.member_id)
if value is None:
raise StorageFailure("invalid_member_profile_record")
return value
70 changes: 49 additions & 21 deletions backend/src/domus/application/household_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
from typing import Literal, cast

from domus.application.authorization import HouseholdAccess
from domus.application.member_profile_access import (
ALIAS_ACTION,
PREFERENCES_ACTION,
require_profile_original,
)
from domus.application.ports import Transactions, UnitOfWork
from domus.domain.errors import Conflict, Forbidden, NotFound, StorageFailure
from domus.domain.identity import (
Expand All @@ -17,7 +22,7 @@
allows,
effective_role,
)
from domus.domain.storage import Household, Receipt, validate_identifier
from domus.domain.storage import CommandKey, Household, Receipt, validate_identifier

_ADMINISTRATION_SCOPE = (
Permission.HOUSEHOLD_READ,
Expand All @@ -36,6 +41,9 @@
"policy.update": Permission.POLICY_MANAGE,
"recovery.complete": Permission.POLICY_MANAGE,
}
ADMINISTRATION_ACTIONS = frozenset(
{"household.update", "member.update", "policy.update", ALIAS_ACTION, PREFERENCES_ACTION}
)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -157,31 +165,51 @@ def policy(self) -> HouseholdPolicy:
self._require(unit, Permission.POLICY_MANAGE)
return cast(HouseholdPolicy, unit.policies.get(self.household_id))

def _operation_access(self, unit: UnitOfWork) -> None:
member = unit.members.by_user(self.household_id, self.human.ha_user_id)
HouseholdAccess(self.human).require(
unit,
self.household_id,
Permission.OPERATIONS_READ,
subject_member_id=member.member_id if member is not None else None,
)

def _operation_view(self, unit: UnitOfWork, receipt: Receipt) -> OperationView:
if not require_profile_original(
unit, self.human, self.household_id, receipt
) and receipt.command.action not in (
"member.enroll",
"recovery.request",
"recovery.cancel",
):
original = _ORIGINAL_PERMISSION.get(receipt.command.action)
if original is None:
raise Forbidden("permission_denied")
self._require(unit, original)
delivered = unit.outbox.delivered(receipt.operation_id)
if delivered is None:
raise StorageFailure("invalid_operation_record")
return OperationView(receipt, delivered)

def operation(self, operation_id: str) -> OperationView:
validate_identifier(operation_id)
with self.transactions.read() as unit:
member = unit.members.by_user(self.household_id, self.human.ha_user_id)
HouseholdAccess(self.human).require(
unit,
self.household_id,
Permission.OPERATIONS_READ,
subject_member_id=member.member_id if member is not None else None,
)
self._operation_access(unit)
receipt = unit.receipts.get_operation(
self.household_id, self.human.ha_user_id, operation_id
)
if receipt is None:
raise NotFound("operation_missing")
if receipt.command.action not in (
"member.enroll",
"recovery.request",
"recovery.cancel",
):
original = _ORIGINAL_PERMISSION.get(receipt.command.action)
if original is None:
raise Forbidden("permission_denied")
self._require(unit, original)
delivered = unit.outbox.delivered(receipt.operation_id)
if delivered is None:
raise StorageFailure("invalid_operation_record")
return OperationView(receipt, delivered)
return self._operation_view(unit, receipt)

def administration_request(self, action: str, key: str) -> OperationView:
"""Read an owned original even if its response/operation ID was lost; never execute it."""
if action not in ADMINISTRATION_ACTIONS:
raise Forbidden("permission_denied")
command = CommandKey(self.household_id, self.human.ha_user_id, action, key)
with self.transactions.read() as unit:
self._operation_access(unit)
receipt = unit.receipts.get(command)
if receipt is None:
raise NotFound("operation_missing")
return self._operation_view(unit, receipt)
132 changes: 132 additions & 0 deletions backend/src/domus/application/household_roster.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Current-authorized roster/directory projections fence bounded provider reads outside SQLite."""

from collections.abc import Callable
from dataclasses import dataclass
from typing import cast

from domus.application.authorization import HouseholdAccess
from domus.application.household_queries import HouseholdQueries, MemberView
from domus.application.identity_ports import ReadHuman
from domus.application.member_source_ports import MemberProfileSource
from domus.application.ports import Transactions, UnitOfWork
from domus.domain.errors import Conflict, IdentityRejected, StorageFailure
from domus.domain.identity import HouseholdPolicy, HumanIdentity, Member, Permission, allows
from domus.domain.member_profiles import MemberPresentation
from domus.domain.member_sources import (
MemberSourceSnapshot,
ProfileDirectory,
SourceState,
)
from domus.domain.storage import validate_identifier


@dataclass(frozen=True, slots=True)
class RosterMetadata:
state: SourceState
name: str | None


@dataclass(frozen=True, slots=True)
class RosterMember:
member: MemberView
presentation: MemberPresentation
metadata: RosterMetadata


@dataclass(frozen=True, slots=True)
class RosterView:
members: tuple[RosterMember, ...]
directory_state: SourceState


@dataclass(frozen=True, slots=True)
class _Identity:
actor: Member
policy: HouseholdPolicy
members: tuple[Member, ...]


@dataclass(frozen=True, slots=True)
class HouseholdRosterQueries:
transactions: Transactions
actor: ReadHuman
source: MemberProfileSource
household_id: str = "home"

def __post_init__(self) -> None:
validate_identifier(self.household_id)

def _identity(
self, unit: UnitOfWork, human: HumanIdentity, permission: Permission
) -> _Identity:
actor = HouseholdAccess(human).require(unit, self.household_id, permission)
members = unit.members.list(self.household_id)
if len(members) > 256:
raise StorageFailure("invalid_identity_record")
return _Identity(
actor, cast(HouseholdPolicy, unit.policies.get(self.household_id)), members
)

def _read[T](
self,
permission: Permission,
project: Callable[[UnitOfWork, HumanIdentity, MemberSourceSnapshot, _Identity], T],
) -> T:
human = self.actor.read()
with self.transactions.read() as unit:
before = self._identity(unit, human, permission)
sources = self.source.read()
if not isinstance(sources, MemberSourceSnapshot):
raise StorageFailure("invalid_member_source_record")
if sources.subject != human.ha_user_id:
raise IdentityRejected("identity_mismatch")
current = self.actor.read()
if current.ha_user_id != human.ha_user_id:
raise IdentityRejected("identity_mismatch")
with self.transactions.read() as unit:
after = self._identity(unit, current, permission)
if after != before:
raise Conflict("identity_state_changed")
return project(unit, current, sources, after)

def roster(self) -> RosterView:
def project(
unit: UnitOfWork, human: HumanIdentity, sources: MemberSourceSnapshot, state: _Identity
) -> RosterView:
privileged = allows(
human, state.actor, state.policy, Permission.MEMBERS_MANAGE, self.household_id
)
existing = HouseholdQueries(self.transactions, human, self.household_id)
result = []
for member in state.members:
presentation = unit.presentations.get(self.household_id, member.member_id)
if presentation is None:
raise StorageFailure("invalid_member_profile_record")
metadata = sources.profile(member.ha_user_id)
name = metadata.profile.name if metadata.profile is not None else None
result.append(
RosterMember(
existing._view(member, privileged=privileged),
presentation,
RosterMetadata(
SourceState.UNAVAILABLE
if metadata.state is SourceState.AVAILABLE and name is None
else metadata.state,
name,
),
)
)
return RosterView(tuple(result), sources.directory.state)

return self._read(Permission.MEMBERS_READ, project)

def directory(self) -> ProfileDirectory:
def project(
unit: UnitOfWork, human: HumanIdentity, sources: MemberSourceSnapshot, state: _Identity
) -> ProfileDirectory:
return ProfileDirectory(
sources.directory.state,
tuple(profile for profile in sources.directory.profiles if profile.eligible),
)

return self._read(Permission.POLICY_MANAGE, project)
6 changes: 6 additions & 0 deletions backend/src/domus/application/identity_ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class CurrentHuman(Protocol):
def current(self) -> HumanIdentity: ...


class ReadHuman(Protocol):
"""Current verified read proof, including only the existing bounded outage lease."""

def read(self) -> HumanIdentity: ...


class HumanDirectory(Protocol):
"""Resolve one exact, currently eligible human through a configured authority."""

Expand Down
90 changes: 90 additions & 0 deletions backend/src/domus/application/member_profile_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Current public-profile authority and owner-only original preference disclosure."""

from dataclasses import dataclass

from domus.application.authorization import HouseholdAccess
from domus.application.ports import UnitOfWork
from domus.domain.errors import Forbidden, InvalidInput, StorageFailure
from domus.domain.identity import HumanIdentity, Member, Permission, allows
from domus.domain.storage import CommandKey, Receipt, validate_identifier

ALIAS_ACTION = "member.presentation.update"
PREFERENCES_ACTION = "member.preferences.update"


def preference_owner(
unit: UnitOfWork, human: HumanIdentity, household_id: str, *, write: bool = False
) -> Member:
member = unit.members.by_user(household_id, human.ha_user_id)
return HouseholdAccess(human).require(
unit,
household_id,
Permission.PREFERENCES_WRITE if write else Permission.PREFERENCES_READ,
subject_member_id=member.member_id if member is not None else None,
)


def require_alias(
unit: UnitOfWork, human: HumanIdentity, household_id: str, subject_member_id: str
) -> None:
member = unit.members.by_user(household_id, human.ha_user_id)
policy = unit.policies.get(household_id)
if member is None or policy is None:
raise Forbidden("permission_denied")
if not (
allows(human, member, policy, Permission.MEMBERS_MANAGE, household_id)
or allows(
human,
member,
policy,
Permission.PREFERENCES_WRITE,
household_id,
subject_member_id=subject_member_id,
)
):
raise Forbidden("permission_denied")


@dataclass(frozen=True, slots=True)
class ProfileAuthorization:
human: HumanIdentity
action: str
subject_member_id: str | None = None

def require(self, command: CommandKey, unit: UnitOfWork) -> None:
if command.actor_id != self.human.ha_user_id or command.action != self.action:
raise Forbidden("permission_denied")
if self.action == ALIAS_ACTION and self.subject_member_id is not None:
require_alias(unit, self.human, command.household_id, self.subject_member_id)
elif self.action == PREFERENCES_ACTION and self.subject_member_id is None:
preference_owner(unit, self.human, command.household_id, write=True)
else:
raise Forbidden("permission_denied")


def _original_subject(receipt: Receipt) -> str:
try:
value = receipt.result.decode().get("member_id")
if not isinstance(value, str):
raise InvalidInput("invalid_member_id")
validate_identifier(value)
return value
except InvalidInput:
raise StorageFailure("invalid_operation_record") from None


def require_profile_original(
unit: UnitOfWork, human: HumanIdentity, household_id: str, receipt: Receipt
) -> bool:
"""True requires current profile authority; False grants nothing to another action."""
if receipt.command.action not in (ALIAS_ACTION, PREFERENCES_ACTION):
return False
if receipt.command.household_id != household_id or receipt.command.actor_id != human.ha_user_id:
raise Forbidden("permission_denied")
if receipt.command.action == PREFERENCES_ACTION:
member = preference_owner(unit, human, household_id, write=True)
if _original_subject(receipt) != member.member_id:
raise Forbidden("permission_denied")
else:
require_alias(unit, human, household_id, _original_subject(receipt))
return True
Loading
Loading