331 lines
10 KiB
Python
331 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.api.v1.schemas import (
|
|
ReferenceOptionListResponse,
|
|
ReferenceOptionResponse,
|
|
)
|
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
|
from govoplan_core.audit.logging import audit_from_principal
|
|
from govoplan_core.core.references import (
|
|
access_scope_reference_page,
|
|
access_scope_reference_provider_available,
|
|
)
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_files.backend.runtime import get_registry
|
|
from govoplan_files.backend.schemas import (
|
|
BulkFileShareRequest,
|
|
BulkFileShareResponse,
|
|
FileShareRequest,
|
|
FileShareResponse,
|
|
FileSharesResponse,
|
|
)
|
|
from govoplan_files.backend.storage.common import FileStorageError
|
|
from govoplan_files.backend.storage.files import (
|
|
current_file_share_for_target,
|
|
get_asset_for_share_management,
|
|
list_file_shares,
|
|
revoke_file_share,
|
|
share_file,
|
|
share_files,
|
|
)
|
|
from govoplan_files.backend.route_support import (
|
|
_file_share_response,
|
|
_http_error,
|
|
_is_admin,
|
|
)
|
|
|
|
router = APIRouter(prefix="/files", tags=["files"])
|
|
|
|
|
|
@router.get(
|
|
"/{file_id}/share-target-options",
|
|
response_model=ReferenceOptionListResponse,
|
|
)
|
|
def search_share_targets(
|
|
file_id: str,
|
|
target_type: str,
|
|
q: str = "",
|
|
selected: list[str] = Query(default=[]),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
cursor: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
|
) -> ReferenceOptionListResponse:
|
|
if target_type not in {"user", "group"}:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="Share target type must be user or group",
|
|
)
|
|
try:
|
|
get_asset_for_share_management(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
registry = get_registry()
|
|
page = access_scope_reference_page(
|
|
registry,
|
|
principal,
|
|
scope_type=target_type,
|
|
reference_kind="membership" if target_type == "user" else "group",
|
|
query=q,
|
|
selected_values=selected,
|
|
limit=limit,
|
|
cursor=cursor,
|
|
administrative=True,
|
|
session=session,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
except FileStorageError as exc:
|
|
raise _http_error(exc) from exc
|
|
return ReferenceOptionListResponse(
|
|
options=[
|
|
ReferenceOptionResponse(**option.to_dict()) for option in page.options
|
|
],
|
|
provider_available=access_scope_reference_provider_available(registry),
|
|
next_cursor=page.next_cursor,
|
|
has_more=page.has_more,
|
|
)
|
|
|
|
|
|
@router.get("/{file_id}/shares", response_model=FileSharesResponse)
|
|
def list_shares(
|
|
file_id: str,
|
|
include_inactive: bool = False,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
|
):
|
|
try:
|
|
asset = get_asset_for_share_management(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
return FileSharesResponse(
|
|
shares=[
|
|
_file_share_response(share)
|
|
for share in list_file_shares(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
asset_id=asset.id,
|
|
include_inactive=include_inactive,
|
|
)
|
|
]
|
|
)
|
|
except FileStorageError as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.post("/{file_id}/shares", response_model=FileShareResponse)
|
|
def create_share(
|
|
file_id: str,
|
|
payload: FileShareRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
|
):
|
|
try:
|
|
asset = get_asset_for_share_management(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
previous = current_file_share_for_target(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
asset_id=asset.id,
|
|
target_type=payload.target_type,
|
|
target_id=payload.target_id,
|
|
)
|
|
previous_permission = previous.permission if previous else None
|
|
previous_expiry = previous.expires_at if previous else None
|
|
share = share_file(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
asset=asset,
|
|
target_type=payload.target_type,
|
|
target_id=payload.target_id,
|
|
permission=payload.permission,
|
|
user_id=principal.user.id,
|
|
expires_at=payload.expires_at,
|
|
)
|
|
session.flush()
|
|
action = _share_audit_action(
|
|
existed=previous is not None,
|
|
previous_permission=previous_permission,
|
|
permission=share.permission,
|
|
previous_expiry=previous_expiry,
|
|
expiry=share.expires_at,
|
|
)
|
|
if action:
|
|
_audit_share_change(session, principal, share, action=action)
|
|
session.commit()
|
|
return _file_share_response(share)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.post("/bulk-shares", response_model=BulkFileShareResponse)
|
|
def create_bulk_shares(
|
|
payload: BulkFileShareRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
|
):
|
|
try:
|
|
file_ids = list(dict.fromkeys(payload.file_ids))
|
|
assets = [
|
|
get_asset_for_share_management(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
for file_id in file_ids
|
|
]
|
|
previous_by_asset = {
|
|
asset.id: current_file_share_for_target(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
asset_id=asset.id,
|
|
target_type=payload.target_type,
|
|
target_id=payload.target_id,
|
|
)
|
|
for asset in assets
|
|
}
|
|
previous_values = {
|
|
asset_id: (
|
|
share.permission if share else None,
|
|
share.expires_at if share else None,
|
|
)
|
|
for asset_id, share in previous_by_asset.items()
|
|
}
|
|
shares = share_files(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
assets=assets,
|
|
target_type=payload.target_type,
|
|
target_id=payload.target_id,
|
|
permission=payload.permission,
|
|
user_id=principal.user.id,
|
|
expires_at=payload.expires_at,
|
|
)
|
|
session.flush()
|
|
for share in shares:
|
|
previous_permission, previous_expiry = previous_values[share.file_asset_id]
|
|
action = _share_audit_action(
|
|
existed=previous_by_asset[share.file_asset_id] is not None,
|
|
previous_permission=previous_permission,
|
|
permission=share.permission,
|
|
previous_expiry=previous_expiry,
|
|
expiry=share.expires_at,
|
|
)
|
|
if action:
|
|
_audit_share_change(session, principal, share, action=action)
|
|
session.commit()
|
|
return BulkFileShareResponse(
|
|
shared_count=len(shares),
|
|
shares=[_file_share_response(share) for share in shares],
|
|
)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.delete("/{file_id}/shares/{share_id}", response_model=FileShareResponse)
|
|
def revoke_share(
|
|
file_id: str,
|
|
share_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:share")),
|
|
):
|
|
try:
|
|
asset = get_asset_for_share_management(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
share, changed = revoke_file_share(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
asset_id=asset.id,
|
|
share_id=share_id,
|
|
user_id=principal.user.id,
|
|
)
|
|
if changed:
|
|
_audit_share_change(
|
|
session, principal, share, action="files.share.revoked"
|
|
)
|
|
session.commit()
|
|
return _file_share_response(share)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
def _share_audit_action(
|
|
*,
|
|
existed: bool,
|
|
previous_permission: str | None,
|
|
permission: str,
|
|
previous_expiry: datetime | None,
|
|
expiry: datetime | None,
|
|
) -> str | None:
|
|
if not existed:
|
|
return "files.share.granted"
|
|
permission_changed = previous_permission != permission
|
|
expiry_changed = _normalized_expiry(previous_expiry) != _normalized_expiry(expiry)
|
|
if permission_changed:
|
|
return "files.share.changed"
|
|
if expiry_changed:
|
|
return "files.share.expiry_changed"
|
|
return None
|
|
|
|
|
|
def _audit_share_change(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
share,
|
|
*,
|
|
action: str,
|
|
) -> None:
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action=action,
|
|
object_type="file_share",
|
|
object_id=share.id,
|
|
details={
|
|
"file_asset_id": share.file_asset_id,
|
|
"target_type": share.target_type,
|
|
"target_id": share.target_id,
|
|
"permission": share.permission,
|
|
"expires_at": _normalized_expiry(share.expires_at),
|
|
},
|
|
)
|
|
|
|
|
|
def _normalized_expiry(value: datetime | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc).isoformat()
|