feat(policy): resolve scheduling participant privacy

This commit is contained in:
2026-07-20 18:44:48 +02:00
parent bab9402c29
commit b68c3f0473
6 changed files with 437 additions and 1 deletions

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION
from govoplan_core.core.policy import (
CAPABILITY_POLICY_PRIVACY_RETENTION,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
)
from govoplan_core.core.modules import FrontendModule, ModuleContext, ModuleManifest
@@ -19,6 +22,13 @@ def _privacy_retention_service(context: ModuleContext) -> object:
return SqlPrivacyRetentionService()
def _scheduling_participant_privacy_policy(context: ModuleContext) -> object:
del context
from govoplan_policy.backend.scheduling_privacy import SqlSchedulingParticipantPrivacyPolicy
return SqlSchedulingParticipantPrivacyPolicy()
manifest = ModuleManifest(
id="policy",
name="Policy",
@@ -31,6 +41,7 @@ manifest = ModuleManifest(
),
capability_factories={
CAPABILITY_POLICY_PRIVACY_RETENTION: _privacy_retention_service,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: _scheduling_participant_privacy_policy,
},
)

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal, cast
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.core.policy import (
PolicySourceStep,
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyRequest,
SchedulingParticipantVisibility,
)
from govoplan_core.tenancy.scope import Tenant
SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY = "scheduling_participant_privacy_policy"
MAXIMUM_VISIBILITY_KEY = "maximum_visibility"
_AGGREGATES_ONLY: SchedulingParticipantVisibility = "aggregates_only"
_NAMES_AND_STATUSES: SchedulingParticipantVisibility = "names_and_statuses"
@dataclass(frozen=True, slots=True)
class _VisibilityCeiling:
value: SchedulingParticipantVisibility
configured: bool = False
issue: str | None = None
def _visibility_ceiling(settings_payload: object) -> _VisibilityCeiling:
"""Read one ceiling, treating malformed explicit policy as restrictive."""
if settings_payload is None:
return _VisibilityCeiling(value=_NAMES_AND_STATUSES)
if not isinstance(settings_payload, Mapping):
return _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="invalid_settings_shape",
)
if SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY not in settings_payload:
return _VisibilityCeiling(value=_NAMES_AND_STATUSES)
raw_policy = settings_payload.get(SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY)
if not isinstance(raw_policy, Mapping) or MAXIMUM_VISIBILITY_KEY not in raw_policy:
return _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="invalid_policy_shape",
)
raw_visibility = raw_policy.get(MAXIMUM_VISIBILITY_KEY)
if raw_visibility == _AGGREGATES_ONLY:
return _VisibilityCeiling(value=_AGGREGATES_ONLY, configured=True)
if raw_visibility == _NAMES_AND_STATUSES:
return _VisibilityCeiling(value=_NAMES_AND_STATUSES, configured=True)
return _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="invalid_maximum_visibility",
)
def _restrict_visibility(
current: SchedulingParticipantVisibility,
ceiling: SchedulingParticipantVisibility,
) -> SchedulingParticipantVisibility:
if current == _AGGREGATES_ONLY or ceiling == _AGGREGATES_ONLY:
return _AGGREGATES_ONLY
return _NAMES_AND_STATUSES
def _source_step(
*,
scope_type: Literal["system", "tenant"],
scope_id: str | None,
label: str,
ceiling: _VisibilityCeiling,
) -> PolicySourceStep | None:
if not ceiling.configured:
return None
policy: dict[str, Any] = {MAXIMUM_VISIBILITY_KEY: ceiling.value}
if ceiling.issue is not None:
policy["configuration_status"] = "invalid_fail_closed"
return PolicySourceStep(
scope_type=scope_type,
scope_id=scope_id,
label=label,
applied_fields=(MAXIMUM_VISIBILITY_KEY,),
policy=policy,
)
def _invalid_requested_visibility(value: object) -> bool:
return value not in {_AGGREGATES_ONLY, _NAMES_AND_STATUSES}
class SqlSchedulingParticipantPrivacyPolicy:
"""Resolve Scheduling roster disclosure against system and tenant ceilings.
Scheduling owns the request-level visibility setting. This provider is an
optional upper bound: absent policy preserves that setting, while an
explicit or malformed policy may only reduce it.
"""
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
db_session = cast(Session, session)
configuration_errors: list[dict[str, str]] = []
requested_invalid = _invalid_requested_visibility(request.requested_visibility)
requested_visibility: SchedulingParticipantVisibility = (
_AGGREGATES_ONLY if requested_invalid else request.requested_visibility
)
if requested_invalid:
configuration_errors.append({"scope": "request", "code": "invalid_requested_visibility"})
system_settings = db_session.get(SystemSettings, SYSTEM_SETTINGS_ID)
system_ceiling = _visibility_ceiling(system_settings.settings if system_settings is not None else None)
if system_ceiling.issue is not None:
configuration_errors.append({"scope": "system", "code": system_ceiling.issue})
tenant = db_session.get(Tenant, request.tenant_id)
if tenant is None:
tenant_ceiling = _VisibilityCeiling(
value=_AGGREGATES_ONLY,
configured=True,
issue="tenant_not_found",
)
else:
tenant_ceiling = _visibility_ceiling(tenant.settings)
if tenant_ceiling.issue is not None:
configuration_errors.append({"scope": "tenant", "code": tenant_ceiling.issue})
policy_ceiling = _restrict_visibility(system_ceiling.value, tenant_ceiling.value)
effective_visibility = _restrict_visibility(requested_visibility, policy_ceiling)
source_path = tuple(
step
for step in (
_source_step(
scope_type="system",
scope_id=None,
label="System",
ceiling=system_ceiling,
),
_source_step(
scope_type="tenant",
scope_id=request.tenant_id,
label="Tenant",
ceiling=tenant_ceiling,
),
)
if step is not None
)
reason: str | None = None
if configuration_errors:
reason = (
"Participant roster visibility was restricted because policy configuration "
"could not be validated."
)
elif effective_visibility != requested_visibility:
reason = "Participant roster visibility is restricted by policy."
return SchedulingParticipantPrivacyDecision(
effective_visibility=effective_visibility,
reason=reason,
source_path=source_path,
details={
"requested_visibility": request.requested_visibility,
"policy_ceiling": policy_ceiling,
"configured_scopes": [
scope
for scope, ceiling in (("system", system_ceiling), ("tenant", tenant_ceiling))
if ceiling.configured
],
"configuration_errors": configuration_errors,
},
)
__all__ = [
"MAXIMUM_VISIBILITY_KEY",
"SCHEDULING_PARTICIPANT_PRIVACY_SETTINGS_KEY",
"SqlSchedulingParticipantPrivacyPolicy",
]