fix(poll): enforce actor-bound access and persistence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -211,6 +211,126 @@ def get_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
||||
return poll
|
||||
|
||||
|
||||
def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(value for value in values if value))
|
||||
|
||||
|
||||
def _has_active_invitation(session: Session, *, poll: Poll, actor_ids: tuple[str, ...]) -> bool:
|
||||
ids = _actor_ids(actor_ids)
|
||||
if not ids:
|
||||
return False
|
||||
invitations = (
|
||||
session.query(PollInvitation)
|
||||
.filter(
|
||||
PollInvitation.tenant_id == poll.tenant_id,
|
||||
PollInvitation.poll_id == poll.id,
|
||||
PollInvitation.respondent_id.in_(ids),
|
||||
PollInvitation.revoked_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = _now()
|
||||
return any(
|
||||
invitation.expires_at is None or response_datetime(invitation.expires_at) > now
|
||||
for invitation in invitations
|
||||
)
|
||||
|
||||
|
||||
def poll_is_visible(
|
||||
session: Session,
|
||||
*,
|
||||
poll: Poll,
|
||||
actor_ids: tuple[str, ...],
|
||||
can_manage: bool = False,
|
||||
) -> bool:
|
||||
ids = _actor_ids(actor_ids)
|
||||
if can_manage or (poll.created_by_user_id is not None and poll.created_by_user_id in ids):
|
||||
return True
|
||||
if poll.visibility in {"tenant", "public"}:
|
||||
return True
|
||||
if poll.visibility in {"private", "unlisted"}:
|
||||
return _has_active_invitation(session, poll=poll, actor_ids=ids)
|
||||
return False
|
||||
|
||||
|
||||
def list_visible_polls(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_ids: tuple[str, ...],
|
||||
can_manage: bool = False,
|
||||
status: str | None = None,
|
||||
kind: str | None = None,
|
||||
) -> list[Poll]:
|
||||
return [
|
||||
poll
|
||||
for poll in list_polls(session, tenant_id=tenant_id, status=status, kind=kind)
|
||||
if poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage)
|
||||
]
|
||||
|
||||
|
||||
def get_visible_poll(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
actor_ids: tuple[str, ...],
|
||||
can_manage: bool = False,
|
||||
) -> Poll:
|
||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||
if not poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage):
|
||||
# Do not disclose the existence of a private or unlisted poll.
|
||||
raise PollError("Poll not found")
|
||||
return poll
|
||||
|
||||
|
||||
def poll_results_are_visible(
|
||||
session: Session,
|
||||
*,
|
||||
poll: Poll,
|
||||
actor_ids: tuple[str, ...],
|
||||
can_manage: bool = False,
|
||||
) -> bool:
|
||||
ids = _actor_ids(actor_ids)
|
||||
if can_manage or (poll.created_by_user_id is not None and poll.created_by_user_id in ids):
|
||||
return True
|
||||
if poll.result_visibility == "public":
|
||||
return True
|
||||
if poll.result_visibility == "after_close":
|
||||
if poll.status in {"closed", "decided", "archived"}:
|
||||
return True
|
||||
return poll.closes_at is not None and response_datetime(poll.closes_at) <= _now()
|
||||
if poll.result_visibility == "after_response" and ids:
|
||||
return (
|
||||
session.query(PollResponse)
|
||||
.filter(
|
||||
PollResponse.tenant_id == poll.tenant_id,
|
||||
PollResponse.poll_id == poll.id,
|
||||
PollResponse.respondent_id.in_(ids),
|
||||
PollResponse.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def require_visible_poll_results(
|
||||
session: Session,
|
||||
*,
|
||||
poll: Poll,
|
||||
actor_ids: tuple[str, ...],
|
||||
can_manage: bool = False,
|
||||
) -> None:
|
||||
if not poll_results_are_visible(
|
||||
session,
|
||||
poll=poll,
|
||||
actor_ids=actor_ids,
|
||||
can_manage=can_manage,
|
||||
):
|
||||
raise PollError("Poll results are not visible")
|
||||
|
||||
|
||||
def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollUpdateRequest) -> Poll:
|
||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||
if poll.status in {"closed", "decided", "archived"}:
|
||||
@@ -379,8 +499,31 @@ def _existing_response(session: Session, *, poll: Poll, respondent_id: str | Non
|
||||
)
|
||||
|
||||
|
||||
def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
||||
"""Serialize the read-or-create path for identified respondents."""
|
||||
|
||||
poll = (
|
||||
session.query(Poll)
|
||||
.filter(
|
||||
Poll.tenant_id == tenant_id,
|
||||
Poll.id == poll_id,
|
||||
Poll.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if poll is None:
|
||||
raise PollError("Poll not found")
|
||||
return poll
|
||||
|
||||
|
||||
def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||
poll = _lock_poll_for_response(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
poll_id=poll_id,
|
||||
)
|
||||
_assert_poll_accepts_responses(poll)
|
||||
if payload.respondent_id is None and not poll.allow_anonymous:
|
||||
raise PollError("Anonymous responses are not allowed for this poll")
|
||||
@@ -561,7 +704,7 @@ def get_poll_by_invitation_token(session: Session, *, token: str) -> Poll:
|
||||
def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
||||
invitation = get_poll_invitation_by_token(session, token=token)
|
||||
metadata = dict(payload.metadata)
|
||||
metadata.setdefault("invitation_id", invitation.id)
|
||||
metadata["invitation_id"] = invitation.id
|
||||
response_payload = PollSubmitResponseRequest(
|
||||
respondent_id=invitation.respondent_id or f"invitation:{invitation.id}",
|
||||
respondent_label=payload.respondent_label or invitation.respondent_label or invitation.email,
|
||||
|
||||
Reference in New Issue
Block a user