feat: add personal and group view ownership

This commit is contained in:
2026-07-28 22:13:15 +02:00
parent 34221b633b
commit 3de8d4aa5f
12 changed files with 833 additions and 213 deletions
+153 -20
View File
@@ -12,6 +12,10 @@ from govoplan_views.backend.manifest import (
ASSIGNMENT_WRITE_SCOPE,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE,
SYSTEM_ASSIGNMENT_READ_SCOPE,
@@ -82,7 +86,15 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
)
def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None:
def _has_any_scope(principal: ApiPrincipal, *scopes: str) -> bool:
return any(has_scope(principal, scope) for scope in scopes)
def _require_definition_read(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None = None,
) -> None:
if scope_type == "system":
_require_any_scope(
principal,
@@ -90,22 +102,98 @@ def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None:
SYSTEM_DEFINITION_WRITE_SCOPE,
)
return
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
if scope_type == "tenant":
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
)
return
if _has_any_scope(principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE):
return
if scope_type == "group":
if not scope_id or scope_id not in principal.group_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Group Views can only be managed by members of that group.",
)
_require_any_scope(
principal,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
)
return
if scope_type == "user":
if scope_id != principal.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Personal Views can only be managed by their owner.",
)
_require_any_scope(
principal,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
)
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported View definition scope: {scope_type}",
)
def _require_definition_write(principal: ApiPrincipal, scope_type: str) -> None:
_require_any_scope(
principal,
SYSTEM_DEFINITION_WRITE_SCOPE
if scope_type == "system"
else DEFINITION_WRITE_SCOPE,
def _require_definition_write(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None = None,
) -> None:
if scope_type == "system":
_require_any_scope(principal, SYSTEM_DEFINITION_WRITE_SCOPE)
return
if scope_type == "tenant":
_require_any_scope(principal, DEFINITION_WRITE_SCOPE)
return
if has_scope(principal, DEFINITION_WRITE_SCOPE):
return
if scope_type == "group":
if not scope_id or scope_id not in principal.group_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Group Views can only be managed by members of that group.",
)
_require_any_scope(principal, GROUP_DEFINITION_WRITE_SCOPE)
return
if scope_type == "user":
if scope_id != principal.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Personal Views can only be managed by their owner.",
)
_require_any_scope(principal, PERSONAL_DEFINITION_WRITE_SCOPE)
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported View definition scope: {scope_type}",
)
def _definition_scope_id(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
) -> str | None:
if scope_type in {"system", "tenant"}:
return scope_id
if scope_type == "user":
return (scope_id or principal.account_id).strip()
target_id = (scope_id or "").strip()
if not target_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Group View definitions require a target id.",
)
return target_id
def _require_assignment_read(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_any_scope(
@@ -279,6 +367,10 @@ def api_view_surfaces(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
GROUP_DEFINITION_READ_SCOPE,
GROUP_DEFINITION_WRITE_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_WRITE_SCOPE,
SYSTEM_DEFINITION_READ_SCOPE,
SYSTEM_DEFINITION_WRITE_SCOPE,
)
@@ -313,16 +405,22 @@ def api_view_surfaces(
@router.get("/definitions", response_model=ViewDefinitionListResponse)
def api_list_definitions(
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
scope_type: str = Query(
default="tenant",
pattern="^(system|tenant|group|user)$",
),
scope_id: str | None = Query(default=None, max_length=255),
include_inherited: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionListResponse:
_require_definition_read(principal, scope_type)
target_id = _definition_scope_id(principal, scope_type, scope_id)
_require_definition_read(principal, scope_type, target_id)
definitions = list_definitions(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=target_id,
include_inherited=include_inherited,
)
return ViewDefinitionListResponse(
@@ -330,7 +428,16 @@ def api_list_definitions(
_definition_response(
session,
definition,
readonly=(scope_type == "tenant" and definition.scope_type == "system"),
readonly=(
(scope_type == "tenant" and definition.scope_type == "system")
or (
scope_type in {"group", "user"}
and (
definition.scope_type != scope_type
or definition.scope_id != target_id
)
)
),
)
for definition in definitions
]
@@ -347,12 +454,18 @@ def api_create_definition(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse:
_require_definition_write(principal, payload.scope_type)
target_id = _definition_scope_id(
principal,
payload.scope_type,
payload.scope_id,
)
_require_definition_write(principal, payload.scope_type, target_id)
try:
definition = create_definition(
session,
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
scope_id=target_id,
definition_key=payload.definition_key,
name=payload.name,
description=payload.description,
@@ -391,7 +504,11 @@ def api_update_definition(
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
_require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
update_definition(
session,
definition,
@@ -430,7 +547,11 @@ def api_list_revisions(
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_read(principal, definition.scope_type)
_require_definition_read(
principal,
definition.scope_type,
definition.scope_id,
)
return [
ViewRevisionResponse.model_validate(revision)
for revision in definition_revisions(
@@ -458,7 +579,11 @@ def api_create_revision(
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
_require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
revision = create_revision(
session,
definition,
@@ -500,7 +625,11 @@ def api_publish_revision(
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
_require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
revision = get_revision(
session,
definition_id=definition.id,
@@ -543,7 +672,11 @@ def api_archive_definition(
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
_require_definition_write(
principal,
definition.scope_type,
definition.scope_id,
)
archive_definition(
session,
definition,