Files
govoplan-files/src/govoplan_files/backend/capabilities.py

302 lines
12 KiB
Python

from __future__ import annotations
import base64
import binascii
from typing import Any
from sqlalchemy import or_
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.files import FileAccessProvider
from govoplan_core.core.modules import ModuleContext
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
from govoplan_files.backend.runtime import configure_runtime
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
managed_match_payloads,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
from govoplan_files.backend.storage.files import current_version_and_blob
from govoplan_files.backend.storage.paths import normalize_folder
VIRTUAL_FOLDER_RESOURCE_PREFIX = "virtual-folder:v1"
WRITE_ACTIONS = {
"files:file:upload",
"files:file:organize",
"files:file:share",
"files:file:delete",
"files:upload",
"files:organize",
"files:share",
"files:delete",
}
class FilesCampaignCapability:
prepared_campaign_snapshot = staticmethod(prepared_campaign_snapshot)
managed_match_payloads = staticmethod(managed_match_payloads)
public_attachment_summary_payload = staticmethod(public_attachment_summary_payload)
annotate_built_messages_with_managed_files = staticmethod(annotate_built_messages_with_managed_files)
record_campaign_attachment_uses_for_jobs = staticmethod(record_campaign_attachment_uses_for_jobs)
current_version_and_blob = staticmethod(current_version_and_blob)
@staticmethod
def mark_job_attachment_uses_sent(session: Any, job: Any) -> None:
from govoplan_files.backend.storage.campaign_usage import mark_job_attachment_uses_sent
mark_job_attachment_uses_sent(session, job)
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
configure_runtime(registry=context.registry, settings=context.settings)
return FilesCampaignCapability()
class FilesAccessService(FileAccessProvider):
def explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
normalized_type = resource_type.lower().strip()
if normalized_type in {"file", "file_asset", "files:file"}:
return self._explain_file(session, principal, resource_id=resource_id, action=action)
if normalized_type in {"folder", "file_folder", "files:folder"}:
return self._explain_folder(session, principal, resource_id=resource_id, action=action)
return ()
def _explain_file(
self,
session: object,
principal: PrincipalRef,
*,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
asset = session.get(FileAsset, resource_id) # type: ignore[attr-defined]
if asset is None or (principal.tenant_id and asset.tenant_id != principal.tenant_id):
return (_missing_resource("file", resource_id, principal.tenant_id, source="files.not_found"),)
items = [_file_resource(asset)]
items.extend(_owner_provenance(asset.owner_type, _owner_id(asset.owner_type, asset.owner_user_id, asset.owner_group_id), principal, source="files.owner"))
items.extend(_admin_provenance(principal, "files:file:admin", source="files.admin_scope"))
permission_values = {"write", "manage"} if _requires_write_share(action) else {"read", "write", "manage"}
shares = (
session.query(FileShare) # type: ignore[attr-defined]
.filter(
FileShare.tenant_id == asset.tenant_id,
FileShare.file_asset_id == asset.id,
FileShare.revoked_at.is_(None),
FileShare.permission.in_(sorted(permission_values)),
or_(
(FileShare.target_type == "user") & (FileShare.target_id == principal.membership_id),
(FileShare.target_type == "group") & (FileShare.target_id.in_(sorted(principal.group_ids))),
(FileShare.target_type == "tenant") & (FileShare.target_id == asset.tenant_id),
),
)
.order_by(FileShare.target_type.asc(), FileShare.target_id.asc())
.all()
)
for share in shares:
items.append(_share_provenance(share, source="files.share"))
return tuple(items)
def _explain_folder(
self,
session: object,
principal: PrincipalRef,
*,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
del action
folder = session.get(FileFolder, resource_id) # type: ignore[attr-defined]
if folder is None:
return self._explain_virtual_folder(session, principal, resource_id=resource_id)
if principal.tenant_id and folder.tenant_id != principal.tenant_id:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
return tuple([
AccessDecisionProvenance(
kind="resource",
id=folder.id,
label=folder.path,
tenant_id=folder.tenant_id,
source="files.folder",
details={
"resource_type": "folder",
"path": folder.path,
"owner_type": folder.owner_type,
"deleted": folder.deleted_at is not None,
},
),
*_owner_provenance(folder.owner_type, _owner_id(folder.owner_type, folder.owner_user_id, folder.owner_group_id), principal, source="files.owner"),
*_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
])
def _explain_virtual_folder(
self,
session: object,
principal: PrincipalRef,
*,
resource_id: str,
) -> tuple[AccessDecisionProvenance, ...]:
folder_ref = parse_virtual_folder_resource_id(resource_id)
if folder_ref is None:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
tenant_id, owner_type, owner_id, path = folder_ref
if principal.tenant_id and tenant_id != principal.tenant_id:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
child_prefix = f"{path}/"
owner_filter = FileAsset.owner_user_id == owner_id if owner_type == "user" else FileAsset.owner_group_id == owner_id
child = (
session.query(FileAsset.id) # type: ignore[attr-defined]
.filter(
FileAsset.tenant_id == tenant_id,
FileAsset.owner_type == owner_type,
owner_filter,
FileAsset.deleted_at.is_(None),
FileAsset.display_path.like(f"{child_prefix}%"),
)
.first()
)
if child is None:
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
return tuple([
AccessDecisionProvenance(
kind="resource",
id=resource_id,
label=path,
tenant_id=tenant_id,
source="files.virtual_folder",
details={
"resource_type": "folder",
"path": path,
"owner_type": owner_type,
"owner_id": owner_id,
"virtual": True,
"deleted": False,
},
),
*_owner_provenance(owner_type, owner_id, principal, source="files.owner"),
*_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
])
def access_capability(context: ModuleContext) -> FilesAccessService:
configure_runtime(registry=context.registry, settings=context.settings)
return FilesAccessService()
def virtual_folder_resource_id(*, tenant_id: str, owner_type: str, owner_id: str, path: str) -> str:
normalized_path = normalize_folder(path)
encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
return f"{VIRTUAL_FOLDER_RESOURCE_PREFIX}:{tenant_id}:{owner_type}:{owner_id}:{encoded_path}"
def parse_virtual_folder_resource_id(resource_id: str) -> tuple[str, str, str, str] | None:
parts = resource_id.split(":", 5)
if len(parts) != 6 or f"{parts[0]}:{parts[1]}" != VIRTUAL_FOLDER_RESOURCE_PREFIX:
return None
_, _, tenant_id, owner_type, owner_id, encoded_path = parts
if owner_type not in {"user", "group"} or not tenant_id or not owner_id or not encoded_path:
return None
padding = "=" * (-len(encoded_path) % 4)
try:
decoded_path = base64.urlsafe_b64decode(f"{encoded_path}{padding}").decode("utf-8")
path = normalize_folder(decoded_path)
except (binascii.Error, UnicodeDecodeError, ValueError):
return None
if not path:
return None
return tenant_id, owner_type, owner_id, path
def _file_resource(asset: FileAsset) -> AccessDecisionProvenance:
return AccessDecisionProvenance(
kind="resource",
id=asset.id,
label=asset.filename,
tenant_id=asset.tenant_id,
source="files.file",
details={
"resource_type": "file",
"display_path": asset.display_path,
"owner_type": asset.owner_type,
"deleted": asset.deleted_at is not None,
},
)
def _missing_resource(resource_type: str, resource_id: str, tenant_id: str | None, *, source: str) -> AccessDecisionProvenance:
return AccessDecisionProvenance(
kind="resource",
id=resource_id,
tenant_id=tenant_id,
source=source,
details={"resource_type": resource_type, "found": False},
)
def _owner_id(owner_type: str, owner_user_id: str | None, owner_group_id: str | None) -> str | None:
return owner_user_id if owner_type == "user" else owner_group_id
def _owner_provenance(owner_type: str, owner_id: str | None, principal: PrincipalRef, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
if owner_id is None:
return ()
matches_user = owner_type == "user" and owner_id == principal.membership_id
matches_group = owner_type == "group" and owner_id in principal.group_ids
if not (matches_user or matches_group):
return ()
return (
AccessDecisionProvenance(
kind="owner",
id=owner_id,
tenant_id=principal.tenant_id,
source=source,
details={"owner_type": owner_type},
),
)
def _admin_provenance(principal: PrincipalRef, required_scope: str, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
if not scopes_grant_compatible(principal.scopes, required_scope):
return ()
return (
AccessDecisionProvenance(
kind="policy",
id=required_scope,
label=required_scope,
tenant_id=principal.tenant_id,
source=source,
details={"grant": "tenant_admin"},
),
)
def _share_provenance(share: FileShare, *, source: str) -> AccessDecisionProvenance:
return AccessDecisionProvenance(
kind="share",
id=share.id,
label=share.permission,
tenant_id=share.tenant_id,
source=source,
details={
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
},
)
def _requires_write_share(action: str) -> bool:
return action in WRITE_ACTIONS