fix(poll): enforce actor-bound access and persistence

This commit is contained in:
2026-07-20 17:33:56 +02:00
parent bb7a18ef48
commit e7f3b1d7bf
3 changed files with 579 additions and 19 deletions

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_poll.backend.manifest import READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_poll.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_poll.backend.schemas import (
PollCreateRequest,
PollDecisionRequest,
@@ -28,15 +28,16 @@ from govoplan_poll.backend.service import (
create_poll,
decide_poll,
get_poll_by_invitation_token,
get_poll,
get_visible_poll,
list_poll_responses,
list_poll_invitations,
list_polls,
list_visible_polls,
open_poll,
poll_invitation_response,
poll_response,
poll_response_item,
poll_result_summary_by_id,
require_visible_poll_results,
revoke_poll_invitation,
submit_poll_response,
submit_poll_response_with_token,
@@ -57,6 +58,8 @@ def _poll_http_error(exc: PollError) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if str(exc) == "Poll invitation not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if str(exc) == "Poll results are not visible":
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@@ -64,6 +67,38 @@ def _poll_response(poll) -> PollResponse:
return PollResponse.model_validate(poll_response(poll))
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
candidates = (
principal.account_id,
principal.membership_id,
getattr(principal.user, "id", None),
principal.identity_id,
principal.principal.service_account_id,
)
return tuple(dict.fromkeys(str(value) for value in candidates if value))
def _can_manage_polls(principal: ApiPrincipal) -> bool:
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
def _can_view_sensitive_poll_data(principal: ApiPrincipal, poll: object) -> bool:
return _can_manage_polls(principal) or getattr(poll, "created_by_user_id", None) in _principal_actor_ids(principal)
def _require_sensitive_poll_data_access(principal: ApiPrincipal, poll: object) -> None:
if not _can_view_sensitive_poll_data(principal, poll):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Poll respondent and invitation data is restricted to poll organizers",
)
def _require_sensitive_poll_data_scope(principal: ApiPrincipal) -> None:
if not (has_scope(principal, READ_SCOPE) or _can_manage_polls(principal)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {READ_SCOPE}")
@router.get("/polls", response_model=PollListResponse)
def api_list_polls(
status_filter: str | None = Query(default=None, alias="status"),
@@ -72,7 +107,14 @@ def api_list_polls(
principal: ApiPrincipal = Depends(get_api_principal),
) -> PollListResponse:
_require_scope(principal, READ_SCOPE)
polls = list_polls(session, tenant_id=principal.tenant_id, status=status_filter, kind=kind)
polls = list_visible_polls(
session,
tenant_id=principal.tenant_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
status=status_filter,
kind=kind,
)
return PollListResponse(polls=[_poll_response(poll) for poll in polls])
@@ -87,7 +129,9 @@ def api_create_poll(
poll = create_poll(session, tenant_id=principal.tenant_id, user_id=principal.account_id, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
return _poll_response(poll)
response = _poll_response(poll)
session.commit()
return response
@router.get("/polls/{poll_id}", response_model=PollResponse)
@@ -98,7 +142,15 @@ def api_get_poll(
) -> PollResponse:
_require_scope(principal, READ_SCOPE)
try:
return _poll_response(get_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id))
return _poll_response(
get_visible_poll(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
)
except PollError as exc:
raise _poll_http_error(exc) from exc
@@ -112,9 +164,12 @@ def api_update_poll(
) -> PollResponse:
_require_scope(principal, WRITE_SCOPE)
try:
return _poll_response(update_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload))
poll = update_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
response = _poll_response(poll)
session.commit()
return response
@router.post("/polls/{poll_id}/open", response_model=PollStatusResponse)
@@ -128,7 +183,9 @@ def api_open_poll(
poll = open_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollStatusResponse(poll=_poll_response(poll))
response = PollStatusResponse(poll=_poll_response(poll))
session.commit()
return response
@router.post("/polls/{poll_id}/close", response_model=PollStatusResponse)
@@ -142,7 +199,9 @@ def api_close_poll(
poll = close_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollStatusResponse(poll=_poll_response(poll))
response = PollStatusResponse(poll=_poll_response(poll))
session.commit()
return response
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
@@ -157,7 +216,9 @@ def api_decide_poll(
poll = decide_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollStatusResponse(poll=_poll_response(poll))
response = PollStatusResponse(poll=_poll_response(poll))
session.commit()
return response
@router.post("/polls/{poll_id}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
@@ -169,10 +230,31 @@ def api_submit_poll_response(
) -> PollResponseItem:
_require_scope(principal, RESPOND_SCOPE)
try:
response = submit_poll_response(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
get_visible_poll(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
actor_payload = payload.model_copy(
update={
"respondent_id": principal.account_id,
"respondent_label": principal.display_name or principal.email,
"metadata": {key: value for key, value in payload.metadata.items() if key != "invitation_id"},
}
)
response = submit_poll_response(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
payload=actor_payload,
)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollResponseItem.model_validate(poll_response_item(response))
result = PollResponseItem.model_validate(poll_response_item(response))
session.commit()
return result
@router.get("/polls/{poll_id}/responses", response_model=PollResponseListResponse)
@@ -181,8 +263,22 @@ def api_list_poll_responses(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PollResponseListResponse:
_require_scope(principal, READ_SCOPE)
_require_sensitive_poll_data_scope(principal)
try:
poll = get_visible_poll(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
_require_sensitive_poll_data_access(principal, poll)
require_visible_poll_results(
session,
poll=poll,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
responses = list_poll_responses(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
@@ -197,6 +293,19 @@ def api_poll_summary(
) -> PollResultSummaryResponse:
_require_scope(principal, READ_SCOPE)
try:
poll = get_visible_poll(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
require_visible_poll_results(
session,
poll=poll,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id))
except PollError as exc:
raise _poll_http_error(exc) from exc
@@ -214,7 +323,9 @@ def api_create_poll_invitation(
invitation, token = create_poll_invitation(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
response = PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
session.commit()
return response
@router.get("/polls/{poll_id}/invitations", response_model=PollInvitationListResponse)
@@ -223,8 +334,16 @@ def api_list_poll_invitations(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PollInvitationListResponse:
_require_scope(principal, READ_SCOPE)
_require_sensitive_poll_data_scope(principal)
try:
poll = get_visible_poll(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal),
)
_require_sensitive_poll_data_access(principal, poll)
invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
@@ -250,7 +369,9 @@ def api_revoke_poll_invitation(
)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollInvitationResponse.model_validate(poll_invitation_response(invitation))
response = PollInvitationResponse.model_validate(poll_invitation_response(invitation))
session.commit()
return response
@router.get("/public/{token}", response_model=PollResponse)
@@ -274,4 +395,6 @@ def api_submit_public_poll_response(
response = submit_poll_response_with_token(session, token=token, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollResponseItem.model_validate(poll_response_item(response))
result = PollResponseItem.model_validate(poll_response_item(response))
session.commit()
return result