518 lines
21 KiB
Python
518 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.campaigns import (
|
|
CAPABILITY_CAMPAIGNS_ACCESS,
|
|
CampaignAccessProvider,
|
|
)
|
|
from govoplan_core.core.modules import (
|
|
DocumentationCondition,
|
|
DocumentationContext,
|
|
DocumentationLink,
|
|
DocumentationTopic,
|
|
)
|
|
from govoplan_files.backend.storage.archives import ARCHIVE_UPLOAD_MAX_ENTRIES
|
|
from govoplan_files.backend.storage.access import user_group_ids
|
|
from govoplan_files.backend.storage.connector_visibility import (
|
|
connector_profile_usable_for_import,
|
|
visible_connector_profiles_for_actor,
|
|
)
|
|
|
|
|
|
_DEFAULT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024
|
|
_DEFAULT_ZIP_MAX_BYTES = 250 * 1024 * 1024
|
|
_DEFAULT_ARCHIVE_MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024
|
|
_DEFAULT_ARCHIVE_MAX_EXPANSION_RATIO = 100
|
|
_DEFAULT_ARCHIVE_PREVIEW_TTL_SECONDS = 30 * 60
|
|
_FILES_READ_SCOPE = "files:file:read"
|
|
_FILES_UPLOAD_SCOPE = "files:file:upload"
|
|
|
|
|
|
def documentation_topics(
|
|
context: DocumentationContext,
|
|
) -> tuple[DocumentationTopic, ...]:
|
|
if context.documentation_type != "user":
|
|
return ()
|
|
|
|
upload_limit = _configured_positive_int(
|
|
context.settings,
|
|
"file_upload_max_bytes",
|
|
default=_DEFAULT_UPLOAD_MAX_BYTES,
|
|
)
|
|
zip_limit = _configured_positive_int(
|
|
context.settings,
|
|
"file_upload_zip_max_bytes",
|
|
default=_DEFAULT_ZIP_MAX_BYTES,
|
|
)
|
|
archive_expanded_limit = _configured_positive_int(
|
|
context.settings,
|
|
"file_archive_max_expanded_bytes",
|
|
default=_DEFAULT_ARCHIVE_MAX_EXPANDED_BYTES,
|
|
)
|
|
archive_entry_limit = _configured_positive_int(
|
|
context.settings,
|
|
"file_archive_max_entries",
|
|
default=ARCHIVE_UPLOAD_MAX_ENTRIES,
|
|
)
|
|
archive_ratio_limit = _configured_positive_int(
|
|
context.settings,
|
|
"file_archive_max_expansion_ratio",
|
|
default=_DEFAULT_ARCHIVE_MAX_EXPANSION_RATIO,
|
|
)
|
|
archive_preview_ttl = _configured_positive_int(
|
|
context.settings,
|
|
"file_archive_preview_ttl_seconds",
|
|
default=_DEFAULT_ARCHIVE_PREVIEW_TTL_SECONDS,
|
|
)
|
|
topics: list[DocumentationTopic] = []
|
|
if upload_limit is not None:
|
|
topics.append(_upload_topic(upload_limit))
|
|
if (
|
|
upload_limit is not None
|
|
and zip_limit is not None
|
|
and archive_expanded_limit is not None
|
|
and archive_entry_limit is not None
|
|
and archive_ratio_limit is not None
|
|
and archive_preview_ttl is not None
|
|
):
|
|
topics.append(
|
|
_archive_topic(
|
|
upload_limit,
|
|
zip_limit,
|
|
archive_expanded_limit,
|
|
archive_entry_limit,
|
|
archive_ratio_limit,
|
|
archive_preview_ttl,
|
|
)
|
|
)
|
|
topics.append(_connector_import_topic(context))
|
|
return tuple(topics)
|
|
|
|
|
|
def _upload_topic(max_bytes: int) -> DocumentationTopic:
|
|
limit = _format_byte_limit(max_bytes)
|
|
return DocumentationTopic(
|
|
id="files.workflow.upload-managed-files",
|
|
title="Upload managed files",
|
|
summary=f"Upload files to a personal or accessible group space; each uploaded file may contain at most {limit}.",
|
|
body=(
|
|
f"The current deployment accepts at most {limit} for each ordinary upload. "
|
|
"A name conflict is never resolved silently: reject stops the upload, rename chooses a copy name, and overwrite retires the old asset before creating a new one."
|
|
),
|
|
layer="configured",
|
|
documentation_types=("user",),
|
|
audience=("file_user", "file_manager", "process_participant"),
|
|
order=39,
|
|
conditions=(
|
|
DocumentationCondition(
|
|
required_modules=("files",),
|
|
required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
|
|
),
|
|
),
|
|
links=(
|
|
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
|
DocumentationLink(
|
|
label="Files handbook",
|
|
href="govoplan-files/docs/FILES_HANDBOOK.md",
|
|
kind="repository",
|
|
),
|
|
),
|
|
related_modules=("campaigns",),
|
|
unlocks=(
|
|
"Users and connected processes can place governed content in managed storage.",
|
|
),
|
|
source_module_id="files",
|
|
metadata={
|
|
"kind": "workflow",
|
|
"route": "/files",
|
|
"screen": "Files",
|
|
"help_contexts": ["files.list"],
|
|
"prerequisites": [
|
|
"You may view and upload managed files.",
|
|
"The destination personal or group space grants this account write access; upload permission alone does not grant access to every space.",
|
|
],
|
|
"steps": [
|
|
"Open Files and choose My files or an accessible group space.",
|
|
"Open the intended destination folder and choose Upload, or drag files into the file list.",
|
|
"For every name conflict, explicitly reject, rename, overwrite, or skip the affected item.",
|
|
"Wait for the upload to finish, then open the resulting file details.",
|
|
],
|
|
"outcome": "Each accepted file is stored as a governed managed asset in the selected space.",
|
|
"verification": "Confirm the owner, logical path, size, checksum, and current version in Files.",
|
|
"constraints": [
|
|
{
|
|
"id": "ordinary-upload-size",
|
|
"label": "Maximum size per file",
|
|
"description": f"The current safe upload limit is {limit} per file.",
|
|
"values": [limit],
|
|
},
|
|
],
|
|
"related_topic_ids": [
|
|
"files.workflow.upload-and-unpack-zip",
|
|
"files.workflow.organize-managed-files",
|
|
"files.workflow.find-and-download-files",
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def _archive_topic(
|
|
max_file_bytes: int,
|
|
max_archive_request_bytes: int,
|
|
max_expanded_bytes: int,
|
|
max_entries: int,
|
|
max_expansion_ratio: int,
|
|
preview_ttl_seconds: int,
|
|
) -> DocumentationTopic:
|
|
member_limit = _format_byte_limit(max_file_bytes)
|
|
request_limit = _format_byte_limit(max_archive_request_bytes)
|
|
expanded_limit = _format_byte_limit(max_expanded_bytes)
|
|
preview_minutes = max(1, preview_ttl_seconds // 60)
|
|
return DocumentationTopic(
|
|
id="files.workflow.upload-and-unpack-zip",
|
|
title="Preview and unpack an archive",
|
|
summary=(
|
|
f"Review and selectively unpack up to {max_entries:,} entries from ZIP or TAR archives before any managed file is created."
|
|
),
|
|
body=(
|
|
f"The deployment accepts ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ requests up to {request_limit}, limits actual expanded data to {expanded_limit}, "
|
|
f"each member to {member_limit}, the archive to {max_entries:,} entries, and expansion to {max_expansion_ratio}:1. "
|
|
f"The server-issued preview expires after {preview_minutes} minutes. Password-protected ZIP archives are supported; passwords remain request-only. "
|
|
"Unsafe paths and special filesystem entries are rejected. Actual extracted bytes are counted instead of trusting archive headers."
|
|
),
|
|
layer="configured",
|
|
documentation_types=("user",),
|
|
audience=("file_user", "file_manager", "process_participant"),
|
|
order=40,
|
|
conditions=(
|
|
DocumentationCondition(
|
|
required_modules=("files",),
|
|
required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
|
|
),
|
|
),
|
|
links=(
|
|
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
|
DocumentationLink(
|
|
label="Files handbook",
|
|
href="govoplan-files/docs/FILES_HANDBOOK.md",
|
|
kind="repository",
|
|
),
|
|
),
|
|
unlocks=(
|
|
"A bounded archive can become governed managed files without trusting archive paths or size declarations.",
|
|
),
|
|
source_module_id="files",
|
|
metadata={
|
|
"kind": "workflow",
|
|
"route": "/files",
|
|
"screen": "Files",
|
|
"help_contexts": ["files.list"],
|
|
"prerequisites": [
|
|
"You may view and upload managed files.",
|
|
"The archive fits the configured request, expansion, size, and entry limits.",
|
|
"The destination personal or group space grants this account write access.",
|
|
],
|
|
"steps": [
|
|
"Open Files and choose the managed destination space and folder.",
|
|
"Enable Preview and unpack archive, then choose or drag one supported archive.",
|
|
"Review the discovered entries, supply a ZIP password when required, and select the files or folders to import.",
|
|
"Resolve every destination conflict explicitly.",
|
|
"Confirm the selection, then wait for extraction and finalization to finish before leaving the page.",
|
|
],
|
|
"outcome": "Accepted archive members are stored as separate governed managed assets below the selected folder.",
|
|
"verification": "Confirm the expected member paths and inspect representative file sizes, checksums, and versions.",
|
|
"constraints": [
|
|
{
|
|
"id": "archive-request-and-total",
|
|
"label": "Maximum archive request and expanded total",
|
|
"description": f"The compressed request is limited to {request_limit}; actual expanded data is limited to {expanded_limit}.",
|
|
"values": [request_limit, expanded_limit],
|
|
},
|
|
{
|
|
"id": "archive-member-size",
|
|
"label": "Maximum extracted member size",
|
|
"description": f"Each extracted file is limited to {member_limit}.",
|
|
"values": [member_limit],
|
|
},
|
|
{
|
|
"id": "archive-entry-count",
|
|
"label": "Maximum entry count",
|
|
"description": f"An archive may contain at most {max_entries:,} declared entries.",
|
|
"values": [f"{max_entries:,} entries"],
|
|
},
|
|
{
|
|
"id": "archive-expansion-ratio",
|
|
"label": "Maximum expansion ratio",
|
|
"description": f"Declared and actual output may not exceed {max_expansion_ratio} times the compressed request size.",
|
|
"values": [f"{max_expansion_ratio}:1"],
|
|
},
|
|
],
|
|
"related_topic_ids": [
|
|
"files.workflow.upload-managed-files",
|
|
"files.workflow.organize-managed-files",
|
|
"files.workflow.find-and-download-files",
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def _connector_import_topic(context: DocumentationContext) -> DocumentationTopic:
|
|
principal = context.principal
|
|
if not _has_all_scopes(principal, (_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE)):
|
|
return _connector_import_limitation(
|
|
"Connector import is not available to this account because both permission to view Files and permission to upload managed files are required."
|
|
)
|
|
|
|
tenant_id = _safe_text_attribute(principal, "tenant_id")
|
|
user_id = _safe_text_attribute(getattr(principal, "user", None), "id")
|
|
session = context.session
|
|
if not tenant_id or not user_id or not isinstance(session, Session):
|
|
return _connector_import_limitation(
|
|
"Connector import availability could not be safely evaluated for this request. Try again, or ask a Files administrator to verify an actor-visible connection."
|
|
)
|
|
|
|
try:
|
|
member_group_ids = _actor_group_ids(
|
|
session,
|
|
principal=principal,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
include_admin_groups=False,
|
|
)
|
|
connector_group_ids = (
|
|
_actor_group_ids(
|
|
session,
|
|
principal=principal,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
include_admin_groups=True,
|
|
)
|
|
if _has_all_scopes(principal, ("files:file:admin",))
|
|
else member_group_ids
|
|
)
|
|
profiles = visible_connector_profiles_for_actor(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
group_ids=connector_group_ids,
|
|
settings=context.settings,
|
|
campaign_visible=_campaign_visibility(
|
|
context,
|
|
session=session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
group_ids=member_group_ids,
|
|
),
|
|
include_effective_policy=True,
|
|
)
|
|
usable_profiles = tuple(
|
|
profile
|
|
for profile in profiles
|
|
if connector_profile_usable_for_import(profile)
|
|
)
|
|
except Exception:
|
|
return _connector_import_limitation(
|
|
"Connector import availability could not be safely evaluated for this request. Try again, or ask a Files administrator to verify an actor-visible connection."
|
|
)
|
|
if not usable_profiles:
|
|
return _connector_import_limitation(
|
|
"No connection visible to this account is currently eligible to offer an enabled, credential-ready, policy-allowed browse/import path through a pinning-safe provider. Ask a Files administrator to configure or authorize one."
|
|
)
|
|
|
|
return DocumentationTopic(
|
|
id="files.workflow.import-managed-snapshot",
|
|
title="Import an external file as a governed snapshot",
|
|
summary="Browse an authorized connection read-only and import one selected file into managed storage as a frozen, traceable snapshot.",
|
|
body=(
|
|
"At least one connection visible to this account is currently eligible to offer the governed browse/import path. "
|
|
"The selected remote path and item are re-authorized when used, and browse, import, and sync never mutate the remote source. "
|
|
"The managed snapshot stays unchanged until an explicit manual sync."
|
|
),
|
|
layer="configured",
|
|
documentation_types=("user",),
|
|
audience=("file_user", "campaign_manager", "report_author"),
|
|
order=41,
|
|
conditions=(
|
|
DocumentationCondition(
|
|
required_modules=("files",),
|
|
required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
|
|
),
|
|
),
|
|
links=(
|
|
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
|
DocumentationLink(
|
|
label="Files handbook",
|
|
href="govoplan-files/docs/FILES_HANDBOOK.md",
|
|
kind="repository",
|
|
),
|
|
),
|
|
related_modules=("campaigns",),
|
|
unlocks=(
|
|
"Campaigns, reports, and workflows can consume a managed snapshot with stable source evidence.",
|
|
),
|
|
source_module_id="files",
|
|
metadata={
|
|
"kind": "workflow",
|
|
"route": "/files",
|
|
"screen": "Files",
|
|
"help_contexts": ["files.list", "files.connector-import"],
|
|
"prerequisites": [
|
|
"You may view and upload managed files.",
|
|
"At least one enabled, credential-ready, policy-allowed connection using a pinning-safe provider is visible to this account.",
|
|
"The selected remote path and item must pass their operation-time policy checks.",
|
|
"The managed destination space grants this account write access.",
|
|
],
|
|
"steps": [
|
|
"Open Files and choose a managed destination space.",
|
|
"Choose Sync from connection, select an available connection, and browse to the permitted remote file.",
|
|
"Import the selected file and resolve any destination conflict explicitly.",
|
|
"Review the managed file's source and current-version details before using it in another task.",
|
|
],
|
|
"current_configuration": [
|
|
"At least one actor-visible connection is eligible to offer a safe browse/import operation; the selected endpoint, path, and item are still checked when used.",
|
|
"Every selected remote path and item is re-authorized at operation time.",
|
|
],
|
|
"outcome": "The external content is a tenant-managed snapshot with a checksum, exact version, and recorded source context.",
|
|
"verification": "Reopen the managed file and confirm its recorded source context, source revision when available, checksum, and current version.",
|
|
"related_topic_ids": [
|
|
"files.governed-connectors-and-provenance",
|
|
"files.reference.integrity-recovery-and-fail-closed-transports",
|
|
"files.reference.snapshot-provenance-and-capabilities",
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def _connector_import_limitation(message: str) -> DocumentationTopic:
|
|
return DocumentationTopic(
|
|
id="files.connector-import-unavailable",
|
|
title="External file import is not currently available",
|
|
summary=message,
|
|
body=(
|
|
f"{message} Files never exposes connection endpoints, storage paths, credential references, or raw connector policies in user documentation."
|
|
),
|
|
layer="available",
|
|
documentation_types=("user",),
|
|
order=41,
|
|
conditions=(
|
|
DocumentationCondition(
|
|
required_modules=("files",),
|
|
any_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
|
|
),
|
|
),
|
|
links=(DocumentationLink(label="Files", href="/files", kind="runtime"),),
|
|
source_module_id="files",
|
|
metadata={
|
|
"kind": "reference",
|
|
"screen": "Files",
|
|
"help_contexts": ["files.list", "files.connector-import"],
|
|
"limitations": [message],
|
|
},
|
|
)
|
|
|
|
|
|
def _configured_positive_int(
|
|
settings: object | None, name: str, *, default: int
|
|
) -> int | None:
|
|
raw_value = getattr(settings, name, default) if settings is not None else default
|
|
try:
|
|
value = int(raw_value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return value if value > 0 else None
|
|
|
|
|
|
def _format_byte_limit(value: int) -> str:
|
|
units = ((1024**3, "GiB"), (1024**2, "MiB"), (1024, "KiB"))
|
|
for divisor, label in units:
|
|
if value % divisor == 0:
|
|
return f"{value // divisor:,} {label} ({value:,} bytes)"
|
|
return f"{value:,} bytes"
|
|
|
|
|
|
def _has_all_scopes(principal: object | None, scopes: tuple[str, ...]) -> bool:
|
|
checker = getattr(principal, "has", None)
|
|
if callable(checker):
|
|
try:
|
|
return all(bool(checker(scope)) for scope in scopes)
|
|
except Exception:
|
|
return False
|
|
granted = {str(scope) for scope in getattr(principal, "scopes", ())}
|
|
return all(scope in granted for scope in scopes)
|
|
|
|
|
|
def _safe_text_attribute(value: object | None, name: str) -> str:
|
|
try:
|
|
result = getattr(value, name, "")
|
|
except Exception:
|
|
return ""
|
|
return str(result or "")
|
|
|
|
|
|
def _principal_group_ids(principal: object) -> tuple[str, ...]:
|
|
try:
|
|
values = getattr(principal, "group_ids", ())
|
|
except Exception:
|
|
return ()
|
|
return tuple(str(value) for value in values if str(value))
|
|
|
|
|
|
def _actor_group_ids(
|
|
session: Session,
|
|
*,
|
|
principal: object,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
include_admin_groups: bool,
|
|
) -> tuple[str, ...]:
|
|
try:
|
|
values = user_group_ids(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
include_admin_groups=include_admin_groups,
|
|
)
|
|
except Exception:
|
|
return _principal_group_ids(principal)
|
|
return tuple(str(value) for value in values if str(value))
|
|
|
|
|
|
def _campaign_visibility(
|
|
context: DocumentationContext,
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
group_ids: tuple[str, ...],
|
|
):
|
|
principal = context.principal
|
|
registry = context.registry
|
|
if not _has_all_scopes(principal, ("campaigns:campaign:read",)):
|
|
return None
|
|
try:
|
|
if not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
|
|
return None
|
|
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
|
|
except Exception:
|
|
return None
|
|
if not isinstance(capability, CampaignAccessProvider):
|
|
return None
|
|
|
|
def campaign_visible(campaign_id: str) -> bool:
|
|
try:
|
|
return capability.campaign_exists(
|
|
session, tenant_id=tenant_id, campaign_id=campaign_id
|
|
) and capability.can_read_campaign(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign_id,
|
|
user_id=user_id,
|
|
group_ids=group_ids,
|
|
tenant_admin=_has_all_scopes(principal, ("tenant:*",)),
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
return campaign_visible
|