Persist hierarchical definition policy overrides
This commit is contained in:
@@ -26,8 +26,18 @@ from govoplan_policy.backend.retention import (
|
||||
set_privacy_policy_for_scope,
|
||||
simulate_privacy_policy_change,
|
||||
)
|
||||
from govoplan_policy.backend.definition_policy_service import (
|
||||
DefinitionPolicyError,
|
||||
definition_policy_response_payload,
|
||||
definition_policy_state,
|
||||
remove_definition_policy,
|
||||
save_definition_policy,
|
||||
)
|
||||
from govoplan_policy.backend.policy_overrides import PolicyOverrideError
|
||||
|
||||
from .schemas import (
|
||||
DefinitionPolicyScopeRequest,
|
||||
DefinitionPolicyScopeResponse,
|
||||
PrivacyRetentionPolicyExplainResponse,
|
||||
PrivacyRetentionPolicyItem,
|
||||
PrivacyRetentionPolicyScopeRequest,
|
||||
@@ -42,7 +52,9 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}"
|
||||
)
|
||||
|
||||
|
||||
def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
@@ -66,7 +78,237 @@ def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPExc
|
||||
)
|
||||
|
||||
|
||||
@router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse)
|
||||
def _definition_policy_response(
|
||||
*,
|
||||
module_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
state,
|
||||
) -> DefinitionPolicyScopeResponse:
|
||||
return DefinitionPolicyScopeResponse(
|
||||
module_id=module_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
**definition_policy_response_payload(state),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/definition-policies/{module_id}/{scope_type}",
|
||||
response_model=DefinitionPolicyScopeResponse,
|
||||
)
|
||||
def read_definition_policy(
|
||||
module_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
_require_privacy_policy_read(principal, clean_scope)
|
||||
try:
|
||||
state = definition_policy_state(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return _definition_policy_response(
|
||||
module_id=module_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
state=state,
|
||||
)
|
||||
except (DefinitionPolicyError, PolicyOverrideError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/definition-policies/{module_id}/{scope_type}",
|
||||
response_model=DefinitionPolicyScopeResponse,
|
||||
)
|
||||
def write_definition_policy(
|
||||
module_id: str,
|
||||
scope_type: str,
|
||||
payload: DefinitionPolicyScopeRequest,
|
||||
scope_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
_require_privacy_policy_write(principal, clean_scope)
|
||||
policy_value = payload.policy.model_dump(mode="json", exclude_none=True)
|
||||
try:
|
||||
before = definition_policy_state(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
if clean_scope == "system":
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="definition_policy",
|
||||
value=policy_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=payload.change_request_id,
|
||||
target={"module_id": module_id, "scope_type": clean_scope},
|
||||
)
|
||||
else:
|
||||
approval = None
|
||||
state = save_definition_policy(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=policy_value,
|
||||
actor_id=principal.user.id,
|
||||
)
|
||||
if clean_scope == "system":
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="definition_policy",
|
||||
before_value=definition_policy_response_payload(before)["policy"],
|
||||
after_value=policy_value,
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"module_id": module_id, "scope_type": clean_scope},
|
||||
audit_event="definition_policy.updated",
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="definition_policy.updated",
|
||||
scope="system" if clean_scope == "system" else "tenant",
|
||||
object_type="definition_policy",
|
||||
object_id=f"{module_id}:{clean_scope}:{scope_id or ''}",
|
||||
details={
|
||||
"module_id": module_id,
|
||||
"scope_type": clean_scope,
|
||||
"scope_id": scope_id,
|
||||
"fields": sorted(policy_value),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _definition_policy_response(
|
||||
module_id=module_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
state=state,
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
session.rollback()
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
except (DefinitionPolicyError, PolicyOverrideError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/definition-policies/{module_id}/{scope_type}",
|
||||
response_model=DefinitionPolicyScopeResponse,
|
||||
)
|
||||
def delete_definition_policy_route(
|
||||
module_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None = Query(default=None),
|
||||
change_request_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
_require_privacy_policy_write(principal, clean_scope)
|
||||
try:
|
||||
before = definition_policy_state(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
if clean_scope == "system":
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="definition_policy",
|
||||
value={},
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=change_request_id,
|
||||
target={"module_id": module_id, "scope_type": clean_scope},
|
||||
)
|
||||
else:
|
||||
approval = None
|
||||
removed = remove_definition_policy(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
if removed:
|
||||
if clean_scope == "system":
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="definition_policy",
|
||||
before_value=definition_policy_response_payload(before)["policy"],
|
||||
after_value={},
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"module_id": module_id, "scope_type": clean_scope},
|
||||
audit_event="definition_policy.removed",
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="definition_policy.removed",
|
||||
scope="system" if clean_scope == "system" else "tenant",
|
||||
object_type="definition_policy",
|
||||
object_id=f"{module_id}:{clean_scope}:{scope_id or ''}",
|
||||
details={
|
||||
"module_id": module_id,
|
||||
"scope_type": clean_scope,
|
||||
"scope_id": scope_id,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
state = definition_policy_state(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return _definition_policy_response(
|
||||
module_id=module_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
state=state,
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
session.rollback()
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
except (DefinitionPolicyError, PolicyOverrideError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/privacy-retention/policies/{scope_type}",
|
||||
response_model=PrivacyRetentionPolicyScopeResponse,
|
||||
)
|
||||
def read_privacy_retention_policy(
|
||||
scope_type: str,
|
||||
scope_id: str | None = Query(default=None),
|
||||
@@ -76,23 +318,59 @@ def read_privacy_retention_policy(
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
_require_privacy_policy_read(principal, clean_scope)
|
||||
try:
|
||||
policy = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
policy = get_privacy_policy_for_scope(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
effective = _effective_privacy_policy_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
parent = _parent_privacy_policy_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return PrivacyRetentionPolicyScopeResponse(
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=policy,
|
||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
|
||||
effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
|
||||
parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
|
||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(
|
||||
effective.model_dump(mode="json")
|
||||
),
|
||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(
|
||||
parent.model_dump(mode="json")
|
||||
)
|
||||
if parent
|
||||
else None,
|
||||
effective_policy_sources=_effective_privacy_policy_sources_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
),
|
||||
parent_policy_sources=_parent_privacy_policy_sources_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
),
|
||||
)
|
||||
except PrivacyPolicyError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.put("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse)
|
||||
@router.put(
|
||||
"/privacy-retention/policies/{scope_type}",
|
||||
response_model=PrivacyRetentionPolicyScopeResponse,
|
||||
)
|
||||
def write_privacy_retention_policy(
|
||||
scope_type: str,
|
||||
payload: PrivacyRetentionPolicyScopeRequest,
|
||||
@@ -105,7 +383,12 @@ def write_privacy_retention_policy(
|
||||
policy_value = payload.policy.model_dump(mode="json", exclude_none=True)
|
||||
before_value: dict[str, Any] | None = None
|
||||
if clean_scope == "system":
|
||||
before_value = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
before_value = get_privacy_policy_for_scope(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
@@ -149,23 +432,54 @@ def write_privacy_retention_policy(
|
||||
details={"scope_type": clean_scope, "scope_id": scope_id},
|
||||
)
|
||||
session.commit()
|
||||
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
effective = _effective_privacy_policy_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
parent = _parent_privacy_policy_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return PrivacyRetentionPolicyScopeResponse(
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
policy=policy,
|
||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
|
||||
effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
|
||||
parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
|
||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(
|
||||
effective.model_dump(mode="json")
|
||||
),
|
||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(
|
||||
parent.model_dump(mode="json")
|
||||
)
|
||||
if parent
|
||||
else None,
|
||||
effective_policy_sources=_effective_privacy_policy_sources_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
),
|
||||
parent_policy_sources=_parent_privacy_policy_sources_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
),
|
||||
)
|
||||
except PrivacyPolicyError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/privacy-retention/policies/{scope_type}/explain", response_model=PrivacyRetentionPolicyExplainResponse)
|
||||
@router.get(
|
||||
"/privacy-retention/policies/{scope_type}/explain",
|
||||
response_model=PrivacyRetentionPolicyExplainResponse,
|
||||
)
|
||||
def explain_privacy_retention_policy(
|
||||
scope_type: str,
|
||||
scope_id: str | None = Query(default=None),
|
||||
@@ -175,16 +489,40 @@ def explain_privacy_retention_policy(
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
_require_privacy_policy_read(principal, clean_scope)
|
||||
try:
|
||||
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
effective_sources = _effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
parent_sources = _parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
|
||||
effective = _effective_privacy_policy_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
parent = _parent_privacy_policy_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
effective_sources = _effective_privacy_policy_sources_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
parent_sources = _parent_privacy_policy_sources_for_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
blocked_fields = _blocked_privacy_retention_fields(parent)
|
||||
decision_sources = parent_sources or effective_sources
|
||||
decision = PolicyDecision(
|
||||
allowed=not blocked_fields,
|
||||
reason="Parent retention policy locks lower-level changes." if blocked_fields else None,
|
||||
source_path=tuple(PolicySourceStep.from_mapping(source) for source in decision_sources),
|
||||
reason="Parent retention policy locks lower-level changes."
|
||||
if blocked_fields
|
||||
else None,
|
||||
source_path=tuple(
|
||||
PolicySourceStep.from_mapping(source) for source in decision_sources
|
||||
),
|
||||
requirements=tuple(blocked_fields),
|
||||
details={"blocked_fields": blocked_fields},
|
||||
)
|
||||
@@ -192,17 +530,28 @@ def explain_privacy_retention_policy(
|
||||
scope_type=clean_scope,
|
||||
scope_id=scope_id,
|
||||
decision=decision.to_dict(),
|
||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
|
||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
|
||||
effective_policy=PrivacyRetentionPolicyItem.model_validate(
|
||||
effective.model_dump(mode="json")
|
||||
),
|
||||
parent_policy=PrivacyRetentionPolicyItem.model_validate(
|
||||
parent.model_dump(mode="json")
|
||||
)
|
||||
if parent
|
||||
else None,
|
||||
effective_policy_sources=effective_sources,
|
||||
parent_policy_sources=parent_sources,
|
||||
blocked_fields=blocked_fields,
|
||||
)
|
||||
except PrivacyPolicyError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/privacy-retention/policies/{scope_type}/simulate", response_model=PrivacyRetentionPolicySimulationResponse)
|
||||
@router.post(
|
||||
"/privacy-retention/policies/{scope_type}/simulate",
|
||||
response_model=PrivacyRetentionPolicySimulationResponse,
|
||||
)
|
||||
def simulate_privacy_retention_policy(
|
||||
scope_type: str,
|
||||
payload: PrivacyRetentionPolicyScopeRequest,
|
||||
@@ -225,7 +574,9 @@ def simulate_privacy_retention_policy(
|
||||
),
|
||||
)
|
||||
except PrivacyPolicyError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def _blocked_privacy_retention_fields(parent) -> list[str]:
|
||||
@@ -233,36 +584,64 @@ def _blocked_privacy_retention_fields(parent) -> list[str]:
|
||||
return []
|
||||
payload = parent.model_dump(mode="json")
|
||||
allow_lower_level_limits = payload.get("allow_lower_level_limits") or {}
|
||||
return [key for key in RETENTION_POLICY_FIELD_KEYS if allow_lower_level_limits.get(key) is False]
|
||||
return [
|
||||
key
|
||||
for key in RETENTION_POLICY_FIELD_KEYS
|
||||
if allow_lower_level_limits.get(key) is False
|
||||
]
|
||||
|
||||
|
||||
def _parent_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
||||
def _parent_privacy_policy_sources_for_response(
|
||||
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
|
||||
):
|
||||
if scope_type == "system":
|
||||
return []
|
||||
return parent_privacy_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None))
|
||||
return parent_privacy_policy_sources(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id or (tenant_id if scope_type == "tenant" else None),
|
||||
)
|
||||
|
||||
|
||||
def _effective_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
||||
def _effective_privacy_policy_sources_for_response(
|
||||
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
|
||||
):
|
||||
if scope_type == "system":
|
||||
return effective_privacy_policy_sources(session)
|
||||
if scope_type == "tenant":
|
||||
return effective_privacy_policy_sources(session, tenant_id=scope_id or tenant_id)
|
||||
return effective_privacy_policy_sources(
|
||||
session, tenant_id=scope_id or tenant_id
|
||||
)
|
||||
if scope_type == "campaign" and scope_id:
|
||||
return effective_privacy_policy_sources(session, campaign_id=scope_id)
|
||||
if scope_type == "user" and scope_id:
|
||||
return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_user_id=scope_id)
|
||||
return effective_privacy_policy_sources(
|
||||
session, tenant_id=tenant_id, owner_user_id=scope_id
|
||||
)
|
||||
if scope_type == "group" and scope_id:
|
||||
return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_group_id=scope_id)
|
||||
return effective_privacy_policy_sources(
|
||||
session, tenant_id=tenant_id, owner_group_id=scope_id
|
||||
)
|
||||
return effective_privacy_policy_sources(session, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
||||
def _parent_privacy_policy_for_response(
|
||||
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
|
||||
):
|
||||
if scope_type == "system":
|
||||
return None
|
||||
return parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None))
|
||||
return parent_privacy_policy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id or (tenant_id if scope_type == "tenant" else None),
|
||||
)
|
||||
|
||||
|
||||
def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
||||
def _effective_privacy_policy_for_response(
|
||||
session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None
|
||||
):
|
||||
if scope_type == "system":
|
||||
return effective_privacy_policy(session)
|
||||
if scope_type == "tenant":
|
||||
@@ -270,9 +649,13 @@ def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str,
|
||||
if scope_type == "campaign" and scope_id:
|
||||
return effective_privacy_policy(session, campaign_id=scope_id)
|
||||
if scope_type == "user" and scope_id:
|
||||
return effective_privacy_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
|
||||
return effective_privacy_policy(
|
||||
session, tenant_id=tenant_id, owner_user_id=scope_id
|
||||
)
|
||||
if scope_type == "group" and scope_id:
|
||||
return effective_privacy_policy(session, tenant_id=tenant_id, owner_group_id=scope_id)
|
||||
return effective_privacy_policy(
|
||||
session, tenant_id=tenant_id, owner_group_id=scope_id
|
||||
)
|
||||
return effective_privacy_policy(session, tenant_id=tenant_id)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||
from govoplan_core.privacy.schemas import (
|
||||
PrivacyRetentionPolicyItem,
|
||||
PrivacyRetentionPolicyPatchItem,
|
||||
)
|
||||
|
||||
|
||||
class PolicySourceStepItem(BaseModel):
|
||||
@@ -19,7 +22,9 @@ class PolicySourceStepItem(BaseModel):
|
||||
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
|
||||
policy: PrivacyRetentionPolicyPatchItem = Field(
|
||||
default_factory=PrivacyRetentionPolicyPatchItem
|
||||
)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
@@ -41,6 +46,37 @@ class PolicyDecisionItem(BaseModel):
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DefinitionPolicyItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
allow_view: bool | None = None
|
||||
allow_edit: bool | None = None
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
allow_run: bool | None = None
|
||||
allow_reuse: bool | None = None
|
||||
allow_automation: bool | None = None
|
||||
|
||||
|
||||
class DefinitionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: DefinitionPolicyItem = Field(default_factory=DefinitionPolicyItem)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class DefinitionPolicyScopeResponse(BaseModel):
|
||||
module_id: str
|
||||
scope_type: Literal["system", "tenant", "group", "user"]
|
||||
scope_id: str | None = None
|
||||
id: str | None = None
|
||||
revision: int | None = None
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
effective_policy: dict[str, bool] = Field(default_factory=dict)
|
||||
parent_policy: dict[str, bool] = Field(default_factory=dict)
|
||||
source_path: list[PolicySourceStepItem] = Field(default_factory=list)
|
||||
diagnostics: list[dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyExplainResponse(BaseModel):
|
||||
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
scope_id: str | None = None
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Policy-owned persistence models."""
|
||||
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
|
||||
__all__ = ["PolicyOverride"]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Index, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class PolicyOverride(Base, TimestampMixin):
|
||||
__tablename__ = "policy_overrides"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"policy_family",
|
||||
"target_key",
|
||||
"scope_key",
|
||||
name="uq_policy_override_family_target_scope",
|
||||
),
|
||||
Index(
|
||||
"ix_policy_overrides_resolution",
|
||||
"policy_family",
|
||||
"target_key",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
policy_family: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
target_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
scope_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
scope_key: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
policy: Mapped[Any] = mapped_column(JSON, default=dict, nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["PolicyOverride"]
|
||||
@@ -1,14 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
DefinitionGovernanceRequest,
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_policy.backend.policy_overrides import resolution_policy_overrides
|
||||
|
||||
|
||||
DEFINITION_POLICY_FIELDS = (
|
||||
"allow_view",
|
||||
"allow_edit",
|
||||
"inherit_to_lower_scopes",
|
||||
"allow_run",
|
||||
"allow_reuse",
|
||||
"allow_automation",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionPolicyResolution:
|
||||
limits: Mapping[str, bool]
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
diagnostics: tuple[Mapping[str, str], ...] = ()
|
||||
|
||||
|
||||
class DefinitionGovernancePolicyProvider:
|
||||
@@ -16,11 +38,19 @@ class DefinitionGovernancePolicyProvider:
|
||||
|
||||
def resolve_definition_action(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: DefinitionGovernanceRequest,
|
||||
) -> PolicyDecision:
|
||||
source_path = _source_path(request)
|
||||
effective = _effective_limits(request)
|
||||
explicit_policy = _explicit_policy_resolution(session, request)
|
||||
source_path = _source_path(
|
||||
request,
|
||||
policy_sources=explicit_policy.source_path,
|
||||
)
|
||||
effective = _effective_limits(
|
||||
request,
|
||||
policy_limits=explicit_policy.limits,
|
||||
)
|
||||
visible, visibility_reason = _visible(
|
||||
request,
|
||||
effective=effective,
|
||||
@@ -34,16 +64,21 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
if action == "edit":
|
||||
editable, reason = _editable(request)
|
||||
if editable and not effective["allow_edit"]:
|
||||
editable = False
|
||||
reason = "Editing is disabled by explicit Policy restrictions."
|
||||
return _decision(
|
||||
editable,
|
||||
reason,
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
if not visible:
|
||||
@@ -53,6 +88,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
if action == "run":
|
||||
@@ -63,6 +99,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
if request.status != "active":
|
||||
return _decision(
|
||||
@@ -71,6 +108,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
return _decision(
|
||||
effective["allow_run"],
|
||||
@@ -82,6 +120,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
if action in {"reuse", "derive"}:
|
||||
@@ -95,6 +134,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
if action == "automate":
|
||||
@@ -105,6 +145,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
if request.status != "active":
|
||||
return _decision(
|
||||
@@ -113,6 +154,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
allowed = effective["allow_run"] and effective["allow_automation"]
|
||||
return _decision(
|
||||
@@ -125,6 +167,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
return _decision(
|
||||
@@ -133,6 +176,7 @@ class DefinitionGovernancePolicyProvider:
|
||||
source_path=source_path,
|
||||
request=request,
|
||||
effective=effective,
|
||||
policy_diagnostics=explicit_policy.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
@@ -141,6 +185,8 @@ def _visible(
|
||||
*,
|
||||
effective: Mapping[str, bool],
|
||||
) -> tuple[bool, str | None]:
|
||||
if not effective["allow_view"]:
|
||||
return False, "Visibility is disabled by explicit Policy restrictions."
|
||||
scope = request.definition_scope
|
||||
actor = request.actor
|
||||
if scope.scope_type == "system":
|
||||
@@ -176,36 +222,56 @@ def _editable(request: DefinitionGovernanceRequest) -> tuple[bool, str | None]:
|
||||
allowed = _has_scope(actor.scopes, "system:governance:write")
|
||||
return (
|
||||
allowed,
|
||||
None if allowed else "System definitions require system governance permission.",
|
||||
None
|
||||
if allowed
|
||||
else "System definitions require system governance permission.",
|
||||
)
|
||||
if actor.tenant_id != request.tenant_id:
|
||||
return False, "Definitions from another tenant are read-only."
|
||||
if scope.scope_type == "tenant":
|
||||
allowed = scope.scope_id == request.tenant_id
|
||||
return allowed, None if allowed else "Inherited tenant definitions are read-only."
|
||||
return (
|
||||
allowed,
|
||||
None if allowed else "Inherited tenant definitions are read-only.",
|
||||
)
|
||||
if scope.scope_type == "group":
|
||||
allowed = scope.scope_id in actor.group_ids
|
||||
return allowed, None if allowed else "Definitions from another group are read-only."
|
||||
return (
|
||||
allowed,
|
||||
None if allowed else "Definitions from another group are read-only.",
|
||||
)
|
||||
if scope.scope_type == "user":
|
||||
allowed = scope.scope_id in {actor.membership_id, actor.account_id}
|
||||
return allowed, None if allowed else "Definitions from another user are read-only."
|
||||
return (
|
||||
allowed,
|
||||
None if allowed else "Definitions from another user are read-only.",
|
||||
)
|
||||
return False, "The definition scope is invalid."
|
||||
|
||||
|
||||
def _effective_limits(
|
||||
request: DefinitionGovernanceRequest,
|
||||
*,
|
||||
policy_limits: Mapping[str, bool] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
ancestor = request.context.get("ancestor_limits")
|
||||
ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {}
|
||||
explicit = policy_limits or {}
|
||||
return {
|
||||
"allow_view": _policy_flag(explicit, "allow_view"),
|
||||
"allow_edit": _policy_flag(explicit, "allow_edit"),
|
||||
"inherit_to_lower_scopes": request.inherit_to_lower_scopes
|
||||
and _ancestor_flag(ancestor_limits, "inherit_to_lower_scopes"),
|
||||
and _ancestor_flag(ancestor_limits, "inherit_to_lower_scopes")
|
||||
and _policy_flag(explicit, "inherit_to_lower_scopes"),
|
||||
"allow_run": request.allow_run
|
||||
and _ancestor_flag(ancestor_limits, "allow_run"),
|
||||
and _ancestor_flag(ancestor_limits, "allow_run")
|
||||
and _policy_flag(explicit, "allow_run"),
|
||||
"allow_reuse": request.allow_reuse
|
||||
and _ancestor_flag(ancestor_limits, "allow_reuse"),
|
||||
and _ancestor_flag(ancestor_limits, "allow_reuse")
|
||||
and _policy_flag(explicit, "allow_reuse"),
|
||||
"allow_automation": request.allow_automation
|
||||
and _ancestor_flag(ancestor_limits, "allow_automation"),
|
||||
and _ancestor_flag(ancestor_limits, "allow_automation")
|
||||
and _policy_flag(explicit, "allow_automation"),
|
||||
}
|
||||
|
||||
|
||||
@@ -214,10 +280,17 @@ def _ancestor_flag(value: Mapping[str, Any], key: str) -> bool:
|
||||
return True if raw is None else raw is True
|
||||
|
||||
|
||||
def _policy_flag(value: Mapping[str, bool], key: str) -> bool:
|
||||
raw = value.get(key)
|
||||
return True if raw is None else raw is True
|
||||
|
||||
|
||||
def _source_path(
|
||||
request: DefinitionGovernanceRequest,
|
||||
*,
|
||||
policy_sources: tuple[PolicySourceStep, ...] = (),
|
||||
) -> tuple[PolicySourceStep, ...]:
|
||||
steps: list[PolicySourceStep] = []
|
||||
steps: list[PolicySourceStep] = list(policy_sources)
|
||||
ancestor = request.context.get("ancestor_limits")
|
||||
if isinstance(ancestor, Mapping):
|
||||
source = request.context.get("ancestor_source")
|
||||
@@ -278,15 +351,14 @@ def _decision(
|
||||
source_path: tuple[PolicySourceStep, ...],
|
||||
request: DefinitionGovernanceRequest,
|
||||
effective: Mapping[str, bool],
|
||||
policy_diagnostics: tuple[Mapping[str, str], ...] = (),
|
||||
) -> PolicyDecision:
|
||||
return PolicyDecision(
|
||||
allowed=allowed,
|
||||
reason=reason,
|
||||
source_path=source_path,
|
||||
requirements=(
|
||||
()
|
||||
if allowed
|
||||
else (f"{request.module_id}.definition.{request.action}",)
|
||||
() if allowed else (f"{request.module_id}.definition.{request.action}",)
|
||||
),
|
||||
details={
|
||||
"module_id": request.module_id,
|
||||
@@ -296,12 +368,117 @@ def _decision(
|
||||
"target_scope": request.target_scope.path,
|
||||
"definition_kind": request.definition_kind,
|
||||
"effective_limits": dict(effective),
|
||||
"policy_diagnostics": [dict(item) for item in policy_diagnostics],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _explicit_policy_resolution(
|
||||
session: object | None,
|
||||
request: DefinitionGovernanceRequest,
|
||||
) -> DefinitionPolicyResolution:
|
||||
if not isinstance(session, Session):
|
||||
try:
|
||||
with get_database().SessionLocal() as policy_session:
|
||||
return _explicit_policy_resolution(policy_session, request)
|
||||
except RuntimeError:
|
||||
# Contract-only tests and offline tooling may resolve decisions
|
||||
# without configuring a database. No persisted override exists in
|
||||
# that context.
|
||||
return DefinitionPolicyResolution(limits={})
|
||||
cache_key = (
|
||||
"definition",
|
||||
request.module_id,
|
||||
request.tenant_id,
|
||||
tuple(sorted(request.actor.group_ids)),
|
||||
request.actor.account_id,
|
||||
request.actor.membership_id,
|
||||
)
|
||||
cache = session.info.setdefault("govoplan_policy_override_resolution", {})
|
||||
if isinstance(cache, dict) and cache_key in cache:
|
||||
cached = cache[cache_key]
|
||||
if isinstance(cached, DefinitionPolicyResolution):
|
||||
return cached
|
||||
rows = resolution_policy_overrides(
|
||||
session,
|
||||
policy_family="definition",
|
||||
target_keys=("*", request.module_id),
|
||||
tenant_id=request.tenant_id,
|
||||
group_ids=request.actor.group_ids,
|
||||
user_ids=(request.actor.account_id, request.actor.membership_id or ""),
|
||||
)
|
||||
result = resolve_definition_policy_rows(rows)
|
||||
if isinstance(cache, dict):
|
||||
cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
def resolve_definition_policy_rows(
|
||||
rows: object,
|
||||
) -> DefinitionPolicyResolution:
|
||||
limits = {field: True for field in DEFINITION_POLICY_FIELDS}
|
||||
steps: list[PolicySourceStep] = []
|
||||
diagnostics: list[Mapping[str, str]] = []
|
||||
for row in rows if isinstance(rows, (list, tuple)) else ():
|
||||
policy, malformed = validate_definition_policy(row.policy)
|
||||
if malformed:
|
||||
limits.update({field: False for field in DEFINITION_POLICY_FIELDS})
|
||||
applied_fields = DEFINITION_POLICY_FIELDS
|
||||
source_policy: Mapping[str, Any] = {
|
||||
"configuration_status": "invalid_fail_closed",
|
||||
"target_key": row.target_key,
|
||||
}
|
||||
diagnostics.append(
|
||||
{
|
||||
"code": "definition_policy.invalid",
|
||||
"scope": row.scope_key,
|
||||
"target_key": row.target_key,
|
||||
}
|
||||
)
|
||||
else:
|
||||
for field, value in policy.items():
|
||||
limits[field] = limits[field] and value
|
||||
applied_fields = tuple(sorted(policy))
|
||||
source_policy = {**policy, "target_key": row.target_key}
|
||||
steps.append(
|
||||
PolicySourceStep(
|
||||
scope_type=row.scope_type, # type: ignore[arg-type]
|
||||
scope_id=row.scope_id,
|
||||
label=(f"{row.scope_type.capitalize()} definition policy"),
|
||||
applied_fields=tuple(applied_fields),
|
||||
policy=source_policy,
|
||||
)
|
||||
)
|
||||
return DefinitionPolicyResolution(
|
||||
limits=limits,
|
||||
source_path=tuple(steps),
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def validate_definition_policy(
|
||||
value: object,
|
||||
) -> tuple[dict[str, bool], bool]:
|
||||
if not isinstance(value, Mapping):
|
||||
return {}, True
|
||||
if any(str(key) not in DEFINITION_POLICY_FIELDS for key in value):
|
||||
return {}, True
|
||||
policy: dict[str, bool] = {}
|
||||
for key, raw in value.items():
|
||||
if not isinstance(raw, bool):
|
||||
return {}, True
|
||||
policy[str(key)] = raw
|
||||
return policy, False
|
||||
|
||||
|
||||
def _has_scope(scopes: object, required: str) -> bool:
|
||||
return scopes_grant_compatible(scopes, required) # type: ignore[arg-type]
|
||||
|
||||
|
||||
__all__ = ["DefinitionGovernancePolicyProvider"]
|
||||
__all__ = [
|
||||
"DEFINITION_POLICY_FIELDS",
|
||||
"DefinitionGovernancePolicyProvider",
|
||||
"DefinitionPolicyResolution",
|
||||
"resolve_definition_policy_rows",
|
||||
"validate_definition_policy",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
from govoplan_policy.backend.definition_governance import (
|
||||
DEFINITION_POLICY_FIELDS,
|
||||
DefinitionPolicyResolution,
|
||||
resolve_definition_policy_rows,
|
||||
validate_definition_policy,
|
||||
)
|
||||
from govoplan_policy.backend.policy_overrides import (
|
||||
delete_policy_override,
|
||||
get_policy_override,
|
||||
normalize_policy_scope,
|
||||
resolution_policy_overrides,
|
||||
set_policy_override,
|
||||
)
|
||||
|
||||
|
||||
class DefinitionPolicyError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionPolicyState:
|
||||
row: PolicyOverride | None
|
||||
local_policy: Mapping[str, bool]
|
||||
effective: DefinitionPolicyResolution
|
||||
parent: DefinitionPolicyResolution
|
||||
|
||||
|
||||
def definition_policy_state(
|
||||
session: Session,
|
||||
*,
|
||||
module_id: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None = None,
|
||||
) -> DefinitionPolicyState:
|
||||
_, clean_scope_id, _ = normalize_policy_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
row = get_policy_override(
|
||||
session,
|
||||
policy_family="definition",
|
||||
target_key=module_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=clean_scope_id,
|
||||
)
|
||||
local_policy, malformed = validate_definition_policy(
|
||||
row.policy if row is not None else {}
|
||||
)
|
||||
if malformed:
|
||||
local_policy = {}
|
||||
rows = _rows_for_scope(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=clean_scope_id,
|
||||
)
|
||||
rank = _scope_rank(scope_type)
|
||||
return DefinitionPolicyState(
|
||||
row=row,
|
||||
local_policy=local_policy,
|
||||
effective=resolve_definition_policy_rows(rows),
|
||||
parent=resolve_definition_policy_rows(
|
||||
tuple(item for item in rows if _scope_rank(item.scope_type) < rank)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def save_definition_policy(
|
||||
session: Session,
|
||||
*,
|
||||
module_id: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
policy: object,
|
||||
actor_id: str | None,
|
||||
) -> DefinitionPolicyState:
|
||||
clean_policy, malformed = validate_definition_policy(policy)
|
||||
if malformed:
|
||||
raise DefinitionPolicyError(
|
||||
"Definition policy fields must be known boolean values"
|
||||
)
|
||||
before = definition_policy_state(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
broadened = sorted(
|
||||
field
|
||||
for field, value in clean_policy.items()
|
||||
if value and before.parent.limits.get(field) is False
|
||||
)
|
||||
if broadened:
|
||||
raise DefinitionPolicyError(
|
||||
"Lower-scope policy cannot broaden parent restrictions: "
|
||||
+ ", ".join(broadened)
|
||||
)
|
||||
set_policy_override(
|
||||
session,
|
||||
policy_family="definition",
|
||||
target_key=module_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy=clean_policy,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
_clear_resolution_cache(session)
|
||||
return definition_policy_state(
|
||||
session,
|
||||
module_id=module_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
|
||||
|
||||
def remove_definition_policy(
|
||||
session: Session,
|
||||
*,
|
||||
module_id: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> bool:
|
||||
row = get_policy_override(
|
||||
session,
|
||||
policy_family="definition",
|
||||
target_key=module_id,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
delete_policy_override(session, row)
|
||||
_clear_resolution_cache(session)
|
||||
return True
|
||||
|
||||
|
||||
def _rows_for_scope(
|
||||
session: Session,
|
||||
*,
|
||||
module_id: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> tuple[PolicyOverride, ...]:
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
rows = resolution_policy_overrides(
|
||||
session,
|
||||
policy_family="definition",
|
||||
target_keys=("*", module_id),
|
||||
tenant_id=tenant_id,
|
||||
group_ids=(scope_id,) if clean_scope == "group" and scope_id else (),
|
||||
user_ids=(scope_id,) if clean_scope == "user" and scope_id else (),
|
||||
)
|
||||
maximum_rank = _scope_rank(clean_scope)
|
||||
return tuple(row for row in rows if _scope_rank(row.scope_type) <= maximum_rank)
|
||||
|
||||
|
||||
def _scope_rank(scope_type: str) -> int:
|
||||
try:
|
||||
return ("system", "tenant", "group", "user").index(
|
||||
scope_type.strip().casefold()
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise DefinitionPolicyError(
|
||||
"Definition policy scope must be system, tenant, group, or user"
|
||||
) from exc
|
||||
|
||||
|
||||
def _clear_resolution_cache(session: Session) -> None:
|
||||
session.info.pop("govoplan_policy_override_resolution", None)
|
||||
|
||||
|
||||
def definition_policy_response_payload(
|
||||
state: DefinitionPolicyState,
|
||||
) -> dict[str, Any]:
|
||||
local_policy: Mapping[str, Any] = state.local_policy
|
||||
if state.row is not None:
|
||||
_, malformed = validate_definition_policy(state.row.policy)
|
||||
if malformed:
|
||||
local_policy = {"configuration_status": "invalid_fail_closed"}
|
||||
return {
|
||||
"id": state.row.id if state.row is not None else None,
|
||||
"revision": state.row.revision if state.row is not None else None,
|
||||
"policy": dict(local_policy),
|
||||
"effective_policy": dict(state.effective.limits),
|
||||
"parent_policy": dict(state.parent.limits),
|
||||
"source_path": [step.to_dict() for step in state.effective.source_path],
|
||||
"diagnostics": [dict(item) for item in state.effective.diagnostics],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFINITION_POLICY_FIELDS",
|
||||
"DefinitionPolicyError",
|
||||
"DefinitionPolicyState",
|
||||
"definition_policy_response_payload",
|
||||
"definition_policy_state",
|
||||
"remove_definition_policy",
|
||||
"save_definition_policy",
|
||||
]
|
||||
@@ -1,6 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION,
|
||||
@@ -8,11 +17,14 @@ from govoplan_core.core.policy import (
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_policy.backend.db import models as policy_models
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
@@ -31,7 +43,9 @@ def _privacy_retention_service(context: ModuleContext) -> object:
|
||||
|
||||
def _scheduling_participant_privacy_policy(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_policy.backend.scheduling_privacy import SqlSchedulingParticipantPrivacyPolicy
|
||||
from govoplan_policy.backend.scheduling_privacy import (
|
||||
SqlSchedulingParticipantPrivacyPolicy,
|
||||
)
|
||||
|
||||
return SqlSchedulingParticipantPrivacyPolicy()
|
||||
|
||||
@@ -49,7 +63,10 @@ manifest = ModuleManifest(
|
||||
id="policy",
|
||||
name="Policy",
|
||||
version="0.1.9",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(
|
||||
name="policy.definition_governance",
|
||||
@@ -57,14 +74,58 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
route_factory=_route_factory,
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="policy",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
policy_models.PolicyOverride,
|
||||
label="Policy overrides",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement removes explicit definition and View "
|
||||
"policy overrides after a database snapshot."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
policy_models.PolicyOverride,
|
||||
label="Policy overrides",
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="policy",
|
||||
package_name="@govoplan/policy-webui",
|
||||
view_surfaces=(
|
||||
ViewSurface(id="policy.admin.system-retention", module_id="policy", kind="section", label="System retention", order=80),
|
||||
ViewSurface(id="policy.admin.tenant-retention", module_id="policy", kind="section", label="Tenant retention", order=80),
|
||||
ViewSurface(id="policy.admin.group-retention", module_id="policy", kind="section", label="Group retention", order=80),
|
||||
ViewSurface(id="policy.admin.user-retention", module_id="policy", kind="section", label="User retention", order=80),
|
||||
ViewSurface(
|
||||
id="policy.admin.system-retention",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="System retention",
|
||||
order=80,
|
||||
),
|
||||
ViewSurface(
|
||||
id="policy.admin.tenant-retention",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="Tenant retention",
|
||||
order=80,
|
||||
),
|
||||
ViewSurface(
|
||||
id="policy.admin.group-retention",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="Group retention",
|
||||
order=80,
|
||||
),
|
||||
ViewSurface(
|
||||
id="policy.admin.user-retention",
|
||||
module_id="policy",
|
||||
kind="section",
|
||||
label="User retention",
|
||||
order=80,
|
||||
),
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Policy database migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Policy migration revisions."""
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""Add governed hierarchical Policy overrides.
|
||||
|
||||
Revision ID: a9c4e7b2d5f8
|
||||
Revises: None
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a9c4e7b2d5f8"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"policy_overrides",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("policy_family", sa.String(length=40), nullable=False),
|
||||
sa.Column("target_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("scope_key", sa.String(length=320), nullable=False),
|
||||
sa.Column("policy", sa.JSON(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_policy_overrides")),
|
||||
sa.UniqueConstraint(
|
||||
"policy_family",
|
||||
"target_key",
|
||||
"scope_key",
|
||||
name="uq_policy_override_family_target_scope",
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "scope_id", "created_by", "updated_by"):
|
||||
op.create_index(
|
||||
op.f(f"ix_policy_overrides_{column}"),
|
||||
"policy_overrides",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_policy_overrides_resolution",
|
||||
"policy_overrides",
|
||||
[
|
||||
"policy_family",
|
||||
"target_key",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_policy_overrides_resolution",
|
||||
table_name="policy_overrides",
|
||||
)
|
||||
for column in ("updated_by", "created_by", "scope_id", "tenant_id"):
|
||||
op.drop_index(
|
||||
op.f(f"ix_policy_overrides_{column}"),
|
||||
table_name="policy_overrides",
|
||||
)
|
||||
op.drop_table("policy_overrides")
|
||||
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
|
||||
|
||||
POLICY_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user"})
|
||||
POLICY_FAMILIES = frozenset({"definition", "view"})
|
||||
|
||||
|
||||
class PolicyOverrideError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_policy_target(policy_family: str, target_key: str) -> tuple[str, str]:
|
||||
family = policy_family.strip().casefold()
|
||||
target = target_key.strip().casefold()
|
||||
if family not in POLICY_FAMILIES:
|
||||
raise PolicyOverrideError("Policy family must be definition or view")
|
||||
if not target or len(target) > 120:
|
||||
raise PolicyOverrideError("Policy target must contain 1 to 120 characters")
|
||||
if family == "view" and target != "*":
|
||||
raise PolicyOverrideError("View policy uses the shared '*' target")
|
||||
return family, target
|
||||
|
||||
|
||||
def normalize_policy_scope(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> tuple[str | None, str | None, str]:
|
||||
clean_scope = scope_type.strip().casefold()
|
||||
clean_id = str(scope_id or "").strip() or None
|
||||
if clean_scope not in POLICY_SCOPE_TYPES:
|
||||
raise PolicyOverrideError("Policy scope must be system, tenant, group, or user")
|
||||
if clean_scope == "system":
|
||||
if clean_id is not None:
|
||||
raise PolicyOverrideError("System policy cannot declare a scope ID")
|
||||
return None, None, "system"
|
||||
if clean_scope == "tenant":
|
||||
if clean_id not in {None, tenant_id}:
|
||||
raise PolicyOverrideError("Tenant policy must target the active tenant")
|
||||
return tenant_id, tenant_id, f"tenant:{tenant_id}"
|
||||
if clean_id is None:
|
||||
raise PolicyOverrideError(
|
||||
f"{clean_scope.capitalize()} policy requires a scope ID"
|
||||
)
|
||||
return tenant_id, clean_id, f"{clean_scope}:{tenant_id}:{clean_id}"
|
||||
|
||||
|
||||
def get_policy_override(
|
||||
session: Session,
|
||||
*,
|
||||
policy_family: str,
|
||||
target_key: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None = None,
|
||||
) -> PolicyOverride | None:
|
||||
family, target = normalize_policy_target(policy_family, target_key)
|
||||
_, _, scope_key = normalize_policy_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return (
|
||||
session.query(PolicyOverride)
|
||||
.filter(
|
||||
PolicyOverride.policy_family == family,
|
||||
PolicyOverride.target_key == target,
|
||||
PolicyOverride.scope_key == scope_key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def set_policy_override(
|
||||
session: Session,
|
||||
*,
|
||||
policy_family: str,
|
||||
target_key: str,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
policy: Any,
|
||||
actor_id: str | None,
|
||||
) -> PolicyOverride:
|
||||
family, target = normalize_policy_target(policy_family, target_key)
|
||||
row_tenant_id, clean_scope_id, scope_key = normalize_policy_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
row = (
|
||||
session.query(PolicyOverride)
|
||||
.filter(
|
||||
PolicyOverride.policy_family == family,
|
||||
PolicyOverride.target_key == target,
|
||||
PolicyOverride.scope_key == scope_key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
row = PolicyOverride(
|
||||
policy_family=family,
|
||||
target_key=target,
|
||||
tenant_id=row_tenant_id,
|
||||
scope_type=scope_type.strip().casefold(),
|
||||
scope_id=clean_scope_id,
|
||||
scope_key=scope_key,
|
||||
policy=policy,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.policy = policy
|
||||
row.revision += 1
|
||||
row.updated_by = actor_id
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def delete_policy_override(session: Session, row: PolicyOverride) -> None:
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def resolution_policy_overrides(
|
||||
session: Session,
|
||||
*,
|
||||
policy_family: str,
|
||||
target_keys: Iterable[str],
|
||||
tenant_id: str,
|
||||
group_ids: Iterable[str] = (),
|
||||
user_ids: Iterable[str] = (),
|
||||
) -> tuple[PolicyOverride, ...]:
|
||||
family = policy_family.strip().casefold()
|
||||
targets = tuple(
|
||||
dict.fromkeys(
|
||||
normalize_policy_target(family, target)[1] for target in target_keys
|
||||
)
|
||||
)
|
||||
groups = tuple(sorted({str(value) for value in group_ids if str(value)}))
|
||||
users = tuple(sorted({str(value) for value in user_ids if str(value)}))
|
||||
scope_filters = [
|
||||
PolicyOverride.scope_key == "system",
|
||||
PolicyOverride.scope_key == f"tenant:{tenant_id}",
|
||||
]
|
||||
if groups:
|
||||
scope_filters.append(
|
||||
PolicyOverride.scope_key.in_(
|
||||
[f"group:{tenant_id}:{group_id}" for group_id in groups]
|
||||
)
|
||||
)
|
||||
if users:
|
||||
scope_filters.append(
|
||||
PolicyOverride.scope_key.in_(
|
||||
[f"user:{tenant_id}:{user_id}" for user_id in users]
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
session.query(PolicyOverride)
|
||||
.filter(
|
||||
PolicyOverride.policy_family == family,
|
||||
PolicyOverride.target_key.in_(targets),
|
||||
or_(*scope_filters),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
target_order = {target: index for index, target in enumerate(targets)}
|
||||
scope_order = {"system": 0, "tenant": 1, "group": 2, "user": 3}
|
||||
return tuple(
|
||||
sorted(
|
||||
rows,
|
||||
key=lambda row: (
|
||||
scope_order.get(row.scope_type, 99),
|
||||
row.scope_id or "",
|
||||
target_order.get(row.target_key, 99),
|
||||
row.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"POLICY_FAMILIES",
|
||||
"POLICY_SCOPE_TYPES",
|
||||
"PolicyOverrideError",
|
||||
"delete_policy_override",
|
||||
"get_policy_override",
|
||||
"normalize_policy_scope",
|
||||
"normalize_policy_target",
|
||||
"resolution_policy_overrides",
|
||||
"set_policy_override",
|
||||
]
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.policy import DefinitionGovernanceRequest, DefinitionScopeRef
|
||||
from govoplan_policy.backend.db.models import PolicyOverride
|
||||
from govoplan_policy.backend.definition_governance import (
|
||||
DEFINITION_POLICY_FIELDS,
|
||||
DefinitionGovernancePolicyProvider,
|
||||
)
|
||||
from govoplan_policy.backend.definition_policy_service import (
|
||||
DefinitionPolicyError,
|
||||
definition_policy_response_payload,
|
||||
definition_policy_state,
|
||||
save_definition_policy,
|
||||
)
|
||||
|
||||
|
||||
class DefinitionPolicyOverrideTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
PolicyOverride.__table__.create(self.engine)
|
||||
self.session_factory = sessionmaker(
|
||||
bind=self.engine,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
self.session: Session = self.session_factory()
|
||||
self.provider = DefinitionGovernancePolicyProvider()
|
||||
self.actor = PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
group_ids=frozenset({"group-1"}),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _save(
|
||||
self,
|
||||
*,
|
||||
scope_type: str,
|
||||
policy: object,
|
||||
scope_id: str | None = None,
|
||||
module_id: str = "dataflow",
|
||||
):
|
||||
return save_definition_policy(
|
||||
self.session,
|
||||
module_id=module_id,
|
||||
tenant_id="tenant-1",
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy=policy,
|
||||
actor_id="account-1",
|
||||
)
|
||||
|
||||
def _resolve(self, *, action: str, definition_scope: DefinitionScopeRef):
|
||||
return self.provider.resolve_definition_action(
|
||||
self.session,
|
||||
request=DefinitionGovernanceRequest(
|
||||
module_id="dataflow",
|
||||
definition_ref="pipeline:1",
|
||||
tenant_id="tenant-1",
|
||||
definition_scope=definition_scope,
|
||||
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
|
||||
definition_kind="flow",
|
||||
action=action, # type: ignore[arg-type]
|
||||
actor=self.actor,
|
||||
status="active",
|
||||
inherit_to_lower_scopes=True,
|
||||
allow_run=True,
|
||||
allow_reuse=True,
|
||||
allow_automation=True,
|
||||
),
|
||||
)
|
||||
|
||||
def test_persists_and_revises_an_explicit_policy(self) -> None:
|
||||
first = self._save(scope_type="tenant", policy={"allow_run": False})
|
||||
second = self._save(
|
||||
scope_type="tenant",
|
||||
policy={"allow_run": False, "allow_reuse": False},
|
||||
)
|
||||
|
||||
self.assertEqual(first.row.id, second.row.id)
|
||||
self.assertEqual(2, second.row.revision)
|
||||
self.assertEqual(
|
||||
{"allow_run": False, "allow_reuse": False},
|
||||
second.local_policy,
|
||||
)
|
||||
|
||||
def test_system_and_tenant_limits_apply_to_group_and_user_targets(self) -> None:
|
||||
self._save(
|
||||
scope_type="system",
|
||||
module_id="*",
|
||||
policy={"allow_automation": False},
|
||||
)
|
||||
self._save(
|
||||
scope_type="tenant",
|
||||
policy={"allow_reuse": False},
|
||||
)
|
||||
|
||||
group = definition_policy_state(
|
||||
self.session,
|
||||
module_id="dataflow",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="group",
|
||||
scope_id="group-1",
|
||||
)
|
||||
user = definition_policy_state(
|
||||
self.session,
|
||||
module_id="dataflow",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id="user-1",
|
||||
)
|
||||
|
||||
for state in (group, user):
|
||||
self.assertFalse(state.effective.limits["allow_automation"])
|
||||
self.assertFalse(state.effective.limits["allow_reuse"])
|
||||
self.assertEqual(
|
||||
["system", "tenant:tenant-1"],
|
||||
[step.path for step in state.effective.source_path],
|
||||
)
|
||||
|
||||
def test_lower_scopes_cannot_broaden_parent_restrictions(self) -> None:
|
||||
self._save(scope_type="tenant", policy={"allow_run": False})
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
DefinitionPolicyError,
|
||||
"cannot broaden parent restrictions: allow_run",
|
||||
):
|
||||
self._save(
|
||||
scope_type="group",
|
||||
scope_id="group-1",
|
||||
policy={"allow_run": True},
|
||||
)
|
||||
|
||||
def test_explicit_policy_restricts_provider_actions(self) -> None:
|
||||
self._save(
|
||||
scope_type="tenant",
|
||||
policy={"allow_edit": False, "allow_reuse": False},
|
||||
)
|
||||
|
||||
edit = self._resolve(
|
||||
action="edit",
|
||||
definition_scope=DefinitionScopeRef("tenant", "tenant-1"),
|
||||
)
|
||||
reuse = self._resolve(
|
||||
action="reuse",
|
||||
definition_scope=DefinitionScopeRef("group", "group-1"),
|
||||
)
|
||||
|
||||
self.assertFalse(edit.allowed)
|
||||
self.assertFalse(reuse.allowed)
|
||||
self.assertEqual(
|
||||
"Editing is disabled by explicit Policy restrictions.",
|
||||
edit.reason,
|
||||
)
|
||||
self.assertEqual("tenant:tenant-1", edit.source_path[0].path)
|
||||
|
||||
def test_malformed_persisted_policy_fails_closed_and_is_redacted(self) -> None:
|
||||
row = PolicyOverride(
|
||||
policy_family="definition",
|
||||
target_key="dataflow",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
scope_key="tenant:tenant-1",
|
||||
policy={"allow_run": "definitely", "secret": "do-not-echo"},
|
||||
)
|
||||
self.session.add(row)
|
||||
self.session.flush()
|
||||
|
||||
decision = self._resolve(
|
||||
action="run",
|
||||
definition_scope=DefinitionScopeRef("tenant", "tenant-1"),
|
||||
)
|
||||
state = definition_policy_state(
|
||||
self.session,
|
||||
module_id="dataflow",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
)
|
||||
payload = definition_policy_response_payload(state)
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(
|
||||
{field: False for field in DEFINITION_POLICY_FIELDS},
|
||||
decision.details["effective_limits"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
"code": "definition_policy.invalid",
|
||||
"scope": "tenant:tenant-1",
|
||||
"target_key": "dataflow",
|
||||
}
|
||||
],
|
||||
decision.details["policy_diagnostics"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{"configuration_status": "invalid_fail_closed"},
|
||||
payload["policy"],
|
||||
)
|
||||
self.assertNotIn("secret", repr(decision.to_dict()))
|
||||
self.assertNotIn("do-not-echo", repr(payload))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user