From 47221615926ee9f55a73a8c94939de5da9e29d66 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 18:52:39 +0200 Subject: [PATCH] feat(files): surface configured handbook tasks --- docs/FILES_HANDBOOK.md | 8 + src/govoplan_files/backend/documentation.py | 421 ++++++++++++++++++ src/govoplan_files/backend/manifest.py | 214 ++++++--- .../backend/storage/archives.py | 3 +- tests/test_documentation.py | 190 ++++++++ tests/test_manifest_documentation.py | 167 ++++--- 6 files changed, 889 insertions(+), 114 deletions(-) create mode 100644 src/govoplan_files/backend/documentation.py create mode 100644 tests/test_documentation.py diff --git a/docs/FILES_HANDBOOK.md b/docs/FILES_HANDBOOK.md index 0aca792..2299bcf 100644 --- a/docs/FILES_HANDBOOK.md +++ b/docs/FILES_HANDBOOK.md @@ -56,6 +56,14 @@ The main domain objects are: The Files page is available at `/files`. Actions appear only when the current principal has the required permission and resource access. +The configured Help Center projects these sections as independently authorized +tasks, so upload, ZIP import, organization, download, sharing, and deletion do +not disappear merely because an unrelated permission is absent. It states the +deployment's actual upload limits. External import appears as a task only when +the actor has the required permissions and at least one actor-visible profile +is eligible for the current fail-closed browse/import path; endpoint, path, and +item policy are still enforced when the operation runs. + ### Choose a space Every file user sees **My files**. Group spaces are added for active groups of diff --git a/src/govoplan_files/backend/documentation.py b/src/govoplan_files/backend/documentation.py new file mode 100644 index 0000000..08735fd --- /dev/null +++ b/src/govoplan_files/backend/documentation.py @@ -0,0 +1,421 @@ +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 ZIP_UPLOAD_MAX_FILES +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 +_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, + ) + 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: + topics.append(_zip_topic(upload_limit, zip_limit)) + 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.", + "You can access the destination personal or group 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 _zip_topic(max_file_bytes: int, max_zip_bytes: int) -> DocumentationTopic: + member_limit = _format_byte_limit(max_file_bytes) + archive_limit = _format_byte_limit(max_zip_bytes) + return DocumentationTopic( + id="files.workflow.upload-and-unpack-zip", + title="Upload and unpack a ZIP archive", + summary=( + f"Safely unpack up to {ZIP_UPLOAD_MAX_FILES:,} files from a ZIP whose request and extracted total are each limited to {archive_limit}." + ), + body=( + f"The current deployment limits the ZIP request and actual extracted total to {archive_limit}, each member to {member_limit}, " + f"and the archive to {ZIP_UPLOAD_MAX_FILES:,} non-directory members. Encrypted archives and unsafe member paths are rejected. " + "Actual extracted bytes are counted instead of trusting ZIP 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 is not encrypted and fits the current configured limits.", + ], + "steps": [ + "Open Files and choose the managed destination space and folder.", + "Enable Unpack ZIP uploads, then choose or drag the ZIP archive.", + "Resolve every destination conflict explicitly.", + "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": "zip-request-and-total", + "label": "Maximum ZIP request and extracted total", + "description": f"Both the compressed request and the actual extracted total are limited to {archive_limit}.", + "values": [archive_limit], + }, + { + "id": "zip-member-size", + "label": "Maximum extracted member size", + "description": f"Each extracted file is limited to {member_limit}.", + "values": [member_limit], + }, + { + "id": "zip-member-count", + "label": "Maximum file count", + "description": f"A ZIP may contain at most {ZIP_UPLOAD_MAX_FILES:,} non-directory members.", + "values": [f"{ZIP_UPLOAD_MAX_FILES:,} files"], + }, + ], + "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: + group_ids = _principal_group_ids(principal) + profiles = visible_connector_profiles_for_actor( + session, + tenant_id=tenant_id, + user_id=user_id, + group_ids=group_ids, + settings=context.settings, + campaign_visible=_campaign_visibility( + context, + session=session, + tenant_id=tenant_id, + user_id=user_id, + group_ids=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.", + ], + "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 _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 diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 7a0f87f..03d85fe 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -25,6 +25,7 @@ from govoplan_core.core.modules import ( from govoplan_core.db.base import Base from govoplan_files.backend.change_tracking import register_files_change_tracking from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata +from govoplan_files.backend.documentation import documentation_topics register_files_change_tracking() @@ -195,114 +196,196 @@ manifest = ModuleManifest( ), documentation=( DocumentationTopic( - id="files.workflow.upload-organize-and-share", - title="Upload, organize, and share managed files", - summary="Put files in a personal or group space, organize them with explicit conflict handling, and grant or update access through a supported integration or API client.", + id="files.workflow.organize-managed-files", + title="Organize managed files and folders", + summary="Create folders and rename, move, or copy accessible managed content with explicit conflict handling.", body=( - "Work in My files or an accessible group space. Uploads, folders, renames, moves, and copies stay inside governed managed storage and require an explicit choice when a target path already exists. " - "A caller with share permission can grant or update read, write, or manage access for a user, group, tenant, or campaign without changing ownership. The Files page does not yet provide a general share editor, and the API has no share-revocation route; sharing is currently performed by a supporting workflow or API client." + "Organization stays inside governed personal or group spaces. Moves preserve the asset identity, while copies create new assets and versions that reuse immutable blob bytes. " + "Every target conflict must be rejected, renamed, overwritten, or skipped explicitly." ), layer="configured", documentation_types=("user",), audience=("file_user", "file_manager", "process_participant"), - order=39, + order=42, conditions=( DocumentationCondition( required_modules=("files",), - required_scopes=( - "files:file:read", - "files:file:upload", - "files:file:organize", - "files:file:share", - ), + required_scopes=("files:file:read", "files:file:organize"), ), ), links=( DocumentationLink(label="Files", href="/files", kind="runtime"), - DocumentationLink(label="Upload API", href="/api/v1/files/upload", kind="api"), DocumentationLink(label="Move or copy API", href="/api/v1/files/transfer", kind="api"), - DocumentationLink(label="Grant or update a share", href="/api/v1/files/{file_id}/shares", kind="api"), DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), ), - related_modules=("campaigns",), - unlocks=("Users and connected processes can exchange governed managed files without changing file ownership.",), + unlocks=("Managed content can be placed at stable logical paths without bypassing space access.",), metadata={ "kind": "workflow", "route": "/files", "screen": "Files", + "help_contexts": ["files.list"], "prerequisites": [ - "Files is installed and you may read, upload, organize, and share managed files.", - "You can access the destination personal or group space.", - "A supporting workflow or API client is available when a user, group, tenant, or campaign share must be granted or updated.", + "You may view and organize managed files.", + "You can access every source and destination space used by the operation.", ], "steps": [ - "Open Files, choose My files or an accessible group space, and open the intended destination folder.", - "Create folders where needed, then upload or drag files into the destination.", - "Resolve every name conflict explicitly by rejecting, renaming, overwriting, or skipping the affected item.", - "Use rename, move, or copy to place the managed items at their intended logical paths.", - "Review the current version, checksum, owner, and path before granting access.", - "Through the supporting workflow or API, grant or update the required read, write, or manage share; do not promise revocation because no revoke route exists yet.", - "Have the intended recipient verify access, and verify that an unrelated account remains denied.", + "Open Files and select the personal or group space to organize.", + "Create the required destination folders or select the files and folders to rename, move, or copy.", + "Choose the destination and resolve each target conflict explicitly.", + "Apply the operation and reopen the destination folder.", ], - "outcome": "The content is stored as governed managed assets in the intended space and the requested access has been granted or updated without changing ownership.", - "verification": "Reopen the files to confirm owner, logical path, current version, and checksum, then test one intended and one denied access path. A share that must be removed requires an explicit future revocation capability.", + "outcome": "The selected content has the intended governed owner and logical path.", + "verification": "Confirm each resulting path and owner; for a move, also confirm the old path is gone, and for a copy, confirm the source remains.", "related_topic_ids": [ - "files.workflow.import-managed-snapshot", - "files.assurance.process-and-release-readiness", + "files.workflow.upload-managed-files", + "files.workflow.find-and-download-files", + "files.workflow.delete-managed-files", + ], + }, + ), + DocumentationTopic( + id="files.workflow.find-and-download-files", + title="Find and download managed files", + summary="Search accessible managed content and download a current file version or a ZIP archive of a selection.", + body=( + "Files can be sorted and searched by logical path or name pattern. A download always uses the accessible current version; a multi-file selection can be generated as a temporary ZIP archive. " + "There is no dedicated content-preview service yet." + ), + layer="configured", + documentation_types=("user",), + audience=("file_user", "file_manager", "process_participant"), + order=43, + conditions=( + DocumentationCondition( + required_modules=("files",), + required_scopes=("files:file:read", "files:file:download"), + ), + ), + links=( + DocumentationLink(label="Files", href="/files", kind="runtime"), + DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), + ), + unlocks=("Authorized users can retrieve governed current versions without gaining organization or sharing authority.",), + metadata={ + "kind": "workflow", + "route": "/files", + "screen": "Files", + "help_contexts": ["files.list"], + "prerequisites": ["You may view and download managed files in the relevant space."], + "steps": [ + "Open Files and select the relevant personal or group space.", + "Navigate folders, sort the list, or use a path/name pattern to find the intended content.", + "Review the displayed owner, path, size, checksum, and version details.", + "Download one current file version, or select several files and choose Download ZIP.", + ], + "outcome": "The authorized current file bytes or generated archive are downloaded to the local device.", + "verification": "Confirm the downloaded names and, where integrity matters, compare the file bytes with the displayed checksum.", + "related_topic_ids": [ + "files.workflow.organize-managed-files", "files.reference.snapshot-provenance-and-capabilities", ], }, ), DocumentationTopic( - id="files.workflow.import-managed-snapshot", - title="Import an external file as a governed snapshot", - summary="Browse an authorized connection read-only, import one selected file into managed storage, and use the frozen version in another GovOPlaN task.", + id="files.workflow.share-managed-files", + title="Grant or update access to managed files", + summary="Grant or update read, write, or manage access through a supporting workflow or API client without changing ownership.", body=( - "Choose a visible file connection in Files, browse only the permitted remote path, and import the selected content into your personal or group space. " - "The managed copy records source provenance and remains unchanged until an explicit manual sync. Browse, import, and sync never mutate the remote source." + "The Files service can grant or update a share for a user, group, tenant, or campaign. The Files page does not yet provide a general share editor, and the API has no share-revocation route. " + "Do not use this task when access must later be revoked until that explicit capability is implemented." ), - layer="configured", + layer="available", documentation_types=("user",), - audience=("file_user", "campaign_manager", "report_author"), - order=40, + audience=("file_manager", "process_participant"), + order=44, conditions=( DocumentationCondition( required_modules=("files",), - required_scopes=("files:file:read", "files:file:upload"), + required_scopes=("files:file:read", "files:file:share"), ), ), links=( DocumentationLink(label="Files", href="/files", kind="runtime"), - DocumentationLink(label="Visible connector profiles", href="/api/v1/files/connectors/profiles", kind="api"), - DocumentationLink( - label="Connector import API", - href="/api/v1/files/connectors/profiles/{profile_id}/import", - kind="api", - ), + DocumentationLink(label="Grant or update a share", href="/api/v1/files/{file_id}/shares", kind="api"), 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.",), + unlocks=("A supporting process can grant governed file access without changing file ownership.",), metadata={ "kind": "workflow", "route": "/files", "screen": "Files", + "help_contexts": ["files.list"], "prerequisites": [ - "Files is installed and you may read and upload files.", - "An administrator has configured a connector profile visible in your current user, group, tenant, or campaign scope.", + "You may view and share the managed file.", + "A supporting workflow or API client is available; the Files page has no general share editor yet.", + "The process does not require share revocation, because no revocation route exists yet.", ], "steps": [ - "Open Files and choose a managed destination space.", - "Choose Sync from connection, select a visible profile, and browse to the permitted remote file.", - "Import or sync 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.", + "Open Files and verify the file owner, logical path, current version, and checksum.", + "Through the supporting workflow, identify the intended user, group, tenant, or campaign and choose read, write, or manage access.", + "Grant or update the share without changing file ownership.", + "Have the intended recipient verify access and an unrelated account verify denial.", ], - "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 connector/provider, remote identity or path, source revision when available, checksum, and current version.", + "limitations": [ + "The Files page has no general share editor.", + "Shares can be granted or updated, but not revoked through the current API.", + ], + "outcome": "The requested access is granted or updated while the managed asset keeps its owner.", + "verification": "Test one intended and one denied access path. Do not claim that the share can later be revoked.", "related_topic_ids": [ - "files.governed-connectors-and-provenance", - "files.reference.integrity-recovery-and-fail-closed-transports", - "files.reference.snapshot-provenance-and-capabilities", + "files.workflow.find-and-download-files", + "files.assurance.process-and-release-readiness", + ], + }, + ), + DocumentationTopic( + id="files.workflow.delete-managed-files", + title="Delete managed files and folders", + summary="Soft-delete accessible managed files or a folder tree where current policy allows it.", + body=( + "Deletion hides the selected managed assets rather than hard-purging their stored evidence. Folder deletion is recursive by default and includes child folders and files; a non-recursive request fails for a non-empty folder. " + "There is no self-service restore or hard-purge workflow today." + ), + layer="configured", + documentation_types=("user",), + audience=("file_user", "file_manager", "process_participant"), + order=45, + conditions=( + DocumentationCondition( + required_modules=("files",), + required_scopes=("files:file:read", "files:file:delete"), + ), + ), + links=( + DocumentationLink(label="Files", href="/files", kind="runtime"), + DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), + ), + unlocks=("Authorized users can remove obsolete content from active file views while preserving the current soft-delete boundary.",), + metadata={ + "kind": "workflow", + "route": "/files", + "screen": "Files", + "help_contexts": ["files.list"], + "prerequisites": [ + "You may view and delete the selected managed content.", + "You have reviewed the complete folder tree when deleting recursively.", + ], + "steps": [ + "Open Files and select the files or folder to delete.", + "Review the selection and, for a folder, all content below it.", + "Confirm the delete action.", + "Refresh or reopen the space and verify that the selected paths are no longer active.", + ], + "limitations": [ + "Deletion is soft deletion, not a hard purge.", + "There is no self-service restore or hard-purge workflow.", + ], + "outcome": "The selected content is hidden from active Files views under the current soft-delete model.", + "verification": "Confirm the deleted paths no longer appear in the active space; do not treat the action as physical erasure.", + "related_topic_ids": [ + "files.workflow.organize-managed-files", + "files.assurance.process-and-release-readiness", ], }, ), @@ -317,7 +400,7 @@ manifest = ModuleManifest( layer="configured", documentation_types=("admin",), audience=("file_admin", "tenant_admin", "system_admin", "security_auditor"), - order=41, + order=50, conditions=( DocumentationCondition( required_modules=("files",), @@ -374,7 +457,7 @@ manifest = ModuleManifest( layer="configured", documentation_types=("admin",), audience=("operator", "system_admin", "security_auditor"), - order=42, + order=51, conditions=( DocumentationCondition( required_modules=("files",), @@ -428,7 +511,7 @@ manifest = ModuleManifest( layer="available", documentation_types=("admin", "user"), audience=("module_integrator", "file_admin", "campaign_admin", "process_designer"), - order=43, + order=52, conditions=( DocumentationCondition( required_modules=("files",), @@ -467,7 +550,7 @@ manifest = ModuleManifest( layer="configured", documentation_types=("admin", "user"), audience=("process_owner", "release_manager", "file_admin", "operator", "security_auditor"), - order=44, + order=53, conditions=( DocumentationCondition( required_modules=("files",), @@ -500,6 +583,7 @@ manifest = ModuleManifest( "kind": "workflow", "route": "/files", "screen": "Files process and release assurance", + "help_contexts": ["files.list"], "prerequisites": [ "A named process owner has defined the intended users, data classification, retention expectations, and integrations.", "A candidate release is installed on a clean database and an upgrade copy with aligned Python, root package, WebUI package, and module-manifest versions.", @@ -517,7 +601,12 @@ manifest = ModuleManifest( "outcome": "The process or release has an explicit approval record tied to aligned versions, representative evidence, known limitations, and owned residual risks.", "verification": "A reviewer can reproduce the recorded allow/deny, integrity, connector, and recovery checks and can trace each unmet requirement to a documented limitation or accepted compensating control.", "related_topic_ids": [ - "files.workflow.upload-organize-and-share", + "files.workflow.upload-managed-files", + "files.workflow.upload-and-unpack-zip", + "files.workflow.organize-managed-files", + "files.workflow.find-and-download-files", + "files.workflow.share-managed-files", + "files.workflow.delete-managed-files", "files.workflow.import-managed-snapshot", "files.governed-connectors-and-provenance", "files.reference.integrity-recovery-and-fail-closed-transports", @@ -526,6 +615,7 @@ manifest = ModuleManifest( }, ), ), + documentation_providers=(documentation_topics,), migration_spec=MigrationSpec( module_id="files", metadata=Base.metadata, diff --git a/src/govoplan_files/backend/storage/archives.py b/src/govoplan_files/backend/storage/archives.py index 4a0695c..7658d8c 100644 --- a/src/govoplan_files/backend/storage/archives.py +++ b/src/govoplan_files/backend/storage/archives.py @@ -17,6 +17,7 @@ from govoplan_files.backend.storage.paths import filename_from_path, normalize_f _ZIP_READ_CHUNK_SIZE = 1024 * 1024 +ZIP_UPLOAD_MAX_FILES = 1000 def _read_zip_member( @@ -76,7 +77,7 @@ def extract_zip_upload( conflict_resolutions: Iterable[FileConflictResolution] | None = None, metadata: dict[str, Any] | None = None, is_admin: bool = False, - max_files: int = 1000, + max_files: int = ZIP_UPLOAD_MAX_FILES, max_file_bytes: int = 50 * 1024 * 1024, max_total_bytes: int = 250 * 1024 * 1024, ) -> list[UploadedStoredFile]: diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..9f730d4 --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from sqlalchemy.orm import Session + +from govoplan_core.core.modules import DocumentationContext +from govoplan_files.backend.documentation import documentation_topics +from govoplan_files.backend.storage.connector_profiles import ConnectorProfile + + +class _Principal: + tenant_id = "tenant-1" + user = SimpleNamespace(id="user-1") + group_ids = frozenset({"group-1"}) + + def __init__(self, scopes: set[str]) -> None: + self.scopes = frozenset(scopes) + + def has(self, scope: str) -> bool: + return scope in self.scopes + + +class FilesRuntimeDocumentationTests(unittest.TestCase): + def setUp(self) -> None: + self.session = Session() + + def tearDown(self) -> None: + self.session.close() + + def context( + self, + scopes: set[str], + *, + settings: object | None = None, + documentation_type: str = "user", + ) -> DocumentationContext: + return DocumentationContext( + registry=object(), + principal=_Principal(scopes), + settings=settings, + session=self.session, + documentation_type=documentation_type, # type: ignore[arg-type] + ) + + def test_upload_tasks_state_exact_current_safe_limits(self) -> None: + settings = SimpleNamespace( + file_upload_max_bytes=7 * 1024 * 1024, + file_upload_zip_max_bytes=19 * 1024 * 1024, + ) + with patch( + "govoplan_files.backend.documentation.visible_connector_profiles_for_actor", + return_value=[], + ): + topics = { + topic.id: topic + for topic in documentation_topics( + self.context( + {"files:file:read", "files:file:upload"}, settings=settings + ) + ) + } + + upload = topics["files.workflow.upload-managed-files"] + self.assertIn("7 MiB (7,340,032 bytes)", upload.body) + self.assertEqual(["files.list"], upload.metadata["help_contexts"]) + self.assertEqual( + {"files:file:read", "files:file:upload"}, + set(upload.conditions[0].required_scopes), + ) + + archive = topics["files.workflow.upload-and-unpack-zip"] + self.assertIn("19 MiB (19,922,944 bytes)", archive.body) + self.assertIn("7 MiB (7,340,032 bytes)", archive.body) + self.assertIn("1,000", archive.body) + self.assertIn("Actual extracted bytes are counted", archive.body) + + def test_connector_task_requires_authority_and_a_visible_usable_profile( + self, + ) -> None: + usable = ConnectorProfile( + id="private-profile-id", + label="Private profile label", + provider="webdav", + scope_type="tenant", + scope_id="tenant-1", + endpoint_url="https://private.example.invalid/secret-root", + base_path="/secret-root", + credential_mode="anonymous", + ) + context = self.context({"files:file:read", "files:file:upload"}) + with patch( + "govoplan_files.backend.documentation.visible_connector_profiles_for_actor", + return_value=[usable], + ) as visible: + topics = {topic.id: topic for topic in documentation_topics(context)} + + self.assertIn("files.workflow.import-managed-snapshot", topics) + self.assertNotIn("files.connector-import-unavailable", topics) + task = topics["files.workflow.import-managed-snapshot"] + self.assertEqual("configured", task.layer) + self.assertEqual( + ["files.list", "files.connector-import"], task.metadata["help_contexts"] + ) + self.assertIn("re-authorized", task.body) + self.assertNotIn("private-profile-id", repr(task)) + self.assertNotIn("private.example.invalid", repr(task)) + self.assertNotIn("secret-root", repr(task)) + visible.assert_called_once() + self.assertEqual(("group-1",), tuple(visible.call_args.kwargs["group_ids"])) + + without_authority = { + topic.id: topic + for topic in documentation_topics(self.context({"files:file:read"})) + } + self.assertNotIn("files.workflow.import-managed-snapshot", without_authority) + self.assertIn( + "both permission to view Files and permission to upload", + without_authority["files.connector-import-unavailable"].body, + ) + + def test_connector_limitation_is_precise_but_never_exposes_profile_internals( + self, + ) -> None: + blocked = ConnectorProfile( + id="sensitive-profile-id", + label="Sensitive label", + provider="s3", + scope_type="tenant", + scope_id="tenant-1", + endpoint_url="https://internal.example.invalid/private", + base_path="/classified", + credential_mode="basic", + password_value="credential-secret", + ) + with patch( + "govoplan_files.backend.documentation.visible_connector_profiles_for_actor", + return_value=[blocked], + ): + topics = { + topic.id: topic + for topic in documentation_topics( + self.context({"files:file:read", "files:file:upload"}) + ) + } + + self.assertNotIn("files.workflow.import-managed-snapshot", topics) + limitation = topics["files.connector-import-unavailable"] + self.assertEqual("available", limitation.layer) + self.assertIn("pinning-safe provider", limitation.body) + payload = repr(limitation) + for secret in ( + "sensitive-profile-id", + "Sensitive label", + "internal.example.invalid", + "classified", + "credential-secret", + ): + self.assertNotIn(secret, payload) + + def test_connector_evaluation_errors_fail_closed_without_error_details( + self, + ) -> None: + with patch( + "govoplan_files.backend.documentation.visible_connector_profiles_for_actor", + side_effect=RuntimeError("private endpoint /root credential-id"), + ): + topics = { + topic.id: topic + for topic in documentation_topics( + self.context({"files:file:read", "files:file:upload"}) + ) + } + + self.assertNotIn("files.workflow.import-managed-snapshot", topics) + limitation = topics["files.connector-import-unavailable"] + self.assertIn("could not be safely evaluated", limitation.body) + self.assertNotIn("private endpoint", repr(limitation)) + self.assertNotIn("credential-id", repr(limitation)) + + def test_runtime_provider_only_emits_user_documentation(self) -> None: + self.assertEqual( + (), documentation_topics(self.context(set(), documentation_type="admin")) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest_documentation.py b/tests/test_manifest_documentation.py index 6210bbe..5bf5374 100644 --- a/tests/test_manifest_documentation.py +++ b/tests/test_manifest_documentation.py @@ -3,14 +3,22 @@ from __future__ import annotations import unittest -TOPIC_IDS = { - "files.workflow.upload-organize-and-share", - "files.workflow.import-managed-snapshot", +STATIC_TOPIC_IDS = { + "files.workflow.organize-managed-files", + "files.workflow.find-and-download-files", + "files.workflow.share-managed-files", + "files.workflow.delete-managed-files", "files.governed-connectors-and-provenance", "files.reference.integrity-recovery-and-fail-closed-transports", "files.reference.snapshot-provenance-and-capabilities", "files.assurance.process-and-release-readiness", } +RUNTIME_TOPIC_IDS = { + "files.workflow.upload-managed-files", + "files.workflow.upload-and-unpack-zip", + "files.workflow.import-managed-snapshot", + "files.connector-import-unavailable", +} HANDBOOK_HREF = "govoplan-files/docs/FILES_HANDBOOK.md" @@ -19,64 +27,98 @@ class FilesManifestDocumentationTests(unittest.TestCase): def setUpClass(cls) -> None: from govoplan_files.backend.manifest import manifest + cls.manifest = manifest cls.topics = {topic.id: topic for topic in manifest.documentation} def topic(self, topic_id: str): return self.topics[topic_id] - def test_adaptive_topics_have_role_scope_module_and_link_contracts(self) -> None: - self.assertEqual(TOPIC_IDS, set(self.topics)) - self.assertEqual({"admin", "user"}, {item for topic in self.topics.values() for item in topic.documentation_types}) + def test_static_topics_have_role_scope_module_and_link_contracts(self) -> None: + self.assertEqual(STATIC_TOPIC_IDS, set(self.topics)) + self.assertEqual( + {"admin", "user"}, + { + item + for topic in self.topics.values() + for item in topic.documentation_types + }, + ) for topic in self.topics.values(): with self.subTest(topic=topic.id): self.assertTrue(topic.audience) self.assertTrue(topic.conditions) - self.assertTrue(any("files" in condition.required_modules for condition in topic.conditions)) - self.assertTrue(any(condition.any_scopes or condition.required_scopes for condition in topic.conditions)) + self.assertTrue( + any( + "files" in condition.required_modules + for condition in topic.conditions + ) + ) + self.assertTrue( + any( + condition.any_scopes or condition.required_scopes + for condition in topic.conditions + ) + ) self.assertIn("runtime", {link.kind for link in topic.links}) - self.assertIn("api", {link.kind for link in topic.links}) - self.assertIn(HANDBOOK_HREF, {link.href for link in topic.links if link.kind == "repository"}) + self.assertIn( + HANDBOOK_HREF, + {link.href for link in topic.links if link.kind == "repository"}, + ) - def test_import_topic_is_a_complete_user_workflow(self) -> None: - topic = self.topic("files.workflow.import-managed-snapshot") - - self.assertEqual(("user",), topic.documentation_types) - self.assertEqual("workflow", topic.metadata["kind"]) - self.assertEqual("/files", topic.metadata["route"]) - self.assertEqual("Files", topic.metadata["screen"]) - self.assertEqual( - {"files:file:read", "files:file:upload"}, - set(topic.conditions[0].required_scopes), + self.assertIn( + "documentation_topics", + {provider.__name__ for provider in self.manifest.documentation_providers}, ) - for key in ("prerequisites", "steps", "outcome", "verification"): - self.assertTrue(topic.metadata[key]) - self.assertIn("never mutate the remote source", topic.body) - self.assertIn("/api/v1/files/connectors/profiles/{profile_id}/import", {link.href for link in topic.links}) - def test_managed_file_workflow_covers_upload_organization_and_current_share_boundary(self) -> None: - topic = self.topic("files.workflow.upload-organize-and-share") - - self.assertEqual(("user",), topic.documentation_types) - self.assertEqual("workflow", topic.metadata["kind"]) - self.assertEqual( - { + def test_user_tasks_are_independently_authorized_and_contextual(self) -> None: + expected_scopes = { + "files.workflow.organize-managed-files": { "files:file:read", - "files:file:upload", "files:file:organize", + }, + "files.workflow.find-and-download-files": { + "files:file:read", + "files:file:download", + }, + "files.workflow.share-managed-files": { + "files:file:read", "files:file:share", }, - set(topic.conditions[0].required_scopes), - ) - for key in ("prerequisites", "steps", "outcome", "verification"): - self.assertTrue(topic.metadata[key]) - self.assertIn("does not yet provide a general share editor", topic.body) - self.assertIn("no share-revocation route", topic.body) - self.assertIn("/api/v1/files/upload", {link.href for link in topic.links}) - self.assertIn("/api/v1/files/transfer", {link.href for link in topic.links}) - self.assertIn("/api/v1/files/{file_id}/shares", {link.href for link in topic.links}) + "files.workflow.delete-managed-files": { + "files:file:read", + "files:file:delete", + }, + } + for topic_id, scopes in expected_scopes.items(): + with self.subTest(topic=topic_id): + topic = self.topic(topic_id) + self.assertEqual(("user",), topic.documentation_types) + self.assertEqual("workflow", topic.metadata["kind"]) + self.assertEqual(scopes, set(topic.conditions[0].required_scopes)) + self.assertEqual(["files.list"], topic.metadata["help_contexts"]) + for key in ("prerequisites", "steps", "outcome", "verification"): + self.assertTrue(topic.metadata[key]) - def test_admin_topic_covers_policy_redaction_and_atomic_credential_deletion(self) -> None: + def test_share_and_delete_tasks_state_current_boundaries(self) -> None: + share = self.topic("files.workflow.share-managed-files") + self.assertEqual("available", share.layer) + self.assertIn("does not yet provide a general share editor", share.body) + self.assertIn("no share-revocation route", share.body) + self.assertIn( + "/api/v1/files/{file_id}/shares", {link.href for link in share.links} + ) + + delete = self.topic("files.workflow.delete-managed-files") + self.assertIn("Soft-delete", delete.summary) + self.assertIn("no self-service restore or hard-purge", delete.body) + self.assertTrue( + any("not a hard purge" in item for item in delete.metadata["limitations"]) + ) + + def test_admin_topic_covers_policy_redaction_and_atomic_credential_deletion( + self, + ) -> None: topic = self.topic("files.governed-connectors-and-provenance") self.assertEqual(("admin",), topic.documentation_types) @@ -86,12 +128,24 @@ class FilesManifestDocumentationTests(unittest.TestCase): self.assertIn("deny rules win", topic.body) self.assertIn("redact secret values", topic.body) self.assertIn("same transaction", topic.body) - self.assertTrue(any("non-owned external references" in item for item in topic.metadata["security_invariants"])) - self.assertIn("/api/v1/files/connectors/policies/tenant", {link.href for link in topic.links}) - self.assertIn("/api/v1/files/connectors/credentials", {link.href for link in topic.links}) + self.assertTrue( + any( + "non-owned external references" in item + for item in topic.metadata["security_invariants"] + ) + ) + self.assertIn( + "/api/v1/files/connectors/policies/tenant", + {link.href for link in topic.links}, + ) + self.assertIn( + "/api/v1/files/connectors/credentials", {link.href for link in topic.links} + ) def test_operator_topic_covers_recovery_and_fail_closed_s3_smb(self) -> None: - topic = self.topic("files.reference.integrity-recovery-and-fail-closed-transports") + topic = self.topic( + "files.reference.integrity-recovery-and-fail-closed-transports" + ) self.assertEqual(("admin",), topic.documentation_types) self.assertEqual("reference", topic.metadata["kind"]) @@ -102,7 +156,9 @@ class FilesManifestDocumentationTests(unittest.TestCase): self.assertIn("does not remove backend blob objects", topic.body) self.assertIn("MASTER_KEY_B64", topic.metadata["recovery_unit"]) self.assertTrue(topic.metadata["verification"]) - self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys) + self.assertIn( + "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys + ) def test_integrator_topic_exposes_capability_and_provenance_boundary(self) -> None: topic = self.topic("files.reference.snapshot-provenance-and-capabilities") @@ -117,11 +173,14 @@ class FilesManifestDocumentationTests(unittest.TestCase): self.assertIn("exact asset, version, blob, checksum", topic.body) self.assertIn("collaboration", topic.body) - def test_process_and_release_assurance_exposes_gates_evidence_and_limits(self) -> None: + def test_process_and_release_assurance_exposes_gates_evidence_and_limits( + self, + ) -> None: topic = self.topic("files.assurance.process-and-release-readiness") self.assertEqual(("admin", "user"), topic.documentation_types) self.assertEqual("workflow", topic.metadata["kind"]) + self.assertEqual(["files.list"], topic.metadata["help_contexts"]) self.assertIn("process_owner", topic.audience) self.assertIn("release_manager", topic.audience) self.assertIn("versions align", topic.body) @@ -129,16 +188,22 @@ class FilesManifestDocumentationTests(unittest.TestCase): self.assertIn("no enforced retention or legal hold", topic.body) for key in ("prerequisites", "steps", "outcome", "verification"): self.assertTrue(topic.metadata[key]) - self.assertTrue(any("meta-repository security" in step for step in topic.metadata["steps"])) + self.assertTrue( + any("meta-repository security" in step for step in topic.metadata["steps"]) + ) self.assertTrue(any("fails closed" in step for step in topic.metadata["steps"])) self.assertTrue(any("SHA-256" in step for step in topic.metadata["steps"])) - self.assertIn(HANDBOOK_HREF, {link.href for link in topic.links if link.kind == "repository"}) def test_internal_related_topic_ids_resolve(self) -> None: + known_ids = STATIC_TOPIC_IDS | RUNTIME_TOPIC_IDS for topic in self.topics.values(): for related_id in topic.metadata.get("related_topic_ids", []): if related_id.startswith("files."): - self.assertIn(related_id, TOPIC_IDS, f"{topic.id} refers to missing topic {related_id}") + self.assertIn( + related_id, + known_ids, + f"{topic.id} refers to missing topic {related_id}", + ) if __name__ == "__main__":