feat: harden file sharing and integrity

This commit is contained in:
2026-07-30 14:26:47 +02:00
parent 85606d5580
commit 835eacfc5d
28 changed files with 2714 additions and 102 deletions

View File

@@ -187,9 +187,10 @@ The managed-file flow is:
8. Connector-originated operations also emit their connector audit evidence. 8. Connector-originated operations also emit their connector audit evidence.
Blob storage is not part of the database transaction. An object may therefore Blob storage is not part of the database transaction. An object may therefore
be left without committed metadata after a process or database failure. No be left without committed metadata after a process or database failure. The
automatic orphan reconciliation exists yet; operators must account for this in operator integrity API scans database blobs and the tenant storage prefix in
integrity checks and retention plans. bounded, resumable phases. It reports orphan objects before any cleanup and
never deletes them as part of a scan.
### Governed connector import ### Governed connector import
@@ -463,9 +464,30 @@ include:
- deployment-owned connector profile files, referenced CA bundles, and secret - deployment-owned connector profile files, referenced CA bundles, and secret
environment configuration where those definitions are in use. environment configuration where those definitions are in use.
There is no Files backup/restore API and no automated blob integrity or orphan There is no Files backup/restore API. Use a write quiesce or coordinated
reconciliation job. Use a write quiesce or coordinated snapshots so database snapshots so database references and objects represent the same recovery point.
references and objects represent the same recovery point. The integrity API verifies a restored set, but it does not replace a coordinated
backup.
Create a scan with `POST /api/v1/files/integrity/scans`, then call
`POST /api/v1/files/integrity/scans/{scan_id}/run` until it reports
`completed`. Each call advances at most the persisted batch size, so a stopped
operator or worker can resume from the committed blob/object cursors.
Findings distinguish:
- `missing`: metadata references an absent object;
- `size_mismatch` or `checksum_mismatch`: bytes do not match immutable blob
metadata and the blob is quarantined;
- `orphan_object`: an object exists in the tenant Files prefix without a
corresponding blob row.
Missing or corrupt blobs fail closed for ordinary downloads and Campaign
attachment materialization. After restoring the expected bytes, use the finding
`recheck` action first in dry-run mode and then apply it. Orphan cleanup is also
dry-run by default, rechecks that no database reference exists, remains scoped
to the scanned tenant prefix, and is idempotent. Both applied and dry-run
actions emit audit evidence.
After restore: After restore:
@@ -493,9 +515,10 @@ Legacy external secret references are detached and identified as non-owned;
Files never calls a provider delete operation for them. Files never calls a provider delete operation for them.
The retirement executor drops database tables but does not delete corresponding The retirement executor drops database tables but does not delete corresponding
objects from the configured blob backend. Operators must include those orphaned objects from the configured blob backend. Operators must include those objects
objects in the approved retention/destruction plan. Validate the installer in the approved retention/destruction plan, report them with an integrity scan,
snapshot and independent blob backup before retirement. and explicitly approve cleanup. Validate the installer snapshot and independent
blob backup before retirement.
### Operational signals ### Operational signals
@@ -657,8 +680,9 @@ delete against an already scrubbed tombstone does not recreate secret evidence.
File versions and blobs are effectively retained indefinitely today. Although a File versions and blobs are effectively retained indefinitely today. Although a
blob has `ref_count` and `retained_until` fields, no complete retention-policy, blob has `ref_count` and `retained_until` fields, no complete retention-policy,
legal-hold, hard-purge, or garbage-collection service enforces them. There is legal-hold, hard-purge, or garbage-collection service enforces them. There is
also no supported user restore endpoint for soft-deleted assets/folders and no also no supported user restore endpoint for soft-deleted assets/folders.
automated orphan-object reconciliation. Integrity reconciliation is operator-triggered and is not a retention or
automatic garbage-collection policy.
Do not promise erasure, timed retention, legal hold, or self-service recovery Do not promise erasure, timed retention, legal hold, or self-service recovery
from the current soft-delete behavior. Those require an explicit, auditable from the current soft-delete behavior. Those require an explicit, auditable
@@ -735,10 +759,10 @@ returning different content or credentials.
| Area | Implemented now | Planned or explicitly outside the current boundary | | Area | Implemented now | Planned or explicitly outside the current boundary |
| --- | --- | --- | | --- | --- | --- |
| Managed storage | Local durable-root backend, fallback read roots, tenant blob deduplication, checksums | Operational S3 after pinned SDK transport ([#34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)); automated integrity/orphan reconciliation ([#36](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/36)) | | Managed storage | Local durable-root backend, fallback read roots, tenant blob deduplication, checksums, bounded resumable integrity scans, quarantine, and dry-run-first orphan cleanup | Operational S3 after pinned SDK transport ([#34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)); scheduled scan execution |
| Upload | Bounded direct upload, drag-and-drop UI, ZIP spool/extract limits, explicit conflicts | Malware scanning, quotas, type policy, resumable/chunked upload | | Upload | Bounded direct upload, drag-and-drop UI, ZIP spool/extract limits, explicit conflicts | Malware scanning, quotas, type policy, resumable/chunked upload |
| Organization | Folders, bulk rename preview/apply, move/copy, drag-and-drop, ZIP download, pattern resolution | General file-history UI and user-driven append-version/restore | | Organization | Folders, bulk rename preview/apply, move/copy, drag-and-drop, ZIP download, pattern resolution | General file-history UI and user-driven append-version/restore |
| Sharing | User/group/tenant/campaign grant or permission update through API; campaign linkage display | Share revocation/expiry and complete general share-management UI ([#37](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/37)) | | Sharing | User/group/tenant/campaign grants, expiry, idempotent revocation, searchable share-management UI, and campaign linkage display | Richer policy-driven share lifecycles |
| Deletion/retention | Soft-delete assets/folders/spaces; immediate audited connector-secret scrubbing | File restore API, hard purge, retention policy, legal hold, and blob GC ([#38](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/38)) | | Deletion/retention | Soft-delete assets/folders/spaces; immediate audited connector-secret scrubbing | File restore API, hard purge, retention policy, legal hold, and blob GC ([#38](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/38)) |
| Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected | | Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected |
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers | | HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |

View File

@@ -22,6 +22,7 @@ from govoplan_files.backend.storage.campaign_attachments import (
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs 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.files import current_version_and_blob
from govoplan_files.backend.storage.paths import normalize_folder from govoplan_files.backend.storage.paths import normalize_folder
from govoplan_files.backend.storage.share_state import effective_file_share_clause
VIRTUAL_FOLDER_RESOURCE_PREFIX = "virtual-folder:v1" VIRTUAL_FOLDER_RESOURCE_PREFIX = "virtual-folder:v1"
@@ -96,7 +97,7 @@ class FilesAccessService(FileAccessProvider):
.filter( .filter(
FileShare.tenant_id == asset.tenant_id, FileShare.tenant_id == asset.tenant_id,
FileShare.file_asset_id == asset.id, FileShare.file_asset_id == asset.id,
FileShare.revoked_at.is_(None), effective_file_share_clause(),
FileShare.permission.in_(sorted(permission_values)), FileShare.permission.in_(sorted(permission_values)),
or_( or_(
(FileShare.target_type == "user") & (FileShare.target_id == principal.membership_id), (FileShare.target_type == "user") & (FileShare.target_id == principal.membership_id),

View File

@@ -117,7 +117,15 @@ def _record_share_visibility_change(session: OrmSession, share: FileShare) -> No
return return
if not state.pending and not has_attr_changes( if not state.pending and not has_attr_changes(
state, state,
("file_asset_id", "target_type", "target_id", "permission", "revoked_at"), (
"file_asset_id",
"target_type",
"target_id",
"permission",
"expires_at",
"revoked_at",
"revoked_by_user_id",
),
): ):
return return
_ensure_id(share) _ensure_id(share)
@@ -136,7 +144,9 @@ def _record_share_visibility_change(session: OrmSession, share: FileShare) -> No
"share_target_type": share.target_type, "share_target_type": share.target_type,
"share_target_id": share.target_id, "share_target_id": share.target_id,
"share_permission": share.permission, "share_permission": share.permission,
"share_expires_at": _isoformat(share.expires_at),
"share_revoked_at": _isoformat(share.revoked_at), "share_revoked_at": _isoformat(share.revoked_at),
"share_revoked_by_user_id": share.revoked_by_user_id,
}, },
) )

View File

@@ -28,6 +28,55 @@ class FileBlob(Base, TimestampMixin):
content_type: Mapped[str | None] = mapped_column(String(255)) content_type: Mapped[str | None] = mapped_column(String(255))
ref_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) ref_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
retained_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) retained_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
integrity_status: Mapped[str] = mapped_column(String(30), default="unchecked", nullable=False, index=True)
integrity_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
integrity_failure: Mapped[str | None] = mapped_column(String(100), nullable=True)
quarantined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class FileIntegrityScan(Base, TimestampMixin):
__tablename__ = "file_integrity_scans"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
storage_backend: Mapped[str] = mapped_column(String(50), nullable=False)
storage_prefix: Mapped[str] = mapped_column(String(1000), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False, index=True)
phase: Mapped[str] = mapped_column(String(30), default="blobs", nullable=False)
verify_checksums: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
batch_size: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
blob_cursor: Mapped[str | None] = mapped_column(String(36), nullable=True)
object_cursor: Mapped[str | None] = mapped_column(String(1000), nullable=True)
scanned_blob_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
verified_blob_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
quarantined_blob_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
scanned_object_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
orphan_object_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(String(255), nullable=True)
class FileIntegrityFinding(Base, TimestampMixin):
__tablename__ = "file_integrity_findings"
__table_args__ = (
Index("ix_file_integrity_findings_scan_state", "scan_id", "state"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
scan_id: Mapped[str] = mapped_column(ForeignKey("file_integrity_scans.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
state: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
blob_id: Mapped[str | None] = mapped_column(ForeignKey("file_blobs.id", ondelete="SET NULL"), nullable=True, index=True)
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
expected_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
observed_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
expected_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
observed_checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class FileFolder(Base, TimestampMixin): class FileFolder(Base, TimestampMixin):
@@ -105,7 +154,9 @@ class FileShare(Base, TimestampMixin):
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
revoked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class FileConnectorProfile(Base, TimestampMixin): class FileConnectorProfile(Base, TimestampMixin):

View File

@@ -34,6 +34,8 @@ register_files_change_tracking()
_files_table_retirement_provider = drop_table_retirement_provider( _files_table_retirement_provider = drop_table_retirement_provider(
file_models.FileBlob, file_models.FileBlob,
file_models.FileIntegrityScan,
file_models.FileIntegrityFinding,
file_models.FileFolder, file_models.FileFolder,
file_models.FileAsset, file_models.FileAsset,
file_models.FileVersion, file_models.FileVersion,
@@ -100,7 +102,7 @@ PERMISSIONS = (
_permission("files:file:download", "Download files", "Download managed files and generated archives."), _permission("files:file:download", "Download files", "Download managed files and generated archives."),
_permission("files:file:upload", "Upload files", "Upload new managed file versions."), _permission("files:file:upload", "Upload files", "Upload new managed file versions."),
_permission("files:file:organize", "Organize files", "Create folders, rename, move or copy managed files."), _permission("files:file:organize", "Organize files", "Create folders, rename, move or copy managed files."),
_permission("files:file:share", "Share files", "Grant or update managed file shares; revocation is not available yet."), _permission("files:file:share", "Share files", "List, grant, update, expire, and revoke managed file shares."),
_permission("files:file:delete", "Delete files", "Delete or hide managed files and folders where policy allows it."), _permission("files:file:delete", "Delete files", "Delete or hide managed files and folders where policy allows it."),
_permission("files:file:admin", "Administer file spaces", "Administer all file spaces in the tenant."), _permission("files:file:admin", "Administer file spaces", "Administer all file spaces in the tenant."),
) )
@@ -344,11 +346,11 @@ manifest = ModuleManifest(
), ),
DocumentationTopic( DocumentationTopic(
id="files.workflow.share-managed-files", id="files.workflow.share-managed-files",
title="Grant or update access to managed files", title="Manage access to managed files",
summary="Grant or update read, write, or manage access through a supporting workflow or API client without changing ownership.", summary="List, grant, update, expire, or revoke direct read, write, and manage access without changing ownership.",
body=( body=(
"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. " "File owners and file administrators can manage direct shares for users, groups, the tenant, and Campaign. Expired and revoked grants stop authorizing access immediately while independent active grants remain effective. "
"Do not use this task when access must later be revoked until that explicit capability is implemented." "The Files share dialog lists active and historical grants, and revocation is idempotent."
), ),
layer="available", layer="available",
documentation_types=("user",), documentation_types=("user",),
@@ -362,7 +364,7 @@ manifest = ModuleManifest(
), ),
links=( links=(
DocumentationLink(label="Files", href="/files", kind="runtime"), DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Grant or update a share", href="/api/v1/files/{file_id}/shares", kind="api"), DocumentationLink(label="List and manage shares", href="/api/v1/files/{file_id}/shares", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
), ),
related_modules=("campaigns",), related_modules=("campaigns",),
@@ -373,22 +375,21 @@ manifest = ModuleManifest(
"screen": "Files", "screen": "Files",
"help_contexts": ["files.list"], "help_contexts": ["files.list"],
"prerequisites": [ "prerequisites": [
"You may view and share the managed file and have write/manage access to that specific asset; the global share permission alone does not grant resource access.", "You have the Files share permission and own the file, or administer file spaces for the active tenant.",
"A supporting workflow or API client is available; the Files page has no general share editor yet.", "The intended user, group, tenant, or Campaign target exists and is active.",
"The process does not require share revocation, because no revocation route exists yet.",
], ],
"steps": [ "steps": [
"Open Files and verify the file owner, logical path, current version, and checksum.", "Select one managed file, choose Manage shares, and review the effective and historical direct grants.",
"Through the supporting workflow, identify the intended user, group, tenant, or campaign and choose read, write, or manage access.", "Select the intended user, group, or tenant and choose read, write, or manage access plus an optional expiry.",
"Grant or update the share without changing file ownership.", "Grant or update the share without changing file ownership.",
"Have the intended recipient verify access and an unrelated account verify denial.", "Revoke a grant when it is no longer needed; repeated revocation is a no-op.",
], ],
"limitations": [ "limitations": [
"The Files page has no general share editor.", "Campaign-target grants are normally created by the Campaign integration rather than selected manually in the Files dialog.",
"Shares can be granted or updated, but not revoked through the current API.", "A user may retain access through another active direct grant or ownership path after one share is revoked.",
], ],
"outcome": "The requested access is granted or updated while the managed asset keeps its owner.", "outcome": "Direct access has the requested permission and lifetime 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.", "verification": "Test one intended and one denied path, then expire or revoke the grant and verify that only independent access paths remain.",
"related_topic_ids": [ "related_topic_ids": [
"files.workflow.find-and-download-files", "files.workflow.find-and-download-files",
"files.assurance.process-and-release-readiness", "files.assurance.process-and-release-readiness",
@@ -507,7 +508,7 @@ manifest = ModuleManifest(
title="Operate Files integrity, recovery, and connector transport safety", title="Operate Files integrity, recovery, and connector transport safety",
summary="Back up database evidence, blob bytes, and the encryption key as one recovery unit, and keep unsupported SDK transports fail-closed.", summary="Back up database evidence, blob bytes, and the encryption key as one recovery unit, and keep unsupported SDK transports fail-closed.",
body=( body=(
"Local durable storage is the operational baseline. Recover Files from a coordinated database/blob snapshot with the original master key, then verify representative checksums and access paths. " "Local durable storage is the operational baseline. Recover Files from a coordinated database/blob snapshot with the original master key, then run the bounded resumable integrity scan and verify representative access paths. Missing or mismatched blobs are quarantined; orphan objects are reported before dry-run-first, explicitly authorized cleanup. "
"Live S3 managed storage/connectors and SMB connectors fail closed until botocore redirects/endpoint discovery and SMB initial connections/DFS referrals support connection-time DNS/IP pinning. Destructive module retirement drops database tables but does not remove backend blob objects." "Live S3 managed storage/connectors and SMB connectors fail closed until botocore redirects/endpoint discovery and SMB initial connections/DFS referrals support connection-time DNS/IP pinning. Destructive module retirement drops database tables but does not remove backend blob objects."
), ),
layer="configured", layer="configured",
@@ -528,6 +529,7 @@ manifest = ModuleManifest(
links=( links=(
DocumentationLink(label="System file connections", href="/admin?section=system-file-connectors", kind="runtime"), DocumentationLink(label="System file connections", href="/admin?section=system-file-connectors", kind="runtime"),
DocumentationLink(label="Connector provider status", href="/api/v1/files/connectors/providers", kind="api"), DocumentationLink(label="Connector provider status", href="/api/v1/files/connectors/providers", kind="api"),
DocumentationLink(label="Create an integrity scan", href="/api/v1/files/integrity/scans", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
), ),
related_modules=("audit", "ops"), related_modules=("audit", "ops"),
@@ -549,7 +551,7 @@ manifest = ModuleManifest(
"screen": "System file connections and deployment operations", "screen": "System file connections and deployment operations",
"section": "Storage integrity, backup/recovery, and fail-closed transports", "section": "Storage integrity, backup/recovery, and fail-closed transports",
"recovery_unit": ["Files database rows", "managed blob namespace", "MASTER_KEY_B64", "deployment-owned connector configuration"], "recovery_unit": ["Files database rows", "managed blob namespace", "MASTER_KEY_B64", "deployment-owned connector configuration"],
"verification": "After restore, download representative files, compare their bytes with recorded SHA-256 values, verify authorized and denied access, and test one permitted pinned HTTP connector.", "verification": "After restore, complete a checksum-enabled integrity scan, resolve every missing/corrupt finding, approve or retain every reported orphan, verify authorized and denied access, and test one permitted pinned HTTP connector.",
"related_topic_ids": [ "related_topic_ids": [
"files.governed-connectors-and-provenance", "files.governed-connectors-and-provenance",
"files.reference.snapshot-provenance-and-capabilities", "files.reference.snapshot-provenance-and-capabilities",
@@ -601,7 +603,7 @@ manifest = ModuleManifest(
summary="Check a process against the implemented Files boundary, exercise permitted and denied paths, and retain evidence before approving a release or operational use.", summary="Check a process against the implemented Files boundary, exercise permitted and denied paths, and retain evidence before approving a release or operational use.",
body=( body=(
"A process owner must distinguish implemented controls from planned capabilities before relying on Files. A release is not ready until all package and manifest versions align and representative authorization, upload limits, conflict handling, download, deletion, connector, and recovery paths have been exercised. " "A process owner must distinguish implemented controls from planned capabilities before relying on Files. A release is not ready until all package and manifest versions align and representative authorization, upload limits, conflict handling, download, deletion, connector, and recovery paths have been exercised. "
"Current limitations include no general share-management UI or share revocation, no self-service restore or hard purge, no enforced retention or legal hold, and no dedicated canonical audit event for every ordinary Files mutation. Record these limitations in the process assessment instead of treating soft deletion or change-sequence entries as stronger evidence." "Current limitations include no self-service restore or hard purge, no enforced retention or legal hold, and no dedicated canonical audit event for every ordinary Files mutation. Share grant, change, expiry, and revocation operations do emit dedicated audit records. Record remaining limitations in the process assessment instead of treating soft deletion or change-sequence entries as stronger evidence."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),

View File

@@ -0,0 +1,53 @@
"""file share lifecycle
Revision ID: b8c9d0e1f2a4
Revises: a7b8c9d0e1f3
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b8c9d0e1f2a4"
down_revision = "a7b8c9d0e1f3"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("file_shares") as batch_op:
batch_op.add_column(
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(
sa.Column("revoked_by_user_id", sa.String(length=36), nullable=True)
)
batch_op.create_foreign_key(
op.f("fk_file_shares_revoked_by_user_id_access_users"),
"access_users",
["revoked_by_user_id"],
["id"],
ondelete="SET NULL",
)
batch_op.create_index(
op.f("ix_file_shares_expires_at"), ["expires_at"], unique=False
)
batch_op.create_index(
op.f("ix_file_shares_revoked_by_user_id"),
["revoked_by_user_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("file_shares") as batch_op:
batch_op.drop_index(op.f("ix_file_shares_revoked_by_user_id"))
batch_op.drop_index(op.f("ix_file_shares_expires_at"))
batch_op.drop_constraint(
op.f("fk_file_shares_revoked_by_user_id_access_users"),
type_="foreignkey",
)
batch_op.drop_column("revoked_by_user_id")
batch_op.drop_column("expires_at")

View File

@@ -0,0 +1,168 @@
"""file integrity reconciliation
Revision ID: c9d0e1f2a3b5
Revises: b8c9d0e1f2a4
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c9d0e1f2a3b5"
down_revision = "b8c9d0e1f2a4"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("file_blobs") as batch_op:
batch_op.add_column(
sa.Column(
"integrity_status",
sa.String(length=30),
nullable=False,
server_default="unchecked",
)
)
batch_op.add_column(
sa.Column("integrity_checked_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(
sa.Column("integrity_failure", sa.String(length=100), nullable=True)
)
batch_op.add_column(
sa.Column("quarantined_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.create_index(
op.f("ix_file_blobs_integrity_status"),
["integrity_status"],
unique=False,
)
batch_op.create_index(
op.f("ix_file_blobs_integrity_checked_at"),
["integrity_checked_at"],
unique=False,
)
batch_op.create_index(
op.f("ix_file_blobs_quarantined_at"),
["quarantined_at"],
unique=False,
)
op.create_table(
"file_integrity_scans",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("storage_backend", sa.String(length=50), nullable=False),
sa.Column("storage_prefix", sa.String(length=1000), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("phase", sa.String(length=30), nullable=False),
sa.Column("verify_checksums", sa.Boolean(), nullable=False),
sa.Column("batch_size", sa.Integer(), nullable=False),
sa.Column("blob_cursor", sa.String(length=36), nullable=True),
sa.Column("object_cursor", sa.String(length=1000), nullable=True),
sa.Column("scanned_blob_count", sa.Integer(), nullable=False),
sa.Column("verified_blob_count", sa.Integer(), nullable=False),
sa.Column("quarantined_blob_count", sa.Integer(), nullable=False),
sa.Column("scanned_object_count", sa.Integer(), nullable=False),
sa.Column("orphan_object_count", sa.Integer(), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f(
"fk_file_integrity_scans_created_by_user_id_access_users"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_integrity_scans")),
)
for column in ("tenant_id", "status", "created_by_user_id"):
op.create_index(
op.f(f"ix_file_integrity_scans_{column}"),
"file_integrity_scans",
[column],
unique=False,
)
op.create_table(
"file_integrity_findings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("scan_id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("kind", sa.String(length=40), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("blob_id", sa.String(length=36), nullable=True),
sa.Column("storage_key", sa.String(length=1000), nullable=False),
sa.Column("expected_size_bytes", sa.Integer(), nullable=True),
sa.Column("observed_size_bytes", sa.Integer(), nullable=True),
sa.Column("expected_checksum_sha256", sa.String(length=64), nullable=True),
sa.Column("observed_checksum_sha256", sa.String(length=64), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolved_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["blob_id"],
["file_blobs.id"],
name=op.f("fk_file_integrity_findings_blob_id_file_blobs"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["resolved_by_user_id"],
["access_users.id"],
name=op.f(
"fk_file_integrity_findings_resolved_by_user_id_access_users"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["scan_id"],
["file_integrity_scans.id"],
name=op.f(
"fk_file_integrity_findings_scan_id_file_integrity_scans"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_integrity_findings")),
)
for column in (
"scan_id",
"tenant_id",
"kind",
"state",
"blob_id",
"resolved_by_user_id",
):
op.create_index(
op.f(f"ix_file_integrity_findings_{column}"),
"file_integrity_findings",
[column],
unique=False,
)
op.create_index(
"ix_file_integrity_findings_scan_state",
"file_integrity_findings",
["scan_id", "state"],
unique=False,
)
def downgrade() -> None:
op.drop_table("file_integrity_findings")
op.drop_table("file_integrity_scans")
with op.batch_alter_table("file_blobs") as batch_op:
batch_op.drop_index(op.f("ix_file_blobs_quarantined_at"))
batch_op.drop_index(op.f("ix_file_blobs_integrity_checked_at"))
batch_op.drop_index(op.f("ix_file_blobs_integrity_status"))
batch_op.drop_column("quarantined_at")
batch_op.drop_column("integrity_failure")
batch_op.drop_column("integrity_checked_at")
batch_op.drop_column("integrity_status")

View File

@@ -96,6 +96,7 @@ from govoplan_files.backend.storage.provenance import (
source_provenance_from_metadata, source_provenance_from_metadata,
source_revision_from_metadata, source_revision_from_metadata,
) )
from govoplan_files.backend.storage.share_state import file_share_is_active
FILES_CONNECTOR_SETTINGS_COLLECTIONS = ( FILES_CONNECTOR_SETTINGS_COLLECTIONS = (
@@ -1022,17 +1023,7 @@ def _asset_response(
.order_by(FileShare.created_at.desc()) .order_by(FileShare.created_at.desc())
.all() .all()
) )
shares = [ shares = [_file_share_response(row) for row in rows]
FileShareResponse(
id=row.id,
target_type=row.target_type,
target_id=row.target_id,
permission=row.permission,
created_at=row.created_at.isoformat(),
revoked_at=row.revoked_at.isoformat() if row.revoked_at else None,
)
for row in rows
]
return FileAssetResponse( return FileAssetResponse(
id=asset.id, id=asset.id,
tenant_id=asset.tenant_id, tenant_id=asset.tenant_id,
@@ -1059,11 +1050,16 @@ def _asset_response(
def _file_share_response(row: FileShare) -> FileShareResponse: def _file_share_response(row: FileShare) -> FileShareResponse:
return FileShareResponse( return FileShareResponse(
id=row.id, id=row.id,
file_asset_id=row.file_asset_id,
target_type=row.target_type, target_type=row.target_type,
target_id=row.target_id, target_id=row.target_id,
permission=row.permission, permission=row.permission,
created_by_user_id=row.created_by_user_id,
created_at=row.created_at.isoformat(), created_at=row.created_at.isoformat(),
expires_at=row.expires_at.isoformat() if row.expires_at else None,
revoked_at=row.revoked_at.isoformat() if row.revoked_at else None, revoked_at=row.revoked_at.isoformat() if row.revoked_at else None,
revoked_by_user_id=row.revoked_by_user_id,
active=file_share_is_active(row),
) )

View File

@@ -11,6 +11,7 @@ from govoplan_files.backend.routes.connector_settings import (
router as connector_settings_router, router as connector_settings_router,
) )
from govoplan_files.backend.routes.folders import router as folders_router from govoplan_files.backend.routes.folders import router as folders_router
from govoplan_files.backend.routes.integrity import router as integrity_router
from govoplan_files.backend.routes.listing import router as listing_router from govoplan_files.backend.routes.listing import router as listing_router
from govoplan_files.backend.routes.shares import router as shares_router from govoplan_files.backend.routes.shares import router as shares_router
from govoplan_files.backend.routes.spaces import router as spaces_router from govoplan_files.backend.routes.spaces import router as spaces_router
@@ -22,6 +23,7 @@ router = APIRouter()
for workflow_router in ( for workflow_router in (
spaces_router, spaces_router,
folders_router, folders_router,
integrity_router,
listing_router, listing_router,
uploads_router, uploads_router,
connector_settings_router, connector_settings_router,

View File

@@ -0,0 +1,337 @@
from __future__ import annotations
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.db.session import get_session
from govoplan_files.backend.db.models import (
FileIntegrityFinding,
FileIntegrityScan,
)
from govoplan_files.backend.schemas import (
FileIntegrityActionRequest,
FileIntegrityActionResponse,
FileIntegrityFindingResponse,
FileIntegrityFindingsResponse,
FileIntegrityScanCreateRequest,
FileIntegrityScanResponse,
FileIntegrityScansResponse,
)
from govoplan_files.backend.storage.backends import StorageBackendError
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.integrity import (
cleanup_orphan_finding,
create_integrity_scan,
mark_integrity_scan_failed,
recheck_integrity_finding,
run_integrity_scan_batch,
)
router = APIRouter(prefix="/files/integrity", tags=["files-integrity"])
@router.get("/scans", response_model=FileIntegrityScansResponse)
def list_integrity_scans(
limit: int = Query(default=50, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:admin")),
) -> FileIntegrityScansResponse:
rows = (
session.query(FileIntegrityScan)
.filter(FileIntegrityScan.tenant_id == principal.tenant_id)
.order_by(FileIntegrityScan.created_at.desc(), FileIntegrityScan.id.desc())
.limit(limit)
.all()
)
return FileIntegrityScansResponse(
scans=[_scan_response(row) for row in rows]
)
@router.post(
"/scans",
response_model=FileIntegrityScanResponse,
status_code=status.HTTP_201_CREATED,
)
def create_scan(
payload: FileIntegrityScanCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:admin")),
) -> FileIntegrityScanResponse:
try:
scan = create_integrity_scan(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
verify_checksums=payload.verify_checksums,
batch_size=payload.batch_size,
)
audit_from_principal(
session,
principal,
action="files.integrity.scan_created",
object_type="file_integrity_scan",
object_id=scan.id,
details={
"storage_backend": scan.storage_backend,
"verify_checksums": scan.verify_checksums,
"batch_size": scan.batch_size,
},
)
session.commit()
return _scan_response(scan)
except (FileStorageError, StorageBackendError) as exc:
session.rollback()
raise _integrity_http_error(exc) from exc
@router.post("/scans/{scan_id}/run", response_model=FileIntegrityScanResponse)
def run_scan_batch(
scan_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:admin")),
) -> FileIntegrityScanResponse:
scan = _scan_for_tenant(session, scan_id, principal.tenant_id)
previous_status = scan.status
try:
run_integrity_scan_batch(session, scan)
if scan.status == "completed" and previous_status != "completed":
audit_from_principal(
session,
principal,
action="files.integrity.scan_completed",
object_type="file_integrity_scan",
object_id=scan.id,
details={
"verified_blob_count": scan.verified_blob_count,
"quarantined_blob_count": scan.quarantined_blob_count,
"orphan_object_count": scan.orphan_object_count,
},
)
session.commit()
return _scan_response(scan)
except (FileStorageError, StorageBackendError) as exc:
session.rollback()
scan = _scan_for_tenant(session, scan_id, principal.tenant_id)
mark_integrity_scan_failed(scan, error=exc)
audit_from_principal(
session,
principal,
action="files.integrity.scan_failed",
object_type="file_integrity_scan",
object_id=scan.id,
details={"error_type": type(exc).__name__},
)
session.commit()
raise _integrity_http_error(exc) from exc
@router.get(
"/scans/{scan_id}/findings",
response_model=FileIntegrityFindingsResponse,
)
def list_integrity_findings(
scan_id: str,
state_filter: str | None = Query(default=None, alias="state"),
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:admin")),
) -> FileIntegrityFindingsResponse:
scan = _scan_for_tenant(session, scan_id, principal.tenant_id)
query = session.query(FileIntegrityFinding).filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.tenant_id == principal.tenant_id,
)
if state_filter:
query = query.filter(FileIntegrityFinding.state == state_filter)
rows = (
query.order_by(
FileIntegrityFinding.created_at.asc(),
FileIntegrityFinding.id.asc(),
)
.limit(limit)
.all()
)
return FileIntegrityFindingsResponse(
findings=[_finding_response(row) for row in rows]
)
@router.post(
"/findings/{finding_id}/recheck",
response_model=FileIntegrityActionResponse,
)
def recheck_finding(
finding_id: str,
payload: FileIntegrityActionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:admin")),
) -> FileIntegrityActionResponse:
finding = _finding_for_tenant(session, finding_id, principal.tenant_id)
try:
result = recheck_integrity_finding(
session,
finding,
user_id=principal.user.id,
dry_run=payload.dry_run,
)
_audit_integrity_action(session, principal, result)
session.commit()
return _action_response(result)
except (FileStorageError, StorageBackendError) as exc:
session.rollback()
raise _integrity_http_error(exc) from exc
@router.post(
"/findings/{finding_id}/cleanup",
response_model=FileIntegrityActionResponse,
)
def cleanup_finding(
finding_id: str,
payload: FileIntegrityActionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("files:file:admin")),
) -> FileIntegrityActionResponse:
finding = _finding_for_tenant(session, finding_id, principal.tenant_id)
try:
result = cleanup_orphan_finding(
session,
finding,
user_id=principal.user.id,
dry_run=payload.dry_run,
)
_audit_integrity_action(session, principal, result)
session.commit()
return _action_response(result)
except (FileStorageError, StorageBackendError) as exc:
session.rollback()
raise _integrity_http_error(exc) from exc
def _scan_for_tenant(
session: Session,
scan_id: str,
tenant_id: str,
) -> FileIntegrityScan:
scan = session.get(FileIntegrityScan, scan_id)
if scan is None or scan.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Integrity scan not found",
)
return scan
def _finding_for_tenant(
session: Session,
finding_id: str,
tenant_id: str,
) -> FileIntegrityFinding:
finding = session.get(FileIntegrityFinding, finding_id)
if finding is None or finding.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Integrity finding not found",
)
return finding
def _audit_integrity_action(session, principal, result) -> None:
audit_from_principal(
session,
principal,
action=f"files.integrity.{result.action}",
object_type="file_integrity_finding",
object_id=result.finding.id,
details={
"scan_id": result.finding.scan_id,
"kind": result.finding.kind,
"dry_run": result.dry_run,
"changed": result.changed,
"storage_key_sha256": hashlib.sha256(
result.finding.storage_key.encode("utf-8")
).hexdigest(),
"inspection": result.inspection.kind
if result.inspection
else None,
},
)
def _scan_response(scan: FileIntegrityScan) -> FileIntegrityScanResponse:
return FileIntegrityScanResponse(
id=scan.id,
tenant_id=scan.tenant_id,
storage_backend=scan.storage_backend,
storage_prefix=scan.storage_prefix,
status=scan.status,
phase=scan.phase,
verify_checksums=scan.verify_checksums,
batch_size=scan.batch_size,
scanned_blob_count=scan.scanned_blob_count,
verified_blob_count=scan.verified_blob_count,
quarantined_blob_count=scan.quarantined_blob_count,
scanned_object_count=scan.scanned_object_count,
orphan_object_count=scan.orphan_object_count,
created_by_user_id=scan.created_by_user_id,
started_at=scan.started_at.isoformat() if scan.started_at else None,
completed_at=scan.completed_at.isoformat()
if scan.completed_at
else None,
last_error=scan.last_error,
created_at=scan.created_at.isoformat(),
updated_at=scan.updated_at.isoformat(),
)
def _finding_response(
finding: FileIntegrityFinding,
) -> FileIntegrityFindingResponse:
return FileIntegrityFindingResponse(
id=finding.id,
scan_id=finding.scan_id,
tenant_id=finding.tenant_id,
kind=finding.kind,
state=finding.state,
blob_id=finding.blob_id,
storage_key=finding.storage_key,
expected_size_bytes=finding.expected_size_bytes,
observed_size_bytes=finding.observed_size_bytes,
expected_checksum_sha256=finding.expected_checksum_sha256,
observed_checksum_sha256=finding.observed_checksum_sha256,
resolved_at=finding.resolved_at.isoformat()
if finding.resolved_at
else None,
resolved_by_user_id=finding.resolved_by_user_id,
created_at=finding.created_at.isoformat(),
updated_at=finding.updated_at.isoformat(),
)
def _action_response(result) -> FileIntegrityActionResponse:
return FileIntegrityActionResponse(
action=result.action,
changed=result.changed,
dry_run=result.dry_run,
finding=_finding_response(result.finding),
inspection_kind=result.inspection.kind if result.inspection else None,
inspection_valid=result.inspection.valid if result.inspection else None,
)
def _integrity_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, FileStorageError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The configured file storage backend could not complete the integrity operation",
)

View File

@@ -1,25 +1,40 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session 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.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 ( from govoplan_files.backend.schemas import (
BulkFileShareRequest, BulkFileShareRequest,
BulkFileShareResponse, BulkFileShareResponse,
FileShareRequest, FileShareRequest,
FileShareResponse, FileShareResponse,
FileSharesResponse,
) )
from govoplan_core.db.session import get_session
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import ( from govoplan_files.backend.storage.files import (
get_asset_for_user, current_file_share_for_target,
get_asset_for_share_management,
list_file_shares,
revoke_file_share,
share_file, share_file,
share_files, share_files,
) )
from govoplan_files.backend.route_support import ( from govoplan_files.backend.route_support import (
_file_share_response,
_http_error, _http_error,
_is_admin, _is_admin,
) )
@@ -27,6 +42,93 @@ from govoplan_files.backend.route_support import (
router = APIRouter(prefix="/files", tags=["files"]) 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) @router.post("/{file_id}/shares", response_model=FileShareResponse)
def create_share( def create_share(
file_id: str, file_id: str,
@@ -35,14 +137,22 @@ def create_share(
principal: ApiPrincipal = Depends(require_scope("files:file:share")), principal: ApiPrincipal = Depends(require_scope("files:file:share")),
): ):
try: try:
asset = get_asset_for_user( asset = get_asset_for_share_management(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
user_id=principal.user.id, user_id=principal.user.id,
asset_id=file_id, asset_id=file_id,
require_write=True,
is_admin=_is_admin(principal), 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( share = share_file(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -51,16 +161,20 @@ def create_share(
target_id=payload.target_id, target_id=payload.target_id,
permission=payload.permission, permission=payload.permission,
user_id=principal.user.id, user_id=principal.user.id,
expires_at=payload.expires_at,
) )
session.commit() session.flush()
return FileShareResponse( action = _share_audit_action(
id=share.id, existed=previous is not None,
target_type=share.target_type, previous_permission=previous_permission,
target_id=share.target_id,
permission=share.permission, permission=share.permission,
created_at=share.created_at.isoformat(), previous_expiry=previous_expiry,
revoked_at=share.revoked_at.isoformat() if share.revoked_at else None, 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: except FileStorageError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -75,16 +189,32 @@ def create_bulk_shares(
try: try:
file_ids = list(dict.fromkeys(payload.file_ids)) file_ids = list(dict.fromkeys(payload.file_ids))
assets = [ assets = [
get_asset_for_user( get_asset_for_share_management(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
user_id=principal.user.id, user_id=principal.user.id,
asset_id=file_id, asset_id=file_id,
require_write=True,
is_admin=_is_admin(principal), is_admin=_is_admin(principal),
) )
for file_id in file_ids 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( shares = share_files(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -93,24 +223,108 @@ def create_bulk_shares(
target_id=payload.target_id, target_id=payload.target_id,
permission=payload.permission, permission=payload.permission,
user_id=principal.user.id, 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() session.commit()
return BulkFileShareResponse( return BulkFileShareResponse(
shared_count=len(shares), shared_count=len(shares),
shares=[ shares=[_file_share_response(share) for share in shares],
FileShareResponse(
id=share.id,
target_type=share.target_type,
target_id=share.target_id,
permission=share.permission,
created_at=share.created_at.isoformat(),
revoked_at=share.revoked_at.isoformat()
if share.revoked_at
else None,
)
for share in shares
],
) )
except FileStorageError as exc: except FileStorageError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc 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()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -73,11 +74,86 @@ class FileConnectorSpacesResponse(BaseModel):
class FileShareResponse(BaseModel): class FileShareResponse(BaseModel):
id: str id: str
file_asset_id: str
target_type: str target_type: str
target_id: str target_id: str
permission: str permission: str
created_by_user_id: str | None = None
created_at: str created_at: str
expires_at: str | None = None
revoked_at: str | None = None revoked_at: str | None = None
revoked_by_user_id: str | None = None
active: bool
class FileSharesResponse(BaseModel):
shares: list[FileShareResponse] = Field(default_factory=list)
class FileIntegrityScanCreateRequest(BaseModel):
verify_checksums: bool = True
batch_size: int = Field(default=100, ge=1, le=1000)
class FileIntegrityScanResponse(BaseModel):
id: str
tenant_id: str
storage_backend: str
storage_prefix: str
status: str
phase: str
verify_checksums: bool
batch_size: int
scanned_blob_count: int
verified_blob_count: int
quarantined_blob_count: int
scanned_object_count: int
orphan_object_count: int
created_by_user_id: str | None = None
started_at: str | None = None
completed_at: str | None = None
last_error: str | None = None
created_at: str
updated_at: str
class FileIntegrityScansResponse(BaseModel):
scans: list[FileIntegrityScanResponse] = Field(default_factory=list)
class FileIntegrityFindingResponse(BaseModel):
id: str
scan_id: str
tenant_id: str
kind: str
state: str
blob_id: str | None = None
storage_key: str
expected_size_bytes: int | None = None
observed_size_bytes: int | None = None
expected_checksum_sha256: str | None = None
observed_checksum_sha256: str | None = None
resolved_at: str | None = None
resolved_by_user_id: str | None = None
created_at: str
updated_at: str
class FileIntegrityFindingsResponse(BaseModel):
findings: list[FileIntegrityFindingResponse] = Field(default_factory=list)
class FileIntegrityActionRequest(BaseModel):
dry_run: bool = True
class FileIntegrityActionResponse(BaseModel):
action: str
changed: bool
dry_run: bool
finding: FileIntegrityFindingResponse
inspection_kind: str | None = None
inspection_valid: bool | None = None
class FileSourceProvenance(BaseModel): class FileSourceProvenance(BaseModel):
@@ -453,6 +529,7 @@ class FileShareRequest(BaseModel):
target_type: Literal["user", "group", "campaign", "tenant"] target_type: Literal["user", "group", "campaign", "tenant"]
target_id: str target_id: str
permission: Literal["read", "write", "manage"] = "read" permission: Literal["read", "write", "manage"] = "read"
expires_at: datetime | None = None
class BulkFileShareRequest(BaseModel): class BulkFileShareRequest(BaseModel):
@@ -460,6 +537,7 @@ class BulkFileShareRequest(BaseModel):
target_type: Literal["user", "group", "campaign", "tenant"] target_type: Literal["user", "group", "campaign", "tenant"]
target_id: str target_id: str
permission: Literal["read", "write", "manage"] = "read" permission: Literal["read", "write", "manage"] = "read"
expires_at: datetime | None = None
class BulkFileShareResponse(BaseModel): class BulkFileShareResponse(BaseModel):

View File

@@ -35,6 +35,7 @@ from govoplan_files.backend.storage.files import (
list_assets_for_user, list_assets_for_user,
) )
from govoplan_files.backend.storage.folders import list_folders_for_user from govoplan_files.backend.storage.folders import list_folders_for_user
from govoplan_files.backend.storage.share_state import effective_file_share_clause
from govoplan_files.backend.route_support import ( from govoplan_files.backend.route_support import (
@@ -354,7 +355,7 @@ def _entry_campaign_matches(session: Session, entry, campaign_id: str | None) ->
FileShare.file_asset_id == entry.resource_id, FileShare.file_asset_id == entry.resource_id,
FileShare.target_type == "campaign", FileShare.target_type == "campaign",
FileShare.target_id == campaign_id, FileShare.target_id == campaign_id,
FileShare.revoked_at.is_(None), effective_file_share_clause(),
) )
.first() .first()
is not None is not None

View File

@@ -17,6 +17,22 @@ class StorageBackendError(RuntimeError):
pass pass
class StorageObjectMissing(StorageBackendError):
pass
@dataclass(frozen=True, slots=True)
class StorageObjectInfo:
key: str
size_bytes: int
@dataclass(frozen=True, slots=True)
class StorageObjectPage:
objects: tuple[StorageObjectInfo, ...]
next_cursor: str | None = None
class StorageBackend(Protocol): class StorageBackend(Protocol):
name: str name: str
@@ -25,6 +41,14 @@ class StorageBackend(Protocol):
def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: ... def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: ...
def delete(self, key: str) -> None: ... def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ... def exists(self, key: str) -> bool: ...
def stat(self, key: str) -> StorageObjectInfo: ...
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage: ...
@dataclass(slots=True) @dataclass(slots=True)
@@ -55,7 +79,7 @@ class LocalFilesystemStorageBackend:
candidate = self._path_for_root(root, key) candidate = self._path_for_root(root, key)
if candidate.exists() and candidate.is_file(): if candidate.exists() and candidate.is_file():
return candidate return candidate
raise StorageBackendError("Stored object does not exist") raise StorageObjectMissing("Stored object does not exist")
def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None:
path = self._path(key) path = self._path(key)
@@ -86,6 +110,33 @@ class LocalFilesystemStorageBackend:
return False return False
return True return True
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
return StorageObjectInfo(key=key, size_bytes=path.stat().st_size)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_limit = max(1, min(int(limit), 5000))
candidates: list[StorageObjectInfo] = []
for path in _iter_local_files(self.root):
key = path.relative_to(self.root).as_posix()
if not key.startswith(prefix) or (after is not None and key <= after):
continue
candidates.append(StorageObjectInfo(key=key, size_bytes=path.stat().st_size))
if len(candidates) > normalized_limit:
break
has_more = len(candidates) > normalized_limit
page = tuple(candidates[:normalized_limit])
return StorageObjectPage(
objects=page,
next_cursor=page[-1].key if has_more and page else None,
)
@dataclass(slots=True) @dataclass(slots=True)
class S3StorageBackend: class S3StorageBackend:
@@ -175,6 +226,52 @@ class S3StorageBackend:
except Exception: except Exception:
return False return False
def stat(self, key: str) -> StorageObjectInfo:
try:
response = self.client.head_object(Bucket=self.bucket, Key=key)
except Exception as exc: # pragma: no cover - depends on S3 backend
if _s3_missing_error(exc):
raise StorageObjectMissing("Stored object does not exist") from exc
raise StorageBackendError(str(exc)) from exc
try:
size = int(response.get("ContentLength"))
except (AttributeError, TypeError, ValueError) as exc:
raise StorageBackendError("S3 object metadata did not include a valid size") from exc
return StorageObjectInfo(key=key, size_bytes=size)
def list_objects(
self,
*,
prefix: str,
after: str | None = None,
limit: int = 500,
) -> StorageObjectPage:
normalized_limit = max(1, min(int(limit), 1000))
kwargs: dict[str, object] = {
"Bucket": self.bucket,
"Prefix": prefix,
"MaxKeys": normalized_limit,
}
if after:
kwargs["StartAfter"] = after
try:
response = self.client.list_objects_v2(**kwargs)
except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc
objects = tuple(
StorageObjectInfo(
key=str(item["Key"]),
size_bytes=int(item.get("Size") or 0),
)
for item in response.get("Contents", ())
if isinstance(item, dict) and item.get("Key")
)
has_more = bool(response.get("IsTruncated"))
return StorageObjectPage(
objects=objects,
next_cursor=objects[-1].key if has_more and objects else None,
)
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None: def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
if not isinstance(obj, dict): if not isinstance(obj, dict):
@@ -187,6 +284,25 @@ def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes") raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes")
def _iter_local_files(root: Path):
for entry in sorted(root.iterdir(), key=lambda item: item.name):
if entry.is_dir():
yield from _iter_local_files(entry)
elif entry.is_file():
yield entry
def _s3_missing_error(exc: Exception) -> bool:
response = getattr(exc, "response", None)
if not isinstance(response, dict):
return False
error = response.get("Error")
metadata = response.get("ResponseMetadata")
code = str(error.get("Code") if isinstance(error, dict) else "")
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
def _fallback_roots() -> tuple[Path, ...]: def _fallback_roots() -> tuple[Path, ...]:
raw = getattr(settings, "file_storage_local_fallback_roots", "") or "" raw = getattr(settings, "file_storage_local_fallback_roots", "") or ""
return tuple(Path(item.strip()) for item in str(raw).split(",") if item.strip()) return tuple(Path(item.strip()) for item in str(raw).split(",") if item.strip())

View File

@@ -13,12 +13,17 @@ from typing import Any, Iterator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset, FileBlob, FileShare, FileVersion from govoplan_files.backend.db.models import FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.storage.backends import StorageBackend, StorageBackendError, get_storage_backend from govoplan_files.backend.storage.backends import StorageBackend, get_storage_backend
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import current_versions_and_blobs, get_asset_for_user, list_assets_for_user, share_files from govoplan_files.backend.storage.files import current_versions_and_blobs, get_asset_for_user, list_assets_for_user, share_files
from govoplan_files.backend.storage.access import ensure_owner_access from govoplan_files.backend.storage.access import ensure_owner_access
from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata, source_revision_from_metadata from govoplan_files.backend.storage.provenance import source_provenance_from_metadata, source_revision_from_metadata
from govoplan_files.backend.storage.share_state import effective_file_share_clause
from govoplan_files.backend.storage.integrity import (
ensure_blob_is_readable,
read_verified_blob_bytes,
)
MANAGED_SOURCE_PREFIX = "managed:" MANAGED_SOURCE_PREFIX = "managed:"
@@ -165,7 +170,7 @@ def _campaign_linked_asset_ids(
FileShare.file_asset_id.in_(chunk), FileShare.file_asset_id.in_(chunk),
FileShare.target_type == "campaign", FileShare.target_type == "campaign",
FileShare.target_id == campaign_id, FileShare.target_id == campaign_id,
FileShare.revoked_at.is_(None), effective_file_share_clause(),
) )
.all() .all()
) )
@@ -301,11 +306,9 @@ def _materialize_managed_asset(
target = _safe_local_target(local_root, relative_path) target = _safe_local_target(local_root, relative_path)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
version, blob = version_blobs[asset.id] version, blob = version_blobs[asset.id]
ensure_blob_is_readable(blob)
if include_bytes: if include_bytes:
try: data = read_verified_blob_bytes(blob, backend=backend) if backend else b""
data = backend.get_bytes(blob.storage_key) if backend else b""
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
target.write_bytes(data) target.write_bytes(data)
else: else:
target.touch() target.touch()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import mimetypes import mimetypes
from datetime import datetime
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any, Iterable from typing import Any, Iterable
from uuid import uuid4 from uuid import uuid4
@@ -13,10 +14,19 @@ from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAc
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.runtime import get_registry, settings from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.backends import (
StorageBackendError,
StorageObjectMissing,
get_storage_backend,
)
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata from govoplan_files.backend.storage.provenance import source_provenance_from_metadata
from govoplan_files.backend.storage.integrity import (
QUARANTINED_BLOB_STATUSES,
read_verified_blob_bytes,
)
from govoplan_files.backend.storage.share_state import effective_file_share_clause
def _campaign_access_provider() -> CampaignAccessProvider: def _campaign_access_provider() -> CampaignAccessProvider:
@@ -72,11 +82,25 @@ def _get_or_create_blob(
) )
if blob: if blob:
backend = get_storage_backend() backend = get_storage_backend()
if not backend.exists(blob.storage_key): repair_required = (
blob.integrity_status in QUARANTINED_BLOB_STATUSES
or blob.quarantined_at is not None
)
try:
backend.stat(blob.storage_key)
except StorageObjectMissing:
repair_required = True
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
if repair_required:
try: try:
backend.put_bytes(blob.storage_key, data, content_type=content_type) backend.put_bytes(blob.storage_key, data, content_type=content_type)
except StorageBackendError as exc: except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc raise FileStorageError(str(exc)) from exc
blob.integrity_status = "verified"
blob.integrity_checked_at = utcnow()
blob.integrity_failure = None
blob.quarantined_at = None
blob.ref_count += 1 blob.ref_count += 1
session.add(blob) session.add(blob)
return blob return blob
@@ -96,6 +120,8 @@ def _get_or_create_blob(
size_bytes=size, size_bytes=size,
content_type=content_type, content_type=content_type,
ref_count=1, ref_count=1,
integrity_status="verified",
integrity_checked_at=utcnow(),
) )
session.add(blob) session.add(blob)
session.flush() session.flush()
@@ -328,7 +354,7 @@ def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_
.filter( .filter(
FileShare.tenant_id == tenant_id, FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset.id, FileShare.file_asset_id == asset.id,
FileShare.revoked_at.is_(None), effective_file_share_clause(),
FileShare.permission.in_(permission_values), FileShare.permission.in_(permission_values),
or_( or_(
(FileShare.target_type == "user") & (FileShare.target_id == user_id), (FileShare.target_type == "user") & (FileShare.target_id == user_id),
@@ -343,6 +369,32 @@ def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_
return asset return asset
def get_asset_for_share_management(
session: Session,
*,
tenant_id: str,
user_id: str,
asset_id: str,
is_admin: bool = False,
) -> FileAsset:
asset = session.get(FileAsset, asset_id)
if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None:
raise FileStorageError("File not found")
if is_admin:
return asset
owns_asset = (
asset.owner_type == "user"
and asset.owner_user_id == user_id
) or (
asset.owner_type == "group"
and asset.owner_group_id
in user_group_ids(session, tenant_id=tenant_id, user_id=user_id)
)
if not owns_asset:
raise FileStorageError("Only file owners and administrators can manage shares")
return asset
def list_assets_for_user( def list_assets_for_user(
session: Session, session: Session,
*, *,
@@ -444,7 +496,7 @@ def _asset_visibility_query_for_user(
FileShare.tenant_id == tenant_id, FileShare.tenant_id == tenant_id,
FileShare.target_type == "campaign", FileShare.target_type == "campaign",
FileShare.target_id == campaign_id, FileShare.target_id == campaign_id,
FileShare.revoked_at.is_(None), effective_file_share_clause(),
) )
elif not is_admin and not owner_type: elif not is_admin and not owner_type:
group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id) group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id)
@@ -452,9 +504,9 @@ def _asset_visibility_query_for_user(
or_( or_(
(FileAsset.owner_type == "user") & (FileAsset.owner_user_id == user_id), (FileAsset.owner_type == "user") & (FileAsset.owner_user_id == user_id),
(FileAsset.owner_type == "group") & (FileAsset.owner_group_id.in_(group_ids)), (FileAsset.owner_type == "group") & (FileAsset.owner_group_id.in_(group_ids)),
(FileShare.revoked_at.is_(None)) & (FileShare.target_type == "user") & (FileShare.target_id == user_id), effective_file_share_clause() & (FileShare.target_type == "user") & (FileShare.target_id == user_id),
(FileShare.revoked_at.is_(None)) & (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)), effective_file_share_clause() & (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)),
(FileShare.revoked_at.is_(None)) & (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id), effective_file_share_clause() & (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id),
) )
) )
if path_prefix: if path_prefix:
@@ -465,7 +517,7 @@ def _asset_visibility_query_for_user(
FileShare.tenant_id == tenant_id, FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == FileAsset.id, FileShare.file_asset_id == FileAsset.id,
FileShare.target_type == "campaign", FileShare.target_type == "campaign",
FileShare.revoked_at.is_(None), effective_file_share_clause(),
) )
campaign_use_exists = exists().where( campaign_use_exists = exists().where(
CampaignAttachmentUse.tenant_id == tenant_id, CampaignAttachmentUse.tenant_id == tenant_id,
@@ -562,10 +614,7 @@ def current_versions_and_blobs(session: Session, assets: Iterable[FileAsset]) ->
def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]: def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]:
version, blob = current_version_and_blob(session, asset) version, blob = current_version_and_blob(session, asset)
backend = get_storage_backend() backend = get_storage_backend()
try: return read_verified_blob_bytes(blob, backend=backend), version, blob
return backend.get_bytes(blob.storage_key), version, blob
except StorageBackendError as exc:
raise FileStorageError(str(exc)) from exc
def share_file( def share_file(
@@ -577,6 +626,7 @@ def share_file(
target_id: str, target_id: str,
permission: str, permission: str,
user_id: str, user_id: str,
expires_at: datetime | None = None,
) -> FileShare: ) -> FileShare:
target_type = target_type.lower().strip() target_type = target_type.lower().strip()
permission = permission.lower().strip() permission = permission.lower().strip()
@@ -586,6 +636,7 @@ def share_file(
raise FileStorageError("Unsupported share target") raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}: if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission") raise FileStorageError("Unsupported file permission")
_validate_share_expiry(expires_at)
if target_type in {"user", "group", "tenant"}: if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign": if target_type == "campaign":
@@ -603,6 +654,7 @@ def share_file(
) )
if existing: if existing:
existing.permission = permission existing.permission = permission
existing.expires_at = expires_at
session.add(existing) session.add(existing)
return existing return existing
share = FileShare( share = FileShare(
@@ -612,6 +664,7 @@ def share_file(
target_id=target_id, target_id=target_id,
permission=permission, permission=permission,
created_by_user_id=user_id, created_by_user_id=user_id,
expires_at=expires_at,
) )
session.add(share) session.add(share)
return share return share
@@ -626,6 +679,7 @@ def share_files(
target_id: str, target_id: str,
permission: str, permission: str,
user_id: str, user_id: str,
expires_at: datetime | None = None,
) -> list[FileShare]: ) -> list[FileShare]:
target_type = target_type.lower().strip() target_type = target_type.lower().strip()
permission = permission.lower().strip() permission = permission.lower().strip()
@@ -633,6 +687,7 @@ def share_files(
raise FileStorageError("Unsupported share target") raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}: if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission") raise FileStorageError("Unsupported file permission")
_validate_share_expiry(expires_at)
if target_type in {"user", "group", "tenant"}: if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign": if target_type == "campaign":
@@ -660,6 +715,7 @@ def share_files(
existing = existing_by_asset.get(asset.id) existing = existing_by_asset.get(asset.id)
if existing: if existing:
existing.permission = permission existing.permission = permission
existing.expires_at = expires_at
session.add(existing) session.add(existing)
shares.append(existing) shares.append(existing)
continue continue
@@ -670,12 +726,88 @@ def share_files(
target_id=target_id, target_id=target_id,
permission=permission, permission=permission,
created_by_user_id=user_id, created_by_user_id=user_id,
expires_at=expires_at,
) )
session.add(share) session.add(share)
shares.append(share) shares.append(share)
return shares return shares
def list_file_shares(
session: Session,
*,
tenant_id: str,
asset_id: str,
include_inactive: bool = False,
) -> list[FileShare]:
query = session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset_id,
)
if not include_inactive:
query = query.filter(effective_file_share_clause())
return query.order_by(FileShare.created_at.desc(), FileShare.id.desc()).all()
def revoke_file_share(
session: Session,
*,
tenant_id: str,
asset_id: str,
share_id: str,
user_id: str,
) -> tuple[FileShare, bool]:
share = (
session.query(FileShare)
.filter(
FileShare.id == share_id,
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset_id,
)
.one_or_none()
)
if share is None:
raise FileStorageError("File share not found")
if share.revoked_at is not None:
return share, False
share.revoked_at = utcnow()
share.revoked_by_user_id = user_id
session.add(share)
return share, True
def current_file_share_for_target(
session: Session,
*,
tenant_id: str,
asset_id: str,
target_type: str,
target_id: str,
) -> FileShare | None:
return (
session.query(FileShare)
.filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id == asset_id,
FileShare.target_type == target_type.lower().strip(),
FileShare.target_id == target_id,
FileShare.revoked_at.is_(None),
)
.order_by(FileShare.created_at.desc(), FileShare.id.desc())
.first()
)
def _validate_share_expiry(expires_at: datetime | None) -> None:
if expires_at is None:
return
from govoplan_files.backend.storage.share_state import file_share_is_active
candidate = FileShare(expires_at=expires_at)
if not file_share_is_active(candidate):
raise FileStorageError("File share expiry must be in the future")
def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int: def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int:
count = 0 count = 0

View File

@@ -0,0 +1,512 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import (
FileBlob,
FileIntegrityFinding,
FileIntegrityScan,
)
from govoplan_files.backend.storage.backends import (
StorageBackend,
StorageBackendError,
StorageObjectMissing,
get_storage_backend,
)
from govoplan_files.backend.storage.common import FileStorageError, utcnow
TERMINAL_SCAN_STATUSES = {"completed", "cancelled"}
QUARANTINED_BLOB_STATUSES = {
"missing",
"size_mismatch",
"checksum_mismatch",
}
@dataclass(frozen=True, slots=True)
class BlobInspection:
valid: bool
kind: str
observed_size_bytes: int | None = None
observed_checksum_sha256: str | None = None
@dataclass(frozen=True, slots=True)
class IntegrityActionResult:
action: str
changed: bool
dry_run: bool
finding: FileIntegrityFinding
inspection: BlobInspection | None = None
def storage_prefix_for_tenant(tenant_id: str) -> str:
return f"tenants/{tenant_id}/files/"
def create_integrity_scan(
session: Session,
*,
tenant_id: str,
user_id: str,
verify_checksums: bool = True,
batch_size: int = 100,
backend: StorageBackend | None = None,
) -> FileIntegrityScan:
active_backend = backend or get_storage_backend()
scan = FileIntegrityScan(
tenant_id=tenant_id,
storage_backend=active_backend.name,
storage_prefix=storage_prefix_for_tenant(tenant_id),
verify_checksums=verify_checksums,
batch_size=max(1, min(int(batch_size), 1000)),
created_by_user_id=user_id,
)
session.add(scan)
session.flush()
return scan
def run_integrity_scan_batch(
session: Session,
scan: FileIntegrityScan,
*,
backend: StorageBackend | None = None,
) -> FileIntegrityScan:
if scan.status in TERMINAL_SCAN_STATUSES:
return scan
active_backend = backend or get_storage_backend()
if active_backend.name != scan.storage_backend:
raise FileStorageError(
"The configured storage backend changed after this integrity scan started"
)
if scan.started_at is None:
scan.started_at = utcnow()
scan.status = "running"
scan.last_error = None
if scan.phase == "blobs":
_scan_blob_batch(session, scan, active_backend)
elif scan.phase == "objects":
_scan_object_batch(session, scan, active_backend)
else:
scan.phase = "completed"
scan.status = "completed"
scan.completed_at = utcnow()
session.add(scan)
return scan
def mark_integrity_scan_failed(
scan: FileIntegrityScan,
*,
error: Exception,
) -> None:
scan.status = "failed"
scan.last_error = type(error).__name__[:255]
def inspect_blob(
blob: FileBlob,
*,
backend: StorageBackend,
verify_checksum: bool = True,
) -> BlobInspection:
try:
info = backend.stat(blob.storage_key)
except StorageObjectMissing:
return BlobInspection(valid=False, kind="missing")
if info.size_bytes != blob.size_bytes:
return BlobInspection(
valid=False,
kind="size_mismatch",
observed_size_bytes=info.size_bytes,
)
if not verify_checksum:
return BlobInspection(
valid=True,
kind="verified",
observed_size_bytes=info.size_bytes,
)
digest = hashlib.sha256()
observed_size = 0
for chunk in backend.iter_bytes(blob.storage_key):
observed_size += len(chunk)
digest.update(chunk)
observed_checksum = digest.hexdigest()
if observed_size != blob.size_bytes:
return BlobInspection(
valid=False,
kind="size_mismatch",
observed_size_bytes=observed_size,
observed_checksum_sha256=observed_checksum,
)
if observed_checksum != blob.checksum_sha256:
return BlobInspection(
valid=False,
kind="checksum_mismatch",
observed_size_bytes=observed_size,
observed_checksum_sha256=observed_checksum,
)
return BlobInspection(
valid=True,
kind="verified",
observed_size_bytes=observed_size,
observed_checksum_sha256=observed_checksum,
)
def apply_blob_inspection(
session: Session,
blob: FileBlob,
inspection: BlobInspection,
*,
checked_at=None,
) -> None:
timestamp = checked_at or utcnow()
blob.integrity_checked_at = timestamp
if inspection.valid:
blob.integrity_status = "verified"
blob.integrity_failure = None
blob.quarantined_at = None
else:
blob.integrity_status = inspection.kind
blob.integrity_failure = inspection.kind
blob.quarantined_at = blob.quarantined_at or timestamp
session.add(blob)
def ensure_blob_is_readable(blob: FileBlob) -> None:
if blob.integrity_status in QUARANTINED_BLOB_STATUSES or blob.quarantined_at:
raise FileStorageError(
"Managed file content is quarantined because integrity verification failed"
)
def read_verified_blob_bytes(
blob: FileBlob,
*,
backend: StorageBackend,
) -> bytes:
ensure_blob_is_readable(blob)
try:
data = backend.get_bytes(blob.storage_key)
except StorageBackendError as exc:
raise FileStorageError("Managed file content is not available") from exc
if len(data) != blob.size_bytes:
raise FileStorageError(
"Managed file content failed its recorded size verification"
)
if hashlib.sha256(data).hexdigest() != blob.checksum_sha256:
raise FileStorageError(
"Managed file content failed its recorded checksum verification"
)
return data
def recheck_integrity_finding(
session: Session,
finding: FileIntegrityFinding,
*,
user_id: str,
dry_run: bool,
backend: StorageBackend | None = None,
) -> IntegrityActionResult:
if finding.kind == "orphan_object" or not finding.blob_id:
raise FileStorageError("Only blob integrity findings can be rechecked")
blob = session.get(FileBlob, finding.blob_id)
if blob is None or blob.tenant_id != finding.tenant_id:
raise FileStorageError("Integrity finding blob no longer exists")
active_backend = backend or get_storage_backend()
scan = session.get(FileIntegrityScan, finding.scan_id)
if scan is None or scan.storage_backend != active_backend.name:
raise FileStorageError(
"The configured storage backend does not match the integrity finding"
)
inspection = inspect_blob(
blob,
backend=active_backend,
verify_checksum=True,
)
changed = False
if not dry_run:
previous = (
blob.integrity_status,
blob.quarantined_at,
finding.state,
)
apply_blob_inspection(session, blob, inspection)
_update_finding_from_inspection(finding, inspection)
if inspection.valid:
finding.state = "resolved"
finding.resolved_at = utcnow()
finding.resolved_by_user_id = user_id
session.add(finding)
changed = previous != (
blob.integrity_status,
blob.quarantined_at,
finding.state,
)
return IntegrityActionResult(
action="recheck",
changed=changed,
dry_run=dry_run,
finding=finding,
inspection=inspection,
)
def cleanup_orphan_finding(
session: Session,
finding: FileIntegrityFinding,
*,
user_id: str,
dry_run: bool,
backend: StorageBackend | None = None,
) -> IntegrityActionResult:
if finding.kind != "orphan_object":
raise FileStorageError("Only orphan-object findings can be cleaned up")
scan = session.get(FileIntegrityScan, finding.scan_id)
if scan is None or scan.tenant_id != finding.tenant_id:
raise FileStorageError("Integrity scan not found")
if not finding.storage_key.startswith(scan.storage_prefix):
raise FileStorageError("Orphan cleanup is outside the scan storage scope")
referenced = (
session.query(FileBlob.id)
.filter(
FileBlob.tenant_id == finding.tenant_id,
FileBlob.storage_backend == scan.storage_backend,
FileBlob.storage_key == finding.storage_key,
)
.first()
)
if referenced:
if not dry_run and finding.state != "resolved":
finding.state = "resolved"
finding.resolved_at = utcnow()
finding.resolved_by_user_id = user_id
session.add(finding)
return IntegrityActionResult(
action="retained_referenced",
changed=True,
dry_run=False,
finding=finding,
)
return IntegrityActionResult(
action="retained_referenced",
changed=False,
dry_run=dry_run,
finding=finding,
)
if finding.state == "deleted":
return IntegrityActionResult(
action="already_deleted",
changed=False,
dry_run=dry_run,
finding=finding,
)
if dry_run:
return IntegrityActionResult(
action="would_delete",
changed=False,
dry_run=True,
finding=finding,
)
active_backend = backend or get_storage_backend()
if active_backend.name != scan.storage_backend:
raise FileStorageError(
"The configured storage backend does not match the integrity finding"
)
try:
active_backend.stat(finding.storage_key)
except StorageObjectMissing:
action = "already_absent"
else:
active_backend.delete(finding.storage_key)
action = "deleted"
finding.state = "deleted"
finding.resolved_at = utcnow()
finding.resolved_by_user_id = user_id
session.add(finding)
return IntegrityActionResult(
action=action,
changed=True,
dry_run=False,
finding=finding,
)
def _scan_blob_batch(
session: Session,
scan: FileIntegrityScan,
backend: StorageBackend,
) -> None:
query = session.query(FileBlob).filter(
FileBlob.tenant_id == scan.tenant_id,
FileBlob.storage_backend == scan.storage_backend,
)
if scan.blob_cursor:
query = query.filter(FileBlob.id > scan.blob_cursor)
blobs = query.order_by(FileBlob.id.asc()).limit(scan.batch_size).all()
for blob in blobs:
inspection = inspect_blob(
blob,
backend=backend,
verify_checksum=scan.verify_checksums,
)
apply_blob_inspection(session, blob, inspection)
scan.scanned_blob_count += 1
if inspection.valid:
scan.verified_blob_count += 1
_resolve_scan_blob_findings(session, scan, blob)
else:
scan.quarantined_blob_count += 1
_record_blob_finding(session, scan, blob, inspection)
scan.blob_cursor = blob.id
if len(blobs) < scan.batch_size:
scan.phase = "objects"
scan.object_cursor = None
def _scan_object_batch(
session: Session,
scan: FileIntegrityScan,
backend: StorageBackend,
) -> None:
page = backend.list_objects(
prefix=scan.storage_prefix,
after=scan.object_cursor,
limit=scan.batch_size,
)
keys = [item.key for item in page.objects]
referenced_keys = {
row[0]
for row in session.query(FileBlob.storage_key)
.filter(
FileBlob.tenant_id == scan.tenant_id,
FileBlob.storage_backend == scan.storage_backend,
FileBlob.storage_key.in_(keys),
)
.all()
} if keys else set()
for item in page.objects:
scan.scanned_object_count += 1
if item.key in referenced_keys:
continue
scan.orphan_object_count += 1
_record_orphan_finding(
session,
scan,
storage_key=item.key,
size_bytes=item.size_bytes,
)
scan.object_cursor = page.next_cursor
if page.next_cursor is None:
scan.phase = "completed"
scan.status = "completed"
scan.completed_at = utcnow()
def _record_blob_finding(
session: Session,
scan: FileIntegrityScan,
blob: FileBlob,
inspection: BlobInspection,
) -> FileIntegrityFinding:
finding = (
session.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.blob_id == blob.id,
FileIntegrityFinding.kind == inspection.kind,
)
.one_or_none()
)
if finding is None:
finding = FileIntegrityFinding(
scan_id=scan.id,
tenant_id=scan.tenant_id,
kind=inspection.kind,
blob_id=blob.id,
storage_key=blob.storage_key,
expected_size_bytes=blob.size_bytes,
expected_checksum_sha256=blob.checksum_sha256,
)
_update_finding_from_inspection(finding, inspection)
session.add(finding)
return finding
def _record_orphan_finding(
session: Session,
scan: FileIntegrityScan,
*,
storage_key: str,
size_bytes: int,
) -> FileIntegrityFinding:
finding = (
session.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.kind == "orphan_object",
FileIntegrityFinding.storage_key == storage_key,
)
.one_or_none()
)
if finding is None:
finding = FileIntegrityFinding(
scan_id=scan.id,
tenant_id=scan.tenant_id,
kind="orphan_object",
storage_key=storage_key,
observed_size_bytes=size_bytes,
)
session.add(finding)
return finding
def _resolve_scan_blob_findings(
session: Session,
scan: FileIntegrityScan,
blob: FileBlob,
) -> None:
for finding in (
session.query(FileIntegrityFinding)
.filter(
FileIntegrityFinding.scan_id == scan.id,
FileIntegrityFinding.blob_id == blob.id,
FileIntegrityFinding.state == "open",
)
.all()
):
finding.state = "resolved"
finding.resolved_at = utcnow()
session.add(finding)
def _update_finding_from_inspection(
finding: FileIntegrityFinding,
inspection: BlobInspection,
) -> None:
finding.observed_size_bytes = inspection.observed_size_bytes
finding.observed_checksum_sha256 = inspection.observed_checksum_sha256
__all__ = [
"BlobInspection",
"IntegrityActionResult",
"QUARANTINED_BLOB_STATUSES",
"apply_blob_inspection",
"cleanup_orphan_finding",
"create_integrity_scan",
"ensure_blob_is_readable",
"inspect_blob",
"mark_integrity_scan_failed",
"read_verified_blob_bytes",
"recheck_integrity_finding",
"run_integrity_scan_batch",
"storage_prefix_for_tenant",
]

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import and_, or_
from govoplan_files.backend.db.models import FileShare
from govoplan_files.backend.storage.common import utcnow
def effective_file_share_clause(*, at: datetime | None = None):
checked_at = at or utcnow()
return and_(
FileShare.revoked_at.is_(None),
or_(FileShare.expires_at.is_(None), FileShare.expires_at > checked_at),
)
def file_share_is_active(share: FileShare, *, at: datetime | None = None) -> bool:
if share.revoked_at is not None:
return False
if share.expires_at is None:
return True
checked_at = _aware_utc(at or utcnow())
return _aware_utc(share.expires_at) > checked_at
def _aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = ["effective_file_share_clause", "file_share_is_active"]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -102,6 +103,15 @@ class FilesAccessProviderTests(unittest.TestCase):
target_id="campaign-1", target_id="campaign-1",
permission="read", permission="read",
), ),
FileShare(
id="share-campaign-expired",
tenant_id=TENANT_ID,
file_asset_id=assets[2].id,
target_type="campaign",
target_id="campaign-1",
permission="read",
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
),
]) ])
session.commit() session.commit()
session.execute( session.execute(

View File

@@ -0,0 +1,196 @@
from __future__ import annotations
import hashlib
import tempfile
import unittest
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.db.base import Base
from govoplan_files.backend.db.models import (
FileBlob,
FileIntegrityFinding,
FileIntegrityScan,
)
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.integrity import (
cleanup_orphan_finding,
create_integrity_scan,
read_verified_blob_bytes,
recheck_integrity_finding,
run_integrity_scan_batch,
)
TENANT_ID = "tenant-1"
USER_ID = "user-1"
class IntegrityReconciliationTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
self.backend = LocalFilesystemStorageBackend(
Path(self.temporary_directory.name)
)
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
bind=self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
FileBlob.__table__,
FileIntegrityScan.__table__,
FileIntegrityFinding.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
self.addCleanup(self._close)
def _close(self) -> None:
self.session.close()
self.engine.dispose()
def test_scan_is_bounded_resumable_and_reconciles_both_orphan_directions(
self,
) -> None:
valid_data = b"valid"
restored_data = b"restore-me"
expected_corrupt_data = b"expected"
stored_corrupt_data = b"corrupt!"
valid = _blob("blob-1", "valid.bin", valid_data)
missing = _blob("blob-2", "missing.bin", restored_data)
corrupt = _blob("blob-3", "corrupt.bin", expected_corrupt_data)
self.session.add_all([valid, missing, corrupt])
self.session.commit()
self.backend.put_bytes(valid.storage_key, valid_data)
self.backend.put_bytes(corrupt.storage_key, stored_corrupt_data)
orphan_key = f"tenants/{TENANT_ID}/files/orphan.bin"
self.backend.put_bytes(orphan_key, b"orphan")
scan = create_integrity_scan(
self.session,
tenant_id=TENANT_ID,
user_id=USER_ID,
batch_size=1,
backend=self.backend,
)
self.session.commit()
invocations = 0
while scan.status != "completed":
run_integrity_scan_batch(
self.session,
scan,
backend=self.backend,
)
self.session.commit()
invocations += 1
scan = self.session.get(FileIntegrityScan, scan.id)
self.assertIsNotNone(scan)
self.session.expire_all()
if invocations > 12:
self.fail("Integrity scan did not complete")
self.assertGreater(invocations, 3)
self.assertEqual(3, scan.scanned_blob_count)
self.assertEqual(1, scan.verified_blob_count)
self.assertEqual(2, scan.quarantined_blob_count)
self.assertEqual(3, scan.scanned_object_count)
self.assertEqual(1, scan.orphan_object_count)
findings = (
self.session.query(FileIntegrityFinding)
.filter(FileIntegrityFinding.scan_id == scan.id)
.all()
)
self.assertEqual(
{"missing", "checksum_mismatch", "orphan_object"},
{finding.kind for finding in findings},
)
missing = self.session.get(FileBlob, missing.id)
corrupt = self.session.get(FileBlob, corrupt.id)
self.assertEqual("missing", missing.integrity_status)
self.assertEqual("checksum_mismatch", corrupt.integrity_status)
with self.assertRaisesRegex(FileStorageError, "quarantined"):
read_verified_blob_bytes(missing, backend=self.backend)
missing_finding = next(
finding for finding in findings if finding.kind == "missing"
)
self.backend.put_bytes(missing.storage_key, restored_data)
preview = recheck_integrity_finding(
self.session,
missing_finding,
user_id=USER_ID,
dry_run=True,
backend=self.backend,
)
self.assertTrue(preview.inspection.valid)
self.assertEqual("open", missing_finding.state)
repaired = recheck_integrity_finding(
self.session,
missing_finding,
user_id=USER_ID,
dry_run=False,
backend=self.backend,
)
self.session.commit()
self.assertTrue(repaired.changed)
self.assertEqual("resolved", missing_finding.state)
self.assertEqual(
restored_data,
read_verified_blob_bytes(missing, backend=self.backend),
)
orphan_finding = next(
finding for finding in findings if finding.kind == "orphan_object"
)
preview_cleanup = cleanup_orphan_finding(
self.session,
orphan_finding,
user_id=USER_ID,
dry_run=True,
backend=self.backend,
)
self.assertEqual("would_delete", preview_cleanup.action)
self.assertTrue(self.backend.exists(orphan_key))
cleanup = cleanup_orphan_finding(
self.session,
orphan_finding,
user_id=USER_ID,
dry_run=False,
backend=self.backend,
)
repeated = cleanup_orphan_finding(
self.session,
orphan_finding,
user_id=USER_ID,
dry_run=False,
backend=self.backend,
)
self.assertEqual("deleted", cleanup.action)
self.assertFalse(self.backend.exists(orphan_key))
self.assertEqual("already_deleted", repeated.action)
self.assertFalse(repeated.changed)
def _blob(blob_id: str, filename: str, expected_data: bytes) -> FileBlob:
return FileBlob(
id=blob_id,
tenant_id=TENANT_ID,
storage_backend="local",
storage_key=f"tenants/{TENANT_ID}/files/{filename}",
checksum_sha256=hashlib.sha256(expected_data).hexdigest(),
size_bytes=len(expected_data),
ref_count=1,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -103,8 +103,8 @@ class FilesManifestDocumentationTests(unittest.TestCase):
def test_share_and_delete_tasks_state_current_boundaries(self) -> None: def test_share_and_delete_tasks_state_current_boundaries(self) -> None:
share = self.topic("files.workflow.share-managed-files") share = self.topic("files.workflow.share-managed-files")
self.assertEqual("available", share.layer) self.assertEqual("available", share.layer)
self.assertIn("does not yet provide a general share editor", share.body) self.assertIn("Expired and revoked grants stop authorizing", share.body)
self.assertIn("no share-revocation route", share.body) self.assertIn("revocation is idempotent", share.body)
self.assertIn( self.assertIn(
"/api/v1/files/{file_id}/shares", {link.href for link in share.links} "/api/v1/files/{file_id}/shares", {link.href for link in share.links}
) )
@@ -154,8 +154,14 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("fail closed", topic.body) self.assertIn("fail closed", topic.body)
self.assertIn("DFS referrals", topic.body) self.assertIn("DFS referrals", topic.body)
self.assertIn("does not remove backend blob objects", topic.body) self.assertIn("does not remove backend blob objects", topic.body)
self.assertIn("bounded resumable integrity scan", topic.body)
self.assertIn("quarantined", topic.body)
self.assertIn("MASTER_KEY_B64", topic.metadata["recovery_unit"]) self.assertIn("MASTER_KEY_B64", topic.metadata["recovery_unit"])
self.assertTrue(topic.metadata["verification"]) self.assertTrue(topic.metadata["verification"])
self.assertIn(
"/api/v1/files/integrity/scans",
{link.href for link in topic.links},
)
self.assertIn( self.assertIn(
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys
) )
@@ -184,7 +190,7 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("process_owner", topic.audience) self.assertIn("process_owner", topic.audience)
self.assertIn("release_manager", topic.audience) self.assertIn("release_manager", topic.audience)
self.assertIn("versions align", topic.body) self.assertIn("versions align", topic.body)
self.assertIn("no general share-management UI or share revocation", topic.body) self.assertIn("Share grant, change, expiry, and revocation", topic.body)
self.assertIn("no enforced retention or legal hold", topic.body) self.assertIn("no enforced retention or legal hold", topic.body)
for key in ("prerequisites", "steps", "outcome", "verification"): for key in ("prerequisites", "steps", "outcome", "verification"):
self.assertTrue(topic.metadata[key]) self.assertTrue(topic.metadata[key])

View File

@@ -10,6 +10,7 @@ from govoplan_files.backend.routes.connector_io import router as connector_io_ro
from govoplan_files.backend.routes.connector_profiles import router as connector_profiles_router from govoplan_files.backend.routes.connector_profiles import router as connector_profiles_router
from govoplan_files.backend.routes.connector_settings import router as connector_settings_router from govoplan_files.backend.routes.connector_settings import router as connector_settings_router
from govoplan_files.backend.routes.folders import router as folders_router from govoplan_files.backend.routes.folders import router as folders_router
from govoplan_files.backend.routes.integrity import router as integrity_router
from govoplan_files.backend.routes.listing import router as listing_router from govoplan_files.backend.routes.listing import router as listing_router
from govoplan_files.backend.routes.shares import router as shares_router from govoplan_files.backend.routes.shares import router as shares_router
from govoplan_files.backend.routes.spaces import router as spaces_router from govoplan_files.backend.routes.spaces import router as spaces_router
@@ -30,6 +31,7 @@ class FilesRouterContractTests(unittest.TestCase):
workflow_routers = ( workflow_routers = (
spaces_router, spaces_router,
folders_router, folders_router,
integrity_router,
listing_router, listing_router,
uploads_router, uploads_router,
connector_settings_router, connector_settings_router,
@@ -47,7 +49,7 @@ class FilesRouterContractTests(unittest.TestCase):
actual = self._operation_keys(router) actual = self._operation_keys(router)
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
self.assertEqual(41, len(actual)) self.assertEqual(50, len(actual))
self.assertFalse( self.assertFalse(
[operation for operation, count in Counter(actual).items() if count > 1] [operation for operation, count in Counter(actual).items() if count > 1]
) )
@@ -77,6 +79,14 @@ class FilesRouterContractTests(unittest.TestCase):
self.assertIn((("POST",), "/files/bulk-rename"), routes) self.assertIn((("POST",), "/files/bulk-rename"), routes)
self.assertIn((("POST",), "/files/transfer"), routes) self.assertIn((("POST",), "/files/transfer"), routes)
def test_share_lifecycle_routes_are_exposed(self) -> None:
routes = {(tuple(sorted(route.methods or ())), route.path) for route in router.routes}
self.assertIn((("GET",), "/files/{file_id}/shares"), routes)
self.assertIn((("POST",), "/files/{file_id}/shares"), routes)
self.assertIn((("DELETE",), "/files/{file_id}/shares/{share_id}"), routes)
self.assertIn((("GET",), "/files/{file_id}/share-target-options"), routes)
def test_file_listing_exposes_structured_property_filters(self) -> None: def test_file_listing_exposes_structured_property_filters(self) -> None:
route = next( route = next(
route route

View File

@@ -0,0 +1,219 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_files.backend.db.models import FileAsset, FileShare
from govoplan_files.backend.storage.common import FileStorageError, utcnow
from govoplan_files.backend.storage.files import (
get_asset_for_user,
list_file_shares,
revoke_file_share,
share_file,
)
TENANT_ID = "tenant-1"
OWNER_ID = "owner-1"
RECIPIENT_ID = "recipient-1"
GROUP_ID = "group-1"
class FileShareLifecycleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
bind=self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
FileAsset.__table__,
FileShare.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
self.asset = FileAsset(
id="file-1",
tenant_id=TENANT_ID,
owner_type="user",
owner_user_id=OWNER_ID,
display_path="shared.pdf",
filename="shared.pdf",
)
self.session.add(self.asset)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
@patch(
"govoplan_files.backend.storage.files.user_group_ids",
return_value=[],
)
def test_expired_share_stops_access_immediately(self, _groups) -> None:
self.session.add(
FileShare(
id="expired-share",
tenant_id=TENANT_ID,
file_asset_id=self.asset.id,
target_type="user",
target_id=RECIPIENT_ID,
permission="read",
expires_at=utcnow() - timedelta(seconds=1),
)
)
self.session.commit()
with self.assertRaisesRegex(FileStorageError, "No access"):
get_asset_for_user(
self.session,
tenant_id=TENANT_ID,
user_id=RECIPIENT_ID,
asset_id=self.asset.id,
)
self.assertEqual(
[],
list_file_shares(
self.session,
tenant_id=TENANT_ID,
asset_id=self.asset.id,
),
)
@patch(
"govoplan_files.backend.storage.files.user_group_ids",
return_value=[GROUP_ID],
)
def test_independent_active_grant_survives_other_expiry(self, _groups) -> None:
self.session.add_all(
[
FileShare(
id="expired-user-share",
tenant_id=TENANT_ID,
file_asset_id=self.asset.id,
target_type="user",
target_id=RECIPIENT_ID,
permission="read",
expires_at=utcnow() - timedelta(seconds=1),
),
FileShare(
id="active-group-share",
tenant_id=TENANT_ID,
file_asset_id=self.asset.id,
target_type="group",
target_id=GROUP_ID,
permission="read",
expires_at=utcnow() + timedelta(hours=1),
),
]
)
self.session.commit()
result = get_asset_for_user(
self.session,
tenant_id=TENANT_ID,
user_id=RECIPIENT_ID,
asset_id=self.asset.id,
)
self.assertEqual(self.asset.id, result.id)
self.assertEqual(
["active-group-share"],
[
share.id
for share in list_file_shares(
self.session,
tenant_id=TENANT_ID,
asset_id=self.asset.id,
)
],
)
@patch(
"govoplan_files.backend.storage.files.ensure_share_target_exists",
return_value=None,
)
def test_revoke_is_idempotent_and_future_expiry_is_persisted(
self, _target_exists
) -> None:
expiry = utcnow() + timedelta(days=1)
share = share_file(
self.session,
tenant_id=TENANT_ID,
asset=self.asset,
target_type="user",
target_id=RECIPIENT_ID,
permission="read",
user_id=OWNER_ID,
expires_at=expiry,
)
self.session.commit()
revoked, first_changed = revoke_file_share(
self.session,
tenant_id=TENANT_ID,
asset_id=self.asset.id,
share_id=share.id,
user_id=OWNER_ID,
)
self.session.commit()
repeated, second_changed = revoke_file_share(
self.session,
tenant_id=TENANT_ID,
asset_id=self.asset.id,
share_id=share.id,
user_id=OWNER_ID,
)
self.assertTrue(first_changed)
self.assertFalse(second_changed)
self.assertEqual(OWNER_ID, revoked.revoked_by_user_id)
self.assertEqual(revoked.revoked_at, repeated.revoked_at)
self.assertEqual([], list_file_shares(
self.session,
tenant_id=TENANT_ID,
asset_id=self.asset.id,
))
self.assertEqual(
[share.id],
[
item.id
for item in list_file_shares(
self.session,
tenant_id=TENANT_ID,
asset_id=self.asset.id,
include_inactive=True,
)
],
)
@patch(
"govoplan_files.backend.storage.files.ensure_share_target_exists",
return_value=None,
)
def test_past_expiry_is_rejected(self, _target_exists) -> None:
with self.assertRaisesRegex(FileStorageError, "future"):
share_file(
self.session,
tenant_id=TENANT_ID,
asset=self.asset,
target_type="user",
target_id=RECIPIENT_ID,
permission="read",
user_id=OWNER_ID,
expires_at=utcnow() - timedelta(seconds=1),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,4 +1,15 @@
import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type DeltaDeletedItem, type FilesManagedFileLinkTarget } from "@govoplan/core-webui"; import {
ApiError,
apiFetch,
apiReferenceOptionProvider,
apiUrl,
authHeaders,
csrfToken,
type ApiSettings,
type DeltaDeletedItem,
type FilesManagedFileLinkTarget,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui"; export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type { export type {
AccessDecisionProvenanceItem, AccessDecisionProvenanceItem,
@@ -24,11 +35,16 @@ export type FileSpace = {
export type FileShare = { export type FileShare = {
id: string; id: string;
file_asset_id: string;
target_type: string; target_type: string;
target_id: string; target_id: string;
permission: string; permission: string;
created_by_user_id?: string | null;
created_at: string; created_at: string;
expires_at?: string | null;
revoked_at?: string | null; revoked_at?: string | null;
revoked_by_user_id?: string | null;
active: boolean;
}; };
export type FileSourceProvenance = { export type FileSourceProvenance = {
@@ -593,6 +609,12 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
} }
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;}; export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
export type FileSharePayload = {
target_type: "user" | "group" | "tenant" | "campaign";
target_id: string;
permission: "read" | "write" | "manage";
expires_at?: string | null;
};
export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string { export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string {
return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`; return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`;
@@ -612,6 +634,54 @@ export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], t
}); });
} }
export function listFileShares(
settings: ApiSettings,
fileId: string,
options: { includeInactive?: boolean } = {}
): Promise<{ shares: FileShare[] }> {
const suffix = options.includeInactive ? "?include_inactive=true" : "";
return apiFetch<{ shares: FileShare[] }>(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/shares${suffix}`
);
}
export function createFileShare(
settings: ApiSettings,
fileId: string,
payload: FileSharePayload
): Promise<FileShare> {
return apiFetch<FileShare>(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/shares`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function revokeFileShare(
settings: ApiSettings,
fileId: string,
shareId: string
): Promise<FileShare> {
return apiFetch<FileShare>(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/shares/${encodeURIComponent(shareId)}`,
{ method: "DELETE" }
);
}
export function fileShareTargetProvider(
settings: ApiSettings,
fileId: string,
targetType: "user" | "group"
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/api/v1/files/${encodeURIComponent(fileId)}/share-target-options`,
{ target_type: targetType }
);
}
export function evaluateFileConnectorPolicy( export function evaluateFileConnectorPolicy(
settings: ApiSettings, settings: ApiSettings,
payload: {source_provenance: FileSourceProvenance;operation?: string;policy_sources?: FileConnectorPolicySource[];}) payload: {source_provenance: FileSourceProvenance;operation?: string;policy_sources?: FileConnectorPolicySource[];})

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react"; import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud } from "lucide-react";
import { import {
Button, Button,
ConfirmDialog, ConfirmDialog,
@@ -48,6 +48,7 @@ import {
"../../api/files"; "../../api/files";
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
import FileShareDialog from "./components/FileShareDialog";
import type { import type {
ContextMenuState, ContextMenuState,
DialogKind, DialogKind,
@@ -106,6 +107,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const canUpload = hasScope(auth, "files:upload"); const canUpload = hasScope(auth, "files:upload");
const canOrganize = hasScope(auth, "files:organize"); const canOrganize = hasScope(auth, "files:organize");
const canDelete = hasScope(auth, "files:delete"); const canDelete = hasScope(auth, "files:delete");
const canShare = hasScope(auth, "files:file:share");
const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES); const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
const [activeSpaceId, setActiveSpaceId] = useState(""); const [activeSpaceId, setActiveSpaceId] = useState("");
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null; const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null;
@@ -154,6 +156,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null); const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null); const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessLoading, setResourceAccessLoading] = useState(false); const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null);
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
const { const {
dialog, dialog,
@@ -245,6 +248,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
onOpenFolder: openFolder onOpenFolder: openFolder
}); });
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]); const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
const shareManageableFile = useMemo(() => {
if (!canShare || selectedFiles.length !== 1 || selectedFolderPaths.size > 0) return null;
const file = selectedFiles[0];
const ownsFile = file.owner_type === "user"
? file.owner_id === auth.user.id
: auth.groups.some((group) => group.id === file.owner_id);
return ownsFile || hasScope(auth, "files:file:admin") ? file : null;
}, [auth, canShare, selectedFiles, selectedFolderPaths]);
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]); const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
const accessExplainableTarget = useMemo( const accessExplainableTarget = useMemo(
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId), () => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
@@ -1896,6 +1907,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4 <Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
</Button> </Button>
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button> <Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
<Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={busy || activeSpaceIsConnector || !shareManageableFile}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button> <Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button> <Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button> <Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
@@ -2307,6 +2319,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
</FileDialog> </FileDialog>
} }
{shareDialogFile &&
<FileShareDialog
settings={settings}
file={shareDialogFile}
tenantId={auth.active_tenant?.id || auth.tenant.id}
onClose={() => setShareDialogFile(null)}
onChanged={() => {
if (activeSpace) void loadSpaceContents(activeSpace, { silent: true });
}} />
}
{dialog === "upload" && {dialog === "upload" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}> <FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
<FileDropZone <FileDropZone
@@ -2709,7 +2732,15 @@ function FileSearchRow({
} }
function activeCampaignShares(file: ManagedFile) { function activeCampaignShares(file: ManagedFile) {
return (file.shares ?? []).filter((share) => share.target_type === "campaign" && !share.revoked_at); return (file.shares ?? []).filter((share) =>
share.target_type === "campaign" && fileShareIsActive(share)
);
}
function fileShareIsActive(share: NonNullable<ManagedFile["shares"]>[number]): boolean {
if (typeof share.active === "boolean") return share.active;
return !share.revoked_at
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now());
} }
function activeCampaignShareCount(file: ManagedFile): number { function activeCampaignShareCount(file: ManagedFile): number {

View File

@@ -0,0 +1,300 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import {
Button,
ConfirmDialog,
DataGrid,
DismissibleAlert,
FormField,
ReferenceSelect,
StatusBadge,
TableActionGroup,
type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui";
import {
createFileShare,
fileShareTargetProvider,
listFileShares,
revokeFileShare,
type FileShare,
type ManagedFile
} from "../../../api/files";
import { FileDialog } from "./FileManagerComponents";
import { formatDate } from "../utils/fileManagerUtils";
type ShareTargetType = "user" | "group" | "tenant";
type SharePermission = "read" | "write" | "manage";
export default function FileShareDialog({
settings,
file,
tenantId,
onClose,
onChanged
}: {
settings: ApiSettings;
file: ManagedFile;
tenantId: string;
onClose: () => void;
onChanged: () => void;
}) {
const [shares, setShares] = useState<FileShare[]>([]);
const [targetType, setTargetType] = useState<ShareTargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<SharePermission>("read");
const [expiresAt, setExpiresAt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [revokeTarget, setRevokeTarget] = useState<FileShare | null>(null);
const targetProvider = useMemo(
() => targetType === "tenant"
? null
: fileShareTargetProvider(settings, file.id, targetType),
[file.id, settings, targetType]
);
useEffect(() => {
void refresh();
}, [file.id]);
async function refresh() {
setBusy(true);
setError("");
try {
const response = await listFileShares(settings, file.id, {
includeInactive: true
});
setShares(response.shares);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function grant() {
const effectiveTargetId = targetType === "tenant" ? tenantId : targetId;
if (!effectiveTargetId) return;
setBusy(true);
setError("");
try {
await createFileShare(settings, file.id, {
target_type: targetType,
target_id: effectiveTargetId,
permission,
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null
});
setTargetId("");
setExpiresAt("");
await refresh();
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}
async function revoke() {
if (!revokeTarget) return;
setBusy(true);
setError("");
try {
await revokeFileShare(settings, file.id, revokeTarget.id);
setRevokeTarget(null);
await refresh();
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}
const columns = useMemo<DataGridColumn<FileShare>[]>(() => [
{
id: "target",
header: "Target",
minWidth: 220,
resizable: true,
sortable: true,
filterable: true,
value: (share) => `${share.target_type}: ${share.target_id}`
},
{
id: "permission",
header: "Permission",
width: 115,
sortable: true,
filterable: true,
value: (share) => share.permission
},
{
id: "created",
header: "Created",
minWidth: 160,
resizable: true,
sortable: true,
value: (share) => formatDate(share.created_at)
},
{
id: "creator",
header: "Created by",
minWidth: 150,
resizable: true,
filterable: true,
value: (share) => share.created_by_user_id || "System"
},
{
id: "expiry",
header: "Expires",
minWidth: 160,
resizable: true,
sortable: true,
value: (share) => share.expires_at ? formatDate(share.expires_at) : "No expiry"
},
{
id: "status",
header: "Status",
width: 115,
sortable: true,
value: (share) => shareStatus(share),
render: (share) => (
<StatusBadge
status={share.active ? "active" : "inactive"}
label={shareStatus(share)}
/>
)
},
{
id: "actions",
header: "Actions",
width: 72,
sticky: "end",
align: "right",
render: (share) => (
<TableActionGroup
minimumSlots={1}
actions={[
{
id: "revoke",
label: "Revoke share",
icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
disabled: !share.active || busy,
disabledReason: !share.active ? "This share is already inactive." : undefined,
onClick: () => setRevokeTarget(share)
}
]}
/>
)
}
], [busy]);
return (
<>
<FileDialog title={`Manage shares - ${file.filename}`} onClose={() => !busy && onClose()}>
<div className="file-share-dialog-content">
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<div className="file-share-form">
<FormField label="Target type">
<select
value={targetType}
disabled={busy}
onChange={(event) => {
setTargetType(event.target.value as ShareTargetType);
setTargetId("");
}}
>
<option value="user">User</option>
<option value="group">Group</option>
<option value="tenant">Entire tenant</option>
</select>
</FormField>
<FormField label="Target">
{targetType === "tenant" ? (
<input value={tenantId} disabled readOnly />
) : targetProvider ? (
<ReferenceSelect
value={targetId}
onChange={setTargetId}
provider={targetProvider}
disabled={busy}
placeholder={`Select a ${targetType}`}
aria-label={`Share target ${targetType}`}
required
/>
) : null}
</FormField>
<FormField label="Permission">
<select
value={permission}
disabled={busy}
onChange={(event) => setPermission(event.target.value as SharePermission)}
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="manage">Manage</option>
</select>
</FormField>
<FormField label="Expires (optional)">
<input
type="datetime-local"
value={expiresAt}
min={minimumLocalDateTime()}
disabled={busy}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</FormField>
<Button
variant="primary"
disabled={busy || (targetType !== "tenant" && !targetId)}
onClick={() => void grant()}
>
Grant access
</Button>
</div>
<DataGrid
id={`file-${file.id}-shares`}
rows={shares}
columns={columns}
getRowKey={(share) => share.id}
emptyText={busy ? "Loading shares..." : "This file has no direct shares."}
initialFit="container"
/>
<div className="button-row compact-actions align-end">
<Button onClick={onClose} disabled={busy}>Close</Button>
</div>
</div>
</FileDialog>
<ConfirmDialog
open={Boolean(revokeTarget)}
title="Revoke file share"
message="Revoke this direct grant? Other independent grants remain in effect."
confirmLabel="Revoke"
cancelLabel="Cancel"
tone="danger"
busy={busy}
onConfirm={() => void revoke()}
onCancel={() => setRevokeTarget(null)}
/>
</>
);
}
function shareStatus(share: FileShare): string {
if (share.revoked_at) return "Revoked";
if (share.expires_at && new Date(share.expires_at).getTime() <= Date.now()) {
return "Expired";
}
return "Active";
}
function minimumLocalDateTime(): string {
const now = new Date(Date.now() + 60_000);
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}

View File

@@ -1024,7 +1024,14 @@ renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"])
function isLinkedToTarget(file: ManagedFile, target?: FilesManagedFileLinkTarget): boolean { function isLinkedToTarget(file: ManagedFile, target?: FilesManagedFileLinkTarget): boolean {
if (!target) return false; if (!target) return false;
return Boolean(file.shares?.some((share) => share.target_type === target.type && share.target_id === target.id && !share.revoked_at)); return Boolean(file.shares?.some((share) =>
share.target_type === target.type
&& share.target_id === target.id
&& (typeof share.active === "boolean"
? share.active
: !share.revoked_at
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now()))
));
} }
function readRememberedState(key: string): RememberedChooserState { function readRememberedState(key: string): RememberedChooserState {

View File

@@ -288,6 +288,35 @@
width: min(820px, 100%); width: min(820px, 100%);
} }
.file-dialog:has(.file-share-dialog-content) {
width: min(1080px, 100%);
}
.file-share-dialog-content {
display: grid;
min-width: 0;
gap: 16px;
}
.file-share-form {
display: grid;
grid-template-columns: minmax(130px, .7fr) minmax(220px, 1.4fr) minmax(120px, .7fr) minmax(190px, 1fr) auto;
gap: 12px;
align-items: end;
}
@media (max-width: 900px) {
.file-share-form {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.file-share-form {
grid-template-columns: minmax(0, 1fr);
}
}
.connector-sync-grid { .connector-sync-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr);