302 lines
12 KiB
Python
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
|