44 Commits
v0.1.1 ... main

Author SHA1 Message Date
93eea839c8 Declare file workspace View surfaces 2026-07-28 21:04:55 +02:00
b667e7ff0e Integrate file connectors with credential envelopes 2026-07-28 19:33:01 +02:00
0fd8f6e02a docs: update GovOPlaN repository links 2026-07-27 15:46:51 +02:00
2b34f6e305 fix(files): align adaptive tasks with live access 2026-07-21 19:15:47 +02:00
58af1a20a7 security(files): filter connectors before secret resolution 2026-07-21 19:15:41 +02:00
4722161592 feat(files): surface configured handbook tasks 2026-07-21 18:54:26 +02:00
1444ba80a0 refactor(files): centralize connector visibility 2026-07-21 18:52:33 +02:00
02ef83ecee test: commit Files retirement transaction 2026-07-21 18:18:33 +02:00
1069f85796 Refactor connector profile updates 2026-07-21 18:16:55 +02:00
1401c78c8a docs: add files workflow assurance topics 2026-07-21 17:13:53 +02:00
b6109245a7 docs(files): describe current sharing permission 2026-07-21 17:07:40 +02:00
f5d40b23c2 docs(files): link lifecycle reliability backlog 2026-07-21 16:48:13 +02:00
94ea629635 docs(files): require import workflow permissions 2026-07-21 16:33:06 +02:00
6c8a8c655d docs(files): expose adaptive handbook topics 2026-07-21 16:31:49 +02:00
062ad5ddfb docs(files): add adaptive operations handbook 2026-07-21 16:27:23 +02:00
9adfa91e74 docs(files): define connector credential retirement 2026-07-21 16:16:53 +02:00
65d8ed80b5 security(files): scrub connector credentials on deletion 2026-07-21 16:16:47 +02:00
cffe161f29 Document governed file connector boundaries 2026-07-21 15:44:00 +02:00
5248e7de4a Fail closed for SMB referral transports 2026-07-21 15:43:56 +02:00
d5d0df792b Disable connector transports without peer pinning 2026-07-21 15:38:09 +02:00
15ade8df75 Use shared settings target layout 2026-07-21 13:48:04 +02:00
f3c485ef61 Use Core explorer work-surface styling 2026-07-21 13:19:29 +02:00
0e36b20a14 refactor(webui): use core access explanation 2026-07-21 13:19:11 +02:00
06e6e7191b refactor(files): decompose S3 connector imports 2026-07-21 12:57:44 +02:00
3449cbc8a5 refactor(files): decompose campaign snapshot preparation 2026-07-21 12:57:40 +02:00
92950af6f4 refactor(files): simplify asset and delta responses 2026-07-21 12:57:37 +02:00
8826cf2890 Use managed secret controls for Files connectors 2026-07-21 12:13:00 +02:00
f2dfb6c90e Harden external file connector boundaries 2026-07-21 12:10:23 +02:00
3bc1d3489e Narrow Files backend import surfaces 2026-07-21 03:16:23 +02:00
d1051293b2 feat(files): expose campaign attachment linking 2026-07-20 20:06:04 +02:00
8c5f149f07 fix(files): route drops through the selected target 2026-07-20 16:57:34 +02:00
f3210234d3 intermittent commit 2026-07-14 13:22:11 +02:00
b8b395e8b5 Run SMB dev connector as non-root 2026-07-11 18:37:51 +02:00
8bf7296bf5 Release v0.1.8 2026-07-11 16:49:02 +02:00
620fdda2fc Release v0.1.7 2026-07-11 02:34:56 +02:00
58c0441763 Add file resource access explanations 2026-07-11 00:39:39 +02:00
ba7fb2def7 Sync GovOPlaN module state 2026-07-10 21:57:26 +02:00
4e7c77135c Use capability-based module boundaries 2026-07-10 17:33:52 +02:00
13fd7fc3bd chore: sync GovOPlaN module split state 2026-07-10 12:51:21 +02:00
18e6c3eb9b Release v0.1.6 2026-07-07 15:55:37 +02:00
a5c24fe73e Release v0.1.5 2026-07-07 15:49:06 +02:00
ee5b06b1e7 Release v0.1.4 2026-07-02 14:59:52 +02:00
a7895ad394 Release v0.1.3 2026-06-26 01:39:18 +02:00
189e950589 Release v0.1.2 2026-06-25 19:58:20 +02:00
86 changed files with 23287 additions and 1588 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/files
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/files
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/files
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/files
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/files
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

18
.gitignore vendored
View File

@@ -327,3 +327,21 @@ dist
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# GovOPlaN shared ignore rules from govoplan-core
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

37
AGENTS.md Normal file
View File

@@ -0,0 +1,37 @@
# GovOPlaN Files Codex Guide
## Scope
This repository owns the `files` module: managed file storage APIs, file metadata, shares, uploads/downloads, folder and pattern helpers, backend module manifest, and `@govoplan/files-webui`.
Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Campaign integration must remain optional and go through core module metadata or capabilities.
## Local Commands
Install and run through core:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan
tools/checks/check-focused.sh
```
For WebUI permutation checks that include files:
```bash
cd /mnt/DATA/git/govoplan-core/webui
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations
```
## Working Rules
- Keep files behavior in this module, not core.
- Do not require campaign or mail imports for files-only startup.
- Shared WebUI components belong in core; files WebUI should consume them through `@govoplan/core-webui`.
- Preserve optional campaign usage tracking through capability/registry boundaries.

102
README.md
View File

@@ -1,5 +1,9 @@
# govoplan-files # govoplan-files
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Files is the managed file module. It bundles backend storage APIs and the Files WebUI package so file features can be installed as one module. GovOPlaN Files is the managed file module. It bundles backend storage APIs and the Files WebUI package so file features can be installed as one module.
## Ownership ## Ownership
@@ -46,10 +50,106 @@ Frontend package:
@govoplan/files-webui @govoplan/files-webui
``` ```
The campaign module can integrate with files when both modules are installed, for example for managed attachment selection and campaign file sharing. The campaign module can integrate with files when both modules are installed,
for example for managed attachment selection and campaign file sharing. Files
does not import campaign internals; campaign share/existence checks use the core
`campaigns.access` capability registered by the campaign module.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
Managed files can carry source provenance for connector and import workflows.
Upload callers may provide `source_provenance_json` and `source_revision`; the
module stores those values under file metadata, returns normalized
`source_provenance` and `source_revision` fields in file responses, and carries
them into managed campaign attachment matches for frozen execution evidence.
Connector policy preflight is available through
`POST /api/v1/files/connector-policy/evaluate`, and provenance-bearing uploads
can pass `connector_policy_json` to enforce the same policy before file content
is read. Policy payloads contain ordered `sources`; each source has
`scope_type`, optional `scope_id`, optional `label`, and a `policy` object. The
policy object supports `allow`/`allowlist`/`whitelist` and
`deny`/`denylist`/`blacklist` rules for `connectors`, `providers`,
`external_ids`, `external_paths`, and `external_urls`. Deny rules win across the
hierarchy; allow rules narrow access at each source that defines them.
Connector endpoint settings are exposed through governed connector profiles.
Profiles can be supplied as JSON through
`GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON`, or from a JSON file path through
`GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE`. Each profile has an `id`, `provider`,
`endpoint_url`, governance `scope_type`/`scope_id`, optional `capabilities`, and
deployment-owned credential references such as `password_env`, `token_env`, or
`secret_ref`. Environment references require an exact name in the deployment-wide
`GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; API-managed profiles cannot select
process environment variables and may use only Files-owned encrypted password or
token values. API-created `secret_ref` values fail closed until Files has an
ownership contract that can confirm provider-side deletion. Legacy external
references are treated as non-owned: deleting a profile or credential detaches
and audits the reference but never passes it to an arbitrary secret provider.
Profile and credential deletion immediately clears encrypted values, credential
identities, deployment references, and private metadata in the same transaction
as the non-secret audit record; an audit failure rolls the deletion back.
`GET /api/v1/files/connectors/profiles` returns only profiles visible to the
current principal (system, tenant, user, group, or accessible campaign scope) and
redacts secret values and environment variable names. Use the returned
`policy_sources` with connector policy preflight before importing files.
`GET /api/v1/files/connectors/providers` exposes provider descriptors for
Seafile, Nextcloud, WebDAV, SMB, NFS, and local filesystem connectors. The
descriptor declares implementation status, optional dependencies, permission
mapping, sync/index strategy, conflict handling, preview behavior, and audit
events so provider coverage remains visible without forcing every optional
protocol dependency to be installed.
`GET /api/v1/files/connectors/profiles/{profile_id}/browse` provides read-only
connector browsing. Browse entries use a shared `library`/`folder`/`file` shape,
run profile policy with the `browse` operation, and support Seafile,
WebDAV/Nextcloud, and SMB when the optional `smb` extra is installed. Seafile
profiles browse libraries and directories via Seafile's read-only API; profiles
can still opt into WebDAV browsing by setting `metadata.webdav_endpoint_url` or
`metadata.browse_protocol` to `webdav`.
`POST /api/v1/files/connectors/profiles/{profile_id}/import` imports a Seafile
or WebDAV/Nextcloud/SMB file into managed storage through the same governance,
conflict handling, source provenance, and connector audit path as direct
uploads. The Seafile provider uses account-token auth and the native file
download-link API; Nextcloud and generic WebDAV profiles use authenticated `GET`
requests against the configured WebDAV endpoint. SMB profiles use
`smb://server[:port]/share[/path]` endpoints and deployment-owned or encrypted
stored credentials through `smbprotocol`. Because that SDK cannot accept a
preconnected socket and may follow DFS referrals to additional hosts, live SMB
access fails closed in both public-only and private-network deployments. It will
remain disabled until every initial connection and referral target can be
policy-validated and pinned.
The S3 browse/import implementation and S3 managed-storage backend currently
fail closed before creating a `boto3` client. Botocore does not yet use the
GovOPlaN pinned HTTP transport and may manage redirects itself, so neither an
explicit endpoint nor SDK endpoint discovery is allowed until both peer pinning
and redirect revalidation are enforced.
Destructive Files-module retirement applies the same credential lifecycle before
dropping tables. Every remaining Files-owned encrypted connector secret is
scrubbed and audited first, while legacy non-owned external references are
detached and identified as such in the audit record. Retirement does not claim
or attempt provider-side deletion for references Files cannot prove it owns.
Local connector development assets live in `dev/connectors/`. The compose stack
boots Nextcloud, Seafile, WebDAV, and SMB endpoints for provider development and
manual interoperability testing.
Connector and collaboration ownership boundaries are documented in
`docs/CONNECTOR_BOUNDARY.md` and `docs/DOCUMENT_COLLABORATION_BOUNDARY.md`.
The role-adaptive user, administration, integration, and operator guide is the
[Files handbook](docs/FILES_HANDBOOK.md).
ZIP uploads are processed without buffering the whole archive in memory. The API
spools incoming ZIP request bodies to a bounded temporary file, then extracts
members with per-file and total extracted-size limits before storing managed
files.
Bulk rename and transfer APIs are owner-scoped: callers must provide the active
user or group file space with `owner_type` and `owner_id`. The storage layer
keeps a named legacy file-only helper for historical callers that lack owner
context, and regression tests cover its write-access checks.
## Release packaging ## Release packaging
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths.

View File

@@ -0,0 +1,25 @@
NEXTCLOUD_HOST_PORT=9081
NEXTCLOUD_DB_ROOT_PASSWORD=govoplan-nextcloud-root
NEXTCLOUD_DB_PASSWORD=govoplan-nextcloud
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=govoplan-nextcloud-admin
SEAFILE_HOST_PORT=9082
SEAFILE_MYSQL_ROOT_PASSWORD=govoplan-seafile-root
SEAFILE_ADMIN_EMAIL=admin@example.local
SEAFILE_ADMIN_PASSWORD=govoplan-seafile-admin
WEBDAV_HOST_PORT=9083
WEBDAV_USER=govoplan
WEBDAV_PASSWORD=govoplan-webdav
SMB_HOST_PORT=1445
SMB_SHARE_NAME=files
SMB_USER=govoplan
SMB_PASSWORD=govoplan-smb
MINIO_API_HOST_PORT=9000
MINIO_CONSOLE_HOST_PORT=9001
MINIO_ROOT_USER=govoplan
MINIO_ROOT_PASSWORD=govoplan-minio
MINIO_BUCKET=govoplan

172
dev/connectors/README.md Normal file
View File

@@ -0,0 +1,172 @@
# Connector Dev Stack
This directory keeps local Docker Compose assets for GovOPlaN file connector
development. The stack is intentionally separate from production deployment and
binds services to localhost high ports.
## Start
```bash
cd /mnt/DATA/git/govoplan-files/dev/connectors
cp .env.example .env
mkdir -p data/smb data/webdav
docker compose up -d nextcloud nextcloud-db webdav smb minio
docker compose up -d seafile-db seafile-memcached seafile
```
If the SMB service was already running before a compose change, recreate it so
the image and share configuration are applied:
```bash
docker compose rm -sf smb
docker compose up -d --build smb
```
Endpoints:
- Nextcloud: `http://127.0.0.1:9081`, WebDAV root
`http://127.0.0.1:9081/remote.php/dav/files/admin/`
- Seafile: `http://127.0.0.1:9082`, WebDAV root
`http://127.0.0.1:9082/seafdav/`
- WebDAV: `http://127.0.0.1:9083`
- SMB: `smb://127.0.0.1:1445/files`
- MinIO/S3 API: `http://127.0.0.1:9000`, console
`http://127.0.0.1:9001`
The local fixture data under `data/` is ignored by git.
## GovOPlaN Profile Config
Connector profiles are read from `GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON` or
`GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE`. This deployment-owned configuration
may reference environment variables only when their exact names are listed in
`GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; profile API responses expose only the
credential source and configured state.
Example local profile file:
```json
{
"profiles": [
{
"id": "dev-seafile",
"label": "Dev Seafile",
"provider": "seafile",
"endpoint_url": "http://127.0.0.1:9082",
"scope_type": "system",
"credential_mode": "basic",
"username": "admin@example.local",
"password_env": "SEAFILE_ADMIN_PASSWORD",
"capabilities": ["browse", "import"],
"metadata": { "webdav_endpoint_url": "http://127.0.0.1:9082/seafdav/" },
"policy": { "allow": { "providers": ["seafile"] } }
},
{
"id": "dev-nextcloud",
"label": "Dev Nextcloud",
"provider": "nextcloud",
"endpoint_url": "http://127.0.0.1:9081/remote.php/dav/files/admin/",
"scope_type": "system",
"credential_mode": "basic",
"username": "admin",
"password_env": "NEXTCLOUD_ADMIN_PASSWORD",
"capabilities": ["browse", "import"],
"policy": { "allow": { "providers": ["nextcloud", "webdav"] } }
},
{
"id": "dev-webdav",
"label": "Dev WebDAV",
"provider": "webdav",
"endpoint_url": "http://127.0.0.1:9083",
"scope_type": "system",
"credential_mode": "basic",
"username": "govoplan",
"password_env": "WEBDAV_PASSWORD",
"capabilities": ["browse", "import"],
"policy": { "allow": { "providers": ["webdav"] } }
},
{
"id": "dev-smb",
"label": "Dev SMB",
"provider": "smb",
"endpoint_url": "smb://127.0.0.1:1445/files",
"scope_type": "system",
"credential_mode": "basic",
"username": "govoplan",
"password_env": "SMB_PASSWORD",
"capabilities": ["browse", "import"],
"policy": { "allow": { "providers": ["smb"] } }
},
{
"id": "dev-s3",
"label": "Dev MinIO",
"provider": "s3",
"endpoint_url": "http://127.0.0.1:9000",
"base_path": "",
"scope_type": "system",
"credential_mode": "basic",
"username": "govoplan",
"password_env": "MINIO_ROOT_PASSWORD",
"capabilities": ["browse", "import"],
"metadata": {
"bucket": "govoplan",
"region": "us-east-1",
"path_style": true,
"verify_tls": false
},
"policy": { "allow": { "providers": ["s3"] } }
}
]
}
```
Start GovOPlaN with:
```bash
export GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE=/mnt/DATA/git/govoplan-files/dev/connectors/profiles.local.json
export GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=SEAFILE_ADMIN_PASSWORD,NEXTCLOUD_ADMIN_PASSWORD,WEBDAV_PASSWORD,SMB_PASSWORD,MINIO_ROOT_PASSWORD
export GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
```
## Smoke Test
Run the connector helper smoke checks from an environment where
`govoplan-files` and `httpx` are importable:
```bash
cd /mnt/DATA/git/govoplan-files/dev/connectors
/mnt/DATA/git/govoplan-core/.venv/bin/python smoke.py
```
The script reads `.env` from this directory when present and seeds tiny WebDAV,
Nextcloud, SMB, and MinIO fixtures. WebDAV and Nextcloud are browsed and imported
through the pinned connector transport. SMB and S3 product access deliberately
fails closed until their SDK transports support peer pinning, so their fixtures
are retained for transport development and reported as expected optional skips.
The `--require-smb` and `--require-s3` switches are useful only while developing
that transport support and currently make the smoke check fail by design.
SMB smoke checks need the optional Python dependency in the environment running
the script:
```bash
/mnt/DATA/git/govoplan-core/.venv/bin/python -m pip install -e /mnt/DATA/git/govoplan-files[smb]
```
S3 smoke checks need the optional boto3 dependency in the environment running
the script:
```bash
/mnt/DATA/git/govoplan-core/.venv/bin/python -m pip install -e /mnt/DATA/git/govoplan-files[s3]
```
The SMB service is built from `dev/connectors/smb/` so the development share is
deterministic: one `files` share backed by `data/smb`, with the credentials from
`.env`.
The SMB image runs as the non-root `govoplan` user with UID/GID `1000` and
listens on unprivileged container port `1445`. The default host endpoint remains
`smb://127.0.0.1:1445/files`. `SMB_USER` must stay `govoplan` unless the image is
rebuilt with a matching user; `SMB_PORT` must be `1024` or higher. `SMB_PASSWORD`
is applied when the image is built, so rebuild the `smb` service after changing
it in `.env`.

2
dev/connectors/data/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,120 @@
name: govoplan-files-connectors
services:
nextcloud-db:
image: mariadb:10.11
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${NEXTCLOUD_DB_ROOT_PASSWORD:-govoplan-nextcloud-root}
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-govoplan-nextcloud}
volumes:
- nextcloud-db:/var/lib/mysql
nextcloud:
image: nextcloud:apache
restart: unless-stopped
depends_on:
- nextcloud-db
ports:
- "127.0.0.1:${NEXTCLOUD_HOST_PORT:-9081}:80"
environment:
MYSQL_HOST: nextcloud-db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-govoplan-nextcloud}
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:-admin}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:-govoplan-nextcloud-admin}
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1"
volumes:
- nextcloud:/var/www/html
seafile-db:
image: mariadb:10.11
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${SEAFILE_MYSQL_ROOT_PASSWORD:-govoplan-seafile-root}
MARIADB_AUTO_UPGRADE: "1"
MYSQL_LOG_CONSOLE: "true"
volumes:
- seafile-db:/var/lib/mysql
seafile-memcached:
image: memcached:1.6
restart: unless-stopped
command: memcached -m 256
seafile:
image: seafileltd/seafile-mc:11.0-latest
restart: unless-stopped
depends_on:
- seafile-db
- seafile-memcached
ports:
- "127.0.0.1:${SEAFILE_HOST_PORT:-9082}:80"
environment:
DB_HOST: seafile-db
DB_ROOT_PASSWD: ${SEAFILE_MYSQL_ROOT_PASSWORD:-govoplan-seafile-root}
TIME_ZONE: Etc/UTC
SEAFILE_ADMIN_EMAIL: ${SEAFILE_ADMIN_EMAIL:-admin@example.local}
SEAFILE_ADMIN_PASSWORD: ${SEAFILE_ADMIN_PASSWORD:-govoplan-seafile-admin}
SEAFILE_SERVER_LETSENCRYPT: "false"
SEAFILE_SERVER_HOSTNAME: "127.0.0.1:${SEAFILE_HOST_PORT:-9082}"
volumes:
- seafile-data:/shared
webdav:
image: rclone/rclone:latest
restart: unless-stopped
command:
- serve
- webdav
- /data
- --addr
- :8080
- --user
- ${WEBDAV_USER:-govoplan}
- --pass
- ${WEBDAV_PASSWORD:-govoplan-webdav}
ports:
- "127.0.0.1:${WEBDAV_HOST_PORT:-9083}:8080"
volumes:
- ./data/webdav:/data
smb:
build:
context: ./smb
args:
SMB_PASSWORD: ${SMB_PASSWORD:-govoplan-smb}
image: govoplan-files-samba-dev:latest
restart: unless-stopped
environment:
SMB_SHARE_NAME: ${SMB_SHARE_NAME:-files}
SMB_USER: ${SMB_USER:-govoplan}
SMB_PORT: ${SMB_PORT:-1445}
ports:
- "127.0.0.1:${SMB_HOST_PORT:-1445}:${SMB_PORT:-1445}"
volumes:
- ./data/smb:/storage
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-govoplan}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-govoplan-minio}
ports:
- "127.0.0.1:${MINIO_API_HOST_PORT:-9000}:9000"
- "127.0.0.1:${MINIO_CONSOLE_HOST_PORT:-9001}:9001"
volumes:
- minio:/data
volumes:
nextcloud-db:
nextcloud:
seafile-db:
seafile-data:
minio:

View File

@@ -0,0 +1,29 @@
FROM alpine:3.20
ARG SMB_PASSWORD=govoplan-smb
RUN apk add --no-cache samba-server samba-common-tools \
&& addgroup -S -g 1000 govoplan \
&& adduser -S -D -H -h /nonexistent -s /sbin/nologin -u 1000 -G govoplan govoplan \
&& mkdir -p /storage /var/lib/samba/private /var/cache/samba /run/samba /etc/samba /var/log/samba/cores \
&& printf '%s\n' \
'[global]' \
' passdb backend = tdbsam' \
' private dir = /var/lib/samba/private' \
' lock directory = /run/samba' \
' state directory = /var/lib/samba' \
' cache directory = /var/cache/samba' \
' pid directory = /run/samba' \
> /etc/samba/smb.conf \
&& printf '%s\n%s\n' "$SMB_PASSWORD" "$SMB_PASSWORD" | smbpasswd -s -a govoplan \
&& smbpasswd -e govoplan \
&& chown -R govoplan:govoplan /storage /var/lib/samba /var/cache/samba /run/samba /etc/samba /var/log/samba
COPY entrypoint.sh /usr/local/bin/govoplan-samba-entrypoint
RUN chmod +x /usr/local/bin/govoplan-samba-entrypoint
EXPOSE 1445
USER govoplan:govoplan
ENTRYPOINT ["/usr/local/bin/govoplan-samba-entrypoint"]

View File

@@ -0,0 +1,82 @@
#!/bin/sh
set -eu
share_name="${SMB_SHARE_NAME:-files}"
user_name="${SMB_USER:-govoplan}"
smb_port="${SMB_PORT:-1445}"
runtime_user="$(id -un)"
runtime_group="$(id -gn)"
case "$share_name" in
"" | *[!A-Za-z0-9_.-]*)
printf 'SMB_SHARE_NAME must contain only letters, numbers, dot, dash, or underscore.\n' >&2
exit 1
;;
esac
case "$user_name" in
"" | *[!A-Za-z0-9_.-]*)
printf 'SMB_USER must contain only letters, numbers, dot, dash, or underscore.\n' >&2
exit 1
;;
esac
case "$smb_port" in
"" | *[!0-9]*)
printf 'SMB_PORT must be numeric.\n' >&2
exit 1
;;
esac
if [ "$smb_port" -lt 1024 ]; then
printf 'SMB_PORT must be >= 1024 because the dev Samba container runs as a non-root user.\n' >&2
exit 1
fi
if [ "$user_name" != "$runtime_user" ]; then
printf 'SMB_USER=%s is not supported by the non-root dev image; use %s or rebuild the image with a matching user.\n' "$user_name" "$runtime_user" >&2
exit 1
fi
mkdir -p /storage /var/lib/samba/private /var/cache/samba /run/samba /etc/samba
cat > /etc/samba/smb.conf <<EOF
[global]
server role = standalone server
workgroup = WORKGROUP
security = user
map to guest = Never
guest account = $runtime_user
server min protocol = SMB2
server signing = mandatory
smb ports = $smb_port
load printers = no
printing = bsd
disable spoolss = yes
log level = 1
passdb backend = tdbsam
private dir = /var/lib/samba/private
lock directory = /run/samba
state directory = /var/lib/samba
cache directory = /var/cache/samba
pid directory = /run/samba
[$share_name]
path = /storage
browseable = yes
read only = no
guest ok = no
valid users = $user_name
force user = $runtime_user
force group = $runtime_group
create mask = 0664
directory mask = 0775
EOF
if ! pdbedit -L -u "$user_name" >/dev/null 2>&1; then
printf 'Samba user %s is missing from passdb. Rebuild the smb image so the non-root passdb is seeded.\n' "$user_name" >&2
printf 'Run: docker compose build --no-cache smb && docker compose up -d smb\n' >&2
exit 1
fi
exec smbd --foreground --no-process-group --debug-stdout -p "$smb_port"

282
dev/connectors/smoke.py Normal file
View File

@@ -0,0 +1,282 @@
from __future__ import annotations
import argparse
import os
import socket
from pathlib import Path
import httpx
from govoplan_files.backend.storage.connector_browse import browse_connector_profile
from govoplan_files.backend.storage.connector_imports import read_connector_file
from govoplan_files.backend.storage.connector_profiles import connector_profiles_from_payload
ROOT = Path(__file__).resolve().parent
def main() -> int:
parser = argparse.ArgumentParser(description="Smoke-test GovOPlaN connector helpers against the local dev compose stack.")
parser.add_argument("--require-smb", action="store_true", help="Fail if the SMB connector cannot browse/import.")
parser.add_argument("--require-s3", action="store_true", help="Fail if the S3 connector cannot browse/import.")
parser.add_argument("--debug-smb", action="store_true", help="Print direct smbclient probes before the connector smoke check.")
args = parser.parse_args()
_load_dotenv()
_default_env()
preflight_failures = _preflight_services(require_smb=args.require_smb, require_s3=args.require_s3)
if preflight_failures:
for failure in preflight_failures:
print(f"FAIL {failure}")
print("Start or recreate the connector stack from this directory with: docker compose up -d nextcloud nextcloud-db webdav smb minio")
return 1
_seed_webdav_fixture()
_seed_smb_fixture()
try:
_seed_nextcloud_fixture()
except httpx.HTTPError as exc:
print(f"FAIL dev-nextcloud seed failed: {exc}")
return 1
try:
_seed_s3_fixture()
except Exception as exc:
if args.require_s3:
print(f"FAIL dev-s3 seed failed: {exc}")
return 1
print(f"SKIP dev-s3 seed failed: {exc}")
profiles = {profile.id: profile for profile in connector_profiles_from_payload({"profiles": _profile_payloads()})}
if args.debug_smb:
_debug_smb(profiles["dev-smb"])
failures: list[str] = []
for profile_id, folder, file_path, expected in (
("dev-webdav", "GovOPlaN", "GovOPlaN/webdav-live.txt", "webdav live fixture"),
("dev-nextcloud", "GovOPlaN", "GovOPlaN/nextcloud-live.txt", "nextcloud live fixture"),
):
try:
_exercise_profile(profiles[profile_id], folder=folder, file_path=file_path, expected=expected)
except Exception as exc:
failures.append(f"{profile_id}: {exc}")
try:
_exercise_profile(profiles["dev-smb"], folder="GovOPlaN", file_path="GovOPlaN/smb-live.txt", expected="smb live fixture")
except Exception as exc:
message = f"dev-smb: {exc}"
if args.require_smb:
failures.append(message)
else:
print(f"SKIP {message}")
try:
_exercise_profile(profiles["dev-s3"], folder="GovOPlaN", file_path="GovOPlaN/s3-live.txt", expected="s3 live fixture")
except Exception as exc:
message = f"dev-s3: {exc}"
if args.require_s3:
failures.append(message)
else:
print(f"SKIP {message}")
if failures:
for failure in failures:
print(f"FAIL {failure}")
return 1
print("OK connector dev stack smoke checks passed")
return 0
def _default_env() -> None:
defaults = {
"NEXTCLOUD_ADMIN_USER": "admin",
"NEXTCLOUD_ADMIN_PASSWORD": "govoplan-nextcloud-admin",
"WEBDAV_USER": "govoplan",
"WEBDAV_PASSWORD": "govoplan-webdav",
"SMB_SHARE_NAME": "files",
"SMB_USER": "govoplan",
"SMB_PASSWORD": "govoplan-smb",
"MINIO_ROOT_USER": "govoplan",
"MINIO_ROOT_PASSWORD": "govoplan-minio",
"MINIO_BUCKET": "govoplan",
}
for key, value in defaults.items():
os.environ.setdefault(key, value)
def _load_dotenv() -> None:
path = ROOT / ".env"
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
text = line.strip()
if not text or text.startswith("#") or "=" not in text:
continue
key, value = text.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
os.environ[key] = value.strip().strip("\"'")
def _preflight_services(*, require_smb: bool, require_s3: bool) -> list[str]:
failures: list[str] = []
nextcloud_url = f"http://127.0.0.1:{os.getenv('NEXTCLOUD_HOST_PORT', '9081')}/status.php"
try:
response = httpx.get(nextcloud_url, timeout=3.0)
if response.status_code != 200:
failures.append(f"dev-nextcloud expected HTTP 200 at {nextcloud_url}, got HTTP {response.status_code}")
except httpx.HTTPError as exc:
failures.append(f"dev-nextcloud is not reachable at {nextcloud_url}: {exc}")
webdav_url = f"http://127.0.0.1:{os.getenv('WEBDAV_HOST_PORT', '9083')}/"
try:
response = httpx.get(webdav_url, timeout=3.0)
if response.status_code not in {200, 401}:
failures.append(f"dev-webdav expected HTTP 200/401 at {webdav_url}, got HTTP {response.status_code}")
except httpx.HTTPError as exc:
failures.append(f"dev-webdav is not reachable at {webdav_url}: {exc}")
if require_smb:
smb_port = int(os.getenv("SMB_HOST_PORT", "1445"))
try:
with socket.create_connection(("127.0.0.1", smb_port), timeout=3.0):
pass
except OSError as exc:
failures.append(f"dev-smb is not reachable at 127.0.0.1:{smb_port}: {exc}")
if require_s3:
minio_url = f"http://127.0.0.1:{os.getenv('MINIO_API_HOST_PORT', '9000')}/minio/health/live"
try:
response = httpx.get(minio_url, timeout=3.0)
if response.status_code != 200:
failures.append(f"dev-s3 expected HTTP 200 at {minio_url}, got HTTP {response.status_code}")
except httpx.HTTPError as exc:
failures.append(f"dev-s3 is not reachable at {minio_url}: {exc}")
return failures
def _profile_payloads() -> list[dict[str, object]]:
return [
{
"id": "dev-webdav",
"provider": "webdav",
"endpoint_url": f"http://127.0.0.1:{os.getenv('WEBDAV_HOST_PORT', '9083')}/",
"credential_mode": "basic",
"username": os.getenv("WEBDAV_USER", "govoplan"),
"password_env": "WEBDAV_PASSWORD",
},
{
"id": "dev-nextcloud",
"provider": "nextcloud",
"endpoint_url": f"http://127.0.0.1:{os.getenv('NEXTCLOUD_HOST_PORT', '9081')}/remote.php/dav/files/{os.getenv('NEXTCLOUD_ADMIN_USER', 'admin')}/",
"credential_mode": "basic",
"username": os.getenv("NEXTCLOUD_ADMIN_USER", "admin"),
"password_env": "NEXTCLOUD_ADMIN_PASSWORD",
},
{
"id": "dev-smb",
"provider": "smb",
"endpoint_url": f"smb://127.0.0.1:{os.getenv('SMB_HOST_PORT', '1445')}/{os.getenv('SMB_SHARE_NAME', 'files')}",
"credential_mode": "basic",
"username": os.getenv("SMB_USER", "govoplan"),
"password_env": "SMB_PASSWORD",
},
{
"id": "dev-s3",
"provider": "s3",
"endpoint_url": f"http://127.0.0.1:{os.getenv('MINIO_API_HOST_PORT', '9000')}",
"credential_mode": "basic",
"username": os.getenv("MINIO_ROOT_USER", "govoplan"),
"password_env": "MINIO_ROOT_PASSWORD",
"metadata": {
"bucket": os.getenv("MINIO_BUCKET", "govoplan"),
"region": "us-east-1",
"path_style": True,
"verify_tls": False,
},
},
]
def _exercise_profile(profile, *, folder: str, file_path: str, expected: str) -> None:
items = browse_connector_profile(profile, path=folder)
paths = {item.path for item in items}
if file_path not in paths:
raise RuntimeError(f"{file_path!r} not found; saw {sorted(paths)!r}")
downloaded = read_connector_file(profile, library_id="", path=file_path, max_bytes=1024 * 1024)
text = downloaded.data.decode("utf-8").strip()
if text != expected:
raise RuntimeError(f"{file_path!r} content mismatch: {text!r}")
print(f"OK {profile.id} browse/import {file_path}")
def _debug_smb(profile) -> None:
try:
import smbclient
except ImportError as exc:
print(f"DEBUG smbclient import failed: {exc}")
return
from govoplan_files.backend.storage.connector_browse import _smb_client_kwargs, _smb_location, _smb_unc_path
location = _smb_location(profile)
kwargs = _smb_client_kwargs(profile, location)
print(f"DEBUG SMB endpoint=//{location.server}:{location.port}/{location.share} root_path={location.root_path!r}")
print(f"DEBUG SMB user={kwargs.get('username')!r} require_signing={kwargs.get('require_signing')!r} auth_protocol={kwargs.get('auth_protocol')!r}")
try:
smbclient.reset_connection_cache()
except Exception as exc:
print(f"DEBUG SMB reset cache failed: {exc}")
for path in (_smb_unc_path(location, ""), _smb_unc_path(location, "GovOPlaN")):
try:
print(f"DEBUG SMB list {path}: {smbclient.listdir(path, **kwargs)}")
except Exception as exc:
print(f"DEBUG SMB list {path} failed: {type(exc).__name__}: {exc}")
def _seed_webdav_fixture() -> None:
path = ROOT / "data" / "webdav" / "GovOPlaN"
path.mkdir(parents=True, exist_ok=True)
(path / "webdav-live.txt").write_text("webdav live fixture\n", encoding="utf-8")
def _seed_smb_fixture() -> None:
path = ROOT / "data" / "smb" / "GovOPlaN"
path.mkdir(parents=True, exist_ok=True)
(path / "smb-live.txt").write_text("smb live fixture\n", encoding="utf-8")
def _seed_nextcloud_fixture() -> None:
base_url = f"http://127.0.0.1:{os.getenv('NEXTCLOUD_HOST_PORT', '9081')}/remote.php/dav/files/{os.getenv('NEXTCLOUD_ADMIN_USER', 'admin')}/GovOPlaN"
auth = (os.getenv("NEXTCLOUD_ADMIN_USER", "admin"), os.getenv("NEXTCLOUD_ADMIN_PASSWORD", "govoplan-nextcloud-admin"))
response = httpx.request("MKCOL", base_url, auth=auth, timeout=15.0)
if response.status_code not in {201, 405}:
raise RuntimeError(f"Nextcloud MKCOL failed with HTTP {response.status_code}: {response.text[:200]}")
response = httpx.put(f"{base_url}/nextcloud-live.txt", content=b"nextcloud live fixture\n", auth=auth, timeout=15.0)
if response.status_code not in {200, 201, 204}:
raise RuntimeError(f"Nextcloud PUT failed with HTTP {response.status_code}: {response.text[:200]}")
def _seed_s3_fixture() -> None:
try:
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
except ImportError as exc:
raise RuntimeError("boto3 is not installed; install govoplan-files[s3]") from exc
bucket = os.getenv("MINIO_BUCKET", "govoplan")
client = boto3.client(
"s3",
endpoint_url=f"http://127.0.0.1:{os.getenv('MINIO_API_HOST_PORT', '9000')}",
aws_access_key_id=os.getenv("MINIO_ROOT_USER", "govoplan"),
aws_secret_access_key=os.getenv("MINIO_ROOT_PASSWORD", "govoplan-minio"),
region_name="us-east-1",
config=Config(s3={"addressing_style": "path"}),
)
try:
client.create_bucket(Bucket=bucket)
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code")
if code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}:
raise
client.put_object(Bucket=bucket, Key="GovOPlaN/s3-live.txt", Body=b"s3 live fixture\n", ContentType="text/plain")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,74 @@
# File Connector Boundary
GovOPlaN Files owns the managed-file boundary: frozen blobs, versions, shares,
campaign attachment evidence, provenance, connector policy, connector profiles,
and read-only browse/import APIs that turn external files into managed
GovOPlaN files.
The built-in connector layer is intentionally on-demand. It does not run remote
indexing, background sync, remote mutation, or upstream permission management.
Connectors may browse an external source, import one selected file, freeze it as
a managed file, record provenance, and audit the access.
## Built-In Baseline
The files module may keep small baseline providers when they are needed for
normal product workflows and can share the same governance model:
- Seafile through the native read/download API
- Nextcloud through WebDAV
- generic WebDAV
- SMB through the optional `smb` extra
These providers are surfaced through connector descriptors at
`GET /api/v1/files/connectors/providers`. Provider descriptors declare whether
the provider is implemented, whether its optional dependency is installed, and
which browse/import behaviors are available.
## Separate Connector Module Candidates
A separate connector module becomes appropriate when integration needs exceed
on-demand browse/import:
- background indexing or synchronization
- bidirectional remote mutation
- long-running transfer workers
- provider-specific credential lifecycle or OAuth flows
- DMS/eAkte metadata models, registers, retention, or filing plans
- S3-compatible object store administration
- NFS host-mount lifecycle or sidecar coordination
- provider-specific WebUI administration beyond profile fields
In that model, `govoplan-files` should keep the managed-file import contract,
policy checks, provenance model, and audit events. A connector module should own
provider-specific discovery, synchronization, credentials, health checks, and
any remote write behavior.
## Provider Responsibilities
Every provider must:
- enforce GovOPlaN profile visibility and connector policy before browse/import
- keep credentials as encrypted values or scoped secret references, never API
response values; process-environment references are allowed only in
deployment-owned profiles with an exact deployment allowlist
- import external files into managed storage before they are used in campaigns
or workflows
- preserve source provenance and revision metadata
- emit connector audit events for imported or accessed files
- treat remote ACLs as upstream checks, not as a replacement for GovOPlaN policy
- use a transport that pins every connection to a policy-validated DNS/IP answer
and revalidates redirects; SDK transports without that guarantee fail closed
Live SMB access is disabled in all modes until `smbprotocol` initial connections
and DFS referral targets can be pinned and policy-validated. An explicit IP is
not sufficient because the server may still issue a referral to another peer.
Live S3 access is likewise disabled until `boto3`/botocore can be bound to the
pinned transport, including SDK-managed redirects and endpoint discovery.
## Non-Goals For Files
Files does not own collaborative editing, comments, document review workflows,
remote lock orchestration, DMS records management, or external system data
models. Those belong to future document, workflow, DMS, or connector modules and
should integrate through capabilities and managed-file imports.

148
docs/CONNECTOR_SPACES.md Normal file
View File

@@ -0,0 +1,148 @@
# Connector Spaces
GovOPlaN Files should treat external file shares as two separate product
objects:
1. A governed connection profile defines a reusable remote endpoint such as a
WebDAV server, Nextcloud account, Seafile server, or SMB share. Profiles are
administered in settings at system or tenant scope, with the same
inheritance and limit semantics used by mail-server profiles.
2. A governed credential profile defines reusable authentication material for
one provider or for any compatible provider. Credentials are administered
separately from connection profiles and can carry their own allow/deny
policy.
3. A linked file space binds one concrete remote folder or library path from an
allowed profile to a user or group. Linked spaces appear beside "My files"
and group file spaces in the files module.
This keeps secrets, endpoint governance, and tenant limits in settings, while
keeping user/group workspaces and concrete folder choices in the files module.
## Model
Connection profile:
- provider: `seafile`, `nextcloud`, `webdav`, `smb`, or a future provider
- endpoint URL and optional base path
- scope: `system` or `tenant` for administered profiles; user/group profiles
may be allowed later only when policy explicitly permits them
- optional credential profile id
- capabilities: browse, sync, import, optional write when a provider supports it
- profile-local policy for allow/deny rules
Credential profile:
- provider: a specific provider or any provider
- scope: `system` or `tenant` for administered credentials
- credential mode: anonymous, scoped secret reference, or encrypted stored
password/token; environment references are reserved for deployment-owned JSON
profiles and exact deployment allowlisting
- username and redacted secret configuration
- credential-local policy for allow/deny rules
Connector policy:
- system policy is the baseline
- tenant policy inherits system policy and may narrow it unless system allows
lower-level relaxation
- future user/group policy may narrow tenant policy for self-service links
- policies can allow or block providers, profile ids, endpoint URLs, external
path prefixes, credential ids, credential inheritance, and local linked-space
creation
Linked connector space:
- owner type: user or group
- display name
- connector profile id
- remote library id or share id
- remote root path
- sync mode: manual initially; background sync is future work
- read-only flag from provider/policy
- active/deleted state
The linked space should be addressable as a normal file space in the files UI.
For the first implementation slice, actions may use the existing connector
browse/sync APIs and managed file storage. A linked space can therefore browse
the remote folder and sync selected files into managed storage. Later slices can
add a native remote listing view or background sync jobs.
## Policy Semantics
Use mail-profile terminology because administrators already see it there:
- must use: lower scopes are restricted to selected profile ids or providers
- can use: lower scopes may choose from inherited allowed profiles
- shall not use anything outside: endpoint URLs and path prefixes are enforced
by deny/allow rules before browse, import, or sync
- may create local links: controls whether users/groups can add linked spaces
from inherited profiles
Connector policy should provide an explainable effective policy response with
source path entries: system, tenant, user, group, campaign when applicable.
## Current State
Implemented:
- provider descriptors for Seafile, Nextcloud, WebDAV, and SMB
- database-backed connector profiles with system and tenant scope
- database-backed connector credentials with system and tenant scope
- encrypted stored password/token support for database credentials
- JSON/environment-defined connector profiles with system, tenant, user, group,
and campaign visibility
- connector allow/deny policy enforcement before browse/import/sync, including
provider, connection profile, credential profile, and path checks
- read-only browse endpoints
- import and sync into managed files with provenance and revision metadata
- audit events for connector import, sync, and access
- settings/admin UI sections for system and tenant file connections and
credentials
- linked connector-space rows owned by users or groups
- file-space API responses that include linked connector spaces
- Files UI entry point to create linked connector spaces from a browsed remote
profile/folder
- Files UI sync dialog for choosing a profile, browsing a remote folder, and
syncing a selected file into a managed destination folder
- Files UI connector-space view with provider/read-only/manual-sync state
- Docker dev stack smoke checks for WebDAV, Nextcloud, and SMB
Missing:
- explicit connector profile policy rows equivalent to mail-profile policies
- effective connector policy/explain UI equivalent to mail-profile policies
- edit/manage actions for existing linked connector spaces
- optional background sync or remote-write semantics
## Implementation Phases
1. Persist governed connector profiles and policies.
- `file_connector_profiles` exists for system and tenant profiles.
- `file_connector_credentials` exists for system and tenant credentials.
- Environment JSON profiles remain bootstrap/compatibility profiles.
- Profile and credential CRUD endpoints exist for database-backed records.
- Remaining: `file_connector_policies` plus effective policy/explain output.
2. Add linked connector spaces.
- `file_connector_spaces` exists with owner user/group, profile id, library
id, remote path, display label, sync mode, and active state.
- Connector policy is enforced before creating or using a link.
- Linked connector spaces are returned from `/api/v1/files/spaces`.
3. Surface linked spaces in the Files UI.
- Linked spaces appear beside managed user/group spaces.
- The add-space dialog browses an allowed profile and links the current
remote folder/library root.
- The connector browser/sync flow is reused when a linked space is open.
- Remaining: edit/manage actions for existing linked spaces.
4. Add sync orchestration.
- Manual sync selected file is already available.
- Add folder-level manual sync.
- Add optional scheduled/background sync with conflict reporting.
5. Add provider-specific expansion.
- OAuth/secret-store credentials.
- Remote write and delete only where policy and provider support it.
- DMS/eAkte metadata integrations in a separate connector or documents
module.

View File

@@ -0,0 +1,54 @@
# Document Collaboration Boundary
GovOPlaN Files is the governed managed-file module. It stores uploaded/imported
blobs, versions, shares, provenance, ZIP imports/exports, connector imports, and
campaign attachment evidence. It should stay reliable, auditable, and simple.
Collaborative document behavior should be owned by a future documents module or
by provider-specific connector modules when the behavior is delegated to systems
such as Nextcloud, Seafile, Collabora, OnlyOffice, OpenDesk, or DMS/eAkte
platforms.
## Files Owns
- managed blobs and immutable version evidence
- owner-scoped user/group file spaces
- shares and access checks for managed files
- connector browse/import into managed storage
- source provenance, source revision, and audit events
- freeze-before-send campaign attachment behavior
- previews generated from managed file versions
## Documents Or Connectors Should Own
- co-editing sessions and editor launch URLs
- comments, suggestions, document tasks, and review state
- check-in/check-out, remote locks, and lock timeouts
- semantic document versions beyond blob versions
- template merge flows and generated-document lifecycle
- external DMS metadata, filing plans, record categories, and retention classes
- provider-specific sync state and conflict resolution
- collaborative presence and notification behavior
## Integration Shape
The documents layer should integrate with files through stable contracts:
- create or import a managed file version for evidence points
- attach source provenance when a file came from an external editor or DMS
- ask files for read/download access rather than importing files internals
- emit workflow/audit events when a collaborative document reaches a governed
state such as reviewed, approved, frozen, sent, or archived
- use connector providers for provider-specific browse/import/write behavior
Files may expose links to a document or connector module, but it should not own
the collaboration state machine. A collaborative document can produce many
working states; GovOPlaN Files should persist the governed snapshots that other
modules can safely use as evidence.
## Practical Rule
If a feature is about storing, sharing, importing, downloading, or freezing a
file, it belongs in `govoplan-files`. If a feature is about people jointly
editing, reviewing, locking, commenting on, or routing a document through a
semantic process, it belongs in a documents/workflow/DMS integration module.

782
docs/FILES_HANDBOOK.md Normal file
View File

@@ -0,0 +1,782 @@
# GovOPlaN Files Handbook
This handbook describes the Files module as implemented in version `0.1.9`.
It is the operational source of truth for users, process owners, administrators,
operators, auditors, and module integrators. Statements about future behavior
are marked **planned**; an unmarked statement describes the current code.
Files is a governed snapshot store. It owns managed file content, versions,
logical folders, shares, source provenance, and the evidence that another
GovOPlaN module used a particular file version. It can browse selected external
stores read-only and import a frozen copy. It is not a general remote filesystem,
a document collaboration engine, or a records-management system.
## Choose a reading path
| If you need to... | Start with... |
| --- | --- |
| Upload, find, organize, download, or import a file | [User tasks](#user-tasks) |
| Design a governed process that uses files | [Process perspective](#process-perspective) |
| Decide who can use a file or connector | [Ownership and access](#ownership-and-access) and [Administration and policy](#administration-and-policy) |
| Operate storage, connectors, backups, or recovery | [Operator runbook](#operator-runbook) |
| Integrate another GovOPlaN module | [Capabilities and integration](#capabilities-and-integration) |
| Review evidence, deletion, or security behavior | [Security, provenance, audit, deletion, and retention](#security-provenance-audit-deletion-and-retention) |
| Verify a release or scenario | [Acceptance scenarios](#acceptance-scenarios) |
| Check whether an idea exists today | [Implemented and planned boundary](#implemented-and-planned-boundary) |
## The service contract
A managed file is a tenant-scoped logical asset with exactly one user or group
owner, a normalized path, and a current version. The current version points to a
blob record containing the storage location, SHA-256 checksum, byte size, and
content type. Current service paths append a version when connector sync finds
changed content; they do not mutate the previous version record.
Content with the same tenant, SHA-256 checksum, and size can reuse one blob.
This is storage deduplication and integrity evidence, not proof of authorship or
source authenticity.
The main domain objects are:
| Object | Meaning | Lifecycle today |
| --- | --- | --- |
| File asset | The user-facing file identity, owner, logical path, description, metadata, and current version | Created, organized, shared, and soft-deleted |
| File version | A numbered snapshot of one asset and its blob | Appended by connector sync when bytes change; retained |
| File blob | Stored bytes plus checksum, size, backend, key, reference count, and optional retention timestamp | Reused within a tenant; no automated garbage collection |
| Folder | An explicit logical path in a user or group space | Created, moved/renamed through organize operations, and soft-deleted |
| Share | A grant from an asset to a user, group, tenant, or campaign with `read`, `write`, or `manage` permission | Created or updated; no public revocation endpoint yet |
| Connector profile | A governed external endpoint, scope, optional credential link, policy, and descriptive capabilities | Created, updated, disabled, or credential-scrubbed on deletion |
| Connector credential | Reusable authentication material, optionally limited to a provider and scope | Encrypted when database-managed; immediately scrubbed on deletion |
| Connector policy | Allow and deny rules inherited from system through tenant to one leaf scope | Evaluated before configuration and connector use |
| Connector space | A read-only, manually synchronized remote folder/library linked to a user or group space | Created, updated, disabled, and soft-deleted |
| Campaign attachment use | Evidence connecting a campaign job or entry to an exact asset, version, blob, checksum, and stage | Retained for campaign execution evidence |
## User tasks
The Files page is available at `/files`. Actions appear only when the current
principal has the required permission and resource access.
The configured Help Center projects these sections as independently authorized
tasks, so upload, ZIP import, organization, download, sharing, and deletion do
not disappear merely because an unrelated permission is absent. It states the
deployment's actual upload limits. External import appears as a task only when
the actor has the required permissions and at least one actor-visible profile
is eligible for the current fail-closed browse/import path; endpoint, path, and
item policy are still enforced when the operation runs.
### Choose a space
Every file user sees **My files**. Group spaces are added for active groups of
which the user is a member. The space list also exposes every tenant group to a
Files administrator; governed API operations can administer other tenant-owned
Files resources when their owner is specified. Linked connector spaces appear
beside managed spaces when they are active and visible to the user.
A connector space is intentionally read-only. It is a view of an approved
remote location and a starting point for importing or synchronizing selected
files into managed storage; it is not a mounted write-through filesystem.
### Upload files
The managed-space UI supports upload and drag-and-drop. A caller chooses the
user or group owner and destination folder. The normal per-file deployment
limit defaults to 50 MiB through `FILE_UPLOAD_MAX_BYTES`.
When a target path already exists, the operation must use one of these conflict
strategies:
- `reject` stops instead of silently replacing content;
- `rename` selects the next available `copy` name;
- `overwrite` soft-deletes the conflicting asset and creates the new asset;
- an item-specific conflict resolution can also `skip` that item.
An ordinary upload does not append a new version to an existing asset. The
`overwrite` strategy retires the old asset at that path. Version-preserving
updates currently belong to connector sync.
### Upload and unpack ZIP files
The UI can unpack a ZIP upload. The request body is spooled to a bounded
temporary file rather than buffered wholly in memory. The defaults are:
- 250 MiB compressed request and total extracted data;
- 50 MiB per extracted member;
- 1,000 non-directory members;
- encrypted archives are rejected;
- member paths are normalized and `..` traversal is rejected;
- the actual bytes read are counted, not only the ZIP header declarations.
The byte limits use `FILE_UPLOAD_ZIP_MAX_BYTES` and
`FILE_UPLOAD_MAX_BYTES`. The 1,000-member limit is currently fixed in the
extraction service.
### Organize files and folders
With `files:file:organize`, a user can:
- create logical folders;
- rename one selection directly;
- preview and apply bulk prefix, suffix, or replacement renames;
- move or copy files and folder trees between accessible user/group spaces;
- resolve target conflicts by rejecting, renaming, overwriting, or skipping;
- use drag-and-drop for move operations in the file explorer.
Copies create new assets and versions while reusing the immutable blob bytes.
Moves keep the asset identity and change its owner/path. All source and target
owners are validated; knowing an identifier does not bypass space membership.
Recursive folder deletion is the default. It soft-deletes the selected folder,
its child folders, and files below it. A non-recursive delete fails when the
folder is not empty.
### Find and download files
Files can list by owner and path, use cursor pagination, and consume incremental
changes through a watermark. The UI supports path/name pattern search and
sorting. The pattern API can resolve campaign-style wildcard selections and can
return unmatched files.
A user with download permission and resource access can download one current
version or create a ZIP archive from a selection. Downloads use an attachment
content disposition. The archive is generated in a temporary file and removed
after the response completes.
There is no dedicated content-preview service in the Files API today. File
responses expose content type, size, checksum, and current version metadata.
### Share files
The API can grant or update a share for a user, group, the tenant, or a campaign.
The supported permissions are `read`, `write`, and `manage`. Ownership remains
unchanged. A write operation accepts a `write` or `manage` share; read/download
accepts any of the three.
The current Files page shows campaign linkage but does not offer a general
user/group share editor. The API also has no share-revocation route yet. Treat
share revocation and a complete share-management UI as planned work.
### Browse and import an external file
With a visible connector profile, a user can browse the allowed remote path,
select one file, and import it into an accessible managed space. The imported
asset records the connector, provider, remote identity/path/URL, selected remote
metadata, and source revision when supplied by the provider.
Manual sync looks for an existing managed asset with the same source identity
inside the chosen owner space:
- no match creates a managed asset;
- identical checksum and size updates provenance and returns `unchanged`;
- changed bytes append a version and return `updated`.
Browse, import, and sync never write, rename, or delete the remote source.
## Process perspective
### Managed ingestion
The managed-file flow is:
1. Core authenticates the principal and evaluates the operation permission.
2. Files validates the tenant, owner, group membership, or applicable share.
3. Files normalizes the logical path and resolves conflicts explicitly.
4. Upload or connector response limits are enforced before content is retained.
5. Files calculates SHA-256 and stores or reuses a tenant blob.
6. Files creates the asset/version records and optional campaign share.
7. The database transaction commits and emits change-sequence entries.
8. Connector-originated operations also emit their connector audit evidence.
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
automatic orphan reconciliation exists yet; operators must account for this in
integrity checks and retention plans.
### Governed connector import
The connector flow separates four concerns:
1. An administrator defines reusable credential material.
2. An administrator defines a scoped endpoint profile that may reference that
credential.
3. System, tenant, and leaf policy sources narrow the allowed profile,
credential, provider, URL, and remote path.
4. A user optionally links an allowed remote root as a user/group connector
space, then browses and imports selected content.
Before each network operation, Files checks profile visibility, connector
policy, endpoint safety, transport support, and response size. A successful
import becomes an independent managed snapshot. Later source changes have no
effect until an explicit sync.
### Campaign attachment evidence
When Campaign uses Files, the integration follows a freeze-before-send model:
1. The campaign refers to managed user/group sources and attachment patterns.
2. Files verifies access and resolves matching managed assets.
3. A prepared campaign snapshot records the exact asset, version, blob,
checksum, size, relative path, and source provenance.
4. Files materializes those bytes for the campaign build without exposing its
database models to Campaign.
5. Campaign job/entry use is recorded and later marked as sent.
Changing the current file after preparation does not change the version already
recorded as campaign evidence. A file response is marked `audit_relevant` once
the asset has a sent campaign attachment-use record.
### Process ownership
| Concern | Owner |
| --- | --- |
| Authentication, tenants, RBAC evaluation, audit service, change sequence, settings, and module lifecycle | GovOPlaN Core |
| Managed assets, blobs, versions, folders, shares, connector baseline, provenance, and campaign attachment evidence | Files |
| Campaign definition, recipient data, message build/send state, and delivery policy | Campaign |
| Remote ACLs, remote source content, and upstream revision semantics | The external provider |
| Storage durability, egress policy, master key, secret environment, backup, and recovery | Deployment operator |
| Collaborative editing, comments, review, locks, and semantic document workflows | A future Documents/workflow/provider module |
## Ownership and access
Files applies both permission checks and resource checks. A broad operation
permission alone does not make another user's file visible.
### Resource access
- A user owns their personal space.
- A group member can use the group's file space.
- A Files administrator can access all Files resources in the active tenant.
- A file share can grant read or write access to a user, group, or the tenant.
- Campaign shares are resolved only in a verified campaign context and do not
become ordinary user shares.
- Folders are owned by a user or group; they are not independently shared.
- Soft-deleted resources are excluded from normal access and listing.
- Tenant identifiers are checked on every managed object lookup.
The `files.access` capability can explain why a principal has access: resource,
owner, administrator scope, or active share. It also explains virtual folders
that exist through child assets even when there is no explicit folder row.
Deleting an organization/access group is vetoed while it owns Files assets,
folders, connector spaces, or is the target of file shares. Reassign or remove
those relationships first.
### Operation permissions
| Permission | Allows |
| --- | --- |
| `files:file:read` | List and inspect accessible files, folders, spaces, and visible connectors |
| `files:file:download` | Download an accessible current version or ZIP archive |
| `files:file:upload` | Upload managed assets and import/sync selected connector files |
| `files:file:organize` | Create folders, rename, move/copy, and manage linked connector spaces |
| `files:file:share` | Create or update file shares |
| `files:file:delete` | Soft-delete accessible writable files and folders |
| `files:file:admin` | Administer all Files spaces and connector settings in the active tenant |
The `file_manager` role template grants all normal file operations except
`files:file:admin`. The `file_viewer` template grants read and download.
System and tenant settings permissions can also authorize the corresponding
connector administration endpoints.
## Administration and policy
### Profiles, credentials, policies, and spaces
Keep these definitions separate:
- a **credential** holds reusable authentication material and may be restricted
to a provider;
- a **profile** holds the endpoint, scope, base path, credential reference,
local policy, and descriptive operation capabilities;
- a **policy** restricts what a scope may configure or use;
- a **connector space** links one approved profile/library/path to one user or
group and always uses manual, read-only synchronization today.
Profile capability values such as `browse`, `import`, and `sync` are stored and
returned, but they are descriptive today. Provider implementation and policy
checks enforce actual availability; do not use the capability list as the sole
security control.
Profiles, credentials, and policies support `system`, `tenant`, `user`,
`group`, and `campaign` scopes. A normal user sees system and active-tenant
profiles plus leaf profiles that match the user, one of their groups, or an
accessible campaign. Disabled definitions are visible only through authorized
administrative reads.
### Policy evaluation
For a leaf scope, the effective source chain is:
```text
system -> tenant -> user | group | campaign
```
Policy fields are:
- connector/profile IDs;
- credential IDs;
- providers;
- external IDs;
- external path prefixes or glob patterns;
- external URL glob patterns.
Rules use `allow` and `deny` objects. Legacy synonyms `allowlist`, `whitelist`,
`denylist`, and `blacklist` are normalized. A matching deny at any source wins.
Every allow field defined by a source must match, so lower sources can narrow an
inherited set. The effective-policy response includes the contributing source
path and applied fields for explanation.
A parent may set `allow_lower_level_limits` for individual fields such as
`allow.providers` or `deny.external_paths`. An explicit `false` prevents a lower
scope from configuring that field. Absence does not lock the field.
Example tenant policy:
```json
{
"policy": {
"allow": {
"providers": ["webdav", "nextcloud"],
"external_urls": ["https://files.example.edu/*"],
"external_paths": ["departments/finance"]
},
"deny": {
"external_paths": ["departments/finance/private"]
},
"allow_lower_level_limits": {
"allow.providers": true,
"deny.external_paths": true
}
}
}
```
Use `POST /api/v1/files/connector-policy/evaluate` for an explainable preflight.
The normal profile browse/import/sync routes perform their own policy checks;
preflight does not replace enforcement.
### Credential rules
Database-created passwords and tokens use Core's Fernet encryption and require
the deployment `MASTER_KEY_B64` outside development/test/local environments.
Secret values, environment variable names, and local CA paths are never returned
in connector profile responses.
API-managed profiles and credentials cannot select process environment
variables, create an external `secret_ref`, or conceal secret-like values in
nested metadata. Deployment-owned JSON/file profiles may use `password_env` or
`token_env` only when the exact environment variable name appears in
`GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`.
Custom CA bundles must be absolute existing files in
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. TLS verification can be disabled only
in a development/test runtime.
### Provider status
| Provider | Current status |
| --- | --- |
| Seafile | Read-only native API browse/download-link import and manual sync implemented using the pinned HTTP transport; WebDAV opt-in supported |
| Nextcloud | Read-only WebDAV browse/import/manual sync implemented using the pinned HTTP transport |
| Generic WebDAV | Read-only browse/import/manual sync implemented using the pinned HTTP transport |
| SMB | Browse/import code and descriptor implemented, but every live connection fails closed until initial connections and DFS referrals can be policy-validated and pinned |
| S3 connector | Browse/import code and descriptor implemented, but every live SDK connection fails closed until botocore connections, redirects, and endpoint discovery can be validated and pinned |
| SharePoint and OneDrive | Provider keys/descriptors reserved; live Microsoft Graph browse/import is planned |
| NFS and local connector | Described as optional future providers; the local managed-storage backend is a different feature |
Provider descriptors are available from
`GET /api/v1/files/connectors/providers`. Use their `implemented`, `installed`,
and support fields for display, but expect a fail-closed transport error where
the table above says live access is disabled.
## Operator runbook
### Storage configuration
| Setting | Default | Purpose |
| --- | --- | --- |
| `FILE_STORAGE_BACKEND` | `local` | Selects `local` or `s3` managed blob storage |
| `FILE_STORAGE_LOCAL_ROOT` | `runtime/files` | Primary local write/read root |
| `FILE_STORAGE_LOCAL_FALLBACK_ROOTS` | empty | Comma-separated older read-only roots checked after the primary root |
| `FILE_STORAGE_S3_ENDPOINT_URL` and related `FILE_STORAGE_S3_*` values | deployment-specific | S3-compatible endpoint, region, credentials, and bucket |
| `FILE_UPLOAD_MAX_BYTES` | 50 MiB | Direct-upload and ZIP-member maximum |
| `FILE_UPLOAD_ZIP_MAX_BYTES` | 250 MiB | ZIP request and extracted-total maximum |
| `MASTER_KEY_B64` | development fallback only | Encrypts database-managed connector secrets |
The local backend is the operational baseline. It resolves every storage key
under the configured root and rejects escape attempts. Fallback roots support a
controlled storage-root migration: new writes go to the primary root while
reads can still find older objects.
The S3 managed-storage adapter currently fails closed before creating a boto3
client because the SDK cannot yet guarantee connection-time DNS/IP pinning and
redirect revalidation. Do not select `FILE_STORAGE_BACKEND=s3` for a live Files
deployment until that boundary is implemented and the status above changes.
Multiple API replicas require the same durable blob namespace. Separate local
container filesystems will produce incomplete reads. Until a pinned shared
object-storage transport is available, use one durable shared mount or constrain
Files traffic to a deployment topology that preserves one consistent local
root.
### Connector egress
Connector access to private networks is a deployment-wide decision:
```text
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true|false
```
Production-like configuration validation requires an explicit value. Public-only
mode rejects any hostname whose DNS answers include a non-public address.
Private-enabled mode still rejects link-local, multicast, unspecified, and
limited-broadcast addresses.
The built-in HTTP transport:
- resolves and validates every connection attempt;
- connects the socket to the exact approved address while retaining the
original hostname for HTTP Host, TLS SNI, and certificate verification;
- does not inherit proxy settings;
- refuses redirects instead of following a new peer implicitly;
- bounds structured responses to 16 MiB and file transfers to 512 MiB by
default.
Override the connector response limits with
`GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` and
`GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES`. The smaller applicable limit wins
when an import is also subject to `FILE_UPLOAD_MAX_BYTES`.
Never work around a pinning failure by adding a raw IP, disabling TLS, or
enabling private networks. SMB may redirect through DFS, and S3 SDKs may perform
their own redirects or endpoint discovery; both remain disabled even for an IP
literal until every connection peer can be governed.
### Backup and restore
The database and blob namespace are one logical backup set. A usable backup must
include:
- Files database rows, including asset/version/blob relationships, shares,
connector settings, and campaign attachment-use evidence;
- every object below `FILE_STORAGE_LOCAL_ROOT` and any still-used fallback root;
- the exact `MASTER_KEY_B64` needed to decrypt retained connector credentials;
- deployment-owned connector profile files, referenced CA bundles, and secret
environment configuration where those definitions are in use.
There is no Files backup/restore API and no automated blob integrity or orphan
reconciliation job. Use a write quiesce or coordinated snapshots so database
references and objects represent the same recovery point.
After restore:
1. Verify the active tenant and module migration state.
2. Verify the storage backend and roots before allowing writes.
3. Verify that the original master key is available before testing connectors.
4. Download representative files and compare bytes with their recorded SHA-256.
5. Test one authorized and one unauthorized owner/share path.
6. Test a permitted pinned HTTP connector, if connectors are configured.
7. Review audit and change-sequence continuity around the recovery point.
An inconsistent restore should fail closed for missing objects or undecryptable
credentials. Do not repair it by deleting evidence rows without an approved,
audited data-recovery decision.
### Disable, uninstall, and retire
Disabling a module preserves its persistent data. Ordinary uninstall is guarded
while Files tables contain persistent rows.
Destructive retirement is separate and irreversible at the application level.
The installer records a database snapshot, then Files scrubs and audits
remaining encrypted connector material before its database tables are dropped.
Legacy external secret references are detached and identified as non-owned;
Files never calls a provider delete operation for them.
The retirement executor drops database tables but does not delete corresponding
objects from the configured blob backend. Operators must include those orphaned
objects in the approved retention/destruction plan. Validate the installer
snapshot and independent blob backup before retirement.
### Operational signals
Use these symptoms as routing hints:
| Symptom | Likely boundary |
| --- | --- |
| `Stored object does not exist` | Database/blob restore mismatch, wrong root, or missing shared storage |
| `Stored secret cannot be decrypted` | Wrong or rotated `MASTER_KEY_B64` |
| Private/non-public endpoint blocked | Deployment-wide egress policy is public-only or DNS returned a forbidden answer |
| SDK cannot pin redirects/referrals | Expected fail-closed S3/SMB boundary, not a transient connector outage |
| Connector response exceeds limit | Remote payload exceeds connector or upload limit |
| Profile is not visible | Scope, disabled state, campaign access, or policy mismatch |
| Group removal is vetoed | The group still owns a file/folder/connector space or is a share target |
## Capabilities and integration
Other modules must integrate through Core contracts, Files capabilities, or the
public HTTP API. They must not import Files ORM models or storage helpers.
### Provided capabilities
| Capability | Purpose |
| --- | --- |
| `files.access` (`0.1.6`) | Explain resource access provenance for managed files, explicit folders, and virtual folders |
| `files.campaign_attachments` (`0.1.6`) | Resolve managed attachment matches, prepare frozen campaign snapshots, annotate built messages, share assets with a campaign, and record/mark exact attachment use |
Files requires Core principal resolution and permission evaluation. Campaign is
an optional dependency; when installed, Files consumes the optional
`campaigns.access` interface to verify campaign existence and access. Missing
optional Campaign support fails explicitly rather than bypassing the check.
### API families
All routes below are under `/api/v1/files`.
| Area | Routes |
| --- | --- |
| Spaces and content | `GET /spaces`, `GET /`, `GET /folders`, `GET /delta` |
| Upload and folders | `POST /upload`, `POST /upload-zip`, `POST /folders`, `POST /folders/delete` |
| File access | `GET /{file_id}`, `GET /{file_id}/download`, `DELETE /{file_id}`, `POST /bulk-delete` |
| Organization | `POST /bulk-rename`, `POST /transfer`, `POST /archive.zip`, `POST /resolve-patterns` |
| Sharing | `POST /{file_id}/shares`, `POST /bulk-shares` |
| Connector spaces | `GET/POST /connector-spaces`, `PATCH/DELETE /connector-spaces/{space_id}` |
| Connector catalog/discovery | `GET /connectors/providers`, `POST /connectors/discover` |
| Connector profiles | `GET/POST /connectors/profiles`, `GET/PATCH/DELETE /connectors/profiles/{profile_id}` |
| Browse/import/sync | `GET /connectors/profiles/{profile_id}/browse`, `POST /connectors/profiles/{profile_id}/import`, `POST /connectors/profiles/{profile_id}/sync` |
| Credentials | `GET/POST /connectors/credentials`, `GET/PATCH/DELETE /connectors/credentials/{credential_id}` |
| Policy | `GET/PUT /connectors/policies/{scope_type}`, `POST /connector-policy/evaluate` |
| Incremental connector settings | `GET /connectors/settings/delta` |
Consumers should use cursor/watermark contracts instead of assuming an
unbounded complete list. The default full-list page size is 500 and public page
sizes are capped at 1,000.
### Integration invariants
An integrating module should:
- ask Files/Core for access rather than trusting a submitted file ID;
- retain an exact version/blob/checksum at every governed evidence point;
- import external content before using it in a campaign, report, workflow, or
generated document;
- preserve Files provenance when producing a derived managed snapshot;
- treat source provenance as captured context, not cryptographic attestation;
- avoid writing remote providers through the baseline connector layer;
- define a separate capability when it needs behavior beyond managed snapshots;
- tolerate Files being absent when the integration is declared optional.
Future Postbox, Templates/Reports, BI, Documents, DMS, and workflow modules
should keep their own domain state and use Files for governed input/output
snapshots. Long-running provider sync, OAuth, remote mutation, and
provider-specific health belong in connector modules rather than expanding the
Files baseline indiscriminately.
## Security, provenance, audit, deletion, and retention
### Security controls implemented
- Core authentication, tenant scoping, CSRF/API handling, and RBAC protect the
public routes.
- Owner and share checks protect individual resources after operation-scope
checks.
- Logical paths reject traversal and storage keys cannot escape the local root.
- Upload, ZIP extraction, connector response, and S3 stream code use bounded
reads; encrypted ZIP archives are rejected.
- Connector HTTP sockets use connection-time DNS/IP validation and pinning,
redirects are refused, and unsafe SDK transports fail before client creation.
- Database-managed connector passwords/tokens are encrypted; responses redact
secrets and deployment references.
- API metadata is recursively checked for secret-like values.
- Downloads use sanitized attachment filenames.
- SHA-256 and byte size are recorded for every blob/version.
The module does **not** currently provide malware scanning, content disarm and
reconstruction, a file-type allowlist, per-user quota, at-rest encryption for
local blob bytes, or a dedicated preview sandbox. Deployments that require
these controls must supply them outside Files until explicit module contracts
exist.
### Provenance
Connector-originated assets can retain:
- source type;
- connector/profile ID and provider;
- external ID, path, and URL;
- revision and revision label;
- observation/import timestamps when supplied;
- selected provider metadata.
The normalized provenance and source revision are returned in file responses,
carried into campaign attachment matches, and included in connector audit
events. External metadata is provider/user input and is not a digital signature.
### Audit and change evidence
Files records canonical audit events for:
- connector discovery attempts, before the attempted external I/O;
- connector imports and manual syncs;
- download/archive access to connector-originated managed files;
- immediate connector profile and credential deletion/scrubbing;
- credential scrubbing during destructive module retirement.
Connector audit details include the managed asset/version/blob, checksum, size,
operation, source revision, and provenance where applicable. Deletion audit
details name secret/reference kinds but never the secret values.
Assets, folders, shares, profiles, credentials, policies, and connector spaces
also feed Core's incremental change sequence for UI synchronization. A change
entry is not equivalent to a canonical audit event. Ordinary local upload,
rename, share, and soft-delete operations do not yet all emit dedicated Files
audit events.
Campaign attachment-use records provide separate domain evidence for an exact
file version used in campaign preparation and delivery.
### Deletion semantics
The word "delete" has different meanings by object type:
| Object | Current delete behavior |
| --- | --- |
| File asset | Sets `deleted_at`; content, versions, blob references, and campaign evidence remain |
| Folder | Sets `deleted_at`; recursive deletion also soft-deletes descendants |
| Connector space | Sets `deleted_at` and disables the space |
| Connector credential | Immediately disables the tombstone and clears username, encrypted password/token, environment references, legacy external reference, mode, and private metadata; dependent profiles are disabled and detached |
| Connector profile | Immediately disables the tombstone and clears credential links/material/references and private metadata |
| Legacy `secret_ref` | Detached and audited as an unowned external reference; no provider deletion is attempted or claimed |
| Module retirement | Scrubs/audits credential material, then drops Files database tables; blob-backend cleanup is an operator responsibility |
Credential/profile scrubbing and its audit event use the same database
transaction. If audit creation fails, the deletion rolls back. Repeating a
delete against an already scrubbed tombstone does not recreate secret evidence.
### Retention boundary
File versions and blobs are effectively retained indefinitely today. Although a
blob has `ref_count` and `retained_until` fields, no complete retention-policy,
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
automated orphan-object reconciliation.
Do not promise erasure, timed retention, legal hold, or self-service recovery
from the current soft-delete behavior. Those require an explicit, auditable
retention/purge design that preserves campaign and other evidence references.
## Acceptance scenarios
These scenarios describe expected behavior at the current boundary.
### Personal and group ownership
Given Alice has `files:file:upload` and belongs to Finance, when she uploads to
her personal or Finance space, then the asset has the selected owner and tenant.
Given Bob is outside Finance and has no share or Files admin permission, the
same asset ID must not make the asset readable to Bob.
### Safe ZIP import
Given a ZIP member contains `../../secret.txt`, is encrypted, exceeds 50 MiB,
or pushes actual extracted bytes above 250 MiB, unpacking must fail without a
committed managed asset. A valid archive must preserve normalized relative paths
below the chosen logical folder.
### Explicit conflicts
Given a target path already exists, `reject` must leave it unchanged, `rename`
must choose a non-conflicting path, and `overwrite` must soft-delete the old
asset before creating the replacement. A per-item `skip` must not create that
item.
### Policy-denied connector
Given the tenant permits WebDAV only below `departments/finance` and denies its
`private` child, a browse/import/sync request below the denied child must return
an explainable policy denial. Directly invoking the import endpoint must not
bypass the same rule.
### Pinned connector transport
Given public-only mode and DNS returns any private address, the connection must
be rejected before a socket opens. Given private mode, the HTTP connection must
still use the validated address and reject redirects. SMB and S3 must fail
before their SDK clients connect until all SDK-managed peers can be pinned.
### Imported evidence and sync
Given a permitted WebDAV file is imported, its managed response and audit event
must carry source identity, revision when available, current version, checksum,
and size. Re-syncing identical bytes must return `unchanged`; changed bytes must
create a higher version while preserving the previous version.
### Campaign freeze
Given a campaign snapshot selected version V1, when the managed asset later
advances to V2, the prepared/sent attachment-use evidence must still identify
V1 and its original blob/checksum.
### Immediate credential deletion
Given a database credential contains an encrypted password and is referenced by
two profiles, deleting it must scrub the credential, disable/detach both
profiles, record non-secret audit evidence, and publish connector-setting
changes in one transaction. If audit creation fails, no part of the deletion
may commit.
### Recovery
Given a coordinated database/blob backup and the original master key, restoring
it must allow representative downloads whose bytes match recorded SHA-256
values. A missing blob or wrong key must surface an error rather than silently
returning different content or credentials.
## Implemented and planned 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)) |
| 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 |
| 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)) |
| 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 |
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |
| SMB and S3 connectors | Provider descriptors and browse/import logic | Live access only after SDK connections plus DFS referrals/redirects are validated and pinned ([SMB #35](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/35), [S3 #34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)) |
| Other providers | Reserved SharePoint/OneDrive keys and NFS/local descriptors | Graph/OAuth/provider paging, NFS deployment integration, DMS connectors |
| Connector spaces | User/group link, browse, manual selected-file sync, edit/disable/delete | Background sync, remote writes/deletes, full conflict-reporting jobs |
| Profile capabilities | Stored and displayed | Enforce capability flags as an independent operation gate |
| Audit | Connector discovery/import/sync/access and connector deletion; campaign exact-use evidence | Dedicated canonical audit events for every ordinary Files mutation |
| Preview | File metadata and attachment download | Dedicated safe content-preview service |
| Campaign | Stable capability-based frozen attachments and sent-use evidence | Campaign-specific process state remains in Campaign |
| Collaboration | Governed input/output snapshots | Co-editing, comments, review, presence, locks, and semantic document versions belong to Documents/workflow/provider modules |
## Release and change checklist
Before releasing Files:
1. Keep `pyproject.toml`, root `package.json`, WebUI `package.json`, and the
module manifest version aligned. Version alignment is a release gate.
2. Run the Files Python tests and security/static-analysis gates from the
GovOPlaN meta repository.
3. Build/type-check the WebUI through the Core host application.
4. Verify database migrations on an upgrade copy and a clean database.
5. Exercise an allowed and denied owner/share path.
6. Exercise upload, ZIP bounds, conflict handling, download, and soft deletion.
7. Exercise connector policy explanation and one pinned HTTP provider where
configured; verify S3/SMB still fail closed.
8. Verify credential deletion scrubs dependents and produces audit evidence.
9. Verify a campaign attachment snapshot still identifies its exact version and
checksum after the current file changes.
10. Update the implemented/planned table whenever a boundary changes.
## Related documents
- [Repository overview](../README.md)
- [Connector ownership boundary](CONNECTOR_BOUNDARY.md)
- [Connector spaces design and implementation history](CONNECTOR_SPACES.md)
- [Document collaboration boundary](DOCUMENT_COLLABORATION_BOUNDARY.md)
Where an older planning section conflicts with current code or this handbook's
implemented/planned table, verify the code and update both documents in the same
reviewable documentation slice.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.1", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,8 +19,8 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.1", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",

View File

@@ -4,14 +4,24 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-files" name = "govoplan-files"
version = "0.1.1" version = "0.1.9"
description = "GovOPlaN files module with backend and WebUI integration." description = "GovOPlaN files module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.1", "govoplan-core>=0.1.9",
"defusedxml>=0.7,<1",
"python-multipart>=0.0.31,<1",
]
[project.optional-dependencies]
s3 = [
"boto3>=1.34,<2",
]
smb = [
"smbprotocol>=1.13",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

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

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import event
from sqlalchemy.orm import Session as OrmSession
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
has_attr_changes,
object_state,
operation_for_soft_deletable,
previous_value,
)
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare, new_uuid
FILES_MODULE_ID = "files"
FILES_ASSETS_COLLECTION = "files.assets"
FILES_FOLDERS_COLLECTION = "files.folders"
FILES_CONNECTOR_PROFILES_COLLECTION = "files.connector_profiles"
FILES_CONNECTOR_CREDENTIALS_COLLECTION = "files.connector_credentials"
FILES_CONNECTOR_POLICIES_COLLECTION = "files.connector_policies"
FILES_CONNECTOR_SPACES_COLLECTION = "files.connector_spaces"
_REGISTERED = False
def register_files_change_tracking() -> None:
global _REGISTERED
if _REGISTERED:
return
event.listen(OrmSession, "before_flush", _record_files_changes)
_REGISTERED = True
def _record_files_changes(session: OrmSession, _flush_context: object, _instances: object) -> None:
for obj in tuple(session.new) + tuple(session.dirty):
if isinstance(obj, FileAsset):
_record_asset_change(session, obj)
elif isinstance(obj, FileFolder):
_record_folder_change(session, obj)
elif isinstance(obj, FileShare):
_record_share_visibility_change(session, obj)
def _record_asset_change(session: OrmSession, asset: FileAsset) -> None:
operation = operation_for_soft_deletable(
asset,
changed_attrs=(
"owner_type",
"owner_user_id",
"owner_group_id",
"current_version_id",
"display_path",
"filename",
"description",
"deleted_at",
"metadata_",
),
)
if operation is None:
return
resource_id = _ensure_id(asset)
record_change(
session,
module_id=FILES_MODULE_ID,
collection=FILES_ASSETS_COLLECTION,
resource_type="file",
resource_id=resource_id,
operation=operation,
tenant_id=asset.tenant_id,
actor_type="user" if asset.created_by_user_id else None,
actor_id=asset.created_by_user_id,
payload={
"owner_type": asset.owner_type,
"owner_id": _owner_id(asset.owner_type, asset.owner_user_id, asset.owner_group_id),
"path": asset.display_path,
"previous_path": previous_value(asset, "display_path"),
"filename": asset.filename,
"deleted_at": _isoformat(asset.deleted_at),
},
)
def _record_folder_change(session: OrmSession, folder: FileFolder) -> None:
operation = operation_for_soft_deletable(
folder,
changed_attrs=("owner_type", "owner_user_id", "owner_group_id", "path", "deleted_at", "metadata_"),
)
if operation is None:
return
resource_id = _ensure_id(folder)
record_change(
session,
module_id=FILES_MODULE_ID,
collection=FILES_FOLDERS_COLLECTION,
resource_type="folder",
resource_id=resource_id,
operation=operation,
tenant_id=folder.tenant_id,
actor_type="user" if folder.created_by_user_id else None,
actor_id=folder.created_by_user_id,
payload={
"owner_type": folder.owner_type,
"owner_id": _owner_id(folder.owner_type, folder.owner_user_id, folder.owner_group_id),
"path": folder.path,
"previous_path": previous_value(folder, "path"),
"deleted_at": _isoformat(folder.deleted_at),
},
)
def _record_share_visibility_change(session: OrmSession, share: FileShare) -> None:
state = object_state(share)
if not share.file_asset_id:
return
if not state.pending and not has_attr_changes(
state,
("file_asset_id", "target_type", "target_id", "permission", "revoked_at"),
):
return
_ensure_id(share)
record_change(
session,
module_id=FILES_MODULE_ID,
collection=FILES_ASSETS_COLLECTION,
resource_type="file",
resource_id=share.file_asset_id,
operation="updated",
tenant_id=share.tenant_id,
actor_type="user" if share.created_by_user_id else None,
actor_id=share.created_by_user_id,
payload={
"share_id": share.id,
"share_target_type": share.target_type,
"share_target_id": share.target_id,
"share_permission": share.permission,
"share_revoked_at": _isoformat(share.revoked_at),
},
)
def _ensure_id(obj: object) -> str:
return ensure_object_id(obj, new_uuid)
def _owner_id(owner_type: str, owner_user_id: str | None, owner_group_id: str | None) -> str | None:
if owner_type == "user":
return owner_user_id
if owner_type == "group":
return owner_group_id
return None
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value else None
__all__ = [
"FILES_ASSETS_COLLECTION",
"FILES_CONNECTOR_CREDENTIALS_COLLECTION",
"FILES_CONNECTOR_POLICIES_COLLECTION",
"FILES_CONNECTOR_PROFILES_COLLECTION",
"FILES_CONNECTOR_SPACES_COLLECTION",
"FILES_FOLDERS_COLLECTION",
"FILES_MODULE_ID",
"register_files_change_tracking",
]

View File

@@ -1 +1,25 @@
from govoplan_files.backend.db.models import * from govoplan_files.backend.db.models import (
CampaignAttachmentUse,
FileAsset,
FileBlob,
FileConnectorCredential,
FileConnectorPolicy,
FileConnectorProfile,
FileConnectorSpace,
FileFolder,
FileShare,
FileVersion,
)
__all__ = [
"CampaignAttachmentUse",
"FileAsset",
"FileBlob",
"FileConnectorCredential",
"FileConnectorPolicy",
"FileConnectorProfile",
"FileConnectorSpace",
"FileFolder",
"FileShare",
"FileVersion",
]

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.db.base import Base, TimestampMixin
@@ -19,7 +19,7 @@ class FileBlob(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),) __table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
storage_backend: Mapped[str] = mapped_column(String(50), nullable=False) storage_backend: Mapped[str] = mapped_column(String(50), nullable=False)
storage_bucket: Mapped[str | None] = mapped_column(String(255)) storage_bucket: Mapped[str | None] = mapped_column(String(255))
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
@@ -50,12 +50,12 @@ class FileFolder(Base, TimestampMixin):
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("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)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@@ -64,15 +64,15 @@ class FileAsset(Base, TimestampMixin):
__tablename__ = "file_assets" __tablename__ = "file_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True) filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("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)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@@ -82,7 +82,7 @@ class FileVersion(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),) __table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True)
blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False) version_number: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -91,7 +91,7 @@ class FileVersion(Base, TimestampMixin):
content_type: Mapped[str | None] = mapped_column(String(255)) content_type: Mapped[str | None] = mapped_column(String(255))
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("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)
class FileShare(Base, TimestampMixin): class FileShare(Base, TimestampMixin):
@@ -99,21 +99,131 @@ class FileShare(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),) __table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
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("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)
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)
class FileConnectorProfile(Base, TimestampMixin):
__tablename__ = "file_connector_profiles"
__table_args__ = (
Index("ix_file_connector_profiles_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
endpoint_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
credential_profile_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
credential_mode: Mapped[str] = mapped_column(String(30), default="none", nullable=False)
username: Mapped[str | None] = mapped_column(String(320), nullable=True)
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
password_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
token_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
secret_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
capabilities: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
policy: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class FileConnectorCredential(Base, TimestampMixin):
__tablename__ = "file_connector_credentials"
__table_args__ = (
Index("ix_file_connector_credentials_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
provider: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
credential_mode: Mapped[str] = mapped_column(String(30), default="none", nullable=False)
username: Mapped[str | None] = mapped_column(String(320), nullable=True)
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
password_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
token_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
secret_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
policy: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class FileConnectorPolicy(Base, TimestampMixin):
__tablename__ = "file_connector_policies"
__table_args__ = (
UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_file_connector_policies_scope"),
Index("ix_file_connector_policies_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class FileConnectorSpace(Base, TimestampMixin):
__tablename__ = "file_connector_spaces"
__table_args__ = (
Index("ix_file_connector_spaces_owner", "tenant_id", "owner_type", "owner_user_id", "owner_group_id"),
Index(
"uq_file_connector_spaces_active_user_label",
"tenant_id", "owner_user_id", "label",
unique=True,
sqlite_where=text("owner_type = 'user' AND deleted_at IS NULL"),
postgresql_where=text("owner_type = 'user' AND deleted_at IS NULL"),
),
Index(
"uq_file_connector_spaces_active_group_label",
"tenant_id", "owner_group_id", "label",
unique=True,
sqlite_where=text("owner_type = 'group' AND deleted_at IS NULL"),
postgresql_where=text("owner_type = 'group' AND deleted_at IS NULL"),
),
)
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)
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
label: Mapped[str] = mapped_column(String(255), nullable=False)
connector_profile_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
library_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
remote_path: Mapped[str] = mapped_column(String(1000), default="", nullable=False)
sync_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
read_only: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
class CampaignAttachmentUse(Base, TimestampMixin): class CampaignAttachmentUse(Base, TimestampMixin):
__tablename__ = "campaign_attachment_uses" __tablename__ = "campaign_attachment_uses"
__table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),) __table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True) campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
@@ -134,6 +244,10 @@ __all__ = [
"CampaignAttachmentUse", "CampaignAttachmentUse",
"FileAsset", "FileAsset",
"FileBlob", "FileBlob",
"FileConnectorCredential",
"FileConnectorPolicy",
"FileConnectorProfile",
"FileConnectorSpace",
"FileFolder", "FileFolder",
"FileShare", "FileShare",
"FileVersion", "FileVersion",

View File

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

View File

@@ -1,10 +1,84 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from sqlalchemy import inspect
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_files.backend.change_tracking import register_files_change_tracking
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
from govoplan_files.backend.documentation import documentation_topics
register_files_change_tracking()
_files_table_retirement_provider = drop_table_retirement_provider(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.FileConnectorCredential,
file_models.FileConnectorPolicy,
file_models.FileConnectorProfile,
file_models.FileConnectorSpace,
file_models.CampaignAttachmentUse,
label="Files",
)
def _files_retirement_provider(session: object | None, module_id: str):
plan = _files_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Files credential retirement.")
live_inspector = inspect(execute_session.get_bind())
if any(
live_inspector.has_table(table_name)
for table_name in (
file_models.FileConnectorCredential.__tablename__,
file_models.FileConnectorProfile.__tablename__,
)
):
from govoplan_files.backend.storage.connector_credential_deletion import (
delete_connector_credentials_for_retirement,
)
delete_connector_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Files-owned encrypted connector credentials are scrubbed and audited immediately before tables are dropped; legacy non-owned external references are detached without claiming provider-side deletion.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -26,7 +100,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 revoke managed file shares."), _permission("files:file:share", "Share files", "Grant or update managed file shares; revocation is not available yet."),
_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."),
) )
@@ -54,10 +128,40 @@ ROLE_TEMPLATES = (
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_files.backend.db.models import FileAsset, FileConnectorCredential, FileConnectorPolicy, FileConnectorProfile, FileConnectorSpace
return {
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count(),
"connector_credentials": session.query(FileConnectorCredential).filter(FileConnectorCredential.tenant_id == tenant_id).count(),
"connector_policies": session.query(FileConnectorPolicy).filter(FileConnectorPolicy.tenant_id == tenant_id).count(),
"connector_profiles": session.query(FileConnectorProfile).filter(FileConnectorProfile.tenant_id == tenant_id).count(),
"connector_spaces": session.query(FileConnectorSpace).filter(FileConnectorSpace.tenant_id == tenant_id).count(),
}
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
from govoplan_files.backend.db.models import FileAsset, FileConnectorSpace, FileFolder, FileShare
owned_asset_count = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_group_id == group_id).count()
owned_folder_count = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_group_id == group_id).count()
owned_connector_space_count = session.query(FileConnectorSpace).filter(
FileConnectorSpace.tenant_id == tenant_id,
FileConnectorSpace.owner_group_id == group_id,
).count()
shared_asset_count = session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.target_type == "group",
FileShare.target_id == group_id,
).count()
if owned_asset_count or owned_folder_count or owned_connector_space_count or shared_asset_count:
raise ValueError("Cannot remove the group while it owns files, folders, connector spaces or file shares.")
def _files_router(context: ModuleContext): def _files_router(context: ModuleContext):
from govoplan_files.backend.runtime import configure_runtime from govoplan_files.backend.runtime import configure_runtime
configure_runtime(settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
from govoplan_files.backend.router import router from govoplan_files.backend.router import router
return router return router
@@ -66,26 +170,498 @@ def _files_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="files", id="files",
name="Files", name="Files",
version="1.0.0", version="0.1.9",
dependencies=("access",), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=(), optional_dependencies=("campaigns",),
provides_interfaces=(
ModuleInterfaceProvider(name="files.access", version="0.1.6"),
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="campaigns.access",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_files_router, route_factory=_files_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
delete_veto_providers={"group": (_veto_group_delete,)},
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),), nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
frontend=FrontendModule( frontend=FrontendModule(
module_id="files", module_id="files",
package_name="@govoplan/files-webui", package_name="@govoplan/files-webui",
routes=(
FrontendRoute(
path="/files",
component="FilesPage",
required_any=("files:file:read",),
order=40,
),
),
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),), nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
view_surfaces=(
ViewSurface(id="files.admin.system-connectors", module_id="files", kind="section", label="System file connections", order=75),
ViewSurface(id="files.admin.tenant-connectors", module_id="files", kind="section", label="Tenant file connections", order=65),
ViewSurface(id="files.admin.group-connectors", module_id="files", kind="section", label="Group file connections", order=65),
ViewSurface(id="files.admin.user-connectors", module_id="files", kind="section", label="User file connections", order=65),
ViewSurface(id="files.settings.connectors", module_id="files", kind="section", label="Personal file connections", order=20),
),
), ),
documentation=(
DocumentationTopic(
id="files.workflow.organize-managed-files",
title="Organize managed files and folders",
summary="Create folders and rename, move, or copy accessible managed content with explicit conflict handling.",
body=(
"Organization stays inside governed personal or group spaces. Moves preserve the asset identity, while copies create new assets and versions that reuse immutable blob bytes. "
"Every target conflict must be rejected, renamed, overwritten, or skipped explicitly."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=42,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=("files:file:read", "files:file:organize"),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Move or copy API", href="/api/v1/files/transfer", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
unlocks=("Managed content can be placed at stable logical paths without bypassing space access.",),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [
"You may view and organize managed files.",
"You have write or owner access to every source item and destination space used by the operation; the global organize permission alone does not grant resource access.",
],
"steps": [
"Open Files and select the personal or group space to organize.",
"Create the required destination folders or select the files and folders to rename, move, or copy.",
"Choose the destination and resolve each target conflict explicitly.",
"Apply the operation and reopen the destination folder.",
],
"outcome": "The selected content has the intended governed owner and logical path.",
"verification": "Confirm each resulting path and owner; for a move, also confirm the old path is gone, and for a copy, confirm the source remains.",
"related_topic_ids": [
"files.workflow.upload-managed-files",
"files.workflow.find-and-download-files",
"files.workflow.delete-managed-files",
],
},
),
DocumentationTopic(
id="files.workflow.find-and-download-files",
title="Find and download managed files",
summary="Search accessible managed content and download a current file version or a ZIP archive of a selection.",
body=(
"Files can be sorted and searched by logical path or name pattern. A download always uses the accessible current version; a multi-file selection can be generated as a temporary ZIP archive. "
"There is no dedicated content-preview service yet."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=("files:file:read", "files:file:download"),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
unlocks=("Authorized users can retrieve governed current versions without gaining organization or sharing authority.",),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": ["You may view and download managed files in the relevant space."],
"steps": [
"Open Files and select the relevant personal or group space.",
"Navigate folders, sort the list, or use a path/name pattern to find the intended content.",
"Review the displayed owner, path, size, checksum, and version details.",
"Download one current file version, or select several files and choose Download ZIP.",
],
"outcome": "The authorized current file bytes or generated archive are downloaded to the local device.",
"verification": "Confirm the downloaded names and, where integrity matters, compare the file bytes with the displayed checksum.",
"related_topic_ids": [
"files.workflow.organize-managed-files",
"files.reference.snapshot-provenance-and-capabilities",
],
},
),
DocumentationTopic(
id="files.workflow.share-managed-files",
title="Grant or update access to managed files",
summary="Grant or update read, write, or manage access through a supporting workflow or API client without changing ownership.",
body=(
"The Files service can grant or update a share for a user, group, tenant, or campaign. The Files page does not yet provide a general share editor, and the API has no share-revocation route. "
"Do not use this task when access must later be revoked until that explicit capability is implemented."
),
layer="available",
documentation_types=("user",),
audience=("file_manager", "process_participant"),
order=44,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=("files:file:read", "files:file:share"),
),
),
links=(
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="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns",),
unlocks=("A supporting process can grant governed file access without changing file ownership.",),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [
"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.",
"A supporting workflow or API client is available; the Files page has no general share editor yet.",
"The process does not require share revocation, because no revocation route exists yet.",
],
"steps": [
"Open Files and verify the file owner, logical path, current version, and checksum.",
"Through the supporting workflow, identify the intended user, group, tenant, or campaign and choose read, write, or manage access.",
"Grant or update the share without changing file ownership.",
"Have the intended recipient verify access and an unrelated account verify denial.",
],
"limitations": [
"The Files page has no general share editor.",
"Shares can be granted or updated, but not revoked through the current API.",
],
"outcome": "The requested access is granted or updated while the managed asset keeps its owner.",
"verification": "Test one intended and one denied access path. Do not claim that the share can later be revoked.",
"related_topic_ids": [
"files.workflow.find-and-download-files",
"files.assurance.process-and-release-readiness",
],
},
),
DocumentationTopic(
id="files.workflow.delete-managed-files",
title="Delete managed files and folders",
summary="Soft-delete accessible managed files or a folder tree where current policy allows it.",
body=(
"Deletion hides the selected managed assets rather than hard-purging their stored evidence. Folder deletion is recursive by default and includes child folders and files; a non-recursive request fails for a non-empty folder. "
"There is no self-service restore or hard-purge workflow today."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=45,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=("files:file:read", "files:file:delete"),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
unlocks=("Authorized users can remove obsolete content from active file views while preserving the current soft-delete boundary.",),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [
"You may view and delete the selected managed content and have write or owner access to every affected asset or folder.",
"You have reviewed the complete folder tree when deleting recursively.",
],
"steps": [
"Open Files and select the files or folder to delete.",
"Review the selection and, for a folder, all content below it.",
"Confirm the delete action.",
"Refresh or reopen the space and verify that the selected paths are no longer active.",
],
"limitations": [
"Deletion is soft deletion, not a hard purge.",
"There is no self-service restore or hard-purge workflow.",
],
"outcome": "The selected content is hidden from active Files views under the current soft-delete model.",
"verification": "Confirm the deleted paths no longer appear in the active space; do not treat the action as physical erasure.",
"related_topic_ids": [
"files.workflow.organize-managed-files",
"files.assurance.process-and-release-readiness",
],
},
),
DocumentationTopic(
id="files.governed-connectors-and-provenance",
title="Govern file connections and credential deletion",
summary="Keep endpoint profiles, reusable credentials, and inherited connector policy separate, and understand what DELETE removes immediately.",
body=(
"System, tenant, and one user/group/campaign leaf form the effective policy chain: deny rules win and every configured allow rule must match. "
"Responses redact secret values and deployment references. Deleting a database-managed credential or profile immediately scrubs Files-owned encrypted material and private metadata in the same transaction as a non-secret audit event; dependent profiles are disabled, while legacy non-owned references are only detached and audited."
),
layer="configured",
documentation_types=("admin",),
audience=("file_admin", "tenant_admin", "system_admin", "security_auditor"),
order=50,
conditions=(
DocumentationCondition(
required_modules=("files",),
any_scopes=(
"files:file:admin",
"admin:settings:read",
"admin:settings:write",
"system:settings:read",
"system:settings:write",
),
),
),
links=(
DocumentationLink(label="Tenant connector administration", href="/admin?section=tenant-file-connectors", kind="runtime"),
DocumentationLink(label="System connector administration", href="/admin?section=system-file-connectors", kind="runtime"),
DocumentationLink(label="Tenant connector policy API", href="/api/v1/files/connectors/policies/tenant", kind="api"),
DocumentationLink(label="Connector credentials API", href="/api/v1/files/connectors/credentials", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
related_modules=("access", "audit", "mail"),
unlocks=("Scoped, explainable external-file access without exposing credentials to consuming modules.",),
configuration_keys=(
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
"GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
"MASTER_KEY_B64",
),
metadata={
"kind": "reference",
"route": "/admin?section=tenant-file-connectors",
"screen": "File connections",
"section": "Profiles, credentials, and effective connector policy",
"security_invariants": [
"New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.",
"Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.",
"Legacy non-owned external references are detached and audited, never sent to an arbitrary provider delete operation.",
],
"related_topic_ids": [
"files.workflow.import-managed-snapshot",
"files.reference.integrity-recovery-and-fail-closed-transports",
"mail.profiles-and-policy",
],
},
),
DocumentationTopic(
id="files.reference.integrity-recovery-and-fail-closed-transports",
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.",
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. "
"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",
documentation_types=("admin",),
audience=("operator", "system_admin", "security_auditor"),
order=51,
conditions=(
DocumentationCondition(
required_modules=("files",),
any_scopes=(
"files:file:admin",
"admin:settings:read",
"system:settings:read",
"system:audit:read",
),
),
),
links=(
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="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
related_modules=("audit", "ops"),
unlocks=("Recoverable managed-file evidence without weakening outbound peer validation.",),
configuration_keys=(
"FILE_STORAGE_BACKEND",
"FILE_STORAGE_LOCAL_ROOT",
"FILE_STORAGE_LOCAL_FALLBACK_ROOTS",
"FILE_UPLOAD_MAX_BYTES",
"FILE_UPLOAD_ZIP_MAX_BYTES",
"MASTER_KEY_B64",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
),
metadata={
"kind": "reference",
"route": "/admin?section=system-file-connectors",
"screen": "System file connections and deployment operations",
"section": "Storage integrity, backup/recovery, and fail-closed transports",
"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.",
"related_topic_ids": [
"files.governed-connectors-and-provenance",
"files.reference.snapshot-provenance-and-capabilities",
],
},
),
DocumentationTopic(
id="files.reference.snapshot-provenance-and-capabilities",
title="Integrate through managed snapshots and Files capabilities",
summary="Other modules consume stable Files capabilities or HTTP contracts and retain exact version evidence instead of importing Files internals.",
body=(
"Use files.access to explain resource access and files.campaign_attachments to freeze campaign inputs at an exact asset, version, blob, checksum, and source revision. "
"Import external content before a governed use, preserve provenance on derived snapshots, and keep collaboration, provider sync, OAuth, remote mutation, and domain workflow state in their owning modules."
),
layer="available",
documentation_types=("admin", "user"),
audience=("module_integrator", "file_admin", "campaign_admin", "process_designer"),
order=52,
conditions=(
DocumentationCondition(
required_modules=("files",),
any_scopes=("files:file:read", "files:file:upload", "files:file:admin"),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Files API", href="/api/v1/files", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns", "docs"),
unlocks=("Campaign, report, template, workflow, and document modules can exchange governed input/output snapshots.",),
metadata={
"kind": "reference",
"route": "/files",
"screen": "Files and module integration",
"section": "Managed snapshot provenance and capability boundaries",
"provided_interfaces": ["files.access@0.1.6", "files.campaign_attachments@0.1.6"],
"provenance_fields": ["connector_id", "provider", "external_id", "external_path", "revision", "metadata"],
"related_topic_ids": [
"files.workflow.import-managed-snapshot",
"files.governed-connectors-and-provenance",
"files.reference.integrity-recovery-and-fail-closed-transports",
],
},
),
DocumentationTopic(
id="files.assurance.process-and-release-readiness",
title="Assure a Files-backed process and release",
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=(
"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."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("process_owner", "release_manager", "file_admin", "operator", "security_auditor"),
order=53,
conditions=(
DocumentationCondition(
required_modules=("files",),
any_scopes=(
"files:file:read",
"files:file:admin",
"admin:settings:read",
"system:settings:read",
"system:audit:read",
),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Files API", href="/api/v1/files", kind="api"),
DocumentationLink(label="Connector provider status", href="/api/v1/files/connectors/providers", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns", "audit", "ops"),
unlocks=("Process owners and release managers can approve Files use against explicit controls, evidence, and known gaps.",),
configuration_keys=(
"FILE_STORAGE_BACKEND",
"FILE_STORAGE_LOCAL_ROOT",
"FILE_UPLOAD_MAX_BYTES",
"FILE_UPLOAD_ZIP_MAX_BYTES",
"MASTER_KEY_B64",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files process and release assurance",
"help_contexts": ["files.list"],
"prerequisites": [
"A named process owner has defined the intended users, data classification, retention expectations, and integrations.",
"A candidate release is installed on a clean database and an upgrade copy with aligned Python, root package, WebUI package, and module-manifest versions.",
"Representative permitted and denied accounts, bounded test files, and a coordinated database/blob/key recovery set are available.",
],
"steps": [
"Compare the process requirements with the handbook's implemented/planned boundary and record every unsupported requirement or compensating control.",
"Confirm version alignment, apply migrations on clean and upgrade databases, and run the repository plus meta-repository security and static-analysis gates.",
"Exercise allowed and denied personal, group, and share access with representative accounts.",
"Exercise bounded upload and ZIP handling, every required conflict strategy, organization, download, and soft deletion.",
"Where connectors are configured, verify policy explanation and one pinned HTTP provider; verify live S3 and SMB access still fails closed.",
"Restore a coordinated database/blob/key backup and compare representative downloaded bytes with their recorded SHA-256 checksums.",
"Record the tested versions, results, known limitations, evidence locations, residual risks, owner, and approval decision.",
],
"outcome": "The process or release has an explicit approval record tied to aligned versions, representative evidence, known limitations, and owned residual risks.",
"verification": "A reviewer can reproduce the recorded allow/deny, integrity, connector, and recovery checks and can trace each unmet requirement to a documented limitation or accepted compensating control.",
"related_topic_ids": [
"files.workflow.upload-managed-files",
"files.workflow.upload-and-unpack-zip",
"files.workflow.organize-managed-files",
"files.workflow.find-and-download-files",
"files.workflow.share-managed-files",
"files.workflow.delete-managed-files",
"files.workflow.import-managed-snapshot",
"files.governed-connectors-and-provenance",
"files.reference.integrity-recovery-and-fail-closed-transports",
"files.reference.snapshot-provenance-and-capabilities",
],
},
),
),
documentation_providers=(documentation_topics,),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="files", module_id="files",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=_files_retirement_provider,
retirement_notes="Destructive retirement drops files-owned database tables after the installer captures a database snapshot.",
), ),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.FileConnectorCredential,
file_models.FileConnectorPolicy,
file_models.FileConnectorProfile,
file_models.FileConnectorSpace,
file_models.CampaignAttachmentUse,
label="Files",
),
),
capability_factories={
CAPABILITY_FILES_ACCESS: lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["access_capability"]).access_capability(context),
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
) )
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest

View File

@@ -0,0 +1,119 @@
"""file connector spaces
Revision ID: 4f5a6b7c8d9e
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-08 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4f5a6b7c8d9e"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
scope_fk_target = _scope_fk_target(tables)
if "file_connector_spaces" not in tables:
op.create_table(
"file_connector_spaces",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("owner_type", sa.String(length=20), nullable=False),
sa.Column("owner_user_id", sa.String(length=36), nullable=True),
sa.Column("owner_group_id", sa.String(length=36), nullable=True),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("connector_profile_id", sa.String(length=255), nullable=False),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("library_id", sa.String(length=255), nullable=True),
sa.Column("remote_path", sa.String(length=1000), nullable=False),
sa.Column("sync_mode", sa.String(length=30), nullable=False),
sa.Column("read_only", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), 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_connector_spaces_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], name=op.f("fk_file_connector_spaces_owner_group_id_groups"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], name=op.f("fk_file_connector_spaces_owner_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_spaces_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_spaces")),
)
inspector = sa.inspect(op.get_bind())
indexes = {item["name"] for item in inspector.get_indexes("file_connector_spaces")}
for column in (
"tenant_id",
"owner_type",
"owner_user_id",
"owner_group_id",
"connector_profile_id",
"provider",
"is_active",
"created_by_user_id",
"deleted_at",
):
name = op.f(f"ix_file_connector_spaces_{column}")
if name not in indexes:
op.create_index(name, "file_connector_spaces", [column], unique=False)
if "ix_file_connector_spaces_owner" not in indexes:
op.create_index(
"ix_file_connector_spaces_owner",
"file_connector_spaces",
["tenant_id", "owner_type", "owner_user_id", "owner_group_id"],
unique=False,
)
if "uq_file_connector_spaces_active_user_label" not in indexes:
op.create_index(
"uq_file_connector_spaces_active_user_label",
"file_connector_spaces",
["tenant_id", "owner_user_id", "label"],
unique=True,
sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"),
postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"),
)
if "uq_file_connector_spaces_active_group_label" not in indexes:
op.create_index(
"uq_file_connector_spaces_active_group_label",
"file_connector_spaces",
["tenant_id", "owner_group_id", "label"],
unique=True,
sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"),
postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "file_connector_spaces" not in inspector.get_table_names():
return
indexes = {item["name"] for item in inspector.get_indexes("file_connector_spaces")}
for name in (
"uq_file_connector_spaces_active_group_label",
"uq_file_connector_spaces_active_user_label",
"ix_file_connector_spaces_owner",
op.f("ix_file_connector_spaces_deleted_at"),
op.f("ix_file_connector_spaces_created_by_user_id"),
op.f("ix_file_connector_spaces_is_active"),
op.f("ix_file_connector_spaces_provider"),
op.f("ix_file_connector_spaces_connector_profile_id"),
op.f("ix_file_connector_spaces_owner_group_id"),
op.f("ix_file_connector_spaces_owner_user_id"),
op.f("ix_file_connector_spaces_owner_type"),
op.f("ix_file_connector_spaces_tenant_id"),
):
if name in indexes:
op.drop_index(name, table_name="file_connector_spaces")
op.drop_table("file_connector_spaces")

View File

@@ -0,0 +1,94 @@
"""file connector profiles
Revision ID: 5a6b7c8d9e0f
Revises: 4f5a6b7c8d9e
Create Date: 2026-07-08 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5a6b7c8d9e0f"
down_revision = "4f5a6b7c8d9e"
branch_labels = None
depends_on = None
def _scope_fk_target(inspector) -> str:
tables = set(inspector.get_table_names())
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
scope_fk_target = _scope_fk_target(inspector)
if "file_connector_profiles" not in inspector.get_table_names():
op.create_table(
"file_connector_profiles",
sa.Column("id", sa.String(length=255), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("endpoint_url", sa.String(length=1000), nullable=True),
sa.Column("base_path", sa.String(length=1000), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("credential_mode", sa.String(length=30), nullable=False),
sa.Column("username", sa.String(length=320), nullable=True),
sa.Column("password_encrypted", sa.Text(), nullable=True),
sa.Column("token_encrypted", sa.Text(), nullable=True),
sa.Column("password_env", sa.String(length=255), nullable=True),
sa.Column("token_env", sa.String(length=255), nullable=True),
sa.Column("secret_ref", sa.String(length=1000), nullable=True),
sa.Column("capabilities", sa.JSON(), nullable=True),
sa.Column("policy", sa.JSON(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_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(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_profiles_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_profiles_tenant_id_scopes"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_profiles_updated_by_user_id_users"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_profiles")),
)
inspector = sa.inspect(op.get_bind())
indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
for column in (
"tenant_id",
"scope_type",
"scope_id",
"provider",
"enabled",
"created_by_user_id",
"updated_by_user_id",
):
name = op.f(f"ix_file_connector_profiles_{column}")
if name not in indexes:
op.create_index(name, "file_connector_profiles", [column], unique=False)
if "ix_file_connector_profiles_scope" not in indexes:
op.create_index("ix_file_connector_profiles_scope", "file_connector_profiles", ["scope_type", "scope_id"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "file_connector_profiles" not in inspector.get_table_names():
return
indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
for name in (
"ix_file_connector_profiles_scope",
op.f("ix_file_connector_profiles_updated_by_user_id"),
op.f("ix_file_connector_profiles_created_by_user_id"),
op.f("ix_file_connector_profiles_enabled"),
op.f("ix_file_connector_profiles_provider"),
op.f("ix_file_connector_profiles_scope_id"),
op.f("ix_file_connector_profiles_scope_type"),
op.f("ix_file_connector_profiles_tenant_id"),
):
if name in indexes:
op.drop_index(name, table_name="file_connector_profiles")
op.drop_table("file_connector_profiles")

View File

@@ -0,0 +1,111 @@
"""file connector credentials
Revision ID: 6b7c8d9e0f1a
Revises: 5a6b7c8d9e0f
Create Date: 2026-07-08 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "6b7c8d9e0f1a"
down_revision = "5a6b7c8d9e0f"
branch_labels = None
depends_on = None
def _scope_fk_target(table_names: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in table_names else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
table_names = set(inspector.get_table_names())
scope_fk_target = _scope_fk_target(table_names)
if "file_connector_profiles" in table_names:
columns = {item["name"] for item in inspector.get_columns("file_connector_profiles")}
if "credential_profile_id" not in columns:
op.add_column("file_connector_profiles", sa.Column("credential_profile_id", sa.String(length=255), nullable=True))
indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
index_name = op.f("ix_file_connector_profiles_credential_profile_id")
if index_name not in indexes:
op.create_index(index_name, "file_connector_profiles", ["credential_profile_id"], unique=False)
if "file_connector_credentials" not in table_names:
op.create_table(
"file_connector_credentials",
sa.Column("id", sa.String(length=255), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("provider", sa.String(length=50), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("credential_mode", sa.String(length=30), nullable=False),
sa.Column("username", sa.String(length=320), nullable=True),
sa.Column("password_encrypted", sa.Text(), nullable=True),
sa.Column("token_encrypted", sa.Text(), nullable=True),
sa.Column("password_env", sa.String(length=255), nullable=True),
sa.Column("token_env", sa.String(length=255), nullable=True),
sa.Column("secret_ref", sa.String(length=1000), nullable=True),
sa.Column("policy", sa.JSON(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_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(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_credentials_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_credentials_tenant_id_scopes"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_credentials_updated_by_user_id_users"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_credentials")),
)
inspector = sa.inspect(op.get_bind())
indexes = {item["name"] for item in inspector.get_indexes("file_connector_credentials")}
for column in (
"tenant_id",
"scope_type",
"scope_id",
"provider",
"enabled",
"created_by_user_id",
"updated_by_user_id",
):
name = op.f(f"ix_file_connector_credentials_{column}")
if name not in indexes:
op.create_index(name, "file_connector_credentials", [column], unique=False)
if "ix_file_connector_credentials_scope" not in indexes:
op.create_index("ix_file_connector_credentials_scope", "file_connector_credentials", ["scope_type", "scope_id"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "file_connector_credentials" in inspector.get_table_names():
indexes = {item["name"] for item in inspector.get_indexes("file_connector_credentials")}
for name in (
"ix_file_connector_credentials_scope",
op.f("ix_file_connector_credentials_updated_by_user_id"),
op.f("ix_file_connector_credentials_created_by_user_id"),
op.f("ix_file_connector_credentials_enabled"),
op.f("ix_file_connector_credentials_provider"),
op.f("ix_file_connector_credentials_scope_id"),
op.f("ix_file_connector_credentials_scope_type"),
op.f("ix_file_connector_credentials_tenant_id"),
):
if name in indexes:
op.drop_index(name, table_name="file_connector_credentials")
op.drop_table("file_connector_credentials")
inspector = sa.inspect(op.get_bind())
if "file_connector_profiles" not in inspector.get_table_names():
return
profile_indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
profile_index_name = op.f("ix_file_connector_profiles_credential_profile_id")
if profile_index_name in profile_indexes:
op.drop_index(profile_index_name, table_name="file_connector_profiles")
profile_columns = {item["name"] for item in inspector.get_columns("file_connector_profiles")}
if "credential_profile_id" in profile_columns:
op.drop_column("file_connector_profiles", "credential_profile_id")

View File

@@ -0,0 +1,77 @@
"""file connector policies
Revision ID: a7b8c9d0e1f3
Revises: 6b7c8d9e0f1a
Create Date: 2026-07-08 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a7b8c9d0e1f3"
down_revision = "6b7c8d9e0f1a"
branch_labels = None
depends_on = None
def _scope_fk_target(inspector) -> str:
tables = set(inspector.get_table_names())
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
scope_fk_target = _scope_fk_target(inspector)
if "file_connector_policies" not in inspector.get_table_names():
op.create_table(
"file_connector_policies",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("policy", sa.JSON(), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_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(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_policies_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_policies_tenant_id_scopes"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_policies_updated_by_user_id_users"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_policies")),
sa.UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_file_connector_policies_scope"),
)
inspector = sa.inspect(op.get_bind())
indexes = {item["name"] for item in inspector.get_indexes("file_connector_policies")}
for column in (
"tenant_id",
"scope_type",
"scope_id",
"created_by_user_id",
"updated_by_user_id",
):
name = op.f(f"ix_file_connector_policies_{column}")
if name not in indexes:
op.create_index(name, "file_connector_policies", [column], unique=False)
if "ix_file_connector_policies_scope" not in indexes:
op.create_index("ix_file_connector_policies_scope", "file_connector_policies", ["scope_type", "scope_id"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "file_connector_policies" not in inspector.get_table_names():
return
indexes = {item["name"] for item in inspector.get_indexes("file_connector_policies")}
for name in (
"ix_file_connector_policies_scope",
op.f("ix_file_connector_policies_updated_by_user_id"),
op.f("ix_file_connector_policies_created_by_user_id"),
op.f("ix_file_connector_policies_scope_id"),
op.f("ix_file_connector_policies_scope_type"),
op.f("ix_file_connector_policies_tenant_id"),
):
if name in indexes:
op.drop_index(name, table_name="file_connector_policies")
op.drop_table("file_connector_policies")

View File

@@ -0,0 +1,158 @@
"""v0.1.7 files baseline
Revision ID: a7b8c9d0e1f3
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = 'a7b8c9d0e1f3'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('file_connector_credentials',
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('credential_mode', sa.String(length=30), nullable=False),
sa.Column('username', sa.String(length=320), nullable=True),
sa.Column('password_encrypted', sa.Text(), nullable=True),
sa.Column('token_encrypted', sa.Text(), nullable=True),
sa.Column('password_env', sa.String(length=255), nullable=True),
sa.Column('token_env', sa.String(length=255), nullable=True),
sa.Column('secret_ref', sa.String(length=1000), nullable=True),
sa.Column('policy', sa.JSON(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_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(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_credentials_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_credentials_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_credentials_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_credentials'))
)
op.create_index(op.f('ix_file_connector_credentials_created_by_user_id'), 'file_connector_credentials', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_enabled'), 'file_connector_credentials', ['enabled'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_provider'), 'file_connector_credentials', ['provider'], unique=False)
op.create_index('ix_file_connector_credentials_scope', 'file_connector_credentials', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_scope_id'), 'file_connector_credentials', ['scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_scope_type'), 'file_connector_credentials', ['scope_type'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_tenant_id'), 'file_connector_credentials', ['tenant_id'], unique=False)
op.create_index(op.f('ix_file_connector_credentials_updated_by_user_id'), 'file_connector_credentials', ['updated_by_user_id'], unique=False)
op.create_table('file_connector_policies',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('policy', sa.JSON(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_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(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_policies_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_policies_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_policies_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_policies')),
sa.UniqueConstraint('tenant_id', 'scope_type', 'scope_id', name='uq_file_connector_policies_scope')
)
op.create_index(op.f('ix_file_connector_policies_created_by_user_id'), 'file_connector_policies', ['created_by_user_id'], unique=False)
op.create_index('ix_file_connector_policies_scope', 'file_connector_policies', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_policies_scope_id'), 'file_connector_policies', ['scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_policies_scope_type'), 'file_connector_policies', ['scope_type'], unique=False)
op.create_index(op.f('ix_file_connector_policies_tenant_id'), 'file_connector_policies', ['tenant_id'], unique=False)
op.create_index(op.f('ix_file_connector_policies_updated_by_user_id'), 'file_connector_policies', ['updated_by_user_id'], unique=False)
op.create_table('file_connector_profiles',
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('endpoint_url', sa.String(length=1000), nullable=True),
sa.Column('base_path', sa.String(length=1000), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('credential_profile_id', sa.String(length=255), nullable=True),
sa.Column('credential_mode', sa.String(length=30), nullable=False),
sa.Column('username', sa.String(length=320), nullable=True),
sa.Column('password_encrypted', sa.Text(), nullable=True),
sa.Column('token_encrypted', sa.Text(), nullable=True),
sa.Column('password_env', sa.String(length=255), nullable=True),
sa.Column('token_env', sa.String(length=255), nullable=True),
sa.Column('secret_ref', sa.String(length=1000), nullable=True),
sa.Column('capabilities', sa.JSON(), nullable=True),
sa.Column('policy', sa.JSON(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_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(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_profiles_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_profiles_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_profiles_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_profiles'))
)
op.create_index(op.f('ix_file_connector_profiles_created_by_user_id'), 'file_connector_profiles', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_credential_profile_id'), 'file_connector_profiles', ['credential_profile_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_enabled'), 'file_connector_profiles', ['enabled'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_provider'), 'file_connector_profiles', ['provider'], unique=False)
op.create_index('ix_file_connector_profiles_scope', 'file_connector_profiles', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_scope_id'), 'file_connector_profiles', ['scope_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_scope_type'), 'file_connector_profiles', ['scope_type'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_tenant_id'), 'file_connector_profiles', ['tenant_id'], unique=False)
op.create_index(op.f('ix_file_connector_profiles_updated_by_user_id'), 'file_connector_profiles', ['updated_by_user_id'], unique=False)
op.create_table('file_connector_spaces',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('owner_type', sa.String(length=20), nullable=False),
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
sa.Column('owner_group_id', sa.String(length=36), nullable=True),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('connector_profile_id', sa.String(length=255), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('library_id', sa.String(length=255), nullable=True),
sa.Column('remote_path', sa.String(length=1000), nullable=False),
sa.Column('sync_mode', sa.String(length=30), nullable=False),
sa.Column('read_only', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), 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_connector_spaces_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['owner_group_id'], ['access_groups.id'], name=op.f('fk_file_connector_spaces_owner_group_id_access_groups'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_file_connector_spaces_owner_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_spaces_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_spaces'))
)
op.create_index(op.f('ix_file_connector_spaces_connector_profile_id'), 'file_connector_spaces', ['connector_profile_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_created_by_user_id'), 'file_connector_spaces', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_deleted_at'), 'file_connector_spaces', ['deleted_at'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_is_active'), 'file_connector_spaces', ['is_active'], unique=False)
op.create_index('ix_file_connector_spaces_owner', 'file_connector_spaces', ['tenant_id', 'owner_type', 'owner_user_id', 'owner_group_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_owner_group_id'), 'file_connector_spaces', ['owner_group_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_owner_type'), 'file_connector_spaces', ['owner_type'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_owner_user_id'), 'file_connector_spaces', ['owner_user_id'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_provider'), 'file_connector_spaces', ['provider'], unique=False)
op.create_index(op.f('ix_file_connector_spaces_tenant_id'), 'file_connector_spaces', ['tenant_id'], unique=False)
op.create_index('uq_file_connector_spaces_active_group_label', 'file_connector_spaces', ['tenant_id', 'owner_group_id', 'label'], unique=True, sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"))
op.create_index('uq_file_connector_spaces_active_user_label', 'file_connector_spaces', ['tenant_id', 'owner_user_id', 'label'], unique=True, sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"))
def downgrade() -> None:
op.drop_table('file_connector_spaces')
op.drop_table('file_connector_profiles')
op.drop_table('file_connector_policies')
op.drop_table('file_connector_credentials')

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_settings: object | None = None _runtime = ModuleRuntimeState("Files")
configure_runtime = _runtime.configure_runtime
def configure_runtime(*, settings: object | None = None) -> None: get_registry = _runtime.get_registry
global _runtime_settings get_settings = _runtime.get_settings
if settings is not None: settings = _runtime.settings
_runtime_settings = settings
def get_settings() -> object:
if _runtime_settings is not None:
return _runtime_settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError("GovOPlaN Files runtime settings are not configured") from exc
return legacy_settings
class SettingsProxy:
def __getattr__(self, name: str) -> Any:
return getattr(get_settings(), name)
settings = SettingsProxy()

View File

@@ -4,6 +4,7 @@ from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_files.backend.storage.common import FileConflictResolution from govoplan_files.backend.storage.common import FileConflictResolution
@@ -13,12 +14,63 @@ class FileSpaceResponse(BaseModel):
owner_type: Literal["user", "group"] owner_type: Literal["user", "group"]
owner_id: str owner_id: str
description: str | None = None description: str | None = None
space_type: Literal["managed", "connector"] = "managed"
connector_space_id: str | None = None
connector_profile_id: str | None = None
provider: str | None = None
library_id: str | None = None
remote_path: str | None = None
sync_mode: str | None = None
read_only: bool = False
class FileSpacesResponse(BaseModel): class FileSpacesResponse(BaseModel):
spaces: list[FileSpaceResponse] spaces: list[FileSpaceResponse]
class FileConnectorSpaceCreateRequest(BaseModel):
owner_type: Literal["user", "group"] = "user"
owner_id: str | None = None
label: str
connector_profile_id: str
library_id: str | None = None
remote_path: str = ""
sync_mode: Literal["manual"] = "manual"
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorSpaceUpdateRequest(BaseModel):
label: str | None = None
library_id: str | None = None
remote_path: str | None = None
sync_mode: Literal["manual"] | None = None
is_active: bool | None = None
metadata: dict[str, Any] | None = None
class FileConnectorSpaceResponse(BaseModel):
id: str
tenant_id: str
owner_type: Literal["user", "group"]
owner_id: str
label: str
connector_profile_id: str
provider: str
library_id: str | None = None
remote_path: str = ""
sync_mode: str
read_only: bool
is_active: bool
created_at: str
updated_at: str
deleted_at: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorSpacesResponse(BaseModel):
spaces: list[FileConnectorSpaceResponse] = Field(default_factory=list)
class FileShareResponse(BaseModel): class FileShareResponse(BaseModel):
id: str id: str
target_type: str target_type: str
@@ -28,6 +80,20 @@ class FileShareResponse(BaseModel):
revoked_at: str | None = None revoked_at: str | None = None
class FileSourceProvenance(BaseModel):
source_type: str | None = None
connector_id: str | None = None
provider: str | None = None
external_id: str | None = None
external_path: str | None = None
external_url: str | None = None
revision: str | None = None
revision_label: str | None = None
observed_at: str | None = None
imported_at: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class FileAssetResponse(BaseModel): class FileAssetResponse(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
@@ -45,6 +111,8 @@ class FileAssetResponse(BaseModel):
deleted_at: str | None = None deleted_at: str | None = None
audit_relevant: bool = False audit_relevant: bool = False
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
source_provenance: FileSourceProvenance | None = None
source_revision: str | None = None
shares: list[FileShareResponse] = Field(default_factory=list) shares: list[FileShareResponse] = Field(default_factory=list)
@@ -61,6 +129,9 @@ class FileFolderResponse(BaseModel):
class FileFoldersResponse(BaseModel): class FileFoldersResponse(BaseModel):
folders: list[FileFolderResponse] folders: list[FileFolderResponse]
cursor: str | None = None
next_cursor: str | None = None
watermark: str | None = None
class FileFolderCreateRequest(BaseModel): class FileFolderCreateRequest(BaseModel):
@@ -83,12 +154,282 @@ class FileFolderDeleteResponse(BaseModel):
class FileListResponse(BaseModel): class FileListResponse(BaseModel):
files: list[FileAssetResponse] files: list[FileAssetResponse]
cursor: str | None = None
next_cursor: str | None = None
watermark: str | None = None
class FileDeltaResponse(BaseModel):
files: list[FileAssetResponse] = Field(default_factory=list)
folders: list[FileFolderResponse] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class FileUploadResponse(BaseModel): class FileUploadResponse(BaseModel):
files: list[FileAssetResponse] files: list[FileAssetResponse]
class FileConnectorPolicySource(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "system"
scope_id: str | None = None
label: str | None = None
policy: dict[str, Any] = Field(default_factory=dict)
class FileConnectorPolicyEvaluateRequest(BaseModel):
source_provenance: FileSourceProvenance
operation: str = "access"
policy_sources: list[FileConnectorPolicySource] = Field(default_factory=list)
class FileConnectorPolicyEvaluateResponse(BaseModel):
decision: dict[str, Any]
class FileConnectorPolicyUpdateRequest(BaseModel):
policy: dict[str, Any] = Field(default_factory=dict)
class FileConnectorPolicyStepResponse(BaseModel):
scope_type: str
scope_id: str | None = None
path: str
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
class FileConnectorPolicyResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None
policy: dict[str, Any] = Field(default_factory=dict)
effective_policy: dict[str, Any] | None = None
parent_policy: dict[str, Any] | None = None
effective_policy_sources: list[FileConnectorPolicyStepResponse] = Field(default_factory=list)
parent_policy_sources: list[FileConnectorPolicyStepResponse] = Field(default_factory=list)
class FileConnectorProfileResponse(BaseModel):
id: str
label: str
provider: str
endpoint_url: str | None = None
base_path: str | None = None
enabled: bool = True
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "system"
scope_id: str | None = None
source_path: str
credential_profile_id: str | None = None
credential_profile_label: str | None = None
credential_mode: str = "none"
credential_source: str | None = None
credentials_configured: bool = False
username: str | None = None
capabilities: list[str] = Field(default_factory=list)
policy_sources: list[FileConnectorPolicySource] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
source_kind: str = "settings"
class FileConnectorProfilesResponse(BaseModel):
profiles: list[FileConnectorProfileResponse]
class FileConnectorCredentialResponse(BaseModel):
id: str
label: str
provider: str | None = None
enabled: bool = True
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "system"
scope_id: str | None = None
source_path: str
credential_mode: str = "none"
credential_secret_source: str | None = None
credentials_configured: bool = False
username: str | None = None
policy_sources: list[FileConnectorPolicySource] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
source_kind: str = "database"
class FileConnectorCredentialsResponse(BaseModel):
credentials: list[FileConnectorCredentialResponse]
class FileConnectorSettingsDeltaResponse(BaseModel):
profiles: list[FileConnectorProfileResponse] = Field(default_factory=list)
credentials: list[FileConnectorCredentialResponse] = Field(default_factory=list)
spaces: list[FileConnectorSpaceResponse] = Field(default_factory=list)
policy: FileConnectorPolicyResponse | None = None
changed_sections: list[str] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class FileConnectorProfileCredentialsRequest(BaseModel):
username: str | None = None
password: str | None = None
token: str | None = None
password_env: str | None = None
token_env: str | None = None
secret_ref: str | None = None
class FileConnectorDiscoveryCandidate(BaseModel):
endpoint_url: str
status: Literal["failed", "usable", "found", "credentials_rejected"]
message: str
class FileConnectorDiscoveryRequest(BaseModel):
provider: Literal["seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"]
endpoint_url: str
base_path: str | None = None
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] = "none"
credentials: FileConnectorProfileCredentialsRequest = Field(default_factory=FileConnectorProfileCredentialsRequest)
metadata: dict[str, Any] = Field(default_factory=dict)
require_valid_credentials: bool = False
class FileConnectorDiscoveryResponse(BaseModel):
provider: str
endpoint_url: str | None = None
base_path: str | None = None
status: Literal["usable", "found", "credentials_rejected", "not_found", "unsupported"]
message: str
candidates: list[FileConnectorDiscoveryCandidate] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorProfileCreateRequest(BaseModel):
id: str
label: str
provider: Literal["seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"]
endpoint_url: str | None = None
base_path: str | None = None
enabled: bool = True
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "tenant"
scope_id: str | None = None
credential_profile_id: str | None = None
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] = "none"
credentials: FileConnectorProfileCredentialsRequest = Field(default_factory=FileConnectorProfileCredentialsRequest)
capabilities: list[str] = Field(default_factory=lambda: ["browse", "import", "sync"])
policy: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorProfileUpdateRequest(BaseModel):
label: str | None = None
provider: Literal["seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"] | None = None
endpoint_url: str | None = None
base_path: str | None = None
enabled: bool | None = None
credential_profile_id: str | None = None
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] | None = None
credentials: FileConnectorProfileCredentialsRequest | None = None
clear_password: bool = False
clear_token: bool = False
capabilities: list[str] | None = None
policy: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class FileConnectorCredentialCreateRequest(BaseModel):
id: str
label: str
provider: Literal["seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"] | None = None
enabled: bool = True
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "tenant"
scope_id: str | None = None
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] = "none"
credentials: FileConnectorProfileCredentialsRequest = Field(default_factory=FileConnectorProfileCredentialsRequest)
policy: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorCredentialUpdateRequest(BaseModel):
label: str | None = None
provider: Literal["seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"] | None = None
enabled: bool | None = None
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] | None = None
credentials: FileConnectorProfileCredentialsRequest | None = None
clear_password: bool = False
clear_token: bool = False
policy: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class FileConnectorProviderResponse(BaseModel):
provider: str
label: str
protocol: str
implemented: bool
installed: bool
browse_supported: bool
import_supported: bool
optional_dependency: str | None = None
permission_model: str
sync_strategy: str
conflict_strategy: str
preview_strategy: str
audit_events: list[str] = Field(default_factory=list)
notes: str | None = None
class FileConnectorProvidersResponse(BaseModel):
providers: list[FileConnectorProviderResponse]
class FileConnectorBrowseItem(BaseModel):
kind: Literal["library", "folder", "file"]
name: str
path: str
external_id: str | None = None
external_url: str | None = None
size_bytes: int | None = None
content_type: str | None = None
modified_at: str | None = None
etag: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorBrowseResponse(BaseModel):
profile_id: str
provider: str
path: str = ""
library_id: str | None = None
read_only: bool = True
next_continuation_token: str | None = None
has_more: bool = False
decision: dict[str, Any]
items: list[FileConnectorBrowseItem]
class FileConnectorImportRequest(BaseModel):
library_id: str
path: str
owner_type: Literal["user", "group"] = "user"
owner_id: str | None = None
target_folder: str | None = None
target_path: str | None = None
campaign_id: str | None = None
conflict_strategy: Literal["reject", "overwrite", "rename"] = "reject"
source_revision: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class FileConnectorSyncResponse(BaseModel):
file: FileAssetResponse
action: Literal["created", "updated", "unchanged"]
previous_version_id: str | None = None
current_version_id: str
class BulkDeleteRequest(BaseModel): class BulkDeleteRequest(BaseModel):
file_ids: list[str] file_ids: list[str]
@@ -113,11 +454,23 @@ class FileShareRequest(BaseModel):
permission: Literal["read", "write", "manage"] = "read" permission: Literal["read", "write", "manage"] = "read"
class BulkFileShareRequest(BaseModel):
file_ids: list[str] = Field(default_factory=list, max_length=1000)
target_type: Literal["user", "group", "campaign", "tenant"]
target_id: str
permission: Literal["read", "write", "manage"] = "read"
class BulkFileShareResponse(BaseModel):
shares: list[FileShareResponse]
shared_count: int
class RenameRequest(BaseModel): class RenameRequest(BaseModel):
file_ids: list[str] = Field(default_factory=list) file_ids: list[str] = Field(default_factory=list)
folder_paths: list[str] = Field(default_factory=list) folder_paths: list[str] = Field(default_factory=list)
owner_type: Literal["user", "group"] | None = None owner_type: Literal["user", "group"]
owner_id: str | None = None owner_id: str
mode: Literal["direct", "prefix", "suffix", "replace"] mode: Literal["direct", "prefix", "suffix", "replace"]
new_name: str | None = None new_name: str | None = None
find: str | None = None find: str | None = None

View File

@@ -2,33 +2,50 @@ from __future__ import annotations
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, UserGroupMembership from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
)
from govoplan_core.core.runtime import get_registry
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
def _access_directory() -> AccessDirectory:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
raise FileStorageError("Access directory capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise FileStorageError("Access directory capability is invalid")
return capability
def group_refs_for_ids(*, tenant_id: str, group_ids: list[str]) -> list[GroupRef]:
if not group_ids:
return []
groups = _access_directory().get_groups(group_ids)
return sorted(
[group for group in groups.values() if group.tenant_id == tenant_id],
key=lambda group: group.name.casefold(),
)
def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]: def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]:
del session
directory = _access_directory()
if include_admin_groups: if include_admin_groups:
return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()] return [group.id for group in directory.groups_for_tenant(tenant_id)]
return [ return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
row.group_id
for row in session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id)
.all()
]
def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None: def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None:
group = session.get(Group, group_id) group = _access_directory().get_group(group_id)
if not group or group.tenant_id != tenant_id: if not group or group.tenant_id != tenant_id:
raise FileStorageError("Group not found") raise FileStorageError("Group not found")
if is_admin: if is_admin:
return return
membership = ( if group_id not in user_group_ids(session, tenant_id=tenant_id, user_id=user_id):
session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id)
.one_or_none()
)
if membership is None:
raise FileStorageError("No access to this group file space") raise FileStorageError("No access to this group file space")
@@ -42,3 +59,21 @@ def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, ow
ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin) ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin)
return return
raise FileStorageError("Files must be owned by a user or group") raise FileStorageError("Files must be owned by a user or group")
def ensure_share_target_exists(*, tenant_id: str, target_type: str, target_id: str) -> None:
target_type = target_type.lower().strip()
if target_type == "user":
user = _access_directory().get_user(target_id)
if not user or user.tenant_id != tenant_id or user.status != "active":
raise FileStorageError("User not found")
return
if target_type == "group":
group = _access_directory().get_group(target_id)
if not group or group.tenant_id != tenant_id or group.status != "active":
raise FileStorageError("Group not found")
return
if target_type == "tenant":
if target_id != tenant_id:
raise FileStorageError("Tenant not found")
return

View File

@@ -3,19 +3,21 @@ from __future__ import annotations
import mimetypes import mimetypes
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from os import PathLike
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Any, Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset from govoplan_files.backend.db.models import FileAsset
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.files import create_file_asset, current_version_and_blob from govoplan_files.backend.storage.files import create_file_asset, current_versions_and_blobs
from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path
_ZIP_READ_CHUNK_SIZE = 1024 * 1024 _ZIP_READ_CHUNK_SIZE = 1024 * 1024
ZIP_UPLOAD_MAX_FILES = 1000
def _read_zip_member( def _read_zip_member(
@@ -45,9 +47,11 @@ def _read_zip_member(
def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None: def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None:
backend = get_storage_backend() backend = get_storage_backend()
asset_list = list(assets)
version_blobs = current_versions_and_blobs(session, asset_list)
with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for asset in assets: for asset in asset_list:
_version, blob = current_version_and_blob(session, asset) _version, blob = version_blobs[asset.id]
info = zipfile.ZipInfo(asset.display_path) info = zipfile.ZipInfo(asset.display_path)
info.compress_type = zipfile.ZIP_DEFLATED info.compress_type = zipfile.ZIP_DEFLATED
with archive.open(info, "w") as member: with archive.open(info, "w") as member:
@@ -66,13 +70,14 @@ def extract_zip_upload(
owner_type: str, owner_type: str,
owner_id: str, owner_id: str,
user_id: str, user_id: str,
zip_data: bytes, zip_data: bytes | str | PathLike[str],
folder: str | None, folder: str | None,
campaign_id: str | None, campaign_id: str | None,
conflict_strategy: str = "reject", conflict_strategy: str = "reject",
conflict_resolutions: Iterable[FileConflictResolution] | None = None, conflict_resolutions: Iterable[FileConflictResolution] | None = None,
metadata: dict[str, Any] | None = None,
is_admin: bool = False, is_admin: bool = False,
max_files: int = 1000, max_files: int = ZIP_UPLOAD_MAX_FILES,
max_file_bytes: int = 50 * 1024 * 1024, max_file_bytes: int = 50 * 1024 * 1024,
max_total_bytes: int = 250 * 1024 * 1024, max_total_bytes: int = 250 * 1024 * 1024,
) -> list[UploadedStoredFile]: ) -> list[UploadedStoredFile]:
@@ -80,7 +85,8 @@ def extract_zip_upload(
total = 0 total = 0
base_folder = normalize_folder(folder) base_folder = normalize_folder(folder)
try: try:
with zipfile.ZipFile(BytesIO(zip_data)) as archive: source = BytesIO(zip_data) if isinstance(zip_data, bytes) else zip_data
with zipfile.ZipFile(source) as archive:
infos = [info for info in archive.infolist() if not info.is_dir()] infos = [info for info in archive.infolist() if not info.is_dir()]
if len(infos) > max_files: if len(infos) > max_files:
raise FileStorageError(f"ZIP contains too many files (limit {max_files})") raise FileStorageError(f"ZIP contains too many files (limit {max_files})")
@@ -113,6 +119,7 @@ def extract_zip_upload(
data=data, data=data,
display_path=target_path, display_path=target_path,
content_type=mimetypes.guess_type(inner_path)[0] or "application/octet-stream", content_type=mimetypes.guess_type(inner_path)[0] or "application/octet-stream",
metadata=metadata,
campaign_id=campaign_id, campaign_id=campaign_id,
conflict_strategy=conflict_strategy, conflict_strategy=conflict_strategy,
conflict_resolutions=conflict_resolutions, conflict_resolutions=conflict_resolutions,

View File

@@ -4,7 +4,11 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Iterable, Protocol from typing import Iterable, Protocol
import boto3 from govoplan_core.security.outbound_http import (
OutboundHttpError,
response_limit,
validate_unpinned_sdk_http_url,
)
from govoplan_files.backend.runtime import settings from govoplan_files.backend.runtime import settings
@@ -94,15 +98,29 @@ class S3StorageBackend:
@property @property
def client(self): def client(self):
try:
endpoint_url = validate_unpinned_sdk_http_url(
self.endpoint_url,
label="File storage S3 endpoint",
)
except OutboundHttpError as exc:
raise StorageBackendError(str(exc)) from exc
try:
import boto3
except ModuleNotFoundError as exc:
raise StorageBackendError("boto3 is required for the S3 storage backend") from exc
return boto3.client( return boto3.client(
"s3", "s3",
endpoint_url=self.endpoint_url, endpoint_url=endpoint_url,
region_name=self.region_name, region_name=self.region_name,
aws_access_key_id=self.access_key_id, aws_access_key_id=self.access_key_id,
aws_secret_access_key=self.secret_access_key, aws_secret_access_key=self.secret_access_key,
) )
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:
max_bytes = response_limit("file")
if len(data) > max_bytes:
raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes")
kwargs = {"Bucket": self.bucket, "Key": key, "Body": data} kwargs = {"Bucket": self.bucket, "Key": key, "Body": data}
if content_type: if content_type:
kwargs["ContentType"] = content_type kwargs["ContentType"] = content_type
@@ -111,19 +129,39 @@ class S3StorageBackend:
def get_bytes(self, key: str) -> bytes: def get_bytes(self, key: str) -> bytes:
try: try:
obj = self.client.get_object(Bucket=self.bucket, Key=key) obj = self.client.get_object(Bucket=self.bucket, Key=key)
return obj["Body"].read() max_bytes = response_limit("file")
body = obj["Body"]
try:
_reject_declared_object_size(obj, max_bytes=max_bytes)
data = body.read(max_bytes + 1)
if len(data) > max_bytes:
raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes")
return data
finally:
if hasattr(body, "close"):
body.close()
except Exception as exc: # pragma: no cover - depends on S3 backend except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc raise StorageBackendError(str(exc)) from exc
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]:
try: try:
obj = self.client.get_object(Bucket=self.bucket, Key=key) obj = self.client.get_object(Bucket=self.bucket, Key=key)
max_bytes = response_limit("file")
body = obj["Body"] body = obj["Body"]
while True: try:
chunk = body.read(chunk_size) _reject_declared_object_size(obj, max_bytes=max_bytes)
if not chunk: total = 0
break while True:
yield chunk chunk = body.read(chunk_size)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes")
yield chunk
finally:
if hasattr(body, "close"):
body.close()
except Exception as exc: # pragma: no cover - depends on S3 backend except Exception as exc: # pragma: no cover - depends on S3 backend
raise StorageBackendError(str(exc)) from exc raise StorageBackendError(str(exc)) from exc
@@ -138,6 +176,17 @@ class S3StorageBackend:
return False return False
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
if not isinstance(obj, dict):
return
try:
declared_size = int(obj.get("ContentLength"))
except (TypeError, ValueError):
return
if declared_size > max_bytes:
raise StorageBackendError(f"Stored object exceeds the deployment limit of {max_bytes} bytes")
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

@@ -12,9 +12,13 @@ from typing import Any, Iterator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileAsset from govoplan_files.backend.db.models import FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user, read_asset_bytes from govoplan_files.backend.storage.backends import StorageBackend, StorageBackendError, get_storage_backend
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.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
MANAGED_SOURCE_PREFIX = "managed:" MANAGED_SOURCE_PREFIX = "managed:"
@@ -34,10 +38,17 @@ class ManagedAttachmentFile:
checksum_sha256: str checksum_sha256: str
size_bytes: int size_bytes: int
content_type: str | None content_type: str | None
linked_to_campaign: bool = True
source_provenance: dict[str, Any] | None = None
source_revision: str | None = None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
payload = asdict(self) payload = asdict(self)
payload.pop("local_path", None) payload.pop("local_path", None)
if payload.get("source_provenance") is None:
payload.pop("source_provenance", None)
if payload.get("source_revision") is None:
payload.pop("source_revision", None)
return payload return payload
@@ -47,6 +58,7 @@ class PreparedCampaignSnapshot:
raw_json: dict[str, Any] raw_json: dict[str, Any]
managed_files_by_local_path: dict[str, ManagedAttachmentFile] managed_files_by_local_path: dict[str, ManagedAttachmentFile]
shared_assets: list[FileAsset] shared_assets: list[FileAsset]
candidate_assets: list[FileAsset]
def parse_managed_source(value: object) -> tuple[str, str] | None: def parse_managed_source(value: object) -> tuple[str, str] | None:
@@ -108,6 +120,99 @@ def _iter_rule_dicts(attachments: dict[str, Any], raw_json: dict[str, Any]):
yield rule yield rule
def _managed_base_sources(raw_json: dict[str, Any]) -> list[tuple[str, str, str]]:
attachments = raw_json.get("attachments")
if not isinstance(attachments, dict):
return []
base_paths = attachments.get("base_paths")
if not isinstance(base_paths, list):
return []
sources: list[tuple[str, str, str]] = []
seen: set[tuple[str, str, str]] = set()
for item in base_paths:
if not isinstance(item, dict):
continue
parsed_source = parse_managed_source(item.get("source"))
if parsed_source is None:
continue
owner_type, owner_id = parsed_source
old_path = str(item.get("path") or ".").strip() or "."
logical_root = "" if old_path in {"", ".", "/"} else normalize_folder(old_path)
key = (owner_type, owner_id, logical_root)
if key in seen:
continue
seen.add(key)
sources.append(key)
return sources
def _campaign_linked_asset_ids(
session: Session,
*,
tenant_id: str,
campaign_id: str,
asset_ids: list[str],
) -> set[str]:
if not asset_ids:
return set()
linked: set[str] = set()
for offset in range(0, len(asset_ids), 900):
chunk = asset_ids[offset : offset + 900]
rows = (
session.query(FileShare.file_asset_id)
.filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id.in_(chunk),
FileShare.target_type == "campaign",
FileShare.target_id == campaign_id,
FileShare.revoked_at.is_(None),
)
.all()
)
linked.update(row[0] for row in rows)
return linked
def _candidate_assets_for_managed_sources(
session: Session,
*,
tenant_id: str,
campaign_id: str,
raw_json: dict[str, Any],
user_id: str,
is_admin: bool,
shared_assets: list[FileAsset],
) -> tuple[list[FileAsset], set[str]]:
assets_by_id: dict[str, FileAsset] = {asset.id: asset for asset in shared_assets}
for owner_type, owner_id, logical_root in _managed_base_sources(raw_json):
ensure_owner_access(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
user_id=user_id,
is_admin=is_admin,
)
for asset in list_assets_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
path_prefix=logical_root or None,
is_admin=is_admin,
):
assets_by_id.setdefault(asset.id, asset)
candidate_assets = sorted(assets_by_id.values(), key=lambda asset: (asset.display_path, asset.updated_at, asset.id))
linked_ids = _campaign_linked_asset_ids(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
asset_ids=[asset.id for asset in candidate_assets],
)
return candidate_assets, linked_ids
def _selected_base_path( def _selected_base_path(
rule: dict[str, Any], rule: dict[str, Any],
prepared_by_id: dict[str, tuple[str, str]], prepared_by_id: dict[str, tuple[str, str]],
@@ -127,6 +232,177 @@ def _selected_base_path(
return None return None
def _prepared_attachment_config(raw_json: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], list[Any]]:
prepared_json = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {})
attachments = prepared_json.get("attachments")
if not isinstance(attachments, dict):
attachments = {}
prepared_json["attachments"] = attachments
base_paths = attachments.get("base_paths")
if not isinstance(base_paths, list):
base_paths = []
return prepared_json, attachments, base_paths
def _campaign_snapshot_assets(
session: Session,
*,
tenant_id: str,
campaign_id: str,
prepared_json: dict[str, Any],
include_unlinked_candidates: bool,
user_id: str,
is_admin: bool,
) -> tuple[list[FileAsset], list[FileAsset], set[str]]:
shared_assets = list_assets_for_user(
session,
tenant_id=tenant_id,
user_id="",
campaign_id=campaign_id,
is_admin=True,
)
if not include_unlinked_candidates:
return shared_assets, shared_assets, {asset.id for asset in shared_assets}
candidate_assets, linked_asset_ids = _candidate_assets_for_managed_sources(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
raw_json=prepared_json,
user_id=user_id,
is_admin=is_admin,
shared_assets=shared_assets,
)
return shared_assets, candidate_assets, linked_asset_ids
def _assets_grouped_by_owner(assets: list[FileAsset]) -> dict[tuple[str, str], list[FileAsset]]:
assets_by_owner: dict[tuple[str, str], list[FileAsset]] = defaultdict(list)
for asset in assets:
owner_id = _asset_owner_id(asset)
if owner_id:
assets_by_owner[(asset.owner_type, owner_id)].append(asset)
return assets_by_owner
def _materialize_managed_asset(
asset: FileAsset,
*,
owner_id: str,
logical_root: str,
local_root: Path,
version_blobs: dict[str, tuple[FileVersion, FileBlob]],
backend: StorageBackend | None,
include_bytes: bool,
linked_asset_ids: set[str],
) -> tuple[str, ManagedAttachmentFile] | None:
relative_path = _relative_asset_path(asset, logical_root)
if not relative_path:
return None
target = _safe_local_target(local_root, relative_path)
target.parent.mkdir(parents=True, exist_ok=True)
version, blob = version_blobs[asset.id]
if include_bytes:
try:
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)
else:
target.touch()
local_key = str(target.resolve())
return local_key, ManagedAttachmentFile(
local_path=local_key,
asset_id=asset.id,
version_id=version.id,
blob_id=blob.id,
display_path=asset.display_path,
relative_path=normalize_logical_path(relative_path),
filename=asset.filename,
owner_type=asset.owner_type,
owner_id=owner_id,
checksum_sha256=blob.checksum_sha256,
size_bytes=blob.size_bytes,
content_type=blob.content_type,
linked_to_campaign=asset.id in linked_asset_ids,
source_provenance=source_provenance_from_metadata(asset.metadata_),
source_revision=source_revision_from_metadata(asset.metadata_),
)
def _prepare_managed_base_paths(
base_paths: list[Any],
*,
materialized_root: Path,
assets_by_owner: dict[tuple[str, str], list[FileAsset]],
version_blobs: dict[str, tuple[FileVersion, FileBlob]],
backend: StorageBackend | None,
include_bytes: bool,
linked_asset_ids: set[str],
) -> tuple[
dict[str, ManagedAttachmentFile],
dict[str, tuple[str, str]],
dict[str, list[tuple[str, str]]],
tuple[str, str] | None,
]:
manifest: dict[str, ManagedAttachmentFile] = {}
prepared_by_id: dict[str, tuple[str, str]] = {}
prepared_by_old_path: dict[str, list[tuple[str, str]]] = {}
first_prepared: tuple[str, str] | None = None
for index, item in enumerate(base_paths):
if not isinstance(item, dict):
continue
parsed_source = parse_managed_source(item.get("source"))
if parsed_source is None:
continue
owner_type, owner_id = parsed_source
old_path = str(item.get("path") or ".").strip() or "."
logical_root = "" if old_path in {"", ".", "/"} else normalize_folder(old_path)
base_path_id = str(item.get("id") or f"base-path-{index + 1}")
local_root = materialized_root / f"{index + 1:03d}-{safe_storage_component(base_path_id)}"
local_root.mkdir(parents=True, exist_ok=True)
local_root_string = str(local_root.resolve())
prepared = (base_path_id, local_root_string)
prepared_by_id[base_path_id] = prepared
prepared_by_old_path.setdefault(old_path, []).append(prepared)
if first_prepared is None:
first_prepared = prepared
item["path"] = local_root_string
for asset in assets_by_owner.get((owner_type, owner_id), []):
materialized = _materialize_managed_asset(
asset,
owner_id=owner_id,
logical_root=logical_root,
local_root=local_root,
version_blobs=version_blobs,
backend=backend,
include_bytes=include_bytes,
linked_asset_ids=linked_asset_ids,
)
if materialized is not None:
local_key, managed_file = materialized
manifest[local_key] = managed_file
return manifest, prepared_by_id, prepared_by_old_path, first_prepared
def _rewrite_managed_attachment_rules(
attachments: dict[str, Any],
prepared_json: dict[str, Any],
*,
prepared_by_id: dict[str, tuple[str, str]],
prepared_by_old_path: dict[str, list[tuple[str, str]]],
first_prepared: tuple[str, str] | None,
) -> None:
for rule in _iter_rule_dicts(attachments, prepared_json):
selected = _selected_base_path(rule, prepared_by_id, prepared_by_old_path, first_prepared)
if selected is None:
continue
base_path_id, local_root_string = selected
rule["base_path_id"] = base_path_id
rule["base_dir"] = local_root_string
if first_prepared is not None:
attachments["base_path"] = first_prepared[1]
def prepare_campaign_snapshot( def prepare_campaign_snapshot(
session: Session, session: Session,
*, *,
@@ -135,6 +411,9 @@ def prepare_campaign_snapshot(
raw_json: dict[str, Any], raw_json: dict[str, Any],
destination: Path, destination: Path,
include_bytes: bool, include_bytes: bool,
include_unlinked_candidates: bool = False,
user_id: str = "",
is_admin: bool = False,
) -> PreparedCampaignSnapshot: ) -> PreparedCampaignSnapshot:
"""Create a temporary file-oriented campaign snapshot for managed attachments. """Create a temporary file-oriented campaign snapshot for managed attachments.
@@ -150,94 +429,35 @@ def prepare_campaign_snapshot(
materialized_root = destination / "managed-attachments" materialized_root = destination / "managed-attachments"
materialized_root.mkdir(parents=True, exist_ok=True) materialized_root.mkdir(parents=True, exist_ok=True)
prepared_json = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {}) prepared_json, attachments, base_paths = _prepared_attachment_config(raw_json)
attachments = prepared_json.get("attachments") shared_assets, candidate_assets, linked_asset_ids = _campaign_snapshot_assets(
if not isinstance(attachments, dict):
attachments = {}
prepared_json["attachments"] = attachments
base_paths = attachments.get("base_paths")
if not isinstance(base_paths, list):
base_paths = []
shared_assets = list_assets_for_user(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
user_id="",
campaign_id=campaign_id, campaign_id=campaign_id,
is_admin=True, prepared_json=prepared_json,
include_unlinked_candidates=include_unlinked_candidates,
user_id=user_id,
is_admin=is_admin,
)
assets_by_owner = _assets_grouped_by_owner(candidate_assets)
version_blobs = current_versions_and_blobs(session, candidate_assets)
backend = get_storage_backend() if include_bytes else None
manifest, prepared_by_id, prepared_by_old_path, first_prepared = _prepare_managed_base_paths(
base_paths,
materialized_root=materialized_root,
assets_by_owner=assets_by_owner,
version_blobs=version_blobs,
backend=backend,
include_bytes=include_bytes,
linked_asset_ids=linked_asset_ids,
)
_rewrite_managed_attachment_rules(
attachments,
prepared_json,
prepared_by_id=prepared_by_id,
prepared_by_old_path=prepared_by_old_path,
first_prepared=first_prepared,
) )
assets_by_owner: dict[tuple[str, str], list[FileAsset]] = defaultdict(list)
for asset in shared_assets:
owner_id = _asset_owner_id(asset)
if owner_id:
assets_by_owner[(asset.owner_type, owner_id)].append(asset)
manifest: dict[str, ManagedAttachmentFile] = {}
prepared_by_id: dict[str, tuple[str, str]] = {}
prepared_by_old_path: dict[str, list[tuple[str, str]]] = {}
first_prepared: tuple[str, str] | None = None
for index, item in enumerate(base_paths):
if not isinstance(item, dict):
continue
parsed_source = parse_managed_source(item.get("source"))
if parsed_source is None:
continue
owner_type, owner_id = parsed_source
old_path = str(item.get("path") or ".").strip() or "."
logical_root = "" if old_path in {"", ".", "/"} else normalize_folder(old_path)
base_path_id = str(item.get("id") or f"base-path-{index + 1}")
local_root = materialized_root / f"{index + 1:03d}-{safe_storage_component(base_path_id)}"
local_root.mkdir(parents=True, exist_ok=True)
local_root_string = str(local_root.resolve())
prepared = (base_path_id, local_root_string)
prepared_by_id[base_path_id] = prepared
prepared_by_old_path.setdefault(old_path, []).append(prepared)
if first_prepared is None:
first_prepared = prepared
item["path"] = local_root_string
for asset in assets_by_owner.get((owner_type, owner_id), []):
relative_path = _relative_asset_path(asset, logical_root)
if not relative_path:
continue
target = _safe_local_target(local_root, relative_path)
target.parent.mkdir(parents=True, exist_ok=True)
if include_bytes:
data, version, blob = read_asset_bytes(session, asset)
target.write_bytes(data)
else:
version, blob = current_version_and_blob(session, asset)
target.touch()
local_key = str(target.resolve())
manifest[local_key] = ManagedAttachmentFile(
local_path=local_key,
asset_id=asset.id,
version_id=version.id,
blob_id=blob.id,
display_path=asset.display_path,
relative_path=normalize_logical_path(relative_path),
filename=asset.filename,
owner_type=asset.owner_type,
owner_id=owner_id,
checksum_sha256=blob.checksum_sha256,
size_bytes=blob.size_bytes,
content_type=blob.content_type,
)
for rule in _iter_rule_dicts(attachments, prepared_json):
selected = _selected_base_path(rule, prepared_by_id, prepared_by_old_path, first_prepared)
if selected is None:
continue
base_path_id, local_root_string = selected
rule["base_path_id"] = base_path_id
rule["base_dir"] = local_root_string
if first_prepared is not None:
attachments["base_path"] = first_prepared[1]
snapshot_path = destination / "campaign.json" snapshot_path = destination / "campaign.json"
snapshot_path.write_text(json.dumps(prepared_json, ensure_ascii=False, indent=2), encoding="utf-8") snapshot_path.write_text(json.dumps(prepared_json, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -246,6 +466,7 @@ def prepare_campaign_snapshot(
raw_json=prepared_json, raw_json=prepared_json,
managed_files_by_local_path=manifest, managed_files_by_local_path=manifest,
shared_assets=shared_assets, shared_assets=shared_assets,
candidate_assets=candidate_assets,
) )
@@ -257,7 +478,10 @@ def prepared_campaign_snapshot(
campaign_id: str, campaign_id: str,
raw_json: dict[str, Any], raw_json: dict[str, Any],
include_bytes: bool, include_bytes: bool,
prefix: str = "multimailer-managed-campaign-", prefix: str = "govoplan-managed-campaign-",
include_unlinked_candidates: bool = False,
user_id: str = "",
is_admin: bool = False,
) -> Iterator[PreparedCampaignSnapshot]: ) -> Iterator[PreparedCampaignSnapshot]:
temp_dir = Path(tempfile.mkdtemp(prefix=prefix)) temp_dir = Path(tempfile.mkdtemp(prefix=prefix))
try: try:
@@ -268,6 +492,9 @@ def prepared_campaign_snapshot(
raw_json=raw_json, raw_json=raw_json,
destination=temp_dir, destination=temp_dir,
include_bytes=include_bytes, include_bytes=include_bytes,
include_unlinked_candidates=include_unlinked_candidates,
user_id=user_id,
is_admin=is_admin,
) )
finally: finally:
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
@@ -285,6 +512,53 @@ def managed_match_payloads(
return payloads return payloads
def share_assets_with_campaign(
session: Session,
*,
tenant_id: str,
campaign_id: str,
file_ids: list[str],
user_id: str,
is_admin: bool = False,
) -> list[dict[str, Any]]:
unique_ids = list(dict.fromkeys(file_id for file_id in file_ids if file_id))
if not unique_ids:
return []
assets = [
get_asset_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
asset_id=file_id,
require_write=True,
is_admin=is_admin,
)
for file_id in unique_ids
]
shares = share_files(
session,
tenant_id=tenant_id,
assets=assets,
target_type="campaign",
target_id=campaign_id,
permission="read",
user_id=user_id,
)
session.flush()
return [
{
"id": share.id,
"file_asset_id": share.file_asset_id,
"target_type": share.target_type,
"target_id": share.target_id,
"permission": share.permission,
"created_at": share.created_at.isoformat() if share.created_at else None,
"revoked_at": share.revoked_at.isoformat() if share.revoked_at else None,
}
for share in shares
]
def public_attachment_summary_payload(value: Any) -> dict[str, Any]: def public_attachment_summary_payload(value: Any) -> dict[str, Any]:
"""Return an attachment summary without temporary materialization paths. """Return an attachment summary without temporary materialization paths.

View File

@@ -1,15 +1,17 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Iterable from typing import Any, Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import CampaignJob
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion
from govoplan_files.backend.storage.common import utcnow from govoplan_files.backend.storage.common import utcnow
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
CampaignJobLike = Any
def _candidate_match_keys(raw_match: str) -> set[str]: def _candidate_match_keys(raw_match: str) -> set[str]:
@@ -23,11 +25,20 @@ def _candidate_match_keys(raw_match: str) -> set[str]:
AttachmentUseKey = tuple[str, str, str, str] AttachmentUseKey = tuple[str, str, str, str]
@dataclass(slots=True)
class _AttachmentBatchRefs:
managed_by_job: dict[str, list[dict[str, object]]] = field(default_factory=lambda: defaultdict(list))
fallback_attachments_by_job: dict[str, list[dict[str, object]]] = field(default_factory=lambda: defaultdict(list))
asset_ids: set[str] = field(default_factory=set)
version_ids: set[str] = field(default_factory=set)
blob_ids: set[str] = field(default_factory=set)
def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str, stage: str) -> AttachmentUseKey: def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str, stage: str) -> AttachmentUseKey:
return job_id, file_version_id, filename_used, stage return job_id, file_version_id, filename_used, stage
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, stage: str) -> set[AttachmentUseKey]: def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJobLike], *, stage: str) -> set[AttachmentUseKey]:
job_ids = {job.id for job in jobs if job.id} job_ids = {job.id for job in jobs if job.id}
if not job_ids: if not job_ids:
return set() return set()
@@ -70,13 +81,13 @@ def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, s
return keys return keys
def _known_use_keys(session: Session, job: CampaignJob, *, stage: str) -> set[AttachmentUseKey]: def _known_use_keys(session: Session, job: CampaignJobLike, *, stage: str) -> set[AttachmentUseKey]:
return _known_use_keys_for_jobs(session, [job], stage=stage) return _known_use_keys_for_jobs(session, [job], stage=stage)
def _add_use( def _add_use(
session: Session, session: Session,
job: CampaignJob, job: CampaignJobLike,
*, *,
asset: FileAsset, asset: FileAsset,
version: FileVersion, version: FileVersion,
@@ -116,7 +127,7 @@ def _add_use(
def record_campaign_attachment_uses_for_jobs( def record_campaign_attachment_uses_for_jobs(
session: Session, session: Session,
jobs: Iterable[CampaignJob], jobs: Iterable[CampaignJobLike],
*, *,
stage: str = "built", stage: str = "built",
) -> None: ) -> None:
@@ -133,62 +144,91 @@ def record_campaign_attachment_uses_for_jobs(
if not job_list: if not job_list:
return return
managed_by_job: dict[str, list[dict[str, object]]] = defaultdict(list) refs = _collect_attachment_batch_refs(job_list)
fallback_attachments_by_job: dict[str, list[dict[str, object]]] = defaultdict(list)
job_by_id = {job.id: job for job in job_list} job_by_id = {job.id: job for job in job_list}
asset_ids: set[str] = set() known_keys = _known_use_keys_for_jobs(session, job_list, stage=stage)
version_ids: set[str] = set() assets_by_id, versions_by_id, blobs_by_id = _load_managed_attachment_entities(session, refs)
blob_ids: set[str] = set()
for job in job_list: _record_managed_attachment_uses(
session,
job_by_id=job_by_id,
managed_by_job=refs.managed_by_job,
assets_by_id=assets_by_id,
versions_by_id=versions_by_id,
blobs_by_id=blobs_by_id,
stage=stage,
known_keys=known_keys,
)
_record_fallback_attachment_uses(
session,
job_by_id=job_by_id,
fallback_attachments_by_job=refs.fallback_attachments_by_job,
stage=stage,
known_keys=known_keys,
)
def _collect_attachment_batch_refs(jobs: list[CampaignJobLike]) -> _AttachmentBatchRefs:
refs = _AttachmentBatchRefs()
for job in jobs:
attachments = job.resolved_attachments or [] attachments = job.resolved_attachments or []
if not isinstance(attachments, list): if not isinstance(attachments, list):
continue continue
for attachment in attachments: for attachment in attachments:
if not isinstance(attachment, dict): if not isinstance(attachment, dict):
continue continue
managed_matches = attachment.get("managed_matches") if not _collect_managed_attachment_refs(refs, job.id, attachment):
if isinstance(managed_matches, list) and managed_matches: refs.fallback_attachments_by_job[job.id].append(attachment)
for item in managed_matches: return refs
if not isinstance(item, dict):
continue
asset_id = str(item.get("asset_id") or "")
version_id = str(item.get("version_id") or "")
blob_id = str(item.get("blob_id") or "")
if not asset_id or not version_id or not blob_id:
continue
managed_by_job[job.id].append(item)
asset_ids.add(asset_id)
version_ids.add(version_id)
blob_ids.add(blob_id)
else:
fallback_attachments_by_job[job.id].append(attachment)
assets_by_id = {
item.id: item
for item in session.query(FileAsset).filter(FileAsset.id.in_(asset_ids)).all()
} if asset_ids else {}
versions_by_id = {
item.id: item
for item in session.query(FileVersion).filter(FileVersion.id.in_(version_ids)).all()
} if version_ids else {}
blobs_by_id = {
item.id: item
for item in session.query(FileBlob).filter(FileBlob.id.in_(blob_ids)).all()
} if blob_ids else {}
known_keys = _known_use_keys_for_jobs(session, job_list, stage=stage)
def _collect_managed_attachment_refs(refs: _AttachmentBatchRefs, job_id: str, attachment: dict[str, object]) -> bool:
managed_matches = attachment.get("managed_matches")
if not isinstance(managed_matches, list) or not managed_matches:
return False
for item in managed_matches:
if not isinstance(item, dict):
continue
asset_id = str(item.get("asset_id") or "")
version_id = str(item.get("version_id") or "")
blob_id = str(item.get("blob_id") or "")
if not asset_id or not version_id or not blob_id:
continue
refs.managed_by_job[job_id].append(item)
refs.asset_ids.add(asset_id)
refs.version_ids.add(version_id)
refs.blob_ids.add(blob_id)
return True
def _load_managed_attachment_entities(
session: Session,
refs: _AttachmentBatchRefs,
) -> tuple[dict[str, FileAsset], dict[str, FileVersion], dict[str, FileBlob]]:
assets_by_id = {item.id: item for item in session.query(FileAsset).filter(FileAsset.id.in_(refs.asset_ids)).all()} if refs.asset_ids else {}
versions_by_id = {item.id: item for item in session.query(FileVersion).filter(FileVersion.id.in_(refs.version_ids)).all()} if refs.version_ids else {}
blobs_by_id = {item.id: item for item in session.query(FileBlob).filter(FileBlob.id.in_(refs.blob_ids)).all()} if refs.blob_ids else {}
return assets_by_id, versions_by_id, blobs_by_id
def _record_managed_attachment_uses(
session: Session,
*,
job_by_id: dict[str, CampaignJobLike],
managed_by_job: dict[str, list[dict[str, object]]],
assets_by_id: dict[str, FileAsset],
versions_by_id: dict[str, FileVersion],
blobs_by_id: dict[str, FileBlob],
stage: str,
known_keys: set[AttachmentUseKey],
) -> None:
for job_id, items in managed_by_job.items(): for job_id, items in managed_by_job.items():
job = job_by_id[job_id] job = job_by_id[job_id]
for item in items: for item in items:
asset = assets_by_id.get(str(item.get("asset_id") or "")) asset = assets_by_id.get(str(item.get("asset_id") or ""))
version = versions_by_id.get(str(item.get("version_id") or "")) version = versions_by_id.get(str(item.get("version_id") or ""))
blob = blobs_by_id.get(str(item.get("blob_id") or "")) blob = blobs_by_id.get(str(item.get("blob_id") or ""))
if not asset or not version or not blob: if not _managed_attachment_entities_match_job(job, asset, version, blob):
continue
if asset.tenant_id != job.tenant_id or version.tenant_id != job.tenant_id or blob.tenant_id != job.tenant_id:
continue
if version.file_asset_id != asset.id or version.blob_id != blob.id:
continue continue
_add_use( _add_use(
session, session,
@@ -201,53 +241,109 @@ def record_campaign_attachment_uses_for_jobs(
known_keys=known_keys, known_keys=known_keys,
) )
def _managed_attachment_entities_match_job(
job: CampaignJobLike,
asset: FileAsset | None,
version: FileVersion | None,
blob: FileBlob | None,
) -> bool:
if not asset or not version or not blob:
return False
if asset.tenant_id != job.tenant_id or version.tenant_id != job.tenant_id or blob.tenant_id != job.tenant_id:
return False
return version.file_asset_id == asset.id and version.blob_id == blob.id
def _record_fallback_attachment_uses(
session: Session,
*,
job_by_id: dict[str, CampaignJobLike],
fallback_attachments_by_job: dict[str, list[dict[str, object]]],
stage: str,
known_keys: set[AttachmentUseKey],
) -> None:
assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {} assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {}
version_blobs_by_campaign: dict[tuple[str, str], dict[str, tuple[FileVersion, FileBlob]]] = {}
for job_id, attachments in fallback_attachments_by_job.items(): for job_id, attachments in fallback_attachments_by_job.items():
job = job_by_id[job_id] job = job_by_id[job_id]
campaign_key = (job.tenant_id, job.campaign_id) campaign_key = (job.tenant_id, job.campaign_id)
by_key = assets_by_campaign.get(campaign_key) by_key, version_blobs = _fallback_campaign_assets(
if by_key is None: session,
assets = list_assets_for_user( job,
session, campaign_key=campaign_key,
tenant_id=job.tenant_id, assets_by_campaign=assets_by_campaign,
user_id="", version_blobs_by_campaign=version_blobs_by_campaign,
campaign_id=job.campaign_id, )
is_admin=True,
)
by_key = {}
for asset in assets:
by_key[asset.display_path.strip("/")] = asset
by_key.setdefault(asset.filename, asset)
assets_by_campaign[campaign_key] = by_key
for attachment in attachments: for attachment in attachments:
matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else [] _record_fallback_attachment(session, job, attachment, by_key=by_key, version_blobs=version_blobs, stage=stage, known_keys=known_keys)
for raw in matches:
if not isinstance(raw, str):
continue
asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None)
if not asset:
continue
version, blob = current_version_and_blob(session, asset)
_add_use(
session,
job,
asset=asset,
version=version,
blob=blob,
filename_used=asset.filename,
stage=stage,
known_keys=known_keys,
)
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None: def _fallback_campaign_assets(
session: Session,
job: CampaignJobLike,
*,
campaign_key: tuple[str, str],
assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]],
version_blobs_by_campaign: dict[tuple[str, str], dict[str, tuple[FileVersion, FileBlob]]],
) -> tuple[dict[str, FileAsset], dict[str, tuple[FileVersion, FileBlob]]]:
by_key = assets_by_campaign.get(campaign_key)
if by_key is None:
assets = list_assets_for_user(session, tenant_id=job.tenant_id, user_id="", campaign_id=job.campaign_id, is_admin=True)
by_key = _asset_lookup_by_path_and_filename(assets)
assets_by_campaign[campaign_key] = by_key
version_blobs_by_campaign[campaign_key] = current_versions_and_blobs(session, assets)
return by_key, version_blobs_by_campaign[campaign_key]
def _asset_lookup_by_path_and_filename(assets: list[FileAsset]) -> dict[str, FileAsset]:
by_key: dict[str, FileAsset] = {}
for asset in assets:
by_key[asset.display_path.strip("/")] = asset
by_key.setdefault(asset.filename, asset)
return by_key
def _record_fallback_attachment(
session: Session,
job: CampaignJobLike,
attachment: dict[str, object],
*,
by_key: dict[str, FileAsset],
version_blobs: dict[str, tuple[FileVersion, FileBlob]],
stage: str,
known_keys: set[AttachmentUseKey],
) -> None:
matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else []
for raw in matches:
if not isinstance(raw, str):
continue
asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None)
if not asset:
continue
version_blob = version_blobs.get(asset.id)
if not version_blob:
continue
version, blob = version_blob
_add_use(
session,
job,
asset=asset,
version=version,
blob=blob,
filename_used=asset.filename,
stage=stage,
known_keys=known_keys,
)
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJobLike, *, stage: str = "built") -> None:
"""Record immutable managed file versions used by one built/sent job.""" """Record immutable managed file versions used by one built/sent job."""
record_campaign_attachment_uses_for_jobs(session, [job], stage=stage) record_campaign_attachment_uses_for_jobs(session, [job], stage=stage)
def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None: def mark_job_attachment_uses_sent(session: Session, job: CampaignJobLike) -> None:
record_campaign_attachment_uses_for_job(session, job, stage="built") record_campaign_attachment_uses_for_job(session, job, stage="built")
# Sessions use autoflush=False. Flush any compatibility-built evidence so # Sessions use autoflush=False. Flush any compatibility-built evidence so
# the following query can copy it to the sent stage in the same call. # the following query can copy it to the sent stage in the same call.

View File

@@ -0,0 +1,882 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from importlib import import_module
import mimetypes
from typing import Any
from urllib.parse import quote, unquote, urljoin, urlsplit
import xml.etree.ElementTree as ET # nosec B405 - typing/element creation only; parsing uses defusedxml below.
from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
validate_unpinned_sdk_host,
validate_unpinned_sdk_http_url,
)
from govoplan_files.backend.storage.http_client import ConnectorHttpError, request_connector_bytes
from govoplan_files.backend.storage.connector_deployment import (
ConnectorDeploymentConfigurationError,
connector_ca_bundle_path,
connector_secret_env_value,
validate_connector_tls_metadata,
)
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
class ConnectorBrowseError(RuntimeError):
pass
class ConnectorBrowseUnsupported(ConnectorBrowseError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorBrowseItem:
kind: str
name: str
path: str
external_id: str | None = None
external_url: str | None = None
size_bytes: int | None = None
content_type: str | None = None
modified_at: str | None = None
etag: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
def to_response(self) -> dict[str, Any]:
return {
"kind": self.kind,
"name": self.name,
"path": self.path,
"external_id": self.external_id,
"external_url": self.external_url,
"size_bytes": self.size_bytes,
"content_type": self.content_type,
"modified_at": self.modified_at,
"etag": self.etag,
"metadata": dict(self.metadata),
}
def browse_connector_profile(profile: ConnectorProfile, *, path: str | None = None, library_id: str | None = None, continuation_token: str | None = None) -> list[ConnectorBrowseItem]:
browse_path = normalize_connector_browse_path(path)
static_items = _static_listing(profile, path=browse_path, library_id=library_id)
if static_items is not None:
return static_items
if profile.provider == "seafile":
if _metadata_string(profile, "browse_protocol") == "webdav" or _metadata_string(profile, "webdav_endpoint_url"):
return _browse_webdav(profile, path=browse_path)
return _browse_seafile(profile, path=browse_path, library_id=library_id)
if profile.provider in {"webdav", "nextcloud"} or _metadata_string(profile, "browse_protocol") == "webdav":
return _browse_webdav(profile, path=browse_path)
if profile.provider == "smb":
return _browse_smb(profile, path=browse_path)
if profile.provider == "s3":
return _browse_s3(profile, path=browse_path, library_id=library_id, continuation_token=continuation_token)
raise ConnectorBrowseUnsupported(f"Read-only browsing is not implemented for {profile.provider} connector profiles yet")
def parse_webdav_multistatus(*, root_url: str, current_path: str, payload: str | bytes) -> list[ConnectorBrowseItem]:
try:
root = SafeElementTree.fromstring(payload)
except SafeElementTree.ParseError as exc:
raise ConnectorBrowseError("Connector returned invalid WebDAV XML") from exc
root_path = _url_path_root(root_url)
current = normalize_connector_browse_path(current_path)
items: list[ConnectorBrowseItem] = []
for response in root.findall("{DAV:}response"):
href = response.findtext("{DAV:}href")
if not href:
continue
relative_path = _relative_href_path(root_path, href)
if relative_path == current:
continue
if current and not relative_path.startswith(current.rstrip("/") + "/"):
continue
parent = relative_path.rsplit("/", 1)[0] if "/" in relative_path else ""
if parent != current:
continue
prop = _webdav_prop(response)
is_collection = prop.find("{DAV:}resourcetype/{DAV:}collection") is not None if prop is not None else False
name = _webdav_display_name(prop) or _path_name(relative_path)
if not name:
continue
items.append(
ConnectorBrowseItem(
kind="folder" if is_collection else "file",
name=name,
path=relative_path,
external_id=_text(prop, "{DAV:}getetag") or relative_path,
size_bytes=None if is_collection else _int(_text(prop, "{DAV:}getcontentlength")),
content_type=None if is_collection else _text(prop, "{DAV:}getcontenttype"),
modified_at=_http_date(_text(prop, "{DAV:}getlastmodified")),
etag=_text(prop, "{DAV:}getetag"),
)
)
return sorted(items, key=lambda item: (item.kind != "library", item.kind != "folder", item.name.casefold(), item.path.casefold()))
def seafile_libraries_from_payload(payload: object) -> list[ConnectorBrowseItem]:
if not isinstance(payload, list):
raise ConnectorBrowseError("Seafile library response must be a list")
libraries = [_seafile_library_item(item) for item in payload if isinstance(item, Mapping)]
return sorted(libraries, key=lambda item: item.name.casefold())
def seafile_directory_items_from_payload(payload: object, *, path: str | None = None) -> list[ConnectorBrowseItem]:
if payload == "uptodate":
return []
if not isinstance(payload, list):
raise ConnectorBrowseError("Seafile directory response must be a list")
browse_path = normalize_connector_browse_path(path)
items = [_seafile_directory_item(item, parent_path=browse_path) for item in payload if isinstance(item, Mapping)]
return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold()))
def _browse_seafile(profile: ConnectorProfile, *, path: str, library_id: str | None) -> list[ConnectorBrowseItem]:
repo_id, dir_path = _seafile_repo_and_path(path=path, library_id=library_id)
headers = _seafile_headers(profile)
if not repo_id:
payload = _request_json("GET", _seafile_url(profile, "api2/repos/"), headers=headers)
return seafile_libraries_from_payload(payload)
payload = _request_json(
"GET",
_seafile_url(profile, f"api2/repos/{quote(repo_id, safe='')}/dir/"),
headers=headers,
params={"p": "/" + dir_path if dir_path else "/"},
)
return seafile_directory_items_from_payload(payload, path=dir_path)
def _browse_webdav(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowseItem]:
root_url = _metadata_string(profile, "webdav_endpoint_url") or profile.endpoint_url
if not root_url:
raise ConnectorBrowseError("Connector profile does not define an endpoint URL")
url = _webdav_url(root_url, path)
headers = {"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}
auth: tuple[str, str] | None = None
password = _profile_password(profile)
token = _profile_token(profile)
if profile.username and password:
auth = (profile.username, password)
elif token:
headers["Authorization"] = f"Bearer {token}"
elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref:
raise ConnectorBrowseError("Secret-ref connector credentials need a runtime secret resolver before live browsing")
body = """<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname />
<resourcetype />
<getcontentlength />
<getcontenttype />
<getlastmodified />
<getetag />
</prop>
</propfind>"""
try:
response = request_connector_bytes(
"PROPFIND",
url,
headers=headers,
content=body,
auth=auth,
timeout=15.0,
label="WebDAV browse",
)
except ConnectorHttpError as exc:
raise ConnectorBrowseError(f"Connector browse failed: {exc}") from exc
if response.status_code in {401, 403}:
raise ConnectorBrowseError("Connector credentials were rejected")
if response.status_code not in {200, 207}:
raise ConnectorBrowseError(f"Connector browse failed with HTTP {response.status_code}")
return parse_webdav_multistatus(root_url=root_url, current_path=path, payload=response.content)
def _browse_smb(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowseItem]:
location = _smb_location(profile)
unc_path = _smb_unc_path(location, path)
smbclient = _smbclient_module()
try:
entries = smbclient.scandir(unc_path, **_smb_client_kwargs(profile, location))
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorBrowseError(f"SMB connector browse failed: {exc}") from exc
items: list[ConnectorBrowseItem] = []
try:
with entries as iterator:
for entry in iterator:
name = _clean(getattr(entry, "name", None))
if not name or name in {".", ".."}:
continue
try:
is_dir = bool(entry.is_dir())
except Exception:
is_dir = False
stat_result = _smb_entry_stat(entry)
item_path = _join_browse_path(path, name)
items.append(
ConnectorBrowseItem(
kind="folder" if is_dir else "file",
name=name,
path=item_path,
external_id=f"{location.share}:{item_path}",
size_bytes=None if is_dir else _smb_stat_size(stat_result),
content_type=None if is_dir else mimetypes.guess_type(name)[0],
modified_at=_smb_stat_modified_at(stat_result),
etag=_smb_stat_revision(stat_result),
metadata={
"share": location.share,
**({"server": location.server} if _metadata_bool(profile, "expose_server_metadata", default=False) else {}),
},
)
)
except ConnectorBrowseError:
raise
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorBrowseError(f"SMB connector browse failed: {exc}") from exc
return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold()))
def _browse_s3(profile: ConnectorProfile, *, path: str, library_id: str | None, continuation_token: str | None) -> list[ConnectorBrowseItem]:
client = _s3_client(profile)
bucket = _s3_bucket(profile, library_id)
if not bucket:
try:
payload = client.list_buckets()
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorBrowseError(f"S3 connector browse failed: {exc}") from exc
buckets = payload.get("Buckets") if isinstance(payload, Mapping) else None
if not isinstance(buckets, list):
raise ConnectorBrowseError("S3 connector returned invalid bucket list")
return sorted(
(
ConnectorBrowseItem(
kind="library",
name=_clean(item.get("Name")) or "",
path=_clean(item.get("Name")) or "",
external_id=_clean(item.get("Name")),
modified_at=_timestamp(item.get("CreationDate")),
metadata={"bucket": _clean(item.get("Name"))},
)
for item in buckets
if isinstance(item, Mapping) and _clean(item.get("Name"))
),
key=lambda item: item.name.casefold(),
)
prefix = _s3_object_key(profile, path, directory=True)
params: dict[str, object] = {
"Bucket": bucket,
"Prefix": prefix,
"Delimiter": "/",
"MaxKeys": _s3_max_keys(profile),
}
next_page_request = _clean(continuation_token) or _metadata_string(profile, "continuation_token")
if next_page_request:
params["ContinuationToken"] = next_page_request
try:
payload = client.list_objects_v2(**params)
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorBrowseError(f"S3 connector browse failed: {exc}") from exc
if not isinstance(payload, Mapping):
raise ConnectorBrowseError("S3 connector returned invalid object listing")
items = [
*_s3_prefix_items(bucket=bucket, browse_path=path, base_prefix=prefix, prefixes=payload.get("CommonPrefixes")),
*_s3_object_items(bucket=bucket, browse_path=path, base_prefix=prefix, objects=payload.get("Contents")),
]
next_token = _clean(payload.get("NextContinuationToken"))
if next_token and items:
last = items[-1]
items[-1] = ConnectorBrowseItem(
kind=last.kind,
name=last.name,
path=last.path,
external_id=last.external_id,
external_url=last.external_url,
size_bytes=last.size_bytes,
content_type=last.content_type,
modified_at=last.modified_at,
etag=last.etag,
metadata={**dict(last.metadata), "next_continuation_token": next_token, "listing_truncated": True},
)
return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold()))
def _s3_client(profile: ConnectorProfile) -> Any:
if profile.secret_ref:
raise ConnectorBrowseError("Secret-ref S3 credentials need a runtime secret resolver before live browsing")
if profile.endpoint_url:
try:
endpoint_url = validate_unpinned_sdk_http_url(
profile.endpoint_url,
label="S3 connector endpoint",
)
except OutboundHttpError as exc:
raise ConnectorBrowseError(str(exc)) from exc
else:
raise ConnectorBrowseError(
"S3 connector endpoint discovery uses an SDK transport that cannot guarantee connection-time DNS/IP "
"pinning; live S3 access is disabled until that transport supports pinning"
)
try:
boto3 = import_module("boto3")
config_module = import_module("botocore.config")
except ImportError as exc:
raise ConnectorBrowseUnsupported("S3 connector browsing requires the optional boto3 dependency") from exc
kwargs: dict[str, object] = {"endpoint_url": endpoint_url}
region = _metadata_string(profile, "region") or _metadata_string(profile, "aws_region")
if region:
kwargs["region_name"] = region
access_key = profile.username or _metadata_env(profile, "access_key_id_env") or _metadata_string(profile, "access_key_id")
secret_key = _profile_password(profile) or _metadata_env(profile, "secret_access_key_env")
session_token = _profile_token(profile) or _metadata_env(profile, "session_token_env")
if access_key:
kwargs["aws_access_key_id"] = access_key
if secret_key:
kwargs["aws_secret_access_key"] = secret_key
if session_token:
kwargs["aws_session_token"] = session_token
verify = _s3_verify(profile)
if verify is not None:
kwargs["verify"] = verify
addressing_style = _s3_addressing_style(profile)
if addressing_style:
kwargs["config"] = config_module.Config(s3={"addressing_style": addressing_style})
try:
return boto3.client("s3", **kwargs)
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorBrowseError(f"S3 connector could not be initialized: {exc}") from exc
def _s3_bucket(profile: ConnectorProfile, library_id: str | None = None) -> str | None:
return _metadata_string(profile, "bucket") or _metadata_string(profile, "bucket_name") or _clean(library_id)
def _s3_object_key(profile: ConnectorProfile, path: str, *, directory: bool = False) -> str:
base_prefix = normalize_connector_browse_path(profile.base_path or _metadata_string(profile, "base_prefix"))
browse_path = normalize_connector_browse_path(path)
key = "/".join(part for part in (base_prefix, browse_path) if part)
if directory and key:
return key.rstrip("/") + "/"
return key
def _s3_prefix_items(
*,
bucket: str,
browse_path: str,
base_prefix: str,
prefixes: object,
) -> list[ConnectorBrowseItem]:
if not isinstance(prefixes, list):
return []
items: list[ConnectorBrowseItem] = []
for item in prefixes:
if not isinstance(item, Mapping):
continue
key = _clean(item.get("Prefix"))
if not key:
continue
relative = _s3_relative_key(key, base_prefix=base_prefix)
name = _path_name(relative)
if not name:
continue
path = _join_browse_path(browse_path, name)
items.append(
ConnectorBrowseItem(
kind="folder",
name=name,
path=path,
external_id=f"{bucket}:{key}",
metadata={"bucket": bucket, "key": key},
)
)
return items
def _s3_object_items(
*,
bucket: str,
browse_path: str,
base_prefix: str,
objects: object,
) -> list[ConnectorBrowseItem]:
if not isinstance(objects, list):
return []
items: list[ConnectorBrowseItem] = []
for item in objects:
if not isinstance(item, Mapping):
continue
key = _clean(item.get("Key"))
if not key or key == base_prefix:
continue
relative = _s3_relative_key(key, base_prefix=base_prefix)
name = _path_name(relative)
if not name:
continue
path = _join_browse_path(browse_path, name)
items.append(
ConnectorBrowseItem(
kind="file",
name=name,
path=path,
external_id=f"{bucket}:{key}",
size_bytes=_int(item.get("Size")),
content_type=mimetypes.guess_type(name)[0],
modified_at=_timestamp(item.get("LastModified")),
etag=_clean(item.get("ETag")),
metadata={
"bucket": bucket,
"key": key,
**({"storage_class": item["StorageClass"]} if "StorageClass" in item else {}),
},
)
)
return items
def _s3_relative_key(key: str, *, base_prefix: str) -> str:
clean_key = key.rstrip("/")
clean_base = base_prefix.rstrip("/")
if clean_base and clean_key.startswith(f"{clean_base}/"):
return clean_key[len(clean_base) + 1 :]
return clean_key
def _s3_max_keys(profile: ConnectorProfile) -> int:
configured = _int(profile.metadata.get("max_keys"))
if configured is None:
return 1000
return max(1, min(configured, 1000))
def _s3_verify(profile: ConnectorProfile) -> bool | str | None:
try:
validate_connector_tls_metadata(profile.metadata)
except ConnectorDeploymentConfigurationError as exc:
raise ConnectorBrowseError(str(exc)) from exc
ca_bundle = _metadata_string(profile, "ca_bundle")
if ca_bundle:
try:
return connector_ca_bundle_path(ca_bundle)
except ConnectorDeploymentConfigurationError as exc:
raise ConnectorBrowseError(str(exc)) from exc
if "verify_tls" in profile.metadata:
return _metadata_bool(profile, "verify_tls", default=True)
if "tls_verify" in profile.metadata:
return _metadata_bool(profile, "tls_verify", default=True)
return None
def _s3_addressing_style(profile: ConnectorProfile) -> str | None:
style = _metadata_string(profile, "addressing_style")
if style in {"path", "virtual", "auto"}:
return style
if _metadata_bool(profile, "path_style", default=False):
return "path"
return None
def _seafile_headers(profile: ConnectorProfile) -> dict[str, str]:
token = _seafile_token(profile)
return {"Authorization": f"Token {token}", "Accept": "application/json"}
def _seafile_token(profile: ConnectorProfile) -> str:
token = _profile_token(profile)
if token:
return token
password = _profile_password(profile)
if profile.username and password:
payload = _request_json(
"POST",
_seafile_url(profile, "api2/auth-token/"),
data={"username": profile.username, "password": password},
)
if not isinstance(payload, Mapping) or not _clean(payload.get("token")):
raise ConnectorBrowseError("Seafile did not return an account token")
return _clean(payload.get("token")) or ""
if profile.secret_ref:
raise ConnectorBrowseError("Secret-ref Seafile credentials need a runtime secret resolver before live browsing")
raise ConnectorBrowseError("Seafile connector profiles require token credentials or username plus password credentials")
def _seafile_url(profile: ConnectorProfile, suffix: str) -> str:
if not profile.endpoint_url:
raise ConnectorBrowseError("Seafile connector profile does not define endpoint_url")
return urljoin(profile.endpoint_url.rstrip("/") + "/", suffix.lstrip("/"))
def _request_json(
method: str,
url: str,
*,
headers: Mapping[str, str] | None = None,
params: Mapping[str, str] | None = None,
data: Mapping[str, str] | None = None,
) -> object:
try:
response = request_connector_bytes(
method,
url,
headers=dict(headers or {}),
params=params,
data=data,
timeout=15.0,
label="Seafile API",
)
except ConnectorHttpError as exc:
raise ConnectorBrowseError(f"Connector browse failed: {exc}") from exc
if response.status_code in {401, 403}:
raise ConnectorBrowseError("Connector credentials were rejected")
if response.status_code not in {200, 201}:
raise ConnectorBrowseError(f"Connector browse failed with HTTP {response.status_code}")
try:
return json.loads(response.content)
except (UnicodeDecodeError, ValueError) as exc:
raise ConnectorBrowseError("Connector returned invalid JSON") from exc
def _seafile_repo_and_path(*, path: str, library_id: str | None) -> tuple[str | None, str]:
repo_id = _clean(library_id)
if repo_id:
return repo_id, path
if not path:
return None, ""
first, _, rest = path.partition("/")
return first, rest
def _seafile_library_item(value: Mapping[str, Any]) -> ConnectorBrowseItem:
repo_id = _clean(value.get("id") or value.get("repo_id"))
name = _clean(value.get("name") or value.get("repo_name") or repo_id)
if not repo_id or not name:
raise ConnectorBrowseError("Seafile library entries require id and name")
return ConnectorBrowseItem(
kind="library",
name=name,
path=repo_id,
external_id=repo_id,
size_bytes=_int(value.get("size") or value.get("repo_size")),
modified_at=_timestamp(value.get("mtime")),
metadata={
key: value[key]
for key in ("type", "permission", "encrypted", "owner", "file_count")
if key in value
},
)
def _seafile_directory_item(value: Mapping[str, Any], *, parent_path: str) -> ConnectorBrowseItem:
name = _clean(value.get("name") or value.get("obj_name"))
if not name:
raise ConnectorBrowseError("Seafile directory entries require name")
item_type = str(value.get("type") or ("dir" if value.get("is_dir") else "file")).casefold()
kind = "folder" if item_type in {"dir", "folder"} else "file"
path = _join_browse_path(parent_path, name)
content_type = None if kind == "folder" else mimetypes.guess_type(name)[0]
return ConnectorBrowseItem(
kind=kind,
name=name,
path=path,
external_id=_clean(value.get("id") or value.get("obj_id")),
size_bytes=None if kind == "folder" else _int(value.get("size")),
content_type=content_type,
modified_at=_timestamp(value.get("mtime") or value.get("modified")),
etag=_clean(value.get("id") or value.get("obj_id")),
metadata={
key: value[key]
for key in ("permission", "modifier_email", "modifier_name")
if key in value
},
)
def _static_listing(profile: ConnectorProfile, *, path: str, library_id: str | None) -> list[ConnectorBrowseItem] | None:
listing = profile.metadata.get("static_listing")
if listing is None:
return None
if isinstance(listing, list):
raw_items = listing if path == "" else []
elif isinstance(listing, Mapping):
key = library_id or path
raw_items = listing.get(key)
if raw_items is None and key == "":
raw_items = listing.get("/")
else:
raise ConnectorBrowseError("Connector static_listing metadata must be a list or object")
if raw_items is None:
return []
if not isinstance(raw_items, list):
raise ConnectorBrowseError("Connector static_listing entries must be lists")
return sorted(
[_static_item(item, parent_path=path) for item in raw_items if isinstance(item, Mapping)],
key=lambda item: (item.kind != "library", item.kind != "folder", item.name.casefold(), item.path.casefold()),
)
def _static_item(value: Mapping[str, Any], *, parent_path: str) -> ConnectorBrowseItem:
kind = str(value.get("kind") or "file").strip().casefold()
if kind not in {"library", "folder", "file"}:
raise ConnectorBrowseError("Connector browse items must be library, folder or file")
name = str(value.get("name") or value.get("path") or "").strip()
if not name:
raise ConnectorBrowseError("Connector browse items require name")
path = normalize_connector_browse_path(value.get("path"))
if not path:
path = _join_browse_path(parent_path, name)
return ConnectorBrowseItem(
kind=kind,
name=name,
path=path,
external_id=_clean(value.get("external_id")),
external_url=_clean(value.get("external_url")),
size_bytes=_int(value.get("size_bytes")),
content_type=_clean(value.get("content_type")),
modified_at=_clean(value.get("modified_at")),
etag=_clean(value.get("etag")),
metadata=value.get("metadata") if isinstance(value.get("metadata"), Mapping) else {},
)
def _webdav_url(root_url: str, path: str) -> str:
base = root_url if root_url.endswith("/") else root_url + "/"
if not path:
return base
quoted = "/".join(quote(part, safe="") for part in path.split("/") if part)
return urljoin(base, quoted + "/")
def _url_path_root(root_url: str) -> str:
path = unquote(urlsplit(root_url).path or "/")
return path if path.endswith("/") else path + "/"
def _relative_href_path(root_path: str, href: str) -> str:
href_path = unquote(urlsplit(href).path or href).strip()
if href_path.startswith(root_path):
return normalize_connector_browse_path(href_path[len(root_path):])
return normalize_connector_browse_path(href_path.rsplit("/", 1)[-1])
def _webdav_prop(response: ET.Element) -> ET.Element:
prop = response.find("{DAV:}propstat/{DAV:}prop")
if prop is not None:
return prop
prop = response.find(".//{DAV:}prop")
return prop if prop is not None else ET.Element("prop")
def _webdav_display_name(prop: ET.Element | None) -> str | None:
return _clean(_text(prop, "{DAV:}displayname"))
def _text(prop: ET.Element | None, tag: str) -> str | None:
if prop is None:
return None
child = prop.find(tag)
return child.text.strip() if child is not None and child.text else None
def _http_date(value: str | None) -> str | None:
if not value:
return None
try:
return parsedate_to_datetime(value).isoformat()
except (TypeError, ValueError):
return value
def _timestamp(value: object) -> str | None:
number = _int(value)
if number is None:
return _clean(value)
return datetime.fromtimestamp(number, tz=timezone.utc).isoformat()
def _metadata_string(profile: ConnectorProfile, key: str) -> str | None:
return _clean(profile.metadata.get(key))
def _metadata_bool(profile: ConnectorProfile, key: str, *, default: bool = False) -> bool:
value = profile.metadata.get(key)
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
def _metadata_env(profile: ConnectorProfile, key: str) -> str | None:
env_name = _metadata_string(profile, key)
if not env_name:
return None
return _env_required(env_name, profile)
def _env_required(name: str, profile: ConnectorProfile) -> str:
try:
return connector_secret_env_value(name, source_kind=profile.source_kind)
except ConnectorDeploymentConfigurationError as exc:
raise ConnectorBrowseError(f"Connector profile {profile.id}: {exc}") from exc
def normalize_connector_browse_path(value: object) -> str:
if value is None:
return ""
path = str(value).replace("\\", "/").strip().strip("/")
parts = [part for part in path.split("/") if part and part not in {"."}]
if any(part == ".." for part in parts):
raise ConnectorBrowseError("Connector browse paths cannot contain parent directory segments")
return "/".join(parts)
def _join_browse_path(parent: str, name: str) -> str:
child = normalize_connector_browse_path(name)
if not parent:
return child
return f"{parent.rstrip('/')}/{child}"
def _path_name(path: str) -> str:
return path.rstrip("/").rsplit("/", 1)[-1]
def _int(value: object) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _clean(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
@dataclass(frozen=True, slots=True)
class _SmbLocation:
server: str
share: str
port: int
root_path: str = ""
def _smb_location(profile: ConnectorProfile) -> _SmbLocation:
if not profile.endpoint_url:
raise ConnectorBrowseError("SMB connector profile does not define endpoint_url")
parsed = urlsplit(profile.endpoint_url)
if parsed.scheme.casefold() != "smb":
raise ConnectorBrowseError("SMB connector endpoint_url must use smb://")
server = _clean(parsed.hostname)
if not server:
raise ConnectorBrowseError("SMB connector endpoint_url must include a server")
port = parsed.port or _int(profile.metadata.get("port")) or 445
try:
validate_unpinned_sdk_host(server, port=port, label="SMB connector endpoint")
except OutboundHttpError as exc:
raise ConnectorBrowseError(str(exc)) from exc
path_parts = [part for part in unquote(parsed.path or "").strip("/").split("/") if part]
if not path_parts:
raise ConnectorBrowseError("SMB connector endpoint_url must include a share name")
root_parts = path_parts[1:]
if profile.base_path:
root_parts.extend(normalize_connector_browse_path(profile.base_path).split("/"))
return _SmbLocation(
server=server,
share=path_parts[0],
port=port,
root_path=normalize_connector_browse_path("/".join(root_parts)),
)
def _smb_unc_path(location: _SmbLocation, path: str) -> str:
parts = [part for part in (location.root_path, normalize_connector_browse_path(path)) if part]
suffix = "\\".join(part.replace("/", "\\") for part in parts)
base = f"\\\\{location.server}\\{location.share}"
return f"{base}\\{suffix}" if suffix else base
def _smb_client_kwargs(profile: ConnectorProfile, location: _SmbLocation) -> dict[str, object]:
kwargs: dict[str, object] = {
"port": location.port,
"require_signing": _metadata_bool(profile, "require_signing", default=True),
"auth_protocol": _metadata_string(profile, "auth_protocol") or "ntlm",
}
if "encrypt" in profile.metadata:
kwargs["encrypt"] = _metadata_bool(profile, "encrypt", default=False)
password = _profile_password(profile)
token = _profile_token(profile)
if profile.username and password:
kwargs["username"] = profile.username
kwargs["password"] = password
elif token:
raise ConnectorBrowseError("SMB connector profiles do not support bearer-token credentials")
elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref:
raise ConnectorBrowseError("Secret-ref SMB credentials need a runtime secret resolver before live browsing")
return kwargs
def _profile_password(profile: ConnectorProfile) -> str | None:
if profile.password_value:
return profile.password_value
if profile.password_env:
return _env_required(profile.password_env, profile)
return None
def _profile_token(profile: ConnectorProfile) -> str | None:
if profile.token_value:
return profile.token_value
if profile.token_env:
return _env_required(profile.token_env, profile)
return None
def _smbclient_module() -> Any:
try:
return import_module("smbclient")
except ImportError as exc:
raise ConnectorBrowseUnsupported("SMB connector browsing requires the optional smbprotocol dependency") from exc
def _smb_entry_stat(entry: object) -> object | None:
try:
return entry.stat() # type: ignore[attr-defined]
except Exception:
return None
def _smb_stat_size(stat_result: object | None) -> int | None:
return _int(getattr(stat_result, "st_size", None))
def _smb_stat_modified_at(stat_result: object | None) -> str | None:
value = getattr(stat_result, "st_mtime", None)
if value is None:
return None
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
except (TypeError, ValueError, OSError, OverflowError):
return None
def _smb_stat_revision(stat_result: object | None) -> str | None:
mtime_ns = getattr(stat_result, "st_mtime_ns", None)
size = getattr(stat_result, "st_size", None)
if mtime_ns is not None:
return f"{mtime_ns}:{size or 0}"
modified = getattr(stat_result, "st_mtime", None)
if modified is not None:
return f"{modified}:{size or 0}"
return None

View File

@@ -0,0 +1,337 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
@dataclass(frozen=True, slots=True)
class ConnectorCredentialDeletionResult:
changed: bool
affected_profiles: tuple[FileConnectorProfile, ...] = ()
def delete_connector_credential_row(
session: Session,
row: FileConnectorCredential,
*,
deletion_reason: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> ConnectorCredentialDeletionResult:
"""Scrub a credential tombstone and disable every profile that used it.
``secret_ref`` predates a Files-owned secret-provider contract. It may be
shared or deployment-owned, so it is detached locally and explicitly
audited without ever being passed to a provider delete operation.
"""
dependent_profiles = (
tuple(
session.query(FileConnectorProfile)
.filter(FileConnectorProfile.credential_profile_id == row.id)
.order_by(FileConnectorProfile.id.asc())
.all()
)
if inspect(session.get_bind()).has_table(FileConnectorProfile.__tablename__)
else ()
)
affected_profiles: list[FileConnectorProfile] = []
for profile in dependent_profiles:
if _delete_connector_profile_row(
session,
profile,
deletion_reason="credential_deleted",
user_id=user_id,
api_key_id=api_key_id,
):
affected_profiles.append(profile)
deleted_secret_kinds = _encrypted_secret_kinds(row)
removed_reference_kinds = _credential_reference_kinds(row)
removed_metadata = bool(row.metadata_)
changed = _credential_row_requires_deletion(row)
if changed:
_scrub_credential_row(row, user_id=user_id)
session.add(row)
_audit_credential_deletion(
session,
row,
deletion_reason=deletion_reason,
deleted_secret_kinds=deleted_secret_kinds,
removed_reference_kinds=removed_reference_kinds,
removed_metadata=removed_metadata,
affected_profile_count=len(affected_profiles),
user_id=user_id,
api_key_id=api_key_id,
)
session.flush()
return ConnectorCredentialDeletionResult(
changed=changed or bool(affected_profiles),
affected_profiles=tuple(affected_profiles),
)
def delete_connector_profile_row(
session: Session,
row: FileConnectorProfile,
*,
deletion_reason: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> bool:
changed = _delete_connector_profile_row(
session,
row,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
session.flush()
return changed
def delete_connector_credentials_for_retirement(session: Session) -> int:
"""Scrub and audit stored connector material before destructive retirement.
Legacy external references are detached and audited as non-owned. Files
never sends those arbitrary references to a provider delete operation.
"""
inspector = inspect(session.get_bind())
profiles = (
session.query(FileConnectorProfile).order_by(FileConnectorProfile.id.asc()).all()
if inspector.has_table(FileConnectorProfile.__tablename__)
else []
)
credentials = (
session.query(FileConnectorCredential).order_by(FileConnectorCredential.id.asc()).all()
if inspector.has_table(FileConnectorCredential.__tablename__)
else []
)
deleted = 0
for profile in profiles:
if not _profile_row_has_credential_material(profile):
continue
if _delete_connector_profile_row(
session,
profile,
deletion_reason="module_data_retired",
):
deleted += 1
for credential in credentials:
if not _credential_row_has_material(credential):
continue
result = delete_connector_credential_row(
session,
credential,
deletion_reason="module_data_retired",
)
if result.changed:
deleted += 1
session.flush()
return deleted
def _delete_connector_profile_row(
session: Session,
row: FileConnectorProfile,
*,
deletion_reason: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> bool:
deleted_secret_kinds = _encrypted_secret_kinds(row)
removed_reference_kinds = _profile_reference_kinds(row)
removed_metadata = bool(row.metadata_)
changed = _profile_row_requires_deletion(row)
if not changed:
return False
_scrub_profile_row(row, user_id=user_id)
session.add(row)
_audit_profile_deletion(
session,
row,
deletion_reason=deletion_reason,
deleted_secret_kinds=deleted_secret_kinds,
removed_reference_kinds=removed_reference_kinds,
removed_metadata=removed_metadata,
user_id=user_id,
api_key_id=api_key_id,
)
return True
def _scrub_credential_row(row: FileConnectorCredential, *, user_id: str | None) -> None:
row.enabled = False
row.credential_mode = "none"
row.username = None
row.password_encrypted = None
row.token_encrypted = None
row.password_env = None
row.token_env = None
row.secret_ref = None
row.metadata_ = {}
row.updated_by_user_id = user_id
def _scrub_profile_row(row: FileConnectorProfile, *, user_id: str | None) -> None:
row.enabled = False
row.credential_profile_id = None
row.credential_mode = "none"
row.username = None
row.password_encrypted = None
row.token_encrypted = None
row.password_env = None
row.token_env = None
row.secret_ref = None
row.metadata_ = {}
row.updated_by_user_id = user_id
def _credential_row_requires_deletion(row: FileConnectorCredential) -> bool:
return bool(row.enabled or _credential_row_has_material(row))
def _profile_row_requires_deletion(row: FileConnectorProfile) -> bool:
return bool(row.enabled or _profile_row_has_credential_material(row))
def _credential_row_has_material(row: FileConnectorCredential) -> bool:
return bool(
row.username
or row.password_encrypted
or row.token_encrypted
or row.password_env
or row.token_env
or row.secret_ref
or row.metadata_
or row.credential_mode not in {"", "none", "anonymous"}
)
def _profile_row_has_credential_material(row: FileConnectorProfile) -> bool:
return bool(
row.credential_profile_id
or row.username
or row.password_encrypted
or row.token_encrypted
or row.password_env
or row.token_env
or row.secret_ref
or row.metadata_
or row.credential_mode not in {"", "none", "anonymous"}
)
def _encrypted_secret_kinds(row: FileConnectorCredential | FileConnectorProfile) -> list[str]:
kinds: list[str] = []
if row.password_encrypted:
kinds.append("password")
if row.token_encrypted:
kinds.append("token")
return kinds
def _credential_reference_kinds(row: FileConnectorCredential | FileConnectorProfile) -> list[str]:
kinds: list[str] = []
if row.password_env:
kinds.append("password_env")
if row.token_env:
kinds.append("token_env")
if row.secret_ref:
kinds.append("unowned_external_secret_ref")
return kinds
def _profile_reference_kinds(row: FileConnectorProfile) -> list[str]:
kinds = _credential_reference_kinds(row)
if row.credential_profile_id:
kinds.append("credential_profile")
return kinds
def _storage_backend(deleted_secret_kinds: list[str], removed_reference_kinds: list[str]) -> str:
if "unowned_external_secret_ref" in removed_reference_kinds:
return "unowned_external_reference_detached"
if deleted_secret_kinds:
return "encrypted_database"
if removed_reference_kinds:
return "reference_only"
return "none"
def _audit_credential_deletion(
session: Session,
row: FileConnectorCredential,
*,
deletion_reason: str,
deleted_secret_kinds: list[str],
removed_reference_kinds: list[str],
removed_metadata: bool,
affected_profile_count: int,
user_id: str | None,
api_key_id: str | None,
) -> None:
audit_event(
session,
tenant_id=row.tenant_id,
scope=_audit_scope(row.scope_type),
user_id=user_id,
api_key_id=api_key_id,
action="files.connector_credential_deleted",
object_type="file_connector_credential",
object_id=row.id,
details={
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"provider": row.provider,
"storage_backend": _storage_backend(deleted_secret_kinds, removed_reference_kinds),
"deleted_secret_kinds": deleted_secret_kinds,
"removed_reference_kinds": removed_reference_kinds,
"removed_metadata": removed_metadata,
"affected_profile_count": affected_profile_count,
"deletion_reason": deletion_reason,
},
)
def _audit_profile_deletion(
session: Session,
row: FileConnectorProfile,
*,
deletion_reason: str,
deleted_secret_kinds: list[str],
removed_reference_kinds: list[str],
removed_metadata: bool,
user_id: str | None,
api_key_id: str | None,
) -> None:
audit_event(
session,
tenant_id=row.tenant_id,
scope=_audit_scope(row.scope_type),
user_id=user_id,
api_key_id=api_key_id,
action="files.connector_profile_deleted",
object_type="file_connector_profile",
object_id=row.id,
details={
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"provider": row.provider,
"storage_backend": _storage_backend(deleted_secret_kinds, removed_reference_kinds),
"deleted_secret_kinds": deleted_secret_kinds,
"removed_reference_kinds": removed_reference_kinds,
"removed_metadata": removed_metadata,
"deletion_reason": deletion_reason,
},
)
def _audit_scope(scope_type: str) -> str:
return "system" if scope_type == "system" else "tenant"

View File

@@ -0,0 +1,540 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path
from govoplan_core.security.credential_envelopes import (
CredentialAccessContext,
CredentialEnvelope,
CredentialEnvelopeError,
ResolvedCredentialEnvelope,
list_credential_envelopes,
resolve_credential_envelope,
)
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_files.backend.db.models import FileConnectorCredential
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.connector_deployment import reject_api_controlled_deployment_references
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload
from govoplan_files.backend.storage.connector_profiles import supported_connector_providers
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
@dataclass(frozen=True, slots=True)
class ConnectorCredential:
id: str
label: str
scope_type: str
scope_id: str | None = None
provider: str | None = None
enabled: bool = True
credential_mode: str = "none"
username: str | None = None
password_env: str | None = None
token_env: str | None = None
secret_ref: str | None = None
password_value: str | None = None
token_value: str | None = None
policy_sources: tuple[ConnectorPolicySource, ...] = ()
metadata: Mapping[str, Any] | None = None
source_kind: str = "database"
has_secret: bool = False
@property
def source_path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@property
def credential_secret_source(self) -> str | None:
if self.secret_ref:
return "secret_ref"
if self.password_value or self.token_value:
return "stored"
if self.token_env:
return "token_env"
if self.password_env:
return "password_env"
return None
@property
def credentials_configured(self) -> bool:
if not self.enabled:
return False
if self.credential_mode.casefold() in {"", "none", "anonymous"}:
return True
# Environment references are deliberately unavailable to API-managed
# credential rows. Legacy rows remain visible but fail closed.
return bool(self.has_secret or self.secret_ref or self.password_value or self.token_value)
def to_response(self) -> dict[str, Any]:
return {
"id": self.id,
"label": self.label,
"provider": self.provider,
"enabled": self.enabled,
"scope_type": self.scope_type,
"scope_id": self.scope_id,
"source_path": self.source_path,
"credential_mode": self.credential_mode,
"credential_secret_source": self.credential_secret_source,
"credentials_configured": self.credentials_configured,
"username": self.username,
"policy_sources": [_policy_source_response(source) for source in self.policy_sources],
"metadata": dict(self.metadata or {}),
"source_kind": self.source_kind,
}
def list_database_connector_credentials(
session: Session,
*,
tenant_id: str,
include_disabled: bool = False,
) -> list[ConnectorCredential]:
local = [
connector_credential_from_row(row)
for row in list_connector_credential_rows(
session,
tenant_id=tenant_id,
include_disabled=include_disabled,
)
]
return [
*local,
*list_reusable_connector_credentials(
session,
tenant_id=tenant_id,
include_disabled=include_disabled,
),
]
def reusable_credential_reference(credential_id: str) -> str:
return f"{CORE_CREDENTIAL_ENVELOPE_PREFIX}{credential_id}"
def reusable_credential_id(credential_ref: str | None) -> str | None:
if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
return None
value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip()
return value or None
def file_credential_context(
*,
tenant_id: str,
profile_id: str | None = None,
scope_type: str = "tenant",
scope_id: str | None = None,
administrative: bool = False,
) -> CredentialAccessContext:
return CredentialAccessContext(
tenant_id=tenant_id,
user_id=scope_id if scope_type == "user" else None,
group_ids=frozenset({scope_id}) if scope_type == "group" and scope_id else frozenset(),
target_scope_type=scope_type,
target_scope_id=scope_id or tenant_id,
module_id="files",
server_ref=f"files:{profile_id}" if profile_id else None,
administrative=administrative,
)
def list_reusable_connector_credentials(
session: Session,
*,
tenant_id: str,
include_disabled: bool = False,
) -> list[ConnectorCredential]:
if not inspect(session.get_bind()).has_table(CredentialEnvelope.__tablename__):
return []
context = file_credential_context(tenant_id=tenant_id, administrative=True)
return [
connector_credential_from_envelope(row)
for row in list_credential_envelopes(
session,
context=context,
include_inactive=include_disabled,
)
]
def resolve_reusable_connector_credential(
session: Session,
*,
tenant_id: str,
credential_ref: str,
profile_id: str,
scope_type: str,
scope_id: str | None,
) -> ConnectorCredential:
credential_id = reusable_credential_id(credential_ref)
if credential_id is None:
raise FileStorageError("Reusable credential reference is invalid")
try:
resolved = resolve_credential_envelope(
session,
credential_id=credential_id,
context=file_credential_context(
tenant_id=tenant_id,
profile_id=profile_id,
scope_type=scope_type,
scope_id=scope_id,
),
)
except CredentialEnvelopeError as exc:
raise FileStorageError("Reusable credential is unavailable to this file connection") from exc
return connector_credential_from_resolved_envelope(
resolved,
scope_type=scope_type,
scope_id=scope_id,
)
def connector_credential_from_envelope(row: CredentialEnvelope) -> ConnectorCredential:
return ConnectorCredential(
id=reusable_credential_reference(row.id),
label=row.name,
scope_type=row.scope_type,
scope_id=row.scope_id,
enabled=row.is_active,
credential_mode=_envelope_credential_mode(row.credential_kind, row.secret_keys),
username=_clean_public_value(row.public_data, "username"),
source_kind="credential_envelope",
has_secret=bool(row.secret_data_encrypted),
metadata={
"credential_envelope_id": row.id,
"credential_kind": row.credential_kind,
"allowed_modules": list(row.allowed_modules or []),
"allowed_server_refs": list(row.allowed_server_refs or []),
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
"revision": row.revision,
},
)
def connector_credential_from_resolved_envelope(
row: ResolvedCredentialEnvelope,
*,
scope_type: str,
scope_id: str | None,
) -> ConnectorCredential:
return ConnectorCredential(
id=reusable_credential_reference(row.id),
label=row.name,
scope_type=scope_type,
scope_id=scope_id,
enabled=True,
credential_mode=_envelope_credential_mode(row.credential_kind, row.secret_data),
username=_clean_public_value(row.public_data, "username"),
password_value=_first_secret(row.secret_data, "password", "secret"),
token_value=_first_secret(
row.secret_data,
"access_token",
"bearer_token",
"token",
"api_key",
),
source_kind="credential_envelope",
has_secret=bool(row.secret_data),
metadata={
"credential_envelope_id": row.id,
"credential_kind": row.credential_kind,
"inherit_to_lower_scopes": True,
"revision": row.revision,
},
)
def list_connector_credential_rows(
session: Session,
*,
tenant_id: str,
include_disabled: bool = False,
) -> list[FileConnectorCredential]:
query = session.query(FileConnectorCredential).filter(
(FileConnectorCredential.scope_type == "system")
| (FileConnectorCredential.tenant_id == tenant_id)
)
if not include_disabled:
query = query.filter(FileConnectorCredential.enabled.is_(True))
return query.order_by(FileConnectorCredential.scope_type.asc(), FileConnectorCredential.label.asc()).all()
def connector_credential_from_row(row: FileConnectorCredential) -> ConnectorCredential:
policy_sources = []
if row.policy:
policy_sources = connector_policy_sources_from_payload({
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"label": row.label,
"policy": row.policy,
})
return ConnectorCredential(
id=row.id,
label=row.label,
scope_type=row.scope_type,
scope_id=row.scope_id,
provider=row.provider,
enabled=row.enabled,
credential_mode=row.credential_mode,
username=row.username,
password_env=row.password_env,
token_env=row.token_env,
secret_ref=row.secret_ref,
password_value=decrypt_secret(row.password_encrypted),
token_value=decrypt_secret(row.token_encrypted),
policy_sources=tuple(policy_sources),
metadata=dict(row.metadata_ or {}),
)
def get_connector_credential_row(
session: Session,
*,
tenant_id: str,
credential_id: str,
include_disabled: bool = False,
) -> FileConnectorCredential:
row = session.get(FileConnectorCredential, credential_id)
if row is None or (row.scope_type != "system" and row.tenant_id != tenant_id):
raise FileStorageError("Connector credential not found")
if not include_disabled and not row.enabled:
raise FileStorageError("Connector credential not found")
return row
def credential_rows_by_id(
session: Session,
*,
tenant_id: str,
credential_ids: set[str],
include_disabled: bool = False,
) -> dict[str, FileConnectorCredential]:
if not credential_ids:
return {}
rows = session.query(FileConnectorCredential).filter(FileConnectorCredential.id.in_(credential_ids)).all()
result: dict[str, FileConnectorCredential] = {}
for row in rows:
if row.scope_type != "system" and row.tenant_id != tenant_id:
continue
if not include_disabled and not row.enabled:
continue
result[row.id] = row
return result
def create_connector_credential_row(
session: Session,
*,
tenant_id: str,
user_id: str | None,
credential_id: str,
label: str,
scope_type: str,
scope_id: str | None = None,
provider: str | None = None,
enabled: bool = True,
credential_mode: str = "none",
username: str | None = None,
password: str | None = None,
token: str | None = None,
password_env: str | None = None,
token_env: str | None = None,
secret_ref: str | None = None,
policy: Mapping[str, Any] | None = None,
metadata: Mapping[str, Any] | None = None,
) -> FileConnectorCredential:
reject_api_controlled_deployment_references(
password_env=password_env,
token_env=token_env,
secret_ref=secret_ref,
metadata=metadata,
)
clean_id = _normalize_id(credential_id)
if session.get(FileConnectorCredential, clean_id) is not None:
raise FileStorageError(f"Connector credential already exists: {clean_id}")
clean_scope_type, clean_scope_id, row_tenant_id = _normalize_scope(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
row = FileConnectorCredential(
id=clean_id,
tenant_id=row_tenant_id,
scope_type=clean_scope_type,
scope_id=clean_scope_id,
label=_normalize_label(label),
provider=_normalize_provider(provider),
enabled=bool(enabled),
credential_mode=_normalize_credential_mode(credential_mode),
username=_clean(username),
password_encrypted=encrypt_secret(_clean(password)),
token_encrypted=encrypt_secret(_clean(token)),
password_env=_clean(password_env),
token_env=_clean(token_env),
secret_ref=_clean(secret_ref),
policy=dict(policy or {}),
metadata_=dict(metadata or {}),
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
session.add(row)
session.flush()
return row
def update_connector_credential_row(
session: Session,
row: FileConnectorCredential,
*,
user_id: str | None,
label: str | None = None,
provider: str | None = None,
enabled: bool | None = None,
credential_mode: str | None = None,
username: str | None = None,
password: str | None = None,
token: str | None = None,
password_env: str | None = None,
token_env: str | None = None,
secret_ref: str | None = None,
policy: Mapping[str, Any] | None = None,
metadata: Mapping[str, Any] | None = None,
clear_password: bool = False,
clear_token: bool = False,
) -> FileConnectorCredential:
reject_api_controlled_deployment_references(
password_env=password_env,
token_env=token_env,
secret_ref=secret_ref,
metadata=metadata,
)
if secret_ref is not None and _clean(secret_ref) != _clean(row.secret_ref):
if _clean(row.secret_ref):
raise FileStorageError(
"An existing external secret reference cannot be replaced or cleared until Files can prove "
"provider ownership and confirm provider-side deletion"
)
if label is not None:
row.label = _normalize_label(label)
if provider is not None:
row.provider = _normalize_provider(provider)
if enabled is not None:
row.enabled = bool(enabled)
if credential_mode is not None:
row.credential_mode = _normalize_credential_mode(credential_mode)
if username is not None:
row.username = _clean(username)
if password is not None:
row.password_encrypted = encrypt_secret(_clean(password))
elif clear_password:
row.password_encrypted = None
if token is not None:
row.token_encrypted = encrypt_secret(_clean(token))
elif clear_token:
row.token_encrypted = None
if password_env is not None:
row.password_env = _clean(password_env)
if token_env is not None:
row.token_env = _clean(token_env)
if secret_ref is not None:
row.secret_ref = _clean(secret_ref)
if policy is not None:
row.policy = dict(policy)
if metadata is not None:
row.metadata_ = dict(metadata)
row.updated_by_user_id = user_id
session.add(row)
session.flush()
return row
def _normalize_scope(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str, str | None, str | None]:
clean_scope_type = normalize_policy_scope_type(scope_type)
clean_scope_id = _clean(scope_id)
if clean_scope_type == "system":
return "system", None, None
if clean_scope_type == "tenant":
return "tenant", tenant_id, tenant_id
if clean_scope_type in {"user", "group", "campaign"}:
if not clean_scope_id:
raise FileStorageError(f"{clean_scope_type.capitalize()} connector credentials require scope_id")
return clean_scope_type, clean_scope_id, tenant_id
raise FileStorageError("Unsupported connector credential scope")
def _normalize_id(value: str) -> str:
clean = _clean(value)
if not clean:
raise FileStorageError("Connector credential id is required")
if len(clean) > 255:
raise FileStorageError("Connector credential id is too long")
if any(char.isspace() for char in clean):
raise FileStorageError("Connector credential id cannot contain whitespace")
return clean
def _normalize_label(value: str) -> str:
clean = value.strip()
if not clean:
raise FileStorageError("Connector credential label is required")
if len(clean) > 255:
raise FileStorageError("Connector credential label is too long")
return clean
def _normalize_provider(value: str | None) -> str | None:
clean = _clean(value)
if clean is None:
return None
clean = clean.casefold()
if clean not in supported_connector_providers():
raise FileStorageError(f"Unsupported connector credential provider: {value}")
return clean
def _normalize_credential_mode(value: str) -> str:
clean = value.strip().casefold() or "none"
if clean not in {"none", "anonymous", "basic", "token", "secret_ref"}:
raise FileStorageError(f"Unsupported connector credential mode: {value}")
return clean
def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]:
return {
"scope_type": source.scope_type,
"scope_id": source.scope_id,
"label": source.label,
"policy": dict(source.policy or {}),
}
def _envelope_credential_mode(kind: str, secret_values: Mapping[str, Any] | list[str]) -> str:
keys = {str(key) for key in secret_values}
if kind in {"token", "oauth2", "api_key"} or keys.intersection(
{"access_token", "bearer_token", "token", "api_key"}
):
return "token"
return "basic"
def _clean_public_value(values: Mapping[str, Any] | None, key: str) -> str | None:
return _clean((values or {}).get(key))
def _first_secret(values: Mapping[str, Any], *keys: str) -> str | None:
for key in keys:
value = _clean(values.get(key))
if value is not None:
return value
return None
def _clean(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Any
_SECRET_ENV_ALLOWLIST = "GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST" # noqa: S105 # nosec B105 - configuration key.
_CA_BUNDLE_ALLOWLIST = "GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST"
_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
_SECRET_ENV_METADATA_KEYS = frozenset(
{
"access_key_id_env",
"secret_access_key_env",
"session_token_env",
}
)
_SECRET_VALUE_METADATA_KEYS = frozenset(
{
"password",
"token",
"access_token",
"auth_token",
"bearer_token",
"refresh_token",
"api_key",
"access_key",
"access_key_id",
"secret_key",
"secret_access_key",
"session_token",
}
)
_DEVELOPMENT_ENVIRONMENTS = frozenset({"dev", "development", "local", "test", "testing"})
class ConnectorDeploymentConfigurationError(ValueError):
"""Raised when connector data crosses a deployment-owned trust boundary."""
def connector_secret_env_value(name: str, *, source_kind: str) -> str:
clean_name = _validate_secret_env_reference(name, source_kind=source_kind)
value = os.environ.get(clean_name)
if value is None or value == "":
raise ConnectorDeploymentConfigurationError("Connector credential environment variable is not configured")
return value
def connector_secret_env_available(name: str | None, *, source_kind: str) -> bool:
if not name:
return False
try:
clean_name = _validate_secret_env_reference(name, source_kind=source_kind)
except ConnectorDeploymentConfigurationError:
return False
return bool(os.environ.get(clean_name))
def validate_deployment_connector_references(
*,
source_kind: str,
password_env: str | None = None,
token_env: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> None:
"""Validate references in deployment-owned connector configuration."""
for name in (password_env, token_env):
if name:
_validate_secret_env_reference(name, source_kind=source_kind)
for key in _SECRET_ENV_METADATA_KEYS:
name = _clean((metadata or {}).get(key))
if name:
_validate_secret_env_reference(name, source_kind=source_kind)
validate_connector_tls_metadata(metadata)
def reject_api_controlled_deployment_references(
*,
password_env: str | None = None,
token_env: str | None = None,
secret_ref: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> None:
"""Reject process-secret selectors controlled through tenant-facing APIs."""
if _clean(password_env) or _clean(token_env):
raise ConnectorDeploymentConfigurationError(
"Environment-backed credentials may only be declared in deployment-owned connector configuration"
)
if _clean(secret_ref):
raise ConnectorDeploymentConfigurationError(
"External secret references cannot be managed through the Files API until Files can prove "
"provider ownership and confirm provider-side deletion"
)
for key, value in _metadata_entries(metadata or {}):
clean_key = str(key).strip().casefold()
if _clean(value) and (clean_key in _SECRET_ENV_METADATA_KEYS or clean_key.endswith("_env")):
raise ConnectorDeploymentConfigurationError(
"Environment-backed credentials may only be declared in deployment-owned connector configuration"
)
if _clean(value) and (
clean_key in _SECRET_VALUE_METADATA_KEYS
or clean_key.endswith(("_password", "_secret", "_api_key"))
):
raise ConnectorDeploymentConfigurationError(
"Connector credential values must use the dedicated encrypted credential fields, not metadata"
)
validate_connector_tls_metadata(metadata)
def _metadata_entries(value: object) -> list[tuple[str, object]]:
entries: list[tuple[str, object]] = []
if isinstance(value, Mapping):
for key, item in value.items():
entries.append((str(key), item))
entries.extend(_metadata_entries(item))
elif isinstance(value, (list, tuple)):
for item in value:
entries.extend(_metadata_entries(item))
return entries
def connector_ca_bundle_path(value: str) -> str:
clean_value = _clean(value)
if not clean_value:
raise ConnectorDeploymentConfigurationError("Connector CA bundle path is empty")
candidate = Path(clean_value)
if not candidate.is_absolute():
raise ConnectorDeploymentConfigurationError("Connector CA bundle paths must be absolute")
try:
resolved = candidate.resolve(strict=True)
except OSError as exc:
raise ConnectorDeploymentConfigurationError("Connector CA bundle path does not exist") from exc
allowed = _allowed_ca_bundle_paths()
if resolved not in allowed:
raise ConnectorDeploymentConfigurationError(
f"Connector CA bundle path is not listed in {_CA_BUNDLE_ALLOWLIST}"
)
if not resolved.is_file():
raise ConnectorDeploymentConfigurationError("Connector CA bundle path must be a regular file")
return str(resolved)
def validate_connector_tls_metadata(metadata: Mapping[str, Any] | None) -> None:
values = metadata or {}
ca_bundle = _clean(values.get("ca_bundle"))
if ca_bundle:
connector_ca_bundle_path(ca_bundle)
for key in ("verify_tls", "tls_verify"):
if key in values and not _as_bool(values.get(key), default=True) and not _development_runtime():
raise ConnectorDeploymentConfigurationError(
"Connector TLS certificate verification may only be disabled in dev/test environments"
)
def connector_effective_endpoint_url(
*,
provider: str | None,
endpoint_url: str | None,
metadata: Mapping[str, Any] | None,
) -> str | None:
"""Return the endpoint that connector I/O will actually use."""
clean_provider = (provider or "").strip().casefold()
values = metadata or {}
webdav_url = _clean(values.get("webdav_endpoint_url"))
browse_protocol = (_clean(values.get("browse_protocol")) or "").casefold()
if webdav_url and (clean_provider in {"seafile", "webdav", "nextcloud"} or browse_protocol == "webdav"):
return webdav_url
return _clean(endpoint_url)
def _validate_secret_env_reference(name: str, *, source_kind: str) -> str:
if source_kind.strip().casefold() != "settings":
raise ConnectorDeploymentConfigurationError(
"Environment-backed credentials may only be used by deployment-owned connector configuration"
)
clean_name = name.strip()
if not _ENV_NAME.fullmatch(clean_name):
raise ConnectorDeploymentConfigurationError("Connector credential environment variable name is invalid")
if clean_name not in _allowed_secret_env_names():
raise ConnectorDeploymentConfigurationError(
f"Connector credential environment variable is not listed in {_SECRET_ENV_ALLOWLIST}"
)
return clean_name
def _allowed_secret_env_names() -> frozenset[str]:
raw = os.environ.get(_SECRET_ENV_ALLOWLIST, "")
names = frozenset(item.strip() for item in raw.split(",") if item.strip())
invalid = sorted(name for name in names if not _ENV_NAME.fullmatch(name))
if invalid:
raise ConnectorDeploymentConfigurationError(f"{_SECRET_ENV_ALLOWLIST} contains an invalid variable name")
return names
def _allowed_ca_bundle_paths() -> frozenset[Path]:
raw = os.environ.get(_CA_BUNDLE_ALLOWLIST, "")
paths: set[Path] = set()
for item in (part.strip() for part in raw.split(",")):
if not item:
continue
path = Path(item)
if not path.is_absolute():
raise ConnectorDeploymentConfigurationError(f"{_CA_BUNDLE_ALLOWLIST} requires absolute paths")
try:
paths.add(path.resolve(strict=True))
except OSError as exc:
raise ConnectorDeploymentConfigurationError(f"{_CA_BUNDLE_ALLOWLIST} contains a missing path") from exc
return frozenset(paths)
def _development_runtime() -> bool:
app_env = os.environ.get("APP_ENV", "dev").strip().casefold()
install_profile = os.environ.get("GOVOPLAN_INSTALL_PROFILE", "").strip().casefold()
return app_env in _DEVELOPMENT_ENVIRONMENTS and (
not install_profile or install_profile in _DEVELOPMENT_ENVIRONMENTS
)
def _as_bool(value: object, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
clean = str(value).strip().casefold()
if clean in {"1", "true", "yes", "on"}:
return True
if clean in {"0", "false", "no", "off"}:
return False
raise ConnectorDeploymentConfigurationError("Connector TLS verification setting must be true or false")
def _clean(value: object) -> str | None:
if value is None:
return None
clean = str(value).strip()
return clean or None

View File

@@ -0,0 +1,353 @@
from __future__ import annotations
from dataclasses import dataclass, field
import mimetypes
from typing import Any
from govoplan_core.security.outbound_http import response_limit
from govoplan_files.backend.storage.connector_browse import (
ConnectorBrowseError,
ConnectorBrowseUnsupported,
_clean,
_metadata_string,
_profile_password,
_profile_token,
_request_json,
_smb_client_kwargs,
_smb_location,
_smb_stat_modified_at,
_smb_stat_revision,
_smb_stat_size,
_smb_unc_path,
_smbclient_module,
_s3_bucket,
_s3_client,
_s3_object_key,
_seafile_headers,
_seafile_url,
_webdav_url,
normalize_connector_browse_path,
)
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
from govoplan_files.backend.storage.http_client import ConnectorHttpError, request_connector_bytes
from govoplan_files.backend.storage.paths import filename_from_path
class ConnectorImportError(RuntimeError):
pass
class ConnectorImportUnsupported(ConnectorImportError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorDownloadedFile:
filename: str
data: bytes
content_type: str | None = None
revision: str | None = None
external_id: str | None = None
external_url: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def read_connector_file(
profile: ConnectorProfile,
*,
library_id: str,
path: str,
max_bytes: int,
) -> ConnectorDownloadedFile:
max_bytes = min(max_bytes, response_limit("file"))
if profile.provider == "seafile":
if _metadata_string(profile, "browse_protocol") == "webdav" or _metadata_string(profile, "webdav_endpoint_url"):
return _read_webdav_file(profile, path=path, max_bytes=max_bytes)
return _read_seafile_file(profile, library_id=library_id, path=path, max_bytes=max_bytes)
if profile.provider in {"webdav", "nextcloud"}:
return _read_webdav_file(profile, path=path, max_bytes=max_bytes)
if profile.provider == "smb":
return _read_smb_file(profile, path=path, max_bytes=max_bytes)
if profile.provider == "s3":
return _read_s3_file(profile, library_id=library_id, path=path, max_bytes=max_bytes)
raise ConnectorImportUnsupported(f"Connector file import is not implemented for {profile.provider} profiles yet")
def _read_seafile_file(profile: ConnectorProfile, *, library_id: str, path: str, max_bytes: int) -> ConnectorDownloadedFile:
repo_id = _clean(library_id)
if not repo_id:
raise ConnectorImportError("Seafile import requires library_id")
file_path = "/" + normalize_connector_browse_path(path)
if file_path == "/":
raise ConnectorImportError("Seafile import requires a file path")
try:
headers = _seafile_headers(profile)
detail = _request_json(
"GET",
_seafile_url(profile, f"api2/repos/{repo_id}/file/detail/"),
headers=headers,
params={"p": file_path},
)
if isinstance(detail, dict):
size = _int(detail.get("size"))
if size is not None and size > max_bytes:
raise ConnectorImportError(f"Seafile file exceeds limit of {max_bytes} bytes")
download_url = _request_json(
"GET",
_seafile_url(profile, f"api2/repos/{repo_id}/file/"),
headers=headers,
params={"p": file_path, "reuse": "1"},
)
except ConnectorBrowseError as exc:
raise ConnectorImportError(str(exc)) from exc
if not isinstance(download_url, str) or not download_url.strip():
raise ConnectorImportError("Seafile did not return a file download URL")
try:
response = request_connector_bytes(
"GET",
download_url,
timeout=30.0,
kind="file",
max_bytes=max_bytes,
label="Seafile file download",
)
except ConnectorHttpError as exc:
raise ConnectorImportError(f"Seafile file download failed: {exc}") from exc
if response.status_code != 200:
raise ConnectorImportError(f"Seafile file download failed with HTTP {response.status_code}")
data = response.content
if len(data) > max_bytes:
raise ConnectorImportError(f"Seafile file exceeds limit of {max_bytes} bytes")
detail = detail if isinstance(detail, dict) else {}
filename = filename_from_path(str(detail.get("name") or path))
content_type = response.headers.get("content-type") or mimetypes.guess_type(filename)[0]
external_id = f"{repo_id}:{normalize_connector_browse_path(path)}"
return ConnectorDownloadedFile(
filename=filename,
data=data,
content_type=content_type,
revision=_clean(detail.get("id") or detail.get("mtime") or detail.get("last_modified")),
external_id=external_id,
external_url=download_url,
metadata={
"library_id": repo_id,
"library_path": normalize_connector_browse_path(path),
"size": len(data),
**({key: detail[key] for key in ("permission", "last_modified", "mtime", "uploader_email", "last_modifier_email") if key in detail}),
},
)
def _read_webdav_file(profile: ConnectorProfile, *, path: str, max_bytes: int) -> ConnectorDownloadedFile:
root_url = _metadata_string(profile, "webdav_endpoint_url") or profile.endpoint_url
if not root_url:
raise ConnectorImportError("WebDAV connector profile does not define endpoint_url")
file_path = normalize_connector_browse_path(path)
if not file_path:
raise ConnectorImportError("WebDAV import requires a file path")
url = _webdav_url(root_url, file_path).rstrip("/")
headers: dict[str, str] = {}
auth: tuple[str, str] | None = None
password = _profile_password(profile)
token = _profile_token(profile)
if profile.username and password:
auth = (profile.username, password)
elif token:
headers["Authorization"] = f"Bearer {token}"
elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref:
raise ConnectorImportError("Secret-ref WebDAV credentials need a runtime secret resolver before live import")
try:
response = request_connector_bytes(
"GET",
url,
headers=headers,
auth=auth,
timeout=30.0,
kind="file",
max_bytes=max_bytes,
label="WebDAV file download",
)
except ConnectorHttpError as exc:
raise ConnectorImportError(f"WebDAV file download failed: {exc}") from exc
if response.status_code in {401, 403}:
raise ConnectorImportError("Connector credentials were rejected")
if response.status_code != 200:
raise ConnectorImportError(f"WebDAV file download failed with HTTP {response.status_code}")
length = _int(response.headers.get("content-length"))
if length is not None and length > max_bytes:
raise ConnectorImportError(f"WebDAV file exceeds limit of {max_bytes} bytes")
data = response.content
if len(data) > max_bytes:
raise ConnectorImportError(f"WebDAV file exceeds limit of {max_bytes} bytes")
filename = filename_from_path(file_path)
revision = _clean(response.headers.get("etag") or response.headers.get("last-modified"))
return ConnectorDownloadedFile(
filename=filename,
data=data,
content_type=response.headers.get("content-type") or mimetypes.guess_type(filename)[0],
revision=revision,
external_id=file_path,
external_url=url,
metadata={"path": file_path, "size": len(data)},
)
def _read_smb_file(profile: ConnectorProfile, *, path: str, max_bytes: int) -> ConnectorDownloadedFile:
file_path = normalize_connector_browse_path(path)
if not file_path:
raise ConnectorImportError("SMB import requires a file path")
try:
location = _smb_location(profile)
unc_path = _smb_unc_path(location, file_path)
smbclient = _smbclient_module()
kwargs = _smb_client_kwargs(profile, location)
stat_result = smbclient.stat(unc_path, **kwargs)
size = _smb_stat_size(stat_result)
if size is not None and size > max_bytes:
raise ConnectorImportError(f"SMB file exceeds limit of {max_bytes} bytes")
with smbclient.open_file(unc_path, mode="rb", **kwargs) as handle:
data = handle.read(max_bytes + 1)
except ConnectorBrowseUnsupported as exc:
raise ConnectorImportUnsupported(str(exc)) from exc
except ConnectorBrowseError as exc:
raise ConnectorImportError(str(exc)) from exc
except ConnectorImportError:
raise
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorImportError(f"SMB file download failed: {exc}") from exc
if len(data) > max_bytes:
raise ConnectorImportError(f"SMB file exceeds limit of {max_bytes} bytes")
filename = filename_from_path(file_path)
revision = _smb_stat_revision(stat_result)
return ConnectorDownloadedFile(
filename=filename,
data=data,
content_type=mimetypes.guess_type(filename)[0],
revision=revision,
external_id=f"{location.share}:{file_path}",
external_url=f"smb://{location.server}:{location.port}/{location.share}/{file_path}",
metadata={
"share": location.share,
"path": file_path,
"size": len(data),
"modified_at": _smb_stat_modified_at(stat_result),
},
)
def _s3_import_client(profile: ConnectorProfile) -> Any:
try:
return _s3_client(profile)
except ConnectorBrowseUnsupported as exc:
raise ConnectorImportUnsupported(str(exc)) from exc
except ConnectorBrowseError as exc:
raise ConnectorImportError(str(exc)) from exc
def _s3_object_detail(client: Any, *, bucket: str, key: str, max_bytes: int) -> dict[str, Any]:
try:
detail = client.head_object(Bucket=bucket, Key=key)
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorImportError(f"S3 object metadata lookup failed: {exc}") from exc
if not isinstance(detail, dict):
raise ConnectorImportError("S3 connector returned invalid object metadata")
size = _int(detail.get("ContentLength"))
if size is not None and size > max_bytes:
raise ConnectorImportError(f"S3 object exceeds limit of {max_bytes} bytes")
return detail
def _download_s3_object(
client: Any,
*,
bucket: str,
key: str,
version_id: str | None,
max_bytes: int,
) -> tuple[Any, bytes]:
request: dict[str, object] = {"Bucket": bucket, "Key": key}
if version_id:
request["VersionId"] = version_id
try:
response = client.get_object(**request)
body = response.get("Body")
data = body.read(max_bytes + 1) if hasattr(body, "read") else bytes(response.get("Body") or b"")
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
raise ConnectorImportError(f"S3 object download failed: {exc}") from exc
if len(data) > max_bytes:
raise ConnectorImportError(f"S3 object exceeds limit of {max_bytes} bytes")
return response, data
def _s3_download_metadata(
detail: dict[str, Any],
*,
bucket: str,
key: str,
version_id: str | None,
etag: str | None,
size: int,
) -> dict[str, Any]:
metadata: dict[str, Any] = {
"bucket": bucket,
"key": key,
"version_id": version_id,
"etag": etag,
"size": size,
}
for source_key, target_key in (
("ChecksumSHA256", "checksum_sha256"),
("ChecksumCRC32", "checksum_crc32"),
("StorageClass", "storage_class"),
):
if source_key in detail:
metadata[target_key] = detail[source_key]
return metadata
def _read_s3_file(profile: ConnectorProfile, *, library_id: str, path: str, max_bytes: int) -> ConnectorDownloadedFile:
bucket = _s3_bucket(profile, library_id)
if not bucket:
raise ConnectorImportError("S3 import requires a bucket in library_id or profile metadata")
key = _s3_object_key(profile, path)
if not key:
raise ConnectorImportError("S3 import requires an object key")
client = _s3_import_client(profile)
detail = _s3_object_detail(client, bucket=bucket, key=key, max_bytes=max_bytes)
version_id = _clean(detail.get("VersionId"))
response, data = _download_s3_object(
client,
bucket=bucket,
key=key,
version_id=version_id,
max_bytes=max_bytes,
)
content_type = _clean(response.get("ContentType") if isinstance(response, dict) else None) or _clean(detail.get("ContentType")) or mimetypes.guess_type(key)[0]
etag = _clean(response.get("ETag") if isinstance(response, dict) else None) or _clean(detail.get("ETag"))
filename = filename_from_path(key)
return ConnectorDownloadedFile(
filename=filename,
data=data,
content_type=content_type,
revision=version_id or etag or _clean(detail.get("LastModified")),
external_id=f"{bucket}:{key}",
external_url=f"s3://{bucket}/{key}",
metadata=_s3_download_metadata(
detail,
bucket=bucket,
key=key,
version_id=version_id,
etag=etag,
size=len(data),
),
)
def _int(value: object) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None

View File

@@ -0,0 +1,310 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from fnmatch import fnmatchcase
from typing import Any
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep, normalize_policy_scope_type
_ALLOW_KEYS = ("allow", "allowlist", "whitelist")
_DENY_KEYS = ("deny", "denylist", "blacklist")
_FIELD_ALIASES = {
"connectors": ("connectors", "connector_ids", "connector_id"),
"credentials": ("credentials", "credential_ids", "credential_id"),
"providers": ("providers", "provider"),
"external_ids": ("external_ids", "external_id"),
"external_paths": ("external_paths", "paths", "path_prefixes", "external_path"),
"external_urls": ("external_urls", "urls", "external_url"),
}
CONNECTOR_POLICY_FIELDS = tuple(_FIELD_ALIASES)
class ConnectorPolicyDenied(RuntimeError):
def __init__(self, decision: PolicyDecision) -> None:
super().__init__(decision.reason or "Connector policy denied")
self.decision = decision
@dataclass(frozen=True, slots=True)
class ConnectorAccessRequest:
connector_id: str | None = None
credential_id: str | None = None
provider: str | None = None
external_id: str | None = None
external_path: str | None = None
external_url: str | None = None
operation: str = "access"
@classmethod
def from_provenance(cls, value: Mapping[str, Any] | None, *, operation: str = "access") -> "ConnectorAccessRequest":
payload = value or {}
return cls(
connector_id=_clean(payload.get("connector_id")),
credential_id=_clean(payload.get("credential_id") or payload.get("connector_credential_id")),
provider=_clean(payload.get("provider") or payload.get("source_type")),
external_id=_clean(payload.get("external_id")),
external_path=_clean(payload.get("external_path")),
external_url=_clean(payload.get("external_url")),
operation=_clean(operation) or "access",
)
def to_dict(self) -> dict[str, str | None]:
return {
"connector_id": self.connector_id,
"credential_id": self.credential_id,
"provider": self.provider,
"external_id": self.external_id,
"external_path": self.external_path,
"external_url": self.external_url,
"operation": self.operation,
}
@dataclass(frozen=True, slots=True)
class ConnectorPolicySource:
scope_type: str
label: str
scope_id: str | None = None
policy: Mapping[str, Any] | None = None
def source_step(self, applied_fields: Iterable[str]) -> PolicySourceStep:
return PolicySourceStep(
scope_type=normalize_policy_scope_type(self.scope_type),
scope_id=self.scope_id,
label=self.label,
applied_fields=tuple(dict.fromkeys(applied_fields)),
policy=dict(self.policy or {}),
)
def connector_policy_sources_from_payload(value: object) -> list[ConnectorPolicySource]:
if value is None or value == "":
return []
if isinstance(value, Mapping):
raw_sources = value.get("sources")
if raw_sources is None:
raw_sources = [value]
elif isinstance(value, list):
raw_sources = value
else:
raise ValueError("connector_policy must be a JSON object or list")
if not isinstance(raw_sources, list):
raise ValueError("connector_policy.sources must be a list")
return [_source_from_mapping(item) for item in raw_sources if isinstance(item, Mapping)]
def connector_policy_applied_fields(policy: Mapping[str, Any] | None) -> tuple[str, ...]:
return _applied_fields(_policy_mapping(policy))
def filtered_connector_policy_source(source: ConnectorPolicySource, fields: Iterable[str]) -> ConnectorPolicySource:
allowed_fields = {field for field in fields if field in _FIELD_ALIASES}
if not allowed_fields:
return ConnectorPolicySource(scope_type=source.scope_type, scope_id=source.scope_id, label=source.label, policy={})
policy = _policy_mapping(source.policy)
filtered: dict[str, Any] = {}
for prefix, keys in (("allow", _ALLOW_KEYS), ("deny", _DENY_KEYS)):
rules = _merged_rule(policy, keys)
selected = {field: _string_list(rules.get(field)) for field in allowed_fields}
selected = {field: values for field, values in selected.items() if values}
if selected:
filtered[prefix] = selected
return ConnectorPolicySource(scope_type=source.scope_type, scope_id=source.scope_id, label=source.label, policy=filtered)
def connector_policy_sources_for_fields(
sources: Iterable[ConnectorPolicySource],
fields: Iterable[str],
) -> list[ConnectorPolicySource]:
return [filtered_connector_policy_source(source, fields) for source in sources]
def connector_policy_decision(
request: ConnectorAccessRequest,
sources: Iterable[ConnectorPolicySource],
) -> PolicyDecision:
source_list = list(sources)
source_steps: list[PolicySourceStep] = []
deny_matches: list[dict[str, Any]] = []
allow_misses: list[dict[str, Any]] = []
for source in source_list:
policy = _policy_mapping(source.policy)
applied_fields = _applied_fields(policy)
if applied_fields:
source_steps.append(source.source_step(applied_fields))
deny_policy = _merged_rule(policy, _DENY_KEYS)
for field, patterns in _rules_by_field(deny_policy).items():
if _matches_field(request, field, patterns):
deny_matches.append({
"scope": source.source_step((f"deny.{field}",)).to_dict(),
"field": field,
"patterns": patterns,
})
allow_policy = _merged_rule(policy, _ALLOW_KEYS)
for field, patterns in _rules_by_field(allow_policy).items():
if not _matches_field(request, field, patterns):
allow_misses.append({
"scope": source.source_step((f"allow.{field}",)).to_dict(),
"field": field,
"patterns": patterns,
})
details = {
"request": request.to_dict(),
"deny_matches": deny_matches,
"allow_misses": allow_misses,
}
if deny_matches:
return PolicyDecision(
allowed=False,
reason="Connector access is denied by a blacklist rule.",
source_path=tuple(source_steps),
requirements=("connector_policy_denylist",),
details=details,
)
if allow_misses:
return PolicyDecision(
allowed=False,
reason="Connector access is not allowed by the configured whitelist.",
source_path=tuple(source_steps),
requirements=("connector_policy_allowlist",),
details=details,
)
return PolicyDecision(
allowed=True,
reason=None,
source_path=tuple(source_steps),
requirements=(),
details=details,
)
def ensure_connector_policy_allows(
request: ConnectorAccessRequest,
sources: Iterable[ConnectorPolicySource],
) -> PolicyDecision:
decision = connector_policy_decision(request, sources)
if not decision.allowed:
raise ConnectorPolicyDenied(decision)
return decision
def _source_from_mapping(value: Mapping[str, Any]) -> ConnectorPolicySource:
scope_type = normalize_policy_scope_type(str(value.get("scope_type") or "system"))
scope_id = _clean(value.get("scope_id"))
if scope_type == "system":
scope_id = None
elif not scope_id:
raise ValueError(f"{scope_type} connector policy sources require scope_id")
label = _clean(value.get("label")) or scope_type.capitalize()
policy = value.get("policy")
if policy is None:
policy = {key: value[key] for key in (*_ALLOW_KEYS, *_DENY_KEYS) if key in value}
if policy is not None and not isinstance(policy, Mapping):
raise ValueError("connector policy source policy must be a JSON object")
return ConnectorPolicySource(scope_type=scope_type, scope_id=scope_id, label=label, policy=policy or {})
def _policy_mapping(value: Mapping[str, Any] | None) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _merged_rule(policy: Mapping[str, Any], keys: Iterable[str]) -> dict[str, Any]:
merged: dict[str, Any] = {}
for key in keys:
raw = policy.get(key)
if isinstance(raw, Mapping):
merged.update(raw)
return merged
def _rules_by_field(value: Mapping[str, Any]) -> dict[str, list[str]]:
result: dict[str, list[str]] = {}
for field, aliases in _FIELD_ALIASES.items():
items: list[str] = []
for alias in aliases:
items.extend(_string_list(value.get(alias)))
if items:
result[field] = list(dict.fromkeys(items))
return result
def _applied_fields(policy: Mapping[str, Any]) -> tuple[str, ...]:
fields: list[str] = []
for prefix, keys in (("allow", _ALLOW_KEYS), ("deny", _DENY_KEYS)):
rules = _merged_rule(policy, keys)
fields.extend(f"{prefix}.{field}" for field in _rules_by_field(rules))
return tuple(dict.fromkeys(fields))
def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool:
if field == "connectors":
return _matches_exact(request.connector_id, patterns)
if field == "credentials":
return _matches_exact(request.credential_id, patterns)
if field == "providers":
return _matches_exact(request.provider, patterns)
if field == "external_ids":
return _matches_glob(request.external_id, patterns)
if field == "external_paths":
return _matches_path(request.external_path, patterns)
if field == "external_urls":
return _matches_glob(request.external_url, patterns)
return False
def _matches_exact(value: str | None, patterns: list[str]) -> bool:
if value is None:
return False
clean = value.casefold()
return any(pattern == "*" or clean == pattern.casefold() for pattern in patterns)
def _matches_glob(value: str | None, patterns: list[str]) -> bool:
if value is None:
return False
clean = value.casefold()
return any(fnmatchcase(clean, pattern.casefold()) for pattern in patterns)
def _matches_path(value: str | None, patterns: list[str]) -> bool:
if value is None:
return False
clean = value.replace("\\", "/").strip()
for pattern in patterns:
normalized = pattern.replace("\\", "/").strip()
if normalized == "*":
return True
if any(token in normalized for token in "*?[]"):
if fnmatchcase(clean.casefold(), normalized.casefold()):
return True
continue
if clean.casefold() == normalized.casefold() or clean.casefold().startswith(normalized.rstrip("/").casefold() + "/"):
return True
return False
def _string_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
values = [value]
elif isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray, Mapping)):
values = [str(item) for item in value]
else:
values = [str(value)]
return [item.strip() for item in values if item.strip()]
def _clean(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@@ -0,0 +1,334 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.policy import normalize_policy_scope_type
from govoplan_files.backend.db.models import FileConnectorPolicy
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.connector_policy import (
CONNECTOR_POLICY_FIELDS,
ConnectorPolicySource,
connector_policy_applied_fields,
)
_RULE_GROUPS = ("allow", "deny")
_FIELD_ALIASES = {
"connectors": ("connectors", "connector_ids", "connector_id"),
"credentials": ("credentials", "credential_ids", "credential_id"),
"providers": ("providers", "provider"),
"external_ids": ("external_ids", "external_id"),
"external_paths": ("external_paths", "paths", "path_prefixes", "external_path"),
"external_urls": ("external_urls", "urls", "external_url"),
}
def get_connector_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> dict[str, Any]:
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
return _normalize_connector_policy(row.policy if row is not None else None)
def set_connector_policy(
session: Session,
*,
tenant_id: str,
user_id: str | None,
scope_type: str,
scope_id: str | None = None,
policy: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
normalized = _normalize_connector_policy(policy)
parent = parent_connector_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
if parent is not None:
_validate_lower_level_limits(parent, normalized)
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=row_scope_type, scope_id=row_scope_id)
if row is None:
row = FileConnectorPolicy(
tenant_id=row_tenant_id,
scope_type=row_scope_type,
scope_id=row_scope_id,
policy=normalized,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
else:
row.policy = normalized
row.updated_by_user_id = user_id
session.add(row)
session.flush()
return normalized
def connector_policy_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> dict[str, Any]:
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
policy = get_connector_policy(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
effective_sources = effective_connector_policy_sources(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
parent_policy = parent_connector_policy(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
parent_sources = parent_connector_policy_sources(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
return {
"scope_type": clean_scope_type,
"scope_id": clean_scope_id,
"policy": policy,
"effective_policy": merge_connector_policies(source.policy for source in effective_sources),
"parent_policy": parent_policy,
"effective_policy_sources": [_source_step(source) for source in effective_sources],
"parent_policy_sources": [_source_step(source) for source in parent_sources],
}
def effective_connector_policy_sources(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> list[ConnectorPolicySource]:
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
sources: list[ConnectorPolicySource] = []
for source_scope_type, source_scope_id in _policy_source_chain(tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id):
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=source_scope_type, scope_id=source_scope_id)
if row is None:
continue
policy = _normalize_connector_policy(row.policy)
if _policy_is_empty(policy):
continue
sources.append(
ConnectorPolicySource(
scope_type=source_scope_type,
scope_id=source_scope_id,
label=_policy_source_label(source_scope_type),
policy=policy,
)
)
return sources
def parent_connector_policy_sources(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> list[ConnectorPolicySource]:
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
chain = _policy_source_chain(tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
parent_chain = chain[:-1] if chain else []
sources: list[ConnectorPolicySource] = []
for source_scope_type, source_scope_id in parent_chain:
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=source_scope_type, scope_id=source_scope_id)
if row is None:
continue
policy = _normalize_connector_policy(row.policy)
if _policy_is_empty(policy):
continue
sources.append(
ConnectorPolicySource(
scope_type=source_scope_type,
scope_id=source_scope_id,
label=_policy_source_label(source_scope_type),
policy=policy,
)
)
return sources
def parent_connector_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> dict[str, Any] | None:
sources = parent_connector_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
if not sources:
return None
return merge_connector_policies(source.policy for source in sources)
def validate_connector_policy_allowed_by_parent(parent_policy: Mapping[str, Any] | None, local_policy: Mapping[str, Any] | None) -> None:
if parent_policy is None:
return
_validate_lower_level_limits(_normalize_connector_policy(parent_policy), _normalize_connector_policy(local_policy))
def merge_connector_policies(policies: Iterable[Mapping[str, Any] | None]) -> dict[str, Any]:
result = _empty_connector_policy()
for policy in policies:
normalized = _normalize_connector_policy(policy)
for group in _RULE_GROUPS:
rules = normalized.get(group) or {}
if not isinstance(rules, Mapping):
continue
target = result[group]
for field in CONNECTOR_POLICY_FIELDS:
values = _string_list(rules.get(field))
if values:
target[field] = _unique([*target.get(field, []), *values])
lower = normalized.get("allow_lower_level_limits") or {}
if isinstance(lower, Mapping):
for key, value in lower.items():
if isinstance(value, bool):
result["allow_lower_level_limits"][str(key)] = value
return _compact_connector_policy(result, keep_lower=True)
def _connector_policy_row(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> FileConnectorPolicy | None:
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
query = session.query(FileConnectorPolicy).filter(FileConnectorPolicy.scope_type == row_scope_type)
query = query.filter(FileConnectorPolicy.tenant_id.is_(None) if row_tenant_id is None else FileConnectorPolicy.tenant_id == row_tenant_id)
query = query.filter(FileConnectorPolicy.scope_id.is_(None) if row_scope_id is None else FileConnectorPolicy.scope_id == row_scope_id)
return query.order_by(FileConnectorPolicy.created_at.asc()).first()
def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]:
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
if clean_scope_type == "system":
return None, "system", None
return tenant_id, clean_scope_type, clean_scope_id
def _policy_scope_ref(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str, str | None]:
clean_scope_type = normalize_policy_scope_type(scope_type)
clean_scope_id = _clean(scope_id)
if clean_scope_type == "system":
return "system", None
if clean_scope_type == "tenant":
if clean_scope_id and clean_scope_id != tenant_id:
raise FileStorageError("Tenant connector policy scope does not belong to the active tenant")
return "tenant", tenant_id
if clean_scope_type in {"user", "group", "campaign"}:
if not clean_scope_id:
raise FileStorageError(f"{clean_scope_type.capitalize()} connector policy requires scope_id")
return clean_scope_type, clean_scope_id
raise FileStorageError("Unsupported connector policy scope")
def _policy_source_chain(*, tenant_id: str, scope_type: str, scope_id: str | None) -> list[tuple[str, str | None]]:
chain: list[tuple[str, str | None]] = [("system", None)]
if scope_type == "system":
return chain
chain.append(("tenant", tenant_id))
if scope_type == "tenant":
return chain
chain.append((scope_type, scope_id))
return chain
def _normalize_connector_policy(policy: Mapping[str, Any] | None) -> dict[str, Any]:
if policy is None:
return {}
if not isinstance(policy, Mapping):
raise FileStorageError("Connector policy must be a JSON object")
normalized = _empty_connector_policy()
for group in _RULE_GROUPS:
raw = policy.get(group)
if raw is None:
raw = policy.get(f"{group}list")
if raw is None:
raw = policy.get("whitelist" if group == "allow" else "blacklist")
if raw is not None and not isinstance(raw, Mapping):
raise FileStorageError(f"Connector policy {group} rules must be an object")
for field, aliases in _FIELD_ALIASES.items():
values: list[str] = []
if isinstance(raw, Mapping):
for alias in aliases:
values.extend(_string_list(raw.get(alias)))
if values:
normalized[group][field] = _unique(values)
lower = policy.get("allow_lower_level_limits")
if lower is not None:
if not isinstance(lower, Mapping):
raise FileStorageError("Connector policy allow_lower_level_limits must be an object")
for key, value in lower.items():
clean_key = str(key).strip()
if clean_key and isinstance(value, bool):
normalized["allow_lower_level_limits"][clean_key] = value
return _compact_connector_policy(normalized, keep_lower=True)
def _empty_connector_policy() -> dict[str, Any]:
return {"allow": {}, "deny": {}, "allow_lower_level_limits": {}}
def _compact_connector_policy(policy: dict[str, Any], *, keep_lower: bool = False) -> dict[str, Any]:
compact: dict[str, Any] = {}
for group in _RULE_GROUPS:
rules = {field: _unique(_string_list(values)) for field, values in (policy.get(group) or {}).items()}
rules = {field: values for field, values in rules.items() if values}
if rules:
compact[group] = rules
lower = policy.get("allow_lower_level_limits") or {}
if isinstance(lower, Mapping):
lower_compact = {str(key): bool(value) for key, value in lower.items() if isinstance(value, bool)}
if lower_compact or keep_lower:
compact["allow_lower_level_limits"] = lower_compact
return compact
def _policy_is_empty(policy: Mapping[str, Any]) -> bool:
return not connector_policy_applied_fields(policy) and not bool(policy.get("allow_lower_level_limits"))
def _source_step(source: ConnectorPolicySource) -> dict[str, Any]:
return source.source_step(connector_policy_applied_fields(source.policy)).to_dict()
def _policy_source_label(scope_type: str) -> str:
if scope_type == "system":
return "System connector policy"
if scope_type == "tenant":
return "Tenant connector policy"
return f"{scope_type.capitalize()} connector policy"
def _validate_lower_level_limits(parent_policy: Mapping[str, Any], local_policy: Mapping[str, Any]) -> None:
limits = parent_policy.get("allow_lower_level_limits")
if not isinstance(limits, Mapping):
return
for field in connector_policy_applied_fields(local_policy):
allowed = limits.get(field)
if allowed is False:
raise FileStorageError(f"Parent connector policy does not allow lower-level {field} limits")
def _string_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
clean = value.strip()
return [clean] if clean else []
if isinstance(value, Iterable) and not isinstance(value, (str, bytes, bytearray, Mapping)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
def _unique(values: Iterable[str]) -> list[str]:
return list(dict.fromkeys(value for value in values if value))
def _clean(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.policy import normalize_policy_scope_type
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.connector_deployment import reject_api_controlled_deployment_references
from govoplan_files.backend.storage.connector_credential_store import (
ConnectorCredential,
connector_credential_from_row,
credential_rows_by_id,
resolve_reusable_connector_credential,
reusable_credential_id,
)
from govoplan_files.backend.storage.connector_policy import connector_policy_sources_from_payload
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, supported_connector_providers
def list_database_connector_profiles(
session: Session,
*,
tenant_id: str,
include_disabled: bool = False,
row_visible: Callable[[FileConnectorProfile], bool] | None = None,
) -> list[ConnectorProfile]:
profiles, _profile_ids = select_database_connector_profiles(
session,
tenant_id=tenant_id,
include_disabled=include_disabled,
row_visible=row_visible,
)
return profiles
def select_database_connector_profiles(
session: Session,
*,
tenant_id: str,
include_disabled: bool = False,
row_visible: Callable[[FileConnectorProfile], bool] | None = None,
) -> tuple[list[ConnectorProfile], set[str]]:
"""Return visible profiles and every database id that shadows settings."""
query = session.query(FileConnectorProfile).filter(
(FileConnectorProfile.scope_type == "system")
| (FileConnectorProfile.tenant_id == tenant_id)
)
if not include_disabled:
query = query.filter(FileConnectorProfile.enabled.is_(True))
rows = query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
profile_ids = {row.id for row in rows}
if row_visible is not None:
rows = [row for row in rows if row_visible(row)]
credential_ids = {
_clean(row.credential_profile_id)
for row in rows
if _clean(row.credential_profile_id) and not reusable_credential_id(row.credential_profile_id)
}
credentials = credential_rows_by_id(session, tenant_id=tenant_id, credential_ids={item for item in credential_ids if item}, include_disabled=include_disabled)
return (
[
connector_profile_from_row(
row,
credential_row=(
_resolve_profile_reusable_credential(
session,
tenant_id=tenant_id,
row=row,
)
if reusable_credential_id(row.credential_profile_id)
else credentials.get(row.credential_profile_id or "")
),
)
for row in rows
],
profile_ids,
)
def list_connector_profile_rows(
session: Session,
*,
tenant_id: str,
include_disabled: bool = False,
) -> list[FileConnectorProfile]:
query = session.query(FileConnectorProfile).filter(
(FileConnectorProfile.scope_type == "system")
| (FileConnectorProfile.tenant_id == tenant_id)
)
if not include_disabled:
query = query.filter(FileConnectorProfile.enabled.is_(True))
return query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
def connector_profile_from_row(
row: FileConnectorProfile,
credential_row: FileConnectorCredential | ConnectorCredential | None = None,
) -> ConnectorProfile:
policy_sources = []
if row.policy:
policy_sources = connector_policy_sources_from_payload({
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"label": row.label,
"policy": row.policy,
})
credential = (
credential_row
if isinstance(credential_row, ConnectorCredential)
else connector_credential_from_row(credential_row)
if credential_row is not None
else None
)
if credential:
policy_sources.extend(credential.policy_sources)
metadata = dict(row.metadata_ or {})
if reusable_credential_id(row.credential_profile_id) and credential is None:
metadata["credential_unavailable"] = True
return ConnectorProfile(
id=row.id,
label=row.label,
provider=row.provider,
scope_type=row.scope_type,
scope_id=row.scope_id,
endpoint_url=row.endpoint_url,
base_path=row.base_path,
enabled=row.enabled,
credential_profile_id=row.credential_profile_id,
credential_profile_label=credential.label if credential else None,
credential_mode=credential.credential_mode if credential else row.credential_mode,
username=credential.username if credential else row.username,
password_env=credential.password_env if credential else row.password_env,
token_env=credential.token_env if credential else row.token_env,
secret_ref=credential.secret_ref if credential else row.secret_ref,
password_value=credential.password_value if credential else decrypt_secret(row.password_encrypted),
token_value=credential.token_value if credential else decrypt_secret(row.token_encrypted),
capabilities=tuple(_string_list(row.capabilities)),
policy_sources=tuple(policy_sources),
metadata=metadata,
source_kind="database",
)
def _resolve_profile_reusable_credential(
session: Session,
*,
tenant_id: str,
row: FileConnectorProfile,
) -> ConnectorCredential | None:
try:
return resolve_reusable_connector_credential(
session,
tenant_id=tenant_id,
credential_ref=row.credential_profile_id or "",
profile_id=row.id,
scope_type=row.scope_type,
scope_id=row.scope_id,
)
except FileStorageError:
return None
def get_connector_profile_row(
session: Session,
*,
tenant_id: str,
profile_id: str,
include_disabled: bool = False,
) -> FileConnectorProfile:
row = session.get(FileConnectorProfile, profile_id)
if row is None or (row.scope_type != "system" and row.tenant_id != tenant_id):
raise FileStorageError("Connector profile not found")
if not include_disabled and not row.enabled:
raise FileStorageError("Connector profile not found")
return row
def create_connector_profile_row(
session: Session,
*,
tenant_id: str,
user_id: str | None,
profile_id: str,
label: str,
provider: str,
scope_type: str,
scope_id: str | None = None,
endpoint_url: str | None = None,
base_path: str | None = None,
enabled: bool = True,
credential_profile_id: str | None = None,
credential_mode: str = "none",
username: str | None = None,
password: str | None = None,
token: str | None = None,
password_env: str | None = None,
token_env: str | None = None,
secret_ref: str | None = None,
capabilities: list[str] | None = None,
policy: Mapping[str, Any] | None = None,
metadata: Mapping[str, Any] | None = None,
) -> FileConnectorProfile:
reject_api_controlled_deployment_references(
password_env=password_env,
token_env=token_env,
secret_ref=secret_ref,
metadata=metadata,
)
clean_id = _normalize_profile_id(profile_id)
if session.get(FileConnectorProfile, clean_id) is not None:
raise FileStorageError(f"Connector profile already exists: {clean_id}")
clean_scope_type, clean_scope_id, row_tenant_id = _normalize_scope(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
row = FileConnectorProfile(
id=clean_id,
tenant_id=row_tenant_id,
scope_type=clean_scope_type,
scope_id=clean_scope_id,
label=_normalize_label(label),
provider=_normalize_provider(provider),
endpoint_url=_clean(endpoint_url),
base_path=_clean(base_path),
enabled=bool(enabled),
credential_profile_id=_clean(credential_profile_id),
credential_mode=_normalize_credential_mode(credential_mode),
username=_clean(username),
password_encrypted=encrypt_secret(_clean(password)),
token_encrypted=encrypt_secret(_clean(token)),
password_env=_clean(password_env),
token_env=_clean(token_env),
secret_ref=_clean(secret_ref),
capabilities=_string_list(capabilities),
policy=dict(policy or {}),
metadata_=dict(metadata or {}),
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
session.add(row)
session.flush()
return row
def update_connector_profile_row(
session: Session,
row: FileConnectorProfile,
*,
user_id: str | None,
label: str | None = None,
provider: str | None = None,
endpoint_url: str | None = None,
base_path: str | None = None,
enabled: bool | None = None,
credential_profile_id: str | None = None,
credential_mode: str | None = None,
username: str | None = None,
password: str | None = None,
token: str | None = None,
password_env: str | None = None,
token_env: str | None = None,
secret_ref: str | None = None,
capabilities: list[str] | None = None,
policy: Mapping[str, Any] | None = None,
metadata: Mapping[str, Any] | None = None,
clear_password: bool = False,
clear_token: bool = False,
) -> FileConnectorProfile:
_validate_profile_update_references(
row,
password_env=password_env,
token_env=token_env,
secret_ref=secret_ref,
metadata=metadata,
)
_update_profile_connection_fields(
row,
label=label,
provider=provider,
endpoint_url=endpoint_url,
base_path=base_path,
enabled=enabled,
credential_profile_id=credential_profile_id,
credential_mode=credential_mode,
)
_update_profile_credential_fields(
row,
username=username,
password=password,
token=token,
password_env=password_env,
token_env=token_env,
secret_ref=secret_ref,
clear_password=clear_password,
clear_token=clear_token,
)
_update_profile_governance_fields(
row,
capabilities=capabilities,
policy=policy,
metadata=metadata,
)
row.updated_by_user_id = user_id
session.add(row)
session.flush()
return row
def _validate_profile_update_references(
row: FileConnectorProfile,
*,
password_env: str | None,
token_env: str | None,
secret_ref: str | None,
metadata: Mapping[str, Any] | None,
) -> None:
reject_api_controlled_deployment_references(
password_env=password_env,
token_env=token_env,
secret_ref=secret_ref,
metadata=metadata,
)
if secret_ref is None or _clean(secret_ref) == _clean(row.secret_ref):
return
if _clean(row.secret_ref):
raise FileStorageError(
"An existing external secret reference cannot be replaced or cleared until Files can prove "
"provider ownership and confirm provider-side deletion"
)
def _update_profile_connection_fields(
row: FileConnectorProfile,
*,
label: str | None,
provider: str | None,
endpoint_url: str | None,
base_path: str | None,
enabled: bool | None,
credential_profile_id: str | None,
credential_mode: str | None,
) -> None:
if label is not None:
row.label = _normalize_label(label)
if provider is not None:
row.provider = _normalize_provider(provider)
if endpoint_url is not None:
row.endpoint_url = _clean(endpoint_url)
if base_path is not None:
row.base_path = _clean(base_path)
if enabled is not None:
row.enabled = bool(enabled)
if credential_profile_id is not None:
row.credential_profile_id = _clean(credential_profile_id)
if credential_mode is not None:
row.credential_mode = _normalize_credential_mode(credential_mode)
def _update_profile_credential_fields(
row: FileConnectorProfile,
*,
username: str | None,
password: str | None,
token: str | None,
password_env: str | None,
token_env: str | None,
secret_ref: str | None,
clear_password: bool,
clear_token: bool,
) -> None:
if username is not None:
row.username = _clean(username)
if password is not None:
row.password_encrypted = encrypt_secret(_clean(password))
elif clear_password:
row.password_encrypted = None
if token is not None:
row.token_encrypted = encrypt_secret(_clean(token))
elif clear_token:
row.token_encrypted = None
if password_env is not None:
row.password_env = _clean(password_env)
if token_env is not None:
row.token_env = _clean(token_env)
if secret_ref is not None:
row.secret_ref = _clean(secret_ref)
def _update_profile_governance_fields(
row: FileConnectorProfile,
*,
capabilities: list[str] | None,
policy: Mapping[str, Any] | None,
metadata: Mapping[str, Any] | None,
) -> None:
if capabilities is not None:
row.capabilities = _string_list(capabilities)
if policy is not None:
row.policy = dict(policy)
if metadata is not None:
row.metadata_ = dict(metadata)
def _normalize_scope(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str, str | None, str | None]:
clean_scope_type = normalize_policy_scope_type(scope_type)
clean_scope_id = _clean(scope_id)
if clean_scope_type == "system":
return "system", None, None
if clean_scope_type == "tenant":
return "tenant", tenant_id, tenant_id
if clean_scope_type in {"user", "group", "campaign"}:
if not clean_scope_id:
raise FileStorageError(f"{clean_scope_type.capitalize()} connector profiles require scope_id")
return clean_scope_type, clean_scope_id, tenant_id
raise FileStorageError("Unsupported connector profile scope")
def _normalize_profile_id(value: str) -> str:
clean = _clean(value)
if not clean:
raise FileStorageError("Connector profile id is required")
if len(clean) > 255:
raise FileStorageError("Connector profile id is too long")
if any(char.isspace() for char in clean):
raise FileStorageError("Connector profile id cannot contain whitespace")
return clean
def _normalize_label(value: str) -> str:
clean = value.strip()
if not clean:
raise FileStorageError("Connector profile label is required")
if len(clean) > 255:
raise FileStorageError("Connector profile label is too long")
return clean
def _normalize_provider(value: str) -> str:
clean = value.strip().casefold()
if clean not in supported_connector_providers():
raise FileStorageError(f"Unsupported connector provider: {value}")
return clean
def _normalize_credential_mode(value: str) -> str:
clean = value.strip().casefold() or "none"
if clean not in {"none", "anonymous", "basic", "token", "secret_ref"}:
raise FileStorageError(f"Unsupported connector credential mode: {value}")
return clean
def _string_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
values = value.split(",")
elif isinstance(value, (list, tuple, set)):
values = value
else:
values = [value]
return [str(item).strip() for item in values if str(item).strip()]
def _clean(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@@ -0,0 +1,343 @@
from __future__ import annotations
import json
import os
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any
from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path
from govoplan_files.backend.storage.connector_deployment import (
connector_secret_env_available,
validate_deployment_connector_references,
)
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload
_ENV_JSON_KEYS = ("GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON", "FILES_CONNECTOR_PROFILES_JSON")
_ENV_FILE_KEYS = ("GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE", "FILES_CONNECTOR_PROFILES_FILE")
_SUPPORTED_PROVIDERS = {"seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"}
_INLINE_SECRET_FIELDS = (
"password",
"token",
"api_key",
"access_key",
"access_key_id",
"secret_key",
"secret_access_key",
"session_token",
)
def supported_connector_providers() -> set[str]:
return set(_SUPPORTED_PROVIDERS)
@dataclass(frozen=True, slots=True)
class ConnectorProfile:
id: str
label: str
provider: str
scope_type: str = "system"
scope_id: str | None = None
endpoint_url: str | None = None
base_path: str | None = None
enabled: bool = True
credential_profile_id: str | None = None
credential_profile_label: str | None = None
credential_mode: str = "none"
username: str | None = None
password_env: str | None = None
token_env: str | None = None
secret_ref: str | None = None
password_value: str | None = field(default=None, repr=False)
token_value: str | None = field(default=None, repr=False)
has_inline_secret: bool = False
capabilities: tuple[str, ...] = ()
policy_sources: tuple[ConnectorPolicySource, ...] = ()
metadata: Mapping[str, Any] = field(default_factory=dict)
source_kind: str = "settings"
@property
def source_path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@property
def credential_source(self) -> str | None:
if self.credential_profile_id:
return "credential_profile"
if self.secret_ref:
return "secret_ref"
if self.token_value or self.password_value:
return "stored"
if self.token_env:
return "token_env"
if self.password_env:
return "password_env"
if self.has_inline_secret:
return "inline"
return None
@property
def credentials_configured(self) -> bool:
if not self.enabled:
return False
mode = self.credential_mode.casefold()
if mode in {"", "none", "anonymous"}:
return True
if self.secret_ref or self.has_inline_secret or self.password_value or self.token_value:
return True
if self.token_env:
return connector_secret_env_available(self.token_env, source_kind=self.source_kind)
if self.password_env:
return connector_secret_env_available(self.password_env, source_kind=self.source_kind)
return False
def to_response(self) -> dict[str, Any]:
return {
"id": self.id,
"label": self.label,
"provider": self.provider,
"endpoint_url": self.endpoint_url,
"base_path": self.base_path,
"enabled": self.enabled,
"scope_type": self.scope_type,
"scope_id": self.scope_id,
"source_path": self.source_path,
"credential_profile_id": self.credential_profile_id,
"credential_profile_label": self.credential_profile_label,
"credential_mode": self.credential_mode,
"credential_source": self.credential_source,
"credentials_configured": self.credentials_configured,
"username": self.username,
"capabilities": list(self.capabilities),
"policy_sources": [_policy_source_response(source) for source in self.policy_sources],
"metadata": _response_metadata(self.metadata),
"source_kind": self.source_kind,
}
def connector_profiles_from_settings(settings: object | None = None) -> list[ConnectorProfile]:
raw = _raw_profiles_payload(settings)
if raw in (None, ""):
return []
payload = json.loads(raw) if isinstance(raw, str) else raw
return connector_profiles_from_payload(payload)
def connector_profiles_from_payload(value: object) -> list[ConnectorProfile]:
if isinstance(value, Mapping):
raw_profiles = value.get("profiles", [])
elif isinstance(value, list):
raw_profiles = value
else:
raise ValueError("Connector profiles must be a JSON object with profiles or a list")
if not isinstance(raw_profiles, list):
raise ValueError("Connector profiles payload requires a profiles list")
return [_profile_from_mapping(item) for item in raw_profiles if isinstance(item, Mapping)]
def _profile_from_mapping(value: Mapping[str, Any]) -> ConnectorProfile:
profile_id = _profile_id_from_mapping(value)
provider = _provider_from_mapping(value)
scope_type, scope_id = _scope_from_mapping(value)
credentials = _credentials_from_mapping(value)
mode = _clean(value.get("credential_mode") or value.get("auth_type") or credentials.get("mode") or credentials.get("type")) or "none"
profile = _profile_from_normalized_mapping(
value,
profile_id=profile_id,
provider=provider,
scope_type=scope_type,
scope_id=scope_id,
credential_mode=mode,
credentials=credentials,
)
result = ConnectorProfile(
id=profile.id,
label=profile.label,
provider=profile.provider,
scope_type=profile.scope_type,
scope_id=profile.scope_id,
endpoint_url=profile.endpoint_url,
base_path=profile.base_path,
enabled=profile.enabled,
credential_profile_id=profile.credential_profile_id,
credential_profile_label=profile.credential_profile_label,
credential_mode=profile.credential_mode,
username=profile.username,
password_env=profile.password_env,
token_env=profile.token_env,
secret_ref=profile.secret_ref,
password_value=profile.password_value,
token_value=profile.token_value,
has_inline_secret=profile.has_inline_secret,
capabilities=profile.capabilities,
policy_sources=tuple(_policy_sources_from_profile(value, profile)),
metadata=profile.metadata,
source_kind="settings",
)
validate_deployment_connector_references(
source_kind=result.source_kind,
password_env=result.password_env,
token_env=result.token_env,
metadata=result.metadata,
)
return result
def _profile_id_from_mapping(value: Mapping[str, Any]) -> str:
profile_id = _clean(value.get("id") or value.get("connector_id") or value.get("name"))
if not profile_id:
raise ValueError("Connector profiles require id")
return profile_id
def _provider_from_mapping(value: Mapping[str, Any]) -> str:
provider = (_clean(value.get("provider") or value.get("type")) or "generic").casefold()
if provider not in _SUPPORTED_PROVIDERS:
raise ValueError(f"Unsupported connector provider: {provider}")
return provider
def _scope_from_mapping(value: Mapping[str, Any]) -> tuple[str, str | None]:
scope_type = normalize_policy_scope_type(str(value.get("scope_type") or "system"))
scope_id = _clean(value.get("scope_id"))
if scope_type == "system":
return scope_type, None
if not scope_id:
raise ValueError(f"{scope_type} connector profiles require scope_id")
return scope_type, scope_id
def _credentials_from_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]:
credentials = value.get("credentials")
return credentials if isinstance(credentials, Mapping) else {}
def _profile_from_normalized_mapping(
value: Mapping[str, Any],
*,
profile_id: str,
provider: str,
scope_type: str,
scope_id: str | None,
credential_mode: str,
credentials: Mapping[str, Any],
) -> ConnectorProfile:
return ConnectorProfile(
id=profile_id,
label=_clean(value.get("label") or value.get("name")) or profile_id,
provider=provider,
endpoint_url=_clean(value.get("endpoint_url") or value.get("base_url") or value.get("url")),
base_path=_clean(value.get("base_path") or value.get("path") or value.get("root")),
enabled=_bool(value.get("enabled"), default=True),
scope_type=scope_type,
scope_id=scope_id,
credential_profile_id=_clean(value.get("credential_profile_id") or value.get("credential_id")),
credential_profile_label=_clean(value.get("credential_profile_label") or value.get("credential_label")),
credential_mode=credential_mode,
username=_clean(value.get("username") or credentials.get("username")),
password_env=_clean(value.get("password_env") or credentials.get("password_env")),
token_env=_clean(value.get("token_env") or credentials.get("token_env")),
secret_ref=_clean(value.get("secret_ref") or credentials.get("secret_ref")),
password_value=_clean(value.get("password") or credentials.get("password")),
token_value=_clean(value.get("token") or credentials.get("token")),
has_inline_secret=any(_clean(value.get(field) or credentials.get(field)) for field in _INLINE_SECRET_FIELDS),
capabilities=tuple(_string_list(value.get("capabilities"))),
metadata=_public_metadata(value.get("metadata")),
)
def _raw_profiles_payload(settings: object | None) -> object:
for key in _ENV_JSON_KEYS:
value = os.environ.get(key)
if value:
return value
for key in _ENV_FILE_KEYS:
path = os.environ.get(key)
if path:
with open(path, encoding="utf-8") as handle:
return handle.read()
if settings is not None:
value = getattr(settings, "files_connector_profiles_json", None)
if value:
return value
value = getattr(settings, "files_connector_profiles", None)
if value:
return value
return None
def _policy_sources_from_profile(value: Mapping[str, Any], profile: ConnectorProfile) -> list[ConnectorPolicySource]:
raw_sources: list[Mapping[str, Any]] = []
policy = value.get("policy")
if isinstance(policy, Mapping):
raw_sources.append({
"scope_type": profile.scope_type,
"scope_id": profile.scope_id,
"label": profile.label,
"policy": policy,
})
configured_sources = value.get("policy_sources") or value.get("connector_policy_sources")
if configured_sources is not None:
raw = connector_policy_sources_from_payload(configured_sources)
raw_sources.extend(_policy_source_response(source) for source in raw)
return connector_policy_sources_from_payload(raw_sources)
def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]:
return {
"scope_type": source.scope_type,
"scope_id": source.scope_id,
"label": source.label,
"policy": dict(source.policy or {}),
}
def _public_metadata(value: object) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
return {}
return {
str(key): item
for key, item in value.items()
if str(key).strip().casefold() not in _INLINE_SECRET_FIELDS
}
def _response_metadata(value: Mapping[str, Any]) -> dict[str, Any]:
return {
str(key): item
for key, item in value.items()
if str(key).strip().casefold() not in _INLINE_SECRET_FIELDS
and not str(key).strip().casefold().endswith("_env")
and str(key).strip().casefold() != "ca_bundle"
}
def _string_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
values: Iterable[object] = value.split(",")
elif isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray, Mapping)):
values = value
else:
values = (value,)
return [str(item).strip() for item in values if str(item).strip()]
def _bool(value: object, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().casefold() not in {"0", "false", "no", "off"}
return bool(value)
def _clean(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@@ -0,0 +1,190 @@
from __future__ import annotations
from dataclasses import dataclass
from importlib.util import find_spec
CONNECTOR_PROVIDER_CAPABILITY = "files.connector.providers"
@dataclass(frozen=True, slots=True)
class ConnectorProviderDescriptor:
provider: str
label: str
protocol: str
implemented: bool
browse_supported: bool
import_supported: bool
optional_dependency: str | None
permission_model: str
sync_strategy: str
conflict_strategy: str
preview_strategy: str
audit_events: tuple[str, ...]
notes: str | None = None
def to_response(self) -> dict[str, object]:
return {
"provider": self.provider,
"label": self.label,
"protocol": self.protocol,
"implemented": self.implemented,
"installed": self.installed,
"browse_supported": self.browse_supported,
"import_supported": self.import_supported,
"optional_dependency": self.optional_dependency,
"permission_model": self.permission_model,
"sync_strategy": self.sync_strategy,
"conflict_strategy": self.conflict_strategy,
"preview_strategy": self.preview_strategy,
"audit_events": list(self.audit_events),
"notes": self.notes,
}
@property
def installed(self) -> bool:
if self.optional_dependency is None:
return self.implemented
return find_spec(self.optional_dependency) is not None
def connector_provider_descriptors() -> tuple[ConnectorProviderDescriptor, ...]:
freeze_model = "Imported or synced connector files become managed GovOPlaN files with frozen blob/version provenance."
governed_permissions = "GovOPlaN profile scope and connector policy are enforced before browse/import/sync; remote ACLs are still evaluated by the upstream service."
return (
ConnectorProviderDescriptor(
provider="seafile",
label="Seafile",
protocol="seafile-api",
implemented=True,
browse_supported=True,
import_supported=True,
optional_dependency=None,
permission_model=governed_permissions,
sync_strategy="On-demand browse/import/sync; sync updates the managed file version when source content changes and never mutates the remote source.",
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling: reject, overwrite, or rename.",
preview_strategy="Previews are generated from the frozen managed file after import or sync.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Native account-token API for libraries, directories, file detail, and download-link import; WebDAV opt-in remains available.",
),
ConnectorProviderDescriptor(
provider="nextcloud",
label="Nextcloud",
protocol="webdav",
implemented=True,
browse_supported=True,
import_supported=True,
optional_dependency=None,
permission_model=governed_permissions,
sync_strategy="On-demand WebDAV browse/import/sync; sync updates the managed file version when source content changes and never mutates the remote source.",
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling: reject, overwrite, or rename.",
preview_strategy="Previews are generated from the frozen managed file after import or sync.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Uses the configured Nextcloud WebDAV endpoint, basic auth, or bearer token profile credentials.",
),
ConnectorProviderDescriptor(
provider="webdav",
label="WebDAV",
protocol="webdav",
implemented=True,
browse_supported=True,
import_supported=True,
optional_dependency=None,
permission_model=governed_permissions,
sync_strategy="On-demand WebDAV browse/import/sync; sync updates the managed file version when source content changes and never mutates the remote source.",
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling: reject, overwrite, or rename.",
preview_strategy="Previews are generated from the frozen managed file after import or sync.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Generic WebDAV provider for standards-compatible stores.",
),
ConnectorProviderDescriptor(
provider="smb",
label="SMB",
protocol="smb",
implemented=True,
browse_supported=True,
import_supported=True,
optional_dependency="smbprotocol",
permission_model=governed_permissions,
sync_strategy="On-demand browse/import/sync over administrator-declared shares; sync updates managed versions and never mutates the remote share.",
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling after download.",
preview_strategy="Previews are generated from the frozen managed file after import or sync, not directly from the share.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Browse/import logic is implemented, but live smbprotocol access fails closed until the SDK supports connection-time DNS/IP pinning for initial connections and DFS referrals.",
),
ConnectorProviderDescriptor(
provider="s3",
label="S3-compatible object storage",
protocol="s3-api",
implemented=True,
browse_supported=True,
import_supported=True,
optional_dependency="boto3",
permission_model=governed_permissions,
sync_strategy="On-demand bucket/prefix browse and object import/sync; sync updates managed versions and never mutates the remote bucket.",
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling after object download.",
preview_strategy="Previews are generated from the frozen managed file after import or sync, not directly from the bucket.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Browse/import logic is implemented, but live boto3 access fails closed until its HTTP transport supports connection-time DNS/IP pinning and redirect revalidation.",
),
ConnectorProviderDescriptor(
provider="sharepoint",
label="SharePoint",
protocol="microsoft-graph/sharepoint",
implemented=False,
browse_supported=False,
import_supported=False,
optional_dependency=None,
permission_model=governed_permissions,
sync_strategy="Planned read-only browse/import through deployment-managed Microsoft Graph or SharePoint credentials.",
conflict_strategy=freeze_model,
preview_strategy="Will preview from frozen managed file after import, not directly from SharePoint.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Provider/profile key is reserved; live browsing/import still needs Graph/SharePoint authentication and paging implementation.",
),
ConnectorProviderDescriptor(
provider="onedrive",
label="OneDrive",
protocol="microsoft-graph/onedrive",
implemented=False,
browse_supported=False,
import_supported=False,
optional_dependency=None,
permission_model=governed_permissions,
sync_strategy="Planned read-only browse/import through deployment-managed Microsoft Graph credentials.",
conflict_strategy=freeze_model,
preview_strategy="Will preview from frozen managed file after import, not directly from OneDrive.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Provider/profile key is reserved; live browsing/import still needs Graph drive selection, OAuth/app registration, and paging implementation.",
),
ConnectorProviderDescriptor(
provider="nfs",
label="NFS",
protocol="nfs",
implemented=False,
browse_supported=False,
import_supported=False,
optional_dependency=None,
permission_model=governed_permissions,
sync_strategy="Planned optional provider over administrator-mounted paths or a sidecar service.",
conflict_strategy="Will use managed import conflict_strategy handling after download.",
preview_strategy="Will preview from frozen managed file after import, not directly from the mount.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Kept optional because NFS availability is deployment and host-kernel dependent.",
),
ConnectorProviderDescriptor(
provider="local",
label="Local filesystem",
protocol="file",
implemented=False,
browse_supported=False,
import_supported=False,
optional_dependency=None,
permission_model="Local connector access should be restricted to administrator-declared roots plus GovOPlaN profile and policy checks.",
sync_strategy="Planned optional provider; managed storage local backend already exists separately.",
conflict_strategy=freeze_model,
preview_strategy="Will preview from frozen managed file after import or sync.",
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
notes="Separate from the existing managed-file local storage backend.",
),
)

View File

@@ -0,0 +1,248 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy import false, or_
from sqlalchemy.orm import Session
from govoplan_files.backend.db.models import FileConnectorSpace
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids
from govoplan_files.backend.storage.common import FileStorageError, utcnow
from govoplan_files.backend.storage.connector_browse import normalize_connector_browse_path
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
SYNC_MODES = {"manual"}
def connector_space_owner_id(space: FileConnectorSpace) -> str:
return space.owner_user_id if space.owner_type == "user" else space.owner_group_id # type: ignore[return-value]
def create_connector_space(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
user_id: str,
label: str,
profile: ConnectorProfile,
library_id: str | None = None,
remote_path: str | None = None,
sync_mode: str = "manual",
read_only: bool = True,
metadata: Mapping[str, Any] | None = None,
is_admin: bool = False,
) -> FileConnectorSpace:
owner_type = owner_type.lower().strip()
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
label = _normalize_label(label)
sync_mode = _normalize_sync_mode(sync_mode)
remote_path = normalize_connector_browse_path(remote_path)
library_id = _clean_optional(library_id)
existing = _owned_query(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(
FileConnectorSpace.label == label,
FileConnectorSpace.deleted_at.is_(None),
).first()
if existing is not None:
raise FileStorageError(f"Connector space already exists: {label}")
space = FileConnectorSpace(
tenant_id=tenant_id,
owner_type=owner_type,
owner_user_id=owner_id if owner_type == "user" else None,
owner_group_id=owner_id if owner_type == "group" else None,
label=label,
connector_profile_id=profile.id,
provider=profile.provider,
library_id=library_id,
remote_path=remote_path,
sync_mode=sync_mode,
read_only=read_only,
is_active=True,
created_by_user_id=user_id,
metadata_=dict(metadata or {}),
)
session.add(space)
session.flush()
return space
def list_connector_spaces_for_user(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str | None = None,
owner_id: str | None = None,
include_inactive: bool = False,
is_admin: bool = False,
) -> list[FileConnectorSpace]:
query = session.query(FileConnectorSpace).filter(
FileConnectorSpace.tenant_id == tenant_id,
FileConnectorSpace.deleted_at.is_(None),
)
if not include_inactive:
query = query.filter(FileConnectorSpace.is_active.is_(True))
if owner_type:
if not owner_id:
raise FileStorageError("owner_id is required when owner_type is set")
owner_type = owner_type.lower().strip()
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
query = _owner_filter(query, owner_type, owner_id)
elif not is_admin:
group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id)
query = query.filter(
or_(
FileConnectorSpace.owner_user_id == user_id,
FileConnectorSpace.owner_group_id.in_(group_ids) if group_ids else false(),
)
)
return query.order_by(FileConnectorSpace.owner_type.asc(), FileConnectorSpace.label.asc()).all()
def get_connector_space_for_user(
session: Session,
*,
tenant_id: str,
user_id: str,
space_id: str,
include_inactive: bool = False,
is_admin: bool = False,
) -> FileConnectorSpace:
query = session.query(FileConnectorSpace).filter(
FileConnectorSpace.id == space_id,
FileConnectorSpace.tenant_id == tenant_id,
FileConnectorSpace.deleted_at.is_(None),
)
if not include_inactive:
query = query.filter(FileConnectorSpace.is_active.is_(True))
space = query.one_or_none()
if space is None:
raise FileStorageError("Connector space not found")
ensure_owner_access(
session,
tenant_id=tenant_id,
owner_type=space.owner_type,
owner_id=connector_space_owner_id(space),
user_id=user_id,
is_admin=is_admin,
)
return space
def update_connector_space(
session: Session,
space: FileConnectorSpace,
*,
user_id: str,
label: str | None = None,
library_id: str | None = None,
remote_path: str | None = None,
sync_mode: str | None = None,
is_active: bool | None = None,
metadata: Mapping[str, Any] | None = None,
is_admin: bool = False,
) -> FileConnectorSpace:
ensure_owner_access(
session,
tenant_id=space.tenant_id,
owner_type=space.owner_type,
owner_id=connector_space_owner_id(space),
user_id=user_id,
is_admin=is_admin,
)
if label is not None:
new_label = _normalize_label(label)
existing = _owned_query(
session,
tenant_id=space.tenant_id,
owner_type=space.owner_type,
owner_id=connector_space_owner_id(space),
).filter(
FileConnectorSpace.id != space.id,
FileConnectorSpace.label == new_label,
FileConnectorSpace.deleted_at.is_(None),
).first()
if existing is not None:
raise FileStorageError(f"Connector space already exists: {new_label}")
space.label = new_label
if library_id is not None:
space.library_id = _clean_optional(library_id)
if remote_path is not None:
space.remote_path = normalize_connector_browse_path(remote_path)
if sync_mode is not None:
space.sync_mode = _normalize_sync_mode(sync_mode)
if is_active is not None:
space.is_active = bool(is_active)
if metadata is not None:
space.metadata_ = dict(metadata)
session.add(space)
session.flush()
return space
def soft_delete_connector_space(
session: Session,
space: FileConnectorSpace,
*,
user_id: str,
is_admin: bool = False,
) -> FileConnectorSpace:
ensure_owner_access(
session,
tenant_id=space.tenant_id,
owner_type=space.owner_type,
owner_id=connector_space_owner_id(space),
user_id=user_id,
is_admin=is_admin,
)
space.deleted_at = utcnow()
space.is_active = False
session.add(space)
session.flush()
return space
def _owned_query(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
query = session.query(FileConnectorSpace).filter(
FileConnectorSpace.tenant_id == tenant_id,
FileConnectorSpace.owner_type == owner_type,
)
return _owner_filter(query, owner_type, owner_id)
def _owner_filter(query, owner_type: str, owner_id: str):
if owner_type == "user":
return query.filter(FileConnectorSpace.owner_user_id == owner_id)
if owner_type == "group":
return query.filter(FileConnectorSpace.owner_group_id == owner_id)
raise FileStorageError("Files must be owned by a user or group")
def _normalize_label(value: str) -> str:
label = value.strip()
if not label:
raise FileStorageError("Connector space label is required")
if len(label) > 255:
raise FileStorageError("Connector space label is too long")
return label
def _normalize_sync_mode(value: str) -> str:
mode = value.strip().casefold()
if mode not in SYNC_MODES:
raise FileStorageError(f"Unsupported connector space sync mode: {value}")
return mode
def _clean_optional(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@@ -0,0 +1,218 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import replace
from sqlalchemy.orm import Session
from govoplan_files.backend.storage.connector_deployment import (
connector_effective_endpoint_url,
)
from govoplan_files.backend.storage.connector_profile_store import (
select_database_connector_profiles,
)
from govoplan_files.backend.storage.connector_profiles import (
ConnectorProfile,
connector_profiles_from_settings,
)
from govoplan_files.backend.storage.connector_policy import (
ConnectorAccessRequest,
connector_policy_decision,
)
from govoplan_files.backend.storage.connector_policy_store import (
effective_connector_policy_sources,
)
from govoplan_files.backend.storage.connector_providers import (
connector_provider_descriptors,
)
CampaignVisibility = Callable[[str], bool]
def visible_connector_profiles_for_actor(
session: Session,
*,
tenant_id: str,
user_id: str,
group_ids: Iterable[str],
settings: object | None,
provider: str | None = None,
campaign_id: str | None = None,
campaign_visible: CampaignVisibility | None = None,
include_disabled: bool = False,
include_admin_scopes: bool = False,
include_effective_policy: bool = True,
) -> list[ConnectorProfile]:
"""Return the same actor-filtered connector set used by API and docs surfaces."""
provider_norm = provider.strip().casefold() if provider else None
actor_group_ids = {str(group_id) for group_id in group_ids if str(group_id)}
database_profiles, database_profile_ids = select_database_connector_profiles(
session,
tenant_id=tenant_id,
include_disabled=include_disabled,
row_visible=lambda row: (
include_admin_scopes
and row.scope_type in {"user", "group", "campaign"}
) or _scope_visible_to_actor(
scope_type=row.scope_type,
scope_id=row.scope_id,
tenant_id=tenant_id,
user_id=user_id,
group_ids=actor_group_ids,
campaign_id=campaign_id,
campaign_visible=campaign_visible,
),
)
configured_profiles = connector_profiles_from_settings(settings)
profiles_by_id: dict[str, ConnectorProfile] = {}
for profile in database_profiles:
if profile.id not in profiles_by_id:
profiles_by_id[profile.id] = profile
for profile in configured_profiles:
if profile.id not in database_profile_ids and profile.id not in profiles_by_id:
profiles_by_id[profile.id] = profile
visible: list[ConnectorProfile] = []
for profile in profiles_by_id.values():
if not (profile.enabled or include_disabled):
continue
if provider_norm is not None and profile.provider != provider_norm:
continue
admin_scope_visible = include_admin_scopes and profile.scope_type in {
"user",
"group",
"campaign",
}
if not admin_scope_visible and not _profile_visible_to_actor(
profile,
tenant_id=tenant_id,
user_id=user_id,
group_ids=actor_group_ids,
campaign_id=campaign_id,
campaign_visible=campaign_visible,
):
continue
visible.append(
_with_effective_connector_policy(
session, tenant_id=tenant_id, profile=profile
)
if include_effective_policy
else profile
)
return visible
def connector_profile_usable_for_import(profile: ConnectorProfile) -> bool:
"""Whether a visible profile can safely offer the current live browse/import task."""
effective_endpoint_url = connector_effective_endpoint_url(
provider=profile.provider,
endpoint_url=profile.endpoint_url,
metadata=profile.metadata,
)
if (
not profile.enabled
or not effective_endpoint_url
or not profile.credentials_configured
or bool(profile.secret_ref)
):
return False
descriptor = next(
(
item
for item in connector_provider_descriptors()
if item.provider == profile.provider
),
None,
)
if descriptor is None:
return False
if not (
descriptor.implemented
and descriptor.installed
and descriptor.browse_supported
and descriptor.import_supported
):
return False
# These SDK paths intentionally fail closed until every connection peer and
# SDK-managed redirect/referral can be pinned and revalidated.
if profile.provider in {"s3", "smb"}:
return False
# Prove that the initial root browse performed by the current Files UI is
# policy-allowed. A later selected remote path/item is checked again.
return connector_policy_decision(
ConnectorAccessRequest(
connector_id=profile.id,
credential_id=profile.credential_profile_id,
provider=profile.provider,
external_path="",
external_url=effective_endpoint_url,
operation="import",
),
profile.policy_sources,
).allowed
def _profile_visible_to_actor(
profile: ConnectorProfile,
*,
tenant_id: str,
user_id: str,
group_ids: set[str],
campaign_id: str | None,
campaign_visible: CampaignVisibility | None,
) -> bool:
return _scope_visible_to_actor(
scope_type=profile.scope_type,
scope_id=profile.scope_id,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
campaign_id=campaign_id,
campaign_visible=campaign_visible,
)
def _scope_visible_to_actor(
*,
scope_type: str,
scope_id: str | None,
tenant_id: str,
user_id: str,
group_ids: set[str],
campaign_id: str | None,
campaign_visible: CampaignVisibility | None,
) -> bool:
if scope_type == "system":
return True
if scope_type == "tenant":
return scope_id == tenant_id
if scope_type == "user":
return scope_id == user_id
if scope_type == "group":
return bool(scope_id and scope_id in group_ids)
if scope_type != "campaign" or not scope_id:
return False
if campaign_id and scope_id != campaign_id:
return False
return bool(campaign_visible and campaign_visible(scope_id))
def _with_effective_connector_policy(
session: Session,
*,
tenant_id: str,
profile: ConnectorProfile,
) -> ConnectorProfile:
sources = effective_connector_policy_sources(
session,
tenant_id=tenant_id,
scope_type=profile.scope_type,
scope_id=profile.scope_id,
)
if not sources:
return profile
return replace(profile, policy_sources=tuple([*sources, *profile.policy_sources]))

View File

@@ -6,17 +6,32 @@ from pathlib import PurePosixPath
from typing import Any, Iterable from typing import Any, Iterable
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import or_ from sqlalchemy import and_, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, Tenant, User from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
from govoplan_campaign.backend.db.models import Campaign
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 settings from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, 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, 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
def _campaign_access_provider() -> CampaignAccessProvider:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
raise FileStorageError("Campaign module is not installed")
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
if not isinstance(capability, CampaignAccessProvider):
raise FileStorageError("Campaign access capability is invalid")
return capability
def _ensure_campaign_exists(session: Session, *, tenant_id: str, campaign_id: str) -> None:
if not _campaign_access_provider().campaign_exists(session, tenant_id=tenant_id, campaign_id=campaign_id):
raise FileStorageError("Campaign not found")
def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str): def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
@@ -167,6 +182,136 @@ def create_file_asset(
return UploadedStoredFile(asset=asset, version=version, blob=blob) return UploadedStoredFile(asset=asset, version=version, blob=blob)
def sync_file_asset_from_source(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
user_id: str,
filename: str,
data: bytes,
metadata: dict[str, Any],
folder: str | None = None,
display_path: str | None = None,
content_type: str | None = None,
campaign_id: str | None = None,
conflict_strategy: str = "rename",
is_admin: bool = False,
) -> tuple[UploadedStoredFile, str, str | None]:
owner_type = owner_type.lower().strip()
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
provenance = source_provenance_from_metadata(metadata)
if not provenance:
raise FileStorageError("Connector sync requires source provenance")
existing = find_asset_by_source(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
source_provenance=provenance,
)
if existing is None:
stored = create_file_asset(
session,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
user_id=user_id,
filename=filename,
data=data,
folder=folder,
display_path=display_path,
content_type=content_type,
metadata=metadata,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
is_admin=is_admin,
)
return stored, "created", None
previous_version_id = existing.current_version_id
stored, action = update_file_asset_content(
session,
existing,
tenant_id=tenant_id,
user_id=user_id,
filename=filename,
data=data,
content_type=content_type,
metadata=metadata,
)
if campaign_id:
share_file(session, tenant_id=tenant_id, asset=existing, target_type="campaign", target_id=campaign_id, permission="read", user_id=user_id)
return stored, action, previous_version_id
def find_asset_by_source(
session: Session,
*,
tenant_id: str,
owner_type: str,
owner_id: str,
source_provenance: dict[str, Any],
) -> FileAsset | None:
wanted = _source_identity(source_provenance)
if wanted is None:
return None
assets = (
_asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id)
.filter(FileAsset.deleted_at.is_(None))
.order_by(FileAsset.updated_at.desc())
.all()
)
for asset in assets:
if _source_identity(source_provenance_from_metadata(asset.metadata_ or {})) == wanted:
return asset
return None
def update_file_asset_content(
session: Session,
asset: FileAsset,
*,
tenant_id: str,
user_id: str,
filename: str,
data: bytes,
content_type: str | None,
metadata: dict[str, Any],
) -> tuple[UploadedStoredFile, str]:
if asset.tenant_id != tenant_id or asset.deleted_at is not None:
raise FileStorageError("File not found")
safe_filename = filename_from_path(normalize_logical_path(filename, fallback_filename="file"))
if not content_type:
content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
current_version, current_blob = current_version_and_blob(session, asset)
checksum = hashlib.sha256(data).hexdigest()
asset.metadata_ = metadata
session.add(asset)
if current_blob.checksum_sha256 == checksum and current_blob.size_bytes == len(data):
return UploadedStoredFile(asset=asset, version=current_version, blob=current_blob), "unchanged"
blob = _get_or_create_blob(session, tenant_id=tenant_id, data=data, filename=safe_filename, content_type=content_type)
version = FileVersion(
tenant_id=tenant_id,
file_asset_id=asset.id,
blob_id=blob.id,
version_number=_next_version_number(session, asset.id),
filename_at_upload=safe_filename,
display_path_at_upload=asset.display_path,
content_type=content_type,
size_bytes=blob.size_bytes,
checksum_sha256=blob.checksum_sha256,
created_by_user_id=user_id,
)
session.add(version)
session.flush()
asset.current_version_id = version.id
session.add(asset)
return UploadedStoredFile(asset=asset, version=version, blob=blob), "updated"
def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset: def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset:
asset = session.get(FileAsset, asset_id) asset = session.get(FileAsset, asset_id)
if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None: if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None:
@@ -210,6 +355,71 @@ def list_assets_for_user(
include_deleted: bool = False, include_deleted: bool = False,
is_admin: bool = False, is_admin: bool = False,
) -> list[FileAsset]: ) -> list[FileAsset]:
query = _asset_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
include_deleted=include_deleted,
is_admin=is_admin,
)
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).all()
def list_assets_for_user_window(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
include_deleted: bool = False,
is_admin: bool = False,
page_size: int,
after_display_path: str | None = None,
after_updated_at=None,
after_id: str | None = None,
) -> tuple[list[FileAsset], bool]:
query = _asset_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
campaign_id=campaign_id,
path_prefix=path_prefix,
include_deleted=include_deleted,
is_admin=is_admin,
)
if after_display_path is not None and after_updated_at is not None and after_id:
query = query.filter(
or_(
FileAsset.display_path > after_display_path,
and_(FileAsset.display_path == after_display_path, FileAsset.updated_at < after_updated_at),
and_(FileAsset.display_path == after_display_path, FileAsset.updated_at == after_updated_at, FileAsset.id > after_id),
)
)
rows = query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).limit(page_size + 1).all()
return rows[:page_size], len(rows) > page_size
def _asset_visibility_query_for_user(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str | None = None,
owner_id: str | None = None,
campaign_id: str | None = None,
path_prefix: str | None = None,
include_deleted: bool = False,
is_admin: bool = False,
):
query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id) query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id)
if not include_deleted: if not include_deleted:
query = query.filter(FileAsset.deleted_at.is_(None)) query = query.filter(FileAsset.deleted_at.is_(None))
@@ -241,7 +451,7 @@ def list_assets_for_user(
prefix = normalize_folder(path_prefix) prefix = normalize_folder(path_prefix)
if prefix: if prefix:
query = query.filter(FileAsset.display_path.like(f"{prefix}/%")) query = query.filter(FileAsset.display_path.like(f"{prefix}/%"))
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc()).all() return query
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]: def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
@@ -256,6 +466,32 @@ def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVe
return version, blob return version, blob
def current_versions_and_blobs(session: Session, assets: Iterable[FileAsset]) -> dict[str, tuple[FileVersion, FileBlob]]:
asset_list = list(assets)
if not asset_list:
return {}
version_ids = [asset.current_version_id for asset in asset_list if asset.current_version_id]
if len(version_ids) != len(asset_list):
raise FileStorageError("File has no current version")
rows: list[tuple[FileVersion, FileBlob]] = []
for chunk in _chunks(version_ids):
rows.extend(
session.query(FileVersion, FileBlob)
.join(FileBlob, FileBlob.id == FileVersion.blob_id)
.filter(FileVersion.id.in_(chunk))
.all()
)
by_version_id = {version.id: (version, blob) for version, blob in rows}
result: dict[str, tuple[FileVersion, FileBlob]] = {}
for asset in asset_list:
version_blob = by_version_id.get(asset.current_version_id or "")
if not version_blob:
raise FileStorageError("File version not found")
result[asset.id] = version_blob
return result
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()
@@ -283,22 +519,10 @@ 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")
if target_type == "user": if target_type in {"user", "group", "tenant"}:
target_user = session.get(User, target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type == "campaign": if target_type == "campaign":
campaign = session.get(Campaign, target_id) _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
existing = ( existing = (
session.query(FileShare) session.query(FileShare)
.filter( .filter(
@@ -326,6 +550,66 @@ def share_file(
return share return share
def share_files(
session: Session,
*,
tenant_id: str,
assets: Iterable[FileAsset],
target_type: str,
target_id: str,
permission: str,
user_id: str,
) -> list[FileShare]:
target_type = target_type.lower().strip()
permission = permission.lower().strip()
if target_type not in {"user", "group", "campaign", "tenant"}:
raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission")
if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign":
_ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
asset_list = list(assets)
if not asset_list:
return []
for asset in asset_list:
if asset.tenant_id != tenant_id:
raise FileStorageError("File not found")
existing_by_asset: dict[str, FileShare] = {}
for share in session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.file_asset_id.in_([asset.id for asset in asset_list]),
FileShare.target_type == target_type,
FileShare.target_id == target_id,
FileShare.revoked_at.is_(None),
).all():
existing_by_asset.setdefault(share.file_asset_id, share)
shares: list[FileShare] = []
for asset in asset_list:
existing = existing_by_asset.get(asset.id)
if existing:
existing.permission = permission
session.add(existing)
shares.append(existing)
continue
share = FileShare(
tenant_id=tenant_id,
file_asset_id=asset.id,
target_type=target_type,
target_id=target_id,
permission=permission,
created_by_user_id=user_id,
)
session.add(share)
shares.append(share)
return shares
def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int: def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int:
count = 0 count = 0
now = utcnow() now = utcnow()
@@ -354,6 +638,11 @@ def _asset_owner_id(asset: FileAsset) -> str:
raise FileStorageError("File has no valid owner") raise FileStorageError("File has no valid owner")
def _chunks(values: list[str], size: int = 900):
for index in range(0, len(values), size):
yield values[index:index + size]
def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool: def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool:
return _active_asset_at_path( return _active_asset_at_path(
session, session,
@@ -460,6 +749,39 @@ def _normalize_conflict_strategy(strategy: str | None) -> str:
return normalized return normalized
def _next_version_number(session: Session, asset_id: str) -> int:
row = (
session.query(FileVersion.version_number)
.filter(FileVersion.file_asset_id == asset_id)
.order_by(FileVersion.version_number.desc())
.first()
)
return (int(row[0]) if row else 0) + 1
def _source_identity(provenance: dict[str, Any] | None) -> tuple[object, ...] | None:
if not provenance:
return None
connector_id = _clean_identity(provenance.get("connector_id"))
provider = _clean_identity(provenance.get("provider"))
external_id = _clean_identity(provenance.get("external_id"))
if connector_id and external_id:
return ("external_id", connector_id, provider, external_id)
external_path = _clean_identity(provenance.get("external_path"))
metadata = provenance.get("metadata") if isinstance(provenance.get("metadata"), dict) else {}
library_id = _clean_identity(metadata.get("library_id") or metadata.get("profile_id") or metadata.get("share"))
if connector_id and external_path:
return ("external_path", connector_id, provider, library_id, external_path)
return None
def _clean_identity(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _copy_asset_to_path( def _copy_asset_to_path(
session: Session, session: Session,
asset: FileAsset, asset: FileAsset,

View File

@@ -68,13 +68,63 @@ def list_folders_for_user(
include_deleted: bool = False, include_deleted: bool = False,
is_admin: bool = False, is_admin: bool = False,
) -> list[FileFolder]: ) -> list[FileFolder]:
query = _folder_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
include_deleted=include_deleted,
is_admin=is_admin,
)
return query.order_by(FileFolder.path.asc(), FileFolder.id.asc()).all()
def list_folders_for_user_window(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str,
owner_id: str,
include_deleted: bool = False,
is_admin: bool = False,
page_size: int,
after_path: str | None = None,
after_id: str | None = None,
) -> tuple[list[FileFolder], bool]:
query = _folder_visibility_query_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
owner_type=owner_type,
owner_id=owner_id,
include_deleted=include_deleted,
is_admin=is_admin,
)
if after_path is not None and after_id:
query = query.filter(or_(FileFolder.path > after_path, (FileFolder.path == after_path) & (FileFolder.id > after_id)))
rows = query.order_by(FileFolder.path.asc(), FileFolder.id.asc()).limit(page_size + 1).all()
return rows[:page_size], len(rows) > page_size
def _folder_visibility_query_for_user(
session: Session,
*,
tenant_id: str,
user_id: str,
owner_type: str,
owner_id: str,
include_deleted: bool = False,
is_admin: bool = False,
):
owner_type = owner_type.lower().strip() owner_type = owner_type.lower().strip()
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type) query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type)
query = _owner_filter(query, owner_type, owner_id) query = _owner_filter(query, owner_type, owner_id)
if not include_deleted: if not include_deleted:
query = query.filter(FileFolder.deleted_at.is_(None)) query = query.filter(FileFolder.deleted_at.is_(None))
return query.order_by(FileFolder.path.asc()).all() return query
def soft_delete_folder( def soft_delete_folder(

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import socket
import ssl
import select
from collections.abc import Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Iterator
import httpcore
import httpx
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_chunks_bytes,
create_outbound_connection,
response_limit,
validate_outbound_http_url,
)
class ConnectorHttpError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class ConnectorHttpResponse:
status_code: int
headers: Mapping[str, str]
content: bytes
_HTTPCORE_TRANSPORT_ERRORS = (
httpcore.TimeoutException,
httpcore.NetworkError,
httpcore.ProtocolError,
httpcore.ProxyError,
httpcore.UnsupportedProtocol,
)
class _SocketNetworkStream(httpcore.NetworkStream):
"""Public httpcore NetworkStream adapter around an approved socket."""
def __init__(self, sock: socket.socket) -> None:
self._socket = sock
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
try:
self._socket.settimeout(timeout)
return self._socket.recv(max_bytes)
except socket.timeout as exc:
raise httpcore.ReadTimeout(str(exc)) from exc
except OSError as exc:
raise httpcore.ReadError(str(exc)) from exc
def write(self, buffer: bytes, timeout: float | None = None) -> None:
try:
self._socket.settimeout(timeout)
self._socket.sendall(buffer)
except socket.timeout as exc:
raise httpcore.WriteTimeout(str(exc)) from exc
except OSError as exc:
raise httpcore.WriteError(str(exc)) from exc
def close(self) -> None:
self._socket.close()
def start_tls(
self,
ssl_context: ssl.SSLContext,
server_hostname: str | None = None,
timeout: float | None = None,
) -> httpcore.NetworkStream:
try:
self._socket.settimeout(timeout)
tls_socket = ssl_context.wrap_socket(self._socket, server_hostname=server_hostname)
except socket.timeout as exc:
self.close()
raise httpcore.ConnectTimeout(str(exc)) from exc
except OSError as exc:
self.close()
raise httpcore.ConnectError(str(exc)) from exc
return _SocketNetworkStream(tls_socket)
def get_extra_info(self, info: str) -> Any:
if info == "ssl_object" and isinstance(self._socket, ssl.SSLSocket):
return self._socket
if info == "client_addr":
return self._socket.getsockname()
if info == "server_addr":
return self._socket.getpeername()
if info == "socket":
return self._socket
if info == "is_readable":
try:
return bool(select.select([self._socket], [], [], 0)[0])
except (OSError, ValueError):
return True
return None
class _OutboundPolicyNetworkBackend(httpcore.NetworkBackend):
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Any = None,
) -> httpcore.NetworkStream:
source_address = None if local_address is None else (local_address, 0)
try:
sock = create_outbound_connection(
host,
port,
timeout=timeout,
source_address=source_address,
socket_options=socket_options,
label="File connector HTTP endpoint",
)
except socket.timeout as exc:
raise httpcore.ConnectTimeout(str(exc)) from exc
except (OSError, OutboundHttpError) as exc:
raise httpcore.ConnectError(str(exc)) from exc
return _SocketNetworkStream(sock)
def connect_unix_socket(self, path: str, timeout: float | None = None, socket_options: Any = None): # type: ignore[no-untyped-def]
del path, timeout, socket_options
raise httpcore.ConnectError("Unix sockets are not supported for file connectors")
class _HttpcoreResponseStream(httpx.SyncByteStream):
def __init__(self, stream: Any, *, request: httpx.Request) -> None:
self._stream = stream
self._request = request
def __iter__(self): # type: ignore[no-untyped-def]
try:
yield from self._stream
except _HTTPCORE_TRANSPORT_ERRORS as exc:
raise httpx.TransportError(str(exc), request=self._request) from exc
def close(self) -> None:
if hasattr(self._stream, "close"):
self._stream.close()
class _OutboundPolicyHTTPTransport(httpx.BaseTransport):
def __init__(self) -> None:
self._connection_pool = httpcore.ConnectionPool(
ssl_context=httpx.create_ssl_context(verify=True, trust_env=False),
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=5.0,
network_backend=_OutboundPolicyNetworkBackend(),
)
def handle_request(self, request: httpx.Request) -> httpx.Response:
core_request = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
try:
response = self._connection_pool.handle_request(core_request)
except _HTTPCORE_TRANSPORT_ERRORS as exc:
raise httpx.TransportError(str(exc), request=request) from exc
return httpx.Response(
status_code=response.status,
headers=response.headers,
stream=_HttpcoreResponseStream(response.stream, request=request),
extensions=response.extensions,
)
def close(self) -> None:
self._connection_pool.close()
@contextmanager
def _stream_connector_request(method: str, url: str, **kwargs: Any) -> Iterator[httpx.Response]:
with httpx.Client(
transport=_OutboundPolicyHTTPTransport(),
follow_redirects=False,
timeout=kwargs.pop("timeout", 15.0),
) as client:
with client.stream(method, url, **kwargs) as response:
yield response
def request_connector_bytes(
method: str,
url: str,
*,
headers: Mapping[str, str] | None = None,
params: Mapping[str, str] | None = None,
data: Mapping[str, str] | bytes | str | None = None,
content: bytes | str | None = None,
auth: Any = None,
timeout: float = 15.0,
kind: str = "structured",
max_bytes: int | None = None,
label: str = "File connector",
) -> ConnectorHttpResponse:
try:
validated_url = validate_outbound_http_url(url, label=f"{label} URL")
effective_limit = response_limit(kind) if max_bytes is None else min(int(max_bytes), response_limit(kind))
with _stream_connector_request(
method,
validated_url,
headers=dict(headers or {}),
params=params,
data=data,
content=content,
auth=auth,
timeout=timeout,
) as response:
body = bounded_chunks_bytes(
response.iter_bytes(),
headers=response.headers,
max_bytes=effective_limit,
kind=kind,
label=f"{label} response",
)
return ConnectorHttpResponse(
status_code=response.status_code,
headers=dict(response.headers),
content=body,
)
except (httpx.HTTPError, OutboundHttpError, ValueError) as exc:
raise ConnectorHttpError(str(exc)) from exc

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
SOURCE_PROVENANCE_METADATA_KEY = "source_provenance"
SOURCE_REVISION_METADATA_KEY = "source_revision"
_PROVENANCE_STRING_FIELDS = {
"source_type",
"connector_id",
"provider",
"external_id",
"external_path",
"external_url",
"revision",
"revision_label",
"observed_at",
"imported_at",
}
def normalize_source_revision(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def normalize_source_provenance(value: object, *, source_revision: object = None) -> dict[str, Any] | None:
if value is None:
revision = normalize_source_revision(source_revision)
return {"revision": revision} if revision else None
if not isinstance(value, Mapping):
raise ValueError("source_provenance must be a JSON object")
payload: dict[str, Any] = {}
for field in _PROVENANCE_STRING_FIELDS:
normalized = normalize_source_revision(value.get(field))
if normalized:
payload[field] = normalized
extra = value.get("metadata")
if isinstance(extra, Mapping) and extra:
payload["metadata"] = dict(extra)
elif extra is not None and extra != "":
raise ValueError("source_provenance.metadata must be a JSON object")
revision = normalize_source_revision(source_revision)
if revision:
payload["revision"] = revision
return payload or None
def source_metadata(
*,
existing: Mapping[str, Any] | None = None,
source_provenance: object = None,
source_revision: object = None,
) -> dict[str, Any] | None:
payload = dict(existing or {})
normalized_provenance = normalize_source_provenance(source_provenance, source_revision=source_revision)
normalized_revision = normalize_source_revision(source_revision)
if normalized_provenance:
payload[SOURCE_PROVENANCE_METADATA_KEY] = normalized_provenance
if normalized_revision:
payload[SOURCE_REVISION_METADATA_KEY] = normalized_revision
elif normalized_provenance and normalized_provenance.get("revision"):
payload[SOURCE_REVISION_METADATA_KEY] = str(normalized_provenance["revision"])
return payload or None
def source_provenance_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(metadata, Mapping):
return None
value = metadata.get(SOURCE_PROVENANCE_METADATA_KEY)
if isinstance(value, Mapping):
return dict(value)
return None
def source_revision_from_metadata(metadata: Mapping[str, Any] | None) -> str | None:
if not isinstance(metadata, Mapping):
return None
revision = normalize_source_revision(metadata.get(SOURCE_REVISION_METADATA_KEY))
if revision:
return revision
provenance = source_provenance_from_metadata(metadata)
return normalize_source_revision(provenance.get("revision") if provenance else None)

View File

@@ -15,24 +15,10 @@ from govoplan_files.backend.storage.common import (
utcnow, utcnow,
) )
from govoplan_files.backend.storage.files import ( from govoplan_files.backend.storage.files import (
_active_asset_at_path,
_active_asset_exists,
_asset_owner_id,
_asset_query_for_owner,
_candidate_renamed_path,
_copy_asset_to_path,
_get_or_create_blob,
_next_available_logical_path,
_normalize_conflict_strategy,
_resolution_by_path,
_soft_delete_conflicting_asset,
_split_logical_path,
_storage_backend_name,
_storage_bucket_name,
_storage_key,
asset_is_audit_relevant, asset_is_audit_relevant,
create_file_asset, create_file_asset,
current_version_and_blob, current_version_and_blob,
current_versions_and_blobs,
get_asset_for_user, get_asset_for_user,
list_assets_for_user, list_assets_for_user,
read_asset_bytes, read_asset_bytes,
@@ -41,16 +27,12 @@ from govoplan_files.backend.storage.files import (
soft_delete_assets, soft_delete_assets,
) )
from govoplan_files.backend.storage.folders import ( from govoplan_files.backend.storage.folders import (
_active_folder_exists,
_ensure_target_folder_hierarchy,
_folder_query_for_owner,
_owner_filter,
create_folder, create_folder,
list_folders_for_user, list_folders_for_user,
soft_delete_folder, soft_delete_folder,
) )
from govoplan_files.backend.storage.search import match_assets, resolve_patterns from govoplan_files.backend.storage.search import match_assets, resolve_patterns
from govoplan_files.backend.storage.transfers import build_rename_preview, rename_selection, transfer_selection from govoplan_files.backend.storage.transfers import build_rename_preview, legacy_rename_selection_without_owner_context, rename_selection, transfer_selection
__all__ = [ __all__ = [
"FileConflictResolution", "FileConflictResolution",
@@ -64,6 +46,7 @@ __all__ = [
"create_folder", "create_folder",
"create_zip_file", "create_zip_file",
"current_version_and_blob", "current_version_and_blob",
"current_versions_and_blobs",
"ensure_group_access", "ensure_group_access",
"ensure_owner_access", "ensure_owner_access",
"extract_zip_upload", "extract_zip_upload",
@@ -74,6 +57,7 @@ __all__ = [
"match_assets", "match_assets",
"read_asset_bytes", "read_asset_bytes",
"record_campaign_attachment_uses_for_job", "record_campaign_attachment_uses_for_job",
"legacy_rename_selection_without_owner_context",
"rename_asset", "rename_asset",
"rename_selection", "rename_selection",
"resolve_patterns", "resolve_patterns",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
TENANT_ID = "tenant-1"
USER_ID = "user-1"
OTHER_USER_ID = "user-2"
GROUP_ID = "group-1"
class FilesAccessProviderTests(unittest.TestCase):
def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
owned = FileAsset(id="file-owned", tenant_id=TENANT_ID, owner_type="user", owner_user_id=USER_ID, display_path="owned.pdf", filename="owned.pdf")
shared = FileAsset(id="file-shared", tenant_id=TENANT_ID, owner_type="user", owner_user_id=OTHER_USER_ID, display_path="shared.pdf", filename="shared.pdf")
session.add_all([
owned,
shared,
FileShare(id="share-group", tenant_id=TENANT_ID, file_asset_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
])
session.commit()
service = FilesAccessService()
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id=owned.id, action="files:file:read")
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="file", resource_id=shared.id, action="files:file:read")
admin_items = service.explain_resource_provenance(session, _principal(scopes={"files:file:admin"}), resource_type="file", resource_id=shared.id, action="files:file:read")
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id="missing-file", action="files:file:read")
self.assertTrue(any(item.kind == "resource" and item.source == "files.file" and item.id == owned.id for item in owned_items))
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
self.assertTrue(any(item.kind == "policy" and item.id == "files:file:admin" for item in admin_items))
self.assertEqual("files.not_found", missing_items[0].source)
self.assertIs(missing_items[0].details["found"], False)
def test_file_access_provider_explains_persisted_and_virtual_folders(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
persisted = FileFolder(id="folder-persisted", tenant_id=TENANT_ID, owner_type="group", owner_group_id=GROUP_ID, path="records")
virtual_child = FileAsset(
id="file-in-virtual-folder",
tenant_id=TENANT_ID,
owner_type="group",
owner_group_id=GROUP_ID,
display_path="inferred/sub/file.pdf",
filename="file.pdf",
)
session.add_all([persisted, virtual_child])
session.commit()
service = FilesAccessService()
principal = _principal(group_ids={GROUP_ID})
persisted_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=persisted.id, action="files:file:read")
virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/sub")
virtual_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=virtual_id, action="files:file:read")
missing_virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/missing")
missing_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=missing_virtual_id, action="files:file:read")
self.assertTrue(any(item.kind == "resource" and item.source == "files.folder" and item.id == persisted.id for item in persisted_items))
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in persisted_items))
self.assertTrue(any(item.kind == "resource" and item.source == "files.virtual_folder" and item.id == virtual_id for item in virtual_items))
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in virtual_items))
self.assertEqual("files.not_found", missing_items[0].source)
def _session():
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, FileAsset.__table__, FileFolder.__table__, FileShare.__table__])
return sessionmaker(bind=engine, future=True)()
def _close_session(session) -> None:
engine = session.get_bind()
session.close()
engine.dispose()
def _seed_access_subjects(session) -> None:
session.add_all([
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
])
session.commit()
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
return PrincipalRef(
account_id="account-1",
membership_id=USER_ID,
tenant_id=TENANT_ID,
scopes=frozenset(scopes or {"files:file:read"}),
group_ids=frozenset(group_ids or set()),
)

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - resolve Files user foreign keys
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_core.security.secrets import encrypt_secret
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
from govoplan_files.backend.router import deactivate_connector_credential, deactivate_connector_profile
class Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
api_key = None
def has(self, scope: str) -> bool:
return scope == "files:file:admin"
class ConnectorCredentialDeletionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
FileConnectorCredential.__table__,
FileConnectorProfile.__table__,
ChangeSequenceEntry.__table__,
],
)
self.session = sessionmaker(bind=self.engine)()
self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_delete_credential_scrubs_material_audits_and_disables_dependents(self) -> None:
credential = self._credential()
profile = self._profile(credential_profile_id=credential.id)
self.session.add_all([credential, profile])
self.session.commit()
with patch(
"govoplan_files.backend.storage.connector_credential_deletion.audit_event"
) as audit:
response = deactivate_connector_credential(
credential.id,
session=self.session,
principal=self.principal, # type: ignore[arg-type]
)
self.assertFalse(response.enabled)
self.assertFalse(response.credentials_configured)
self.assertIsNone(response.credential_secret_source)
self.session.refresh(credential)
self.session.refresh(profile)
self._assert_credential_scrubbed(credential)
self._assert_profile_scrubbed(profile)
calls = {call.kwargs["action"]: call.kwargs for call in audit.call_args_list}
self.assertEqual(
{"files.connector_profile_deleted", "files.connector_credential_deleted"},
set(calls),
)
credential_audit = calls["files.connector_credential_deleted"]
self.assertEqual("encrypted_database", credential_audit["details"]["storage_backend"])
self.assertEqual(["password", "token"], credential_audit["details"]["deleted_secret_kinds"])
self.assertEqual(1, credential_audit["details"]["affected_profile_count"])
self.assertNotIn("do-not-audit", repr(audit.call_args_list))
changes = self.session.query(ChangeSequenceEntry).order_by(ChangeSequenceEntry.id.asc()).all()
self.assertEqual([credential.id, profile.id], [change.resource_id for change in changes])
self.assertEqual(["deleted", "updated"], [change.operation for change in changes])
def test_delete_profile_scrubs_inline_credentials_and_audits(self) -> None:
profile = self._profile(credential_profile_id="shared-credential")
self.session.add(profile)
self.session.commit()
with patch(
"govoplan_files.backend.storage.connector_credential_deletion.audit_event"
) as audit:
response = deactivate_connector_profile(
profile.id,
session=self.session,
principal=self.principal, # type: ignore[arg-type]
)
self.assertFalse(response.enabled)
self.assertFalse(response.credentials_configured)
self.assertIsNone(response.credential_profile_id)
self.session.refresh(profile)
self._assert_profile_scrubbed(profile)
audit.assert_called_once()
call = audit.call_args.kwargs
self.assertEqual("files.connector_profile_deleted", call["action"])
self.assertEqual("api_delete", call["details"]["deletion_reason"])
self.assertIn("credential_profile", call["details"]["removed_reference_kinds"])
self.assertNotIn("do-not-audit", repr(call))
def test_delete_rolls_back_when_audit_fails(self) -> None:
credential = self._credential()
self.session.add(credential)
self.session.commit()
original_password = credential.password_encrypted
original_token = credential.token_encrypted
with patch(
"govoplan_files.backend.storage.connector_credential_deletion.audit_event",
side_effect=RuntimeError("audit unavailable"),
), self.assertRaisesRegex(RuntimeError, "audit unavailable"):
deactivate_connector_credential(
credential.id,
session=self.session,
principal=self.principal, # type: ignore[arg-type]
)
persisted = self.session.get(FileConnectorCredential, credential.id)
assert persisted is not None
self.assertTrue(persisted.enabled)
self.assertEqual(original_password, persisted.password_encrypted)
self.assertEqual(original_token, persisted.token_encrypted)
self.assertEqual(0, self.session.query(ChangeSequenceEntry).count())
def test_delete_detaches_unowned_legacy_secret_reference_without_claiming_provider_deletion(self) -> None:
credential = self._credential(secret_ref="vault:tenant-1:files:credential")
self.session.add(credential)
self.session.commit()
with patch(
"govoplan_files.backend.storage.connector_credential_deletion.audit_event"
) as audit:
response = deactivate_connector_credential(
credential.id,
session=self.session,
principal=self.principal, # type: ignore[arg-type]
)
self.assertFalse(response.enabled)
persisted = self.session.get(FileConnectorCredential, credential.id)
assert persisted is not None
self._assert_credential_scrubbed(persisted)
audit.assert_called_once()
details = audit.call_args.kwargs["details"]
self.assertEqual("unowned_external_reference_detached", details["storage_backend"])
self.assertIn("unowned_external_secret_ref", details["removed_reference_kinds"])
self.assertNotIn("vault:tenant-1:files:credential", repr(audit.call_args))
def test_retirement_scrubs_and_audits_before_tables_are_dropped(self) -> None:
from govoplan_files.backend.manifest import manifest
self.session.add_all([self._credential(), self._profile()])
self.session.commit()
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(self.session, "files")
assert plan.destroy_data_executor is not None
with patch(
"govoplan_files.backend.storage.connector_credential_deletion.audit_event"
) as audit:
plan.destroy_data_executor(self.session, "files")
# The retirement executor deliberately enlists secret scrubbing, audit
# writes, and DDL in the caller-owned transaction. Commit that unit of
# work before inspecting through a separate Engine connection.
self.session.commit()
self.assertEqual(2, audit.call_count)
self.assertEqual(
{"module_data_retired"},
{call.kwargs["details"]["deletion_reason"] for call in audit.call_args_list},
)
self.assertNotIn("do-not-audit", repr(audit.call_args_list))
self.assertFalse(inspect(self.engine).has_table(FileConnectorCredential.__tablename__))
self.assertFalse(inspect(self.engine).has_table(FileConnectorProfile.__tablename__))
def test_retirement_detaches_unowned_external_reference_before_drop(self) -> None:
from govoplan_files.backend.manifest import manifest
self.session.add(self._credential(secret_ref="vault:tenant-1:files:credential"))
self.session.commit()
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(self.session, "files")
assert plan.destroy_data_executor is not None
with patch(
"govoplan_files.backend.storage.connector_credential_deletion.audit_event"
) as audit:
plan.destroy_data_executor(self.session, "files")
self.session.commit()
audit.assert_called_once()
details = audit.call_args.kwargs["details"]
self.assertIn("unowned_external_secret_ref", details["removed_reference_kinds"])
self.assertNotIn("vault:tenant-1:files:credential", repr(audit.call_args))
self.assertFalse(inspect(self.engine).has_table(FileConnectorCredential.__tablename__))
self.assertFalse(inspect(self.engine).has_table(FileConnectorProfile.__tablename__))
@staticmethod
def _credential(*, secret_ref: str | None = None) -> FileConnectorCredential:
return FileConnectorCredential(
id="credential-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
label="Credential",
provider="webdav",
enabled=True,
credential_mode="basic",
username="connector-user",
password_encrypted=encrypt_secret("do-not-audit-password"),
token_encrypted=encrypt_secret("do-not-audit-token"),
password_env="DEPLOYMENT_PASSWORD",
token_env="DEPLOYMENT_TOKEN",
secret_ref=secret_ref,
policy={},
metadata_={"private_hint": "do-not-audit-metadata"},
)
@staticmethod
def _profile(*, credential_profile_id: str | None = None) -> FileConnectorProfile:
return FileConnectorProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
label="Profile",
provider="webdav",
endpoint_url="https://dav.example.test",
enabled=True,
credential_profile_id=credential_profile_id,
credential_mode="basic",
username="connector-user",
password_encrypted=encrypt_secret("do-not-audit-profile-password"),
token_encrypted=encrypt_secret("do-not-audit-profile-token"),
password_env="DEPLOYMENT_PROFILE_PASSWORD",
token_env="DEPLOYMENT_PROFILE_TOKEN",
policy={},
metadata_={"private_hint": "do-not-audit-profile-metadata"},
)
def _assert_credential_scrubbed(self, row: FileConnectorCredential) -> None:
self.assertFalse(row.enabled)
self.assertEqual("none", row.credential_mode)
self.assertIsNone(row.username)
self.assertIsNone(row.password_encrypted)
self.assertIsNone(row.token_encrypted)
self.assertIsNone(row.password_env)
self.assertIsNone(row.token_env)
self.assertIsNone(row.secret_ref)
self.assertEqual({}, row.metadata_)
def _assert_profile_scrubbed(self, row: FileConnectorProfile) -> None:
self.assertFalse(row.enabled)
self.assertIsNone(row.credential_profile_id)
self.assertEqual("none", row.credential_mode)
self.assertIsNone(row.username)
self.assertIsNone(row.password_encrypted)
self.assertIsNone(row.token_encrypted)
self.assertIsNone(row.password_env)
self.assertIsNone(row.token_env)
self.assertIsNone(row.secret_ref)
self.assertEqual({}, row.metadata_)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock
from fastapi import HTTPException
from govoplan_files.backend.router import discover_connector_endpoint
from govoplan_files.backend.schemas import FileConnectorDiscoveryRequest
from govoplan_files.backend.storage.connector_browse import ConnectorBrowseError, _profile_password, _s3_verify
from govoplan_files.backend.storage.connector_deployment import (
ConnectorDeploymentConfigurationError,
connector_effective_endpoint_url,
connector_secret_env_value,
reject_api_controlled_deployment_references,
)
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
from govoplan_files.backend.storage.connector_profile_store import create_connector_profile_row
class ConnectorDeploymentBoundaryTests(unittest.TestCase):
def test_database_profile_cannot_read_arbitrary_process_environment(self) -> None:
profile = ConnectorProfile(
id="tenant-webdav",
label="Tenant WebDAV",
provider="webdav",
password_env="MASTER_KEY_B64",
source_kind="database",
)
with patch.dict(
os.environ,
{
"MASTER_KEY_B64": "must-not-leave-process",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "MASTER_KEY_B64",
},
clear=False,
), self.assertRaisesRegex(ConnectorBrowseError, "deployment-owned"):
_profile_password(profile)
def test_deployment_profile_requires_exact_secret_allowlist(self) -> None:
with patch.dict(
os.environ,
{
"GOVOPLAN_FILES_WEBDAV_PASSWORD": "deployment-secret",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "GOVOPLAN_FILES_WEBDAV_PASSWORD",
},
clear=False,
):
self.assertEqual(
"deployment-secret",
connector_secret_env_value(
"GOVOPLAN_FILES_WEBDAV_PASSWORD",
source_kind="settings",
),
)
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "not listed"):
connector_secret_env_value("MASTER_KEY_B64", source_kind="settings")
def test_api_profiles_cannot_select_secret_environment_names(self) -> None:
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "deployment-owned"):
reject_api_controlled_deployment_references(password_env="DATABASE_URL")
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "deployment-owned"):
reject_api_controlled_deployment_references(metadata={"secret_access_key_env": "MASTER_KEY_B64"})
session = MagicMock()
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "deployment-owned"):
create_connector_profile_row(
session,
tenant_id="tenant-1",
user_id="user-1",
profile_id="unsafe",
label="Unsafe",
provider="webdav",
scope_type="tenant",
password_env="DATABASE_URL",
)
session.get.assert_not_called()
def test_api_profiles_cannot_create_unowned_external_secret_references(self) -> None:
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "provider ownership"):
reject_api_controlled_deployment_references(secret_ref="vault:tenant-1:files")
session = MagicMock()
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "provider ownership"):
create_connector_profile_row(
session,
tenant_id="tenant-1",
user_id="user-1",
profile_id="unsafe-secret-ref",
label="Unsafe secret reference",
provider="webdav",
scope_type="tenant",
credential_mode="secret_ref",
secret_ref="vault:tenant-1:files",
)
session.get.assert_not_called()
def test_api_profiles_cannot_hide_plaintext_credentials_in_metadata(self) -> None:
for metadata in (
{"secret_access_key": "plaintext-secret"},
{"credentials": {"password": "plaintext-secret"}},
{"provider": {"refresh_token": "plaintext-secret"}},
):
with self.subTest(metadata=metadata), self.assertRaisesRegex(
ConnectorDeploymentConfigurationError,
"dedicated encrypted credential fields",
):
reject_api_controlled_deployment_references(metadata=metadata)
def test_profile_responses_hide_environment_and_local_ca_references(self) -> None:
profile = ConnectorProfile(
id="deployment-s3",
label="Deployment S3",
provider="s3",
metadata={
"bucket": "documents",
"secret_access_key_env": "GOVOPLAN_FILES_S3_SECRET",
"ca_bundle": "/etc/govoplan/connector-ca.pem",
},
)
self.assertEqual({"bucket": "documents"}, profile.to_response()["metadata"])
def test_ca_bundle_must_be_an_exact_deployment_allowlisted_file(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
allowed = Path(temp_dir, "connector-ca.pem")
other = Path(temp_dir, "other-ca.pem")
allowed.write_text("test CA", encoding="utf-8")
other.write_text("other CA", encoding="utf-8")
with patch.dict(
os.environ,
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": str(allowed),
},
clear=False,
):
profile = ConnectorProfile(
id="s3",
label="S3",
provider="s3",
metadata={"ca_bundle": str(allowed)},
)
self.assertEqual(str(allowed.resolve()), _s3_verify(profile))
with self.assertRaisesRegex(ConnectorDeploymentConfigurationError, "not listed"):
reject_api_controlled_deployment_references(metadata={"ca_bundle": str(other)})
def test_tls_verification_can_only_be_disabled_in_development(self) -> None:
with patch.dict(os.environ, {"APP_ENV": "production"}, clear=False), self.assertRaisesRegex(
ConnectorDeploymentConfigurationError,
"dev/test",
):
reject_api_controlled_deployment_references(metadata={"verify_tls": False})
with patch.dict(os.environ, {"APP_ENV": "test"}, clear=False):
reject_api_controlled_deployment_references(metadata={"verify_tls": False})
def test_effective_endpoint_uses_webdav_override(self) -> None:
self.assertEqual(
"https://dav.example.test/root",
connector_effective_endpoint_url(
provider="seafile",
endpoint_url="https://seafile.example.test",
metadata={"webdav_endpoint_url": "https://dav.example.test/root"},
),
)
class ConnectorDiscoveryBoundaryTests(unittest.TestCase):
@staticmethod
def _principal() -> SimpleNamespace:
return SimpleNamespace(tenant_id="tenant-1", user=SimpleNamespace(id="user-1"), api_key=None)
def test_discovery_rejects_environment_credentials_before_io(self) -> None:
payload = FileConnectorDiscoveryRequest(
provider="webdav",
endpoint_url="https://dav.example.test",
credential_mode="basic",
credentials={"username": "admin", "password_env": "MASTER_KEY_B64"},
)
with patch("govoplan_files.backend.router.browse_connector_profile") as browse, self.assertRaises(
HTTPException
) as raised:
discover_connector_endpoint(payload, session=object(), principal=self._principal()) # type: ignore[arg-type]
self.assertEqual(400, raised.exception.status_code)
browse.assert_not_called()
def test_discovery_applies_policy_and_audit_before_each_io_candidate(self) -> None:
payload = FileConnectorDiscoveryRequest(
provider="webdav",
endpoint_url="https://dav.example.test/root",
metadata={
"webdav_endpoint_url": "https://bypass.example.test",
"static_listing": {"": []},
},
)
events: list[str] = []
def ensure(*_args: object, **kwargs: object) -> None:
self.assertEqual("https://dav.example.test/root/", kwargs["endpoint_url"])
events.append("policy")
def audit(*_args: object, **_kwargs: object) -> None:
events.append("audit")
def browse(profile: ConnectorProfile, **_kwargs: object) -> list[object]:
self.assertEqual({}, profile.metadata)
events.append("io")
return []
with patch("govoplan_files.backend.router._ensure_connector_configuration_allowed", side_effect=ensure), patch(
"govoplan_files.backend.router._audit_connector_discovery_attempt",
side_effect=audit,
), patch("govoplan_files.backend.router.browse_connector_profile", side_effect=browse):
response = discover_connector_endpoint(payload, session=object(), principal=self._principal()) # type: ignore[arg-type]
self.assertEqual("usable", response.status)
self.assertEqual(["policy", "audit", "io"], events)
self.assertEqual({"discovered_by": "webdav-propfind"}, response.metadata)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,228 @@
from __future__ import annotations
import json
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - resolve Files user foreign keys
from govoplan_core.db.base import Base
from govoplan_core.security.credential_envelopes import CredentialEnvelope
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_files.backend.db.models import FileConnectorProfile
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.connector_profile_store import (
list_database_connector_profiles,
update_connector_profile_row,
)
class ConnectorProfileStoreUpdateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
FileConnectorProfile.__table__,
CredentialEnvelope.__table__,
],
)
self.session = sessionmaker(bind=self.engine)()
self.row = FileConnectorProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
label="Original profile",
provider="webdav",
endpoint_url="https://dav.example.test/original",
base_path="original",
enabled=True,
credential_mode="basic",
username="original-user",
password_encrypted=encrypt_secret("original-password"),
token_encrypted=encrypt_secret("original-token"),
capabilities=["browse"],
policy={"allow": {"providers": ["webdav"]}},
metadata_={"original": True},
created_by_user_id=None,
updated_by_user_id=None,
)
self.session.add(self.row)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_update_preserves_normalization_and_flush_only_transaction_boundary(self) -> None:
updated = update_connector_profile_row(
self.session,
self.row,
user_id="user-2",
label=" Updated profile ",
provider="NEXTCLOUD",
endpoint_url=" https://cloud.example.test/dav ",
base_path=" shared/reports ",
enabled=False,
credential_profile_id=" credential-2 ",
credential_mode="TOKEN",
username=" updated-user ",
password="updated-password",
token="updated-token",
capabilities=["browse", " import ", ""],
policy={"deny": {"external_paths": ["private"]}},
metadata={"department": "reports"},
)
self.assertIs(self.row, updated)
self.assertEqual("Updated profile", updated.label)
self.assertEqual("nextcloud", updated.provider)
self.assertEqual("https://cloud.example.test/dav", updated.endpoint_url)
self.assertEqual("shared/reports", updated.base_path)
self.assertFalse(updated.enabled)
self.assertEqual("credential-2", updated.credential_profile_id)
self.assertEqual("token", updated.credential_mode)
self.assertEqual("updated-user", updated.username)
self.assertEqual("updated-password", decrypt_secret(updated.password_encrypted))
self.assertEqual("updated-token", decrypt_secret(updated.token_encrypted))
self.assertEqual(["browse", "import"], updated.capabilities)
self.assertEqual({"deny": {"external_paths": ["private"]}}, updated.policy)
self.assertEqual({"department": "reports"}, updated.metadata_)
self.assertEqual("user-2", updated.updated_by_user_id)
self.session.rollback()
persisted = self.session.get(FileConnectorProfile, self.row.id)
assert persisted is not None
self.assertEqual("Original profile", persisted.label)
self.assertEqual("original-password", decrypt_secret(persisted.password_encrypted))
self.assertTrue(persisted.enabled)
def test_secret_replacement_wins_over_clear_while_clear_removes_an_omitted_token(self) -> None:
update_connector_profile_row(
self.session,
self.row,
user_id="user-2",
password="replacement-password",
clear_password=True,
clear_token=True,
)
self.assertEqual("replacement-password", decrypt_secret(self.row.password_encrypted))
self.assertIsNone(self.row.token_encrypted)
self.assertEqual("https://dav.example.test/original", self.row.endpoint_url)
self.assertEqual("original-user", self.row.username)
def test_legacy_external_secret_reference_cannot_be_cleared_or_partially_mutate_the_row(self) -> None:
self.row.secret_ref = "vault:tenant-1:files:profile"
self.session.commit()
with self.assertRaisesRegex(FileStorageError, "provider-side deletion"):
update_connector_profile_row(
self.session,
self.row,
user_id="user-2",
label="Must not be applied",
secret_ref="",
)
self.assertEqual("Original profile", self.row.label)
self.assertEqual("vault:tenant-1:files:profile", self.row.secret_ref)
def test_flush_failure_remains_rollback_safe(self) -> None:
with patch.object(self.session, "flush", side_effect=RuntimeError("database unavailable")), self.assertRaisesRegex(
RuntimeError,
"database unavailable",
):
update_connector_profile_row(
self.session,
self.row,
user_id="user-2",
label="Uncommitted profile",
password="uncommitted-password",
)
self.session.rollback()
persisted = self.session.get(FileConnectorProfile, self.row.id)
assert persisted is not None
self.assertEqual("Original profile", persisted.label)
self.assertEqual("original-password", decrypt_secret(persisted.password_encrypted))
def test_visibility_filter_runs_before_connector_secrets_are_decrypted(self) -> None:
invisible = FileConnectorProfile(
id="invisible-profile",
tenant_id="tenant-1",
scope_type="user",
scope_id="another-user",
label="Invisible profile",
provider="webdav",
endpoint_url="https://invisible.example.test",
enabled=True,
credential_mode="basic",
password_encrypted="not-a-valid-encrypted-secret",
capabilities=["browse"],
policy={},
metadata_={},
)
self.session.add(invisible)
self.session.commit()
profiles = list_database_connector_profiles(
self.session,
tenant_id="tenant-1",
row_visible=lambda row: row.id == self.row.id,
)
self.assertEqual([self.row.id], [profile.id for profile in profiles])
self.assertEqual("original-password", profiles[0].password_value)
def test_reusable_credential_resolves_and_revocation_keeps_profile_visible(self) -> None:
credential = CredentialEnvelope(
id="shared-credential",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared WebDAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
secret_keys=["password"],
allowed_modules=["files"],
allowed_server_refs=[],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
)
self.row.credential_profile_id = "credential-envelope:shared-credential"
self.row.username = None
self.row.password_encrypted = None
self.row.token_encrypted = None
self.session.add(credential)
self.session.commit()
profile = list_database_connector_profiles(
self.session,
tenant_id="tenant-1",
)[0]
self.assertEqual("ada", profile.username)
self.assertEqual("secret", profile.password_value)
self.assertTrue(profile.credentials_configured)
credential.is_active = False
self.session.commit()
profile = list_database_connector_profiles(
self.session,
tenant_id="tenant-1",
)[0]
self.assertIsNone(profile.password_value)
self.assertFalse(profile.credentials_configured)
self.assertTrue(profile.metadata["credential_unavailable"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from unittest.mock import patch
from govoplan_files.backend.storage.connector_browse import ConnectorBrowseError, _smb_location, browse_connector_profile
from govoplan_files.backend.storage.connector_imports import ConnectorImportError, read_connector_file
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, connector_profiles_from_payload
from govoplan_files.backend.storage.connector_providers import connector_provider_descriptors
class FakeBody:
def __init__(self, data: bytes) -> None:
self.data = data
def read(self, _limit: int) -> bytes:
return self.data
class FakeS3Client:
def __init__(self) -> None:
self.list_objects_request: dict[str, object] | None = None
self.head_request: dict[str, object] | None = None
self.get_request: dict[str, object] | None = None
def list_objects_v2(self, **kwargs: object) -> dict[str, object]:
self.list_objects_request = dict(kwargs)
return {
"CommonPrefixes": [{"Prefix": "root/reports/"}],
"Contents": [
{
"Key": "root/report.xlsx",
"Size": 123,
"LastModified": datetime(2026, 7, 12, 10, 0, tzinfo=UTC),
"ETag": '"etag-1"',
"StorageClass": "STANDARD",
}
],
"NextContinuationToken": "next-page",
}
def list_buckets(self) -> dict[str, object]:
return {"Buckets": [{"Name": "archive", "CreationDate": datetime(2026, 7, 12, 9, 0, tzinfo=UTC)}]}
def head_object(self, **kwargs: object) -> dict[str, object]:
self.head_request = dict(kwargs)
return {
"ContentLength": 13,
"ContentType": "text/plain",
"ETag": '"etag-2"',
"VersionId": "version-1",
"ChecksumSHA256": "checksum",
}
def get_object(self, **kwargs: object) -> dict[str, object]:
self.get_request = dict(kwargs)
return {"Body": FakeBody(b"hello s3\n"), "ContentType": "text/plain", "ETag": '"etag-2"'}
def s3_profile(**overrides: object) -> ConnectorProfile:
values = {
"id": "dev-s3",
"label": "Dev S3",
"provider": "s3",
"endpoint_url": "http://127.0.0.1:9000",
"base_path": "root",
"credential_mode": "basic",
"username": "access-key",
"password_value": "secret-key",
"metadata": {"bucket": "govoplan", "region": "eu-central-1", "path_style": True},
}
values.update(overrides)
return ConnectorProfile(**values)
class ConnectorProviderTests(unittest.TestCase):
def test_smb_sdk_transport_fails_closed_before_client_creation_in_all_modes(self) -> None:
profile = ConnectorProfile(
id="smb",
label="SMB",
provider="smb",
endpoint_url="smb://files.example.test/share",
)
for allow_private, address in ((False, "93.184.216.34"), (True, "10.0.0.5")):
with self.subTest(allow_private=allow_private), patch.dict(
"os.environ",
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": str(allow_private).lower(),
},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (address, 445))],
), patch("govoplan_files.backend.storage.connector_browse._smbclient_module") as sdk, self.assertRaisesRegex(
ConnectorBrowseError,
"redirects/referrals.*DNS/IP pinning",
):
browse_connector_profile(profile, path="")
sdk.assert_not_called()
def test_smb_explicit_ip_still_fails_closed_because_the_sdk_may_follow_referrals(self) -> None:
profile = ConnectorProfile(id="smb", label="SMB", provider="smb", endpoint_url="smb://10.0.0.5/share")
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("10.0.0.5", 445))],
), self.assertRaisesRegex(ConnectorBrowseError, "redirects/referrals.*DNS/IP pinning"):
_smb_location(profile)
def test_smb_import_surfaces_fail_closed_policy_as_an_import_error(self) -> None:
profile = ConnectorProfile(id="smb", label="SMB", provider="smb", endpoint_url="smb://10.0.0.5/share")
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("10.0.0.5", 445))],
), patch("govoplan_files.backend.storage.connector_imports._smbclient_module") as sdk, self.assertRaisesRegex(
ConnectorImportError,
"redirects/referrals.*DNS/IP pinning",
):
read_connector_file(profile, library_id="", path="notice.txt", max_bytes=1024)
sdk.assert_not_called()
def test_provider_descriptors_include_s3_and_reserved_microsoft_providers(self) -> None:
descriptors = {descriptor.provider: descriptor for descriptor in connector_provider_descriptors()}
self.assertTrue(descriptors["s3"].implemented)
self.assertTrue(descriptors["s3"].browse_supported)
self.assertEqual("boto3", descriptors["s3"].optional_dependency)
self.assertFalse(descriptors["sharepoint"].implemented)
self.assertFalse(descriptors["onedrive"].implemented)
def test_profile_payload_accepts_new_providers_and_redacts_secret_metadata(self) -> None:
profiles = connector_profiles_from_payload(
{
"profiles": [
{
"id": "dev-s3",
"label": "Dev S3",
"provider": "s3",
"username": "access-key",
"password": "secret-key",
"metadata": {"bucket": "govoplan", "secret_access_key": "do-not-return"},
},
{"id": "sharepoint", "provider": "sharepoint"},
{"id": "onedrive", "provider": "onedrive"},
]
}
)
by_id = {profile.id: profile for profile in profiles}
self.assertEqual("s3", by_id["dev-s3"].provider)
self.assertTrue(by_id["dev-s3"].credentials_configured)
self.assertEqual({"bucket": "govoplan"}, dict(by_id["dev-s3"].metadata))
self.assertEqual("sharepoint", by_id["sharepoint"].provider)
self.assertEqual("onedrive", by_id["onedrive"].provider)
def test_s3_browse_lists_prefixes_and_objects_with_provenance_metadata(self) -> None:
client = FakeS3Client()
with patch("govoplan_files.backend.storage.connector_browse._s3_client", return_value=client):
items = browse_connector_profile(s3_profile(), path="")
self.assertEqual({"Bucket": "govoplan", "Prefix": "root/", "Delimiter": "/", "MaxKeys": 1000}, client.list_objects_request)
self.assertEqual(["folder", "file"], [item.kind for item in items])
self.assertEqual("reports", items[0].path)
self.assertEqual("report.xlsx", items[1].path)
self.assertEqual("govoplan:root/report.xlsx", items[1].external_id)
self.assertEqual("root/report.xlsx", items[1].metadata["key"])
self.assertEqual("next-page", items[1].metadata["next_continuation_token"])
def test_s3_browse_lists_buckets_when_profile_has_no_bucket(self) -> None:
client = FakeS3Client()
with patch("govoplan_files.backend.storage.connector_browse._s3_client", return_value=client):
items = browse_connector_profile(s3_profile(metadata={}), path="")
self.assertEqual(["archive"], [item.path for item in items])
def test_s3_sdk_transport_fails_closed_before_client_creation_in_private_mode(self) -> None:
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 9000))],
), patch("govoplan_files.backend.storage.connector_browse.import_module") as importer, self.assertRaisesRegex(
ConnectorBrowseError,
"until that transport supports.*DNS/IP pinning",
):
browse_connector_profile(s3_profile(), path="")
importer.assert_not_called()
def test_s3_sdk_endpoint_discovery_fails_closed(self) -> None:
with patch("govoplan_files.backend.storage.connector_browse.import_module") as importer, self.assertRaisesRegex(
ConnectorBrowseError,
"endpoint discovery.*cannot guarantee.*DNS/IP pinning",
):
browse_connector_profile(s3_profile(endpoint_url=None), path="")
importer.assert_not_called()
def test_s3_import_downloads_object_and_preserves_remote_identity(self) -> None:
client = FakeS3Client()
with patch("govoplan_files.backend.storage.connector_imports._s3_client", return_value=client):
downloaded = read_connector_file(s3_profile(), library_id="", path="report.txt", max_bytes=1024)
self.assertEqual({"Bucket": "govoplan", "Key": "root/report.txt"}, client.head_request)
self.assertEqual({"Bucket": "govoplan", "Key": "root/report.txt", "VersionId": "version-1"}, client.get_request)
self.assertEqual("report.txt", downloaded.filename)
self.assertEqual(b"hello s3\n", downloaded.data)
self.assertEqual("version-1", downloaded.revision)
self.assertEqual("govoplan:root/report.txt", downloaded.external_id)
self.assertEqual("s3://govoplan/root/report.txt", downloaded.external_url)
self.assertEqual("checksum", downloaded.metadata["checksum_sha256"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,265 @@
from __future__ import annotations
import unittest
from dataclasses import replace
from unittest.mock import patch
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
from govoplan_files.backend.storage.connector_visibility import (
connector_profile_usable_for_import,
visible_connector_profiles_for_actor,
)
def _profile(
profile_id: str,
*,
scope_type: str = "system",
scope_id: str | None = None,
provider: str = "webdav",
enabled: bool = True,
) -> ConnectorProfile:
return ConnectorProfile(
id=profile_id,
label=profile_id,
provider=provider,
scope_type=scope_type,
scope_id=scope_id,
endpoint_url=f"https://{profile_id}.example.invalid",
enabled=enabled,
credential_mode="anonymous",
)
class ConnectorVisibilityTests(unittest.TestCase):
@patch(
"govoplan_files.backend.storage.connector_visibility.effective_connector_policy_sources",
return_value=[],
)
@patch(
"govoplan_files.backend.storage.connector_visibility.connector_profiles_from_settings"
)
@patch(
"govoplan_files.backend.storage.connector_visibility.select_database_connector_profiles"
)
def test_profiles_are_filtered_to_actor_scopes_and_database_definition_wins(
self,
database_profiles,
configured_profiles,
_policy_sources,
) -> None:
profiles = [
_profile("system"),
_profile("tenant", scope_type="tenant", scope_id="tenant-1"),
_profile("other-tenant", scope_type="tenant", scope_id="tenant-2"),
_profile("user", scope_type="user", scope_id="user-1"),
_profile("other-user", scope_type="user", scope_id="user-2"),
_profile("group", scope_type="group", scope_id="group-1"),
_profile("campaign", scope_type="campaign", scope_id="campaign-1"),
_profile("disabled", enabled=False),
_profile("duplicate", provider="webdav"),
]
database_profiles.return_value = (profiles, {profile.id for profile in profiles})
configured_profiles.return_value = [_profile("duplicate", provider="nextcloud")]
visible = visible_connector_profiles_for_actor(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
group_ids={"group-1"},
settings=object(),
campaign_visible=lambda campaign_id: campaign_id == "campaign-1",
)
self.assertEqual(
{"system", "tenant", "user", "group", "campaign", "duplicate"},
{profile.id for profile in visible},
)
duplicate = next(profile for profile in visible if profile.id == "duplicate")
self.assertEqual("webdav", duplicate.provider)
@patch(
"govoplan_files.backend.storage.connector_visibility.effective_connector_policy_sources",
return_value=[],
)
@patch(
"govoplan_files.backend.storage.connector_visibility.connector_profiles_from_settings",
return_value=[],
)
@patch(
"govoplan_files.backend.storage.connector_visibility.select_database_connector_profiles"
)
def test_campaign_and_admin_visibility_remain_explicit(
self,
database_profiles,
_configured_profiles,
_policy_sources,
) -> None:
profiles = [
_profile("campaign-a", scope_type="campaign", scope_id="campaign-a"),
_profile("campaign-b", scope_type="campaign", scope_id="campaign-b"),
_profile("other-user", scope_type="user", scope_id="user-2"),
]
database_profiles.return_value = (profiles, {profile.id for profile in profiles})
campaign_only = visible_connector_profiles_for_actor(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
group_ids=(),
settings=None,
campaign_id="campaign-a",
campaign_visible=lambda _campaign_id: True,
)
self.assertEqual(["campaign-a"], [profile.id for profile in campaign_only])
admin = visible_connector_profiles_for_actor(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
group_ids=(),
settings=None,
campaign_visible=None,
include_admin_scopes=True,
)
self.assertEqual(
{"campaign-a", "campaign-b", "other-user"},
{profile.id for profile in admin},
)
@patch(
"govoplan_files.backend.storage.connector_visibility.connector_profiles_from_settings",
return_value=[],
)
@patch(
"govoplan_files.backend.storage.connector_visibility.select_database_connector_profiles"
)
@patch(
"govoplan_files.backend.storage.connector_visibility.effective_connector_policy_sources"
)
def test_effective_policy_is_attached_without_mutating_profile(
self,
policy_sources,
database_profiles,
_configured_profiles,
) -> None:
profile = _profile("profile")
source = ConnectorPolicySource(
scope_type="system",
label="System",
policy={"allow": {"providers": ["webdav"]}},
)
database_profiles.return_value = ([profile], {profile.id})
policy_sources.return_value = [source]
visible = visible_connector_profiles_for_actor(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
group_ids=(),
settings=None,
)
self.assertEqual((), profile.policy_sources)
self.assertEqual((source,), visible[0].policy_sources)
def test_import_usability_requires_safe_provider_credentials_endpoint_and_identity_policy(
self,
) -> None:
self.assertTrue(connector_profile_usable_for_import(_profile("webdav")))
self.assertFalse(
connector_profile_usable_for_import(_profile("s3", provider="s3"))
)
self.assertFalse(
connector_profile_usable_for_import(_profile("smb", provider="smb"))
)
self.assertFalse(
connector_profile_usable_for_import(
_profile("sharepoint", provider="sharepoint")
)
)
self.assertFalse(
connector_profile_usable_for_import(
ConnectorProfile(
id="no-endpoint",
label="No endpoint",
provider="webdav",
credential_mode="anonymous",
)
)
)
self.assertFalse(
connector_profile_usable_for_import(
ConnectorProfile(
id="no-credentials",
label="No credentials",
provider="webdav",
endpoint_url="https://files.example.invalid",
credential_mode="basic",
)
)
)
self.assertTrue(
connector_profile_usable_for_import(
ConnectorProfile(
id="metadata-endpoint",
label="Metadata endpoint",
provider="webdav",
credential_mode="anonymous",
metadata={"webdav_endpoint_url": "https://files.example.invalid"},
)
)
)
self.assertFalse(
connector_profile_usable_for_import(
ConnectorProfile(
id="unresolved-secret-reference",
label="Unresolved secret reference",
provider="webdav",
endpoint_url="https://files.example.invalid",
credential_mode="basic",
secret_ref="vault://files/webdav",
)
)
)
denied = replace(
_profile("denied"),
policy_sources=(
ConnectorPolicySource(
scope_type="system",
label="System",
policy={"deny": {"providers": ["webdav"]}},
),
),
)
self.assertFalse(connector_profile_usable_for_import(denied))
path_limited = replace(
_profile("path-limited"),
policy_sources=(
ConnectorPolicySource(
scope_type="system",
label="System",
policy={"allow": {"external_paths": ["/approved/*"]}},
),
),
)
self.assertFalse(connector_profile_usable_for_import(path_limited))
endpoint_allowed = replace(
_profile("endpoint-allowed"),
policy_sources=(
ConnectorPolicySource(
scope_type="system",
label="System",
policy={"allow": {"external_urls": ["https://*.example.invalid"]}},
),
),
)
self.assertTrue(connector_profile_usable_for_import(endpoint_allowed))
if __name__ == "__main__":
unittest.main()

241
tests/test_documentation.py Normal file
View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext
from govoplan_files.backend.documentation import documentation_topics
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
group_ids = frozenset({"group-1"})
def __init__(self, scopes: set[str]) -> None:
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes
class FilesRuntimeDocumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.session = Session()
def tearDown(self) -> None:
self.session.close()
def context(
self,
scopes: set[str],
*,
settings: object | None = None,
documentation_type: str = "user",
) -> DocumentationContext:
return DocumentationContext(
registry=object(),
principal=_Principal(scopes),
settings=settings,
session=self.session,
documentation_type=documentation_type, # type: ignore[arg-type]
)
def test_upload_tasks_state_exact_current_safe_limits(self) -> None:
settings = SimpleNamespace(
file_upload_max_bytes=7 * 1024 * 1024,
file_upload_zip_max_bytes=19 * 1024 * 1024,
)
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[],
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context(
{"files:file:read", "files:file:upload"}, settings=settings
)
)
}
upload = topics["files.workflow.upload-managed-files"]
self.assertIn("7 MiB (7,340,032 bytes)", upload.body)
self.assertEqual(["files.list"], upload.metadata["help_contexts"])
self.assertEqual(
{"files:file:read", "files:file:upload"},
set(upload.conditions[0].required_scopes),
)
archive = topics["files.workflow.upload-and-unpack-zip"]
self.assertIn("19 MiB (19,922,944 bytes)", archive.body)
self.assertIn("7 MiB (7,340,032 bytes)", archive.body)
self.assertIn("1,000", archive.body)
self.assertIn("Actual extracted bytes are counted", archive.body)
def test_connector_task_requires_authority_and_a_visible_usable_profile(
self,
) -> None:
usable = ConnectorProfile(
id="private-profile-id",
label="Private profile label",
provider="webdav",
scope_type="tenant",
scope_id="tenant-1",
endpoint_url="https://private.example.invalid/secret-root",
base_path="/secret-root",
credential_mode="anonymous",
)
context = self.context({"files:file:read", "files:file:upload"})
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[usable],
) as visible:
topics = {topic.id: topic for topic in documentation_topics(context)}
self.assertIn("files.workflow.import-managed-snapshot", topics)
self.assertNotIn("files.connector-import-unavailable", topics)
task = topics["files.workflow.import-managed-snapshot"]
self.assertEqual("configured", task.layer)
self.assertEqual(
["files.list", "files.connector-import"], task.metadata["help_contexts"]
)
self.assertIn("re-authorized", task.body)
self.assertNotIn("private-profile-id", repr(task))
self.assertNotIn("private.example.invalid", repr(task))
self.assertNotIn("secret-root", repr(task))
visible.assert_called_once()
self.assertEqual(("group-1",), tuple(visible.call_args.kwargs["group_ids"]))
without_authority = {
topic.id: topic
for topic in documentation_topics(self.context({"files:file:read"}))
}
self.assertNotIn("files.workflow.import-managed-snapshot", without_authority)
self.assertIn(
"both permission to view Files and permission to upload",
without_authority["files.connector-import-unavailable"].body,
)
def test_connector_limitation_is_precise_but_never_exposes_profile_internals(
self,
) -> None:
blocked = ConnectorProfile(
id="sensitive-profile-id",
label="Sensitive label",
provider="s3",
scope_type="tenant",
scope_id="tenant-1",
endpoint_url="https://internal.example.invalid/private",
base_path="/classified",
credential_mode="basic",
password_value="credential-secret",
)
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[blocked],
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context({"files:file:read", "files:file:upload"})
)
}
self.assertNotIn("files.workflow.import-managed-snapshot", topics)
limitation = topics["files.connector-import-unavailable"]
self.assertEqual("available", limitation.layer)
self.assertIn("pinning-safe provider", limitation.body)
payload = repr(limitation)
for secret in (
"sensitive-profile-id",
"Sensitive label",
"internal.example.invalid",
"classified",
"credential-secret",
):
self.assertNotIn(secret, payload)
def test_files_admin_connector_groups_do_not_widen_campaign_membership(self) -> None:
usable = ConnectorProfile(
id="group-profile",
label="Group profile",
provider="webdav",
scope_type="group",
scope_id="admin-visible-group",
endpoint_url="https://files.example.invalid",
credential_mode="anonymous",
)
def groups_for_actor(_session, *, include_admin_groups, **_kwargs):
return ["member-group", "admin-visible-group"] if include_admin_groups else ["member-group"]
with (
patch(
"govoplan_files.backend.documentation.user_group_ids",
side_effect=groups_for_actor,
),
patch(
"govoplan_files.backend.documentation._campaign_visibility",
return_value=None,
) as campaign_visibility,
patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[usable],
) as visible,
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context(
{
"files:file:read",
"files:file:upload",
"files:file:admin",
}
)
)
}
self.assertIn("files.workflow.import-managed-snapshot", topics)
self.assertEqual(
("member-group", "admin-visible-group"),
tuple(visible.call_args.kwargs["group_ids"]),
)
self.assertEqual(
("member-group",),
tuple(campaign_visibility.call_args.kwargs["group_ids"]),
)
def test_connector_evaluation_errors_fail_closed_without_error_details(
self,
) -> None:
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
side_effect=RuntimeError("private endpoint /root credential-id"),
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context({"files:file:read", "files:file:upload"})
)
}
self.assertNotIn("files.workflow.import-managed-snapshot", topics)
limitation = topics["files.connector-import-unavailable"]
self.assertIn("could not be safely evaluated", limitation.body)
self.assertNotIn("private endpoint", repr(limitation))
self.assertNotIn("credential-id", repr(limitation))
def test_runtime_provider_only_emits_user_documentation(self) -> None:
self.assertEqual(
(), documentation_topics(self.context(set(), documentation_type="admin"))
)
if __name__ == "__main__":
unittest.main()

132
tests/test_http_client.py Normal file
View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from unittest.mock import MagicMock, patch
import httpcore
from govoplan_core.security.outbound_http import outbound_http_policy, validate_outbound_http_url
from govoplan_files.backend.storage.http_client import (
ConnectorHttpError,
_OutboundPolicyNetworkBackend,
_SocketNetworkStream,
request_connector_bytes,
)
class _TestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - stdlib handler contract
body = b"connector-ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, _format: str, *_args: object) -> None:
return
class ConnectorHttpClientTests(unittest.TestCase):
def test_public_transport_adapter_performs_a_real_http_request(self) -> None:
server = ThreadingHTTPServer(("127.0.0.1", 0), _TestHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with patch.dict(
"os.environ",
{"APP_ENV": "test", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"},
clear=False,
):
result = request_connector_bytes(
"GET",
f"http://127.0.0.1:{server.server_port}/object",
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
self.assertEqual(200, result.status_code)
self.assertEqual(b"connector-ok", result.content)
def test_connector_responses_are_streamed_without_redirects(self) -> None:
response = MagicMock()
response.status_code = 200
response.headers = {"content-length": "6"}
response.iter_bytes.return_value = iter((b"abc", b"def"))
context = MagicMock()
context.__enter__.return_value = response
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("93.184.216.34", 443))],
), patch("govoplan_files.backend.storage.http_client._stream_connector_request", return_value=context):
result = request_connector_bytes("GET", "https://example.test/object", max_bytes=10)
self.assertEqual(b"abcdef", result.content)
def test_connector_response_limit_is_enforced_while_streaming(self) -> None:
response = MagicMock()
response.status_code = 200
response.headers = {}
response.iter_bytes.return_value = iter((b"12345", b"67890", b"!"))
context = MagicMock()
context.__enter__.return_value = response
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("93.184.216.34", 443))],
), patch(
"govoplan_files.backend.storage.http_client._stream_connector_request",
return_value=context,
), self.assertRaisesRegex(
ConnectorHttpError,
"configured limit",
):
request_connector_bytes("GET", "https://example.test/object", max_bytes=10)
def test_private_destination_is_blocked_before_http_connection(self) -> None:
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("10.0.0.5", 443))],
), patch("govoplan_files.backend.storage.http_client._stream_connector_request") as stream, self.assertRaisesRegex(
ConnectorHttpError,
"non-public network",
):
request_connector_bytes("GET", "https://connector.example.test/object")
stream.assert_not_called()
def test_connection_backend_rejects_private_rebinding_before_socket_open(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory:
validate_outbound_http_url("https://connector.example.test/object", policy=policy)
with self.assertRaises(httpcore.ConnectError):
_OutboundPolicyNetworkBackend().connect_tcp("connector.example.test", 443)
socket_factory.assert_not_called()
def test_network_backend_returns_public_network_stream_adapter(self) -> None:
sock = MagicMock()
with patch(
"govoplan_files.backend.storage.http_client.create_outbound_connection",
return_value=sock,
):
stream = _OutboundPolicyNetworkBackend().connect_tcp("connector.example.test", 443)
self.assertIsInstance(stream, _SocketNetworkStream)
self.assertIs(stream.get_extra_info("socket"), sock)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
import unittest
STATIC_TOPIC_IDS = {
"files.workflow.organize-managed-files",
"files.workflow.find-and-download-files",
"files.workflow.share-managed-files",
"files.workflow.delete-managed-files",
"files.governed-connectors-and-provenance",
"files.reference.integrity-recovery-and-fail-closed-transports",
"files.reference.snapshot-provenance-and-capabilities",
"files.assurance.process-and-release-readiness",
}
RUNTIME_TOPIC_IDS = {
"files.workflow.upload-managed-files",
"files.workflow.upload-and-unpack-zip",
"files.workflow.import-managed-snapshot",
"files.connector-import-unavailable",
}
HANDBOOK_HREF = "govoplan-files/docs/FILES_HANDBOOK.md"
class FilesManifestDocumentationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
from govoplan_files.backend.manifest import manifest
cls.manifest = manifest
cls.topics = {topic.id: topic for topic in manifest.documentation}
def topic(self, topic_id: str):
return self.topics[topic_id]
def test_static_topics_have_role_scope_module_and_link_contracts(self) -> None:
self.assertEqual(STATIC_TOPIC_IDS, set(self.topics))
self.assertEqual(
{"admin", "user"},
{
item
for topic in self.topics.values()
for item in topic.documentation_types
},
)
for topic in self.topics.values():
with self.subTest(topic=topic.id):
self.assertTrue(topic.audience)
self.assertTrue(topic.conditions)
self.assertTrue(
any(
"files" in condition.required_modules
for condition in topic.conditions
)
)
self.assertTrue(
any(
condition.any_scopes or condition.required_scopes
for condition in topic.conditions
)
)
self.assertIn("runtime", {link.kind for link in topic.links})
self.assertIn(
HANDBOOK_HREF,
{link.href for link in topic.links if link.kind == "repository"},
)
self.assertIn(
"documentation_topics",
{provider.__name__ for provider in self.manifest.documentation_providers},
)
def test_user_tasks_are_independently_authorized_and_contextual(self) -> None:
expected_scopes = {
"files.workflow.organize-managed-files": {
"files:file:read",
"files:file:organize",
},
"files.workflow.find-and-download-files": {
"files:file:read",
"files:file:download",
},
"files.workflow.share-managed-files": {
"files:file:read",
"files:file:share",
},
"files.workflow.delete-managed-files": {
"files:file:read",
"files:file:delete",
},
}
for topic_id, scopes in expected_scopes.items():
with self.subTest(topic=topic_id):
topic = self.topic(topic_id)
self.assertEqual(("user",), topic.documentation_types)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertEqual(scopes, set(topic.conditions[0].required_scopes))
self.assertEqual(["files.list"], topic.metadata["help_contexts"])
for key in ("prerequisites", "steps", "outcome", "verification"):
self.assertTrue(topic.metadata[key])
def test_share_and_delete_tasks_state_current_boundaries(self) -> None:
share = self.topic("files.workflow.share-managed-files")
self.assertEqual("available", share.layer)
self.assertIn("does not yet provide a general share editor", share.body)
self.assertIn("no share-revocation route", share.body)
self.assertIn(
"/api/v1/files/{file_id}/shares", {link.href for link in share.links}
)
delete = self.topic("files.workflow.delete-managed-files")
self.assertIn("Soft-delete", delete.summary)
self.assertIn("no self-service restore or hard-purge", delete.body)
self.assertTrue(
any("not a hard purge" in item for item in delete.metadata["limitations"])
)
def test_admin_topic_covers_policy_redaction_and_atomic_credential_deletion(
self,
) -> None:
topic = self.topic("files.governed-connectors-and-provenance")
self.assertEqual(("admin",), topic.documentation_types)
self.assertEqual("reference", topic.metadata["kind"])
self.assertEqual("File connections", topic.metadata["screen"])
self.assertTrue(topic.metadata["section"])
self.assertIn("deny rules win", topic.body)
self.assertIn("redact secret values", topic.body)
self.assertIn("same transaction", topic.body)
self.assertTrue(
any(
"non-owned external references" in item
for item in topic.metadata["security_invariants"]
)
)
self.assertIn(
"/api/v1/files/connectors/policies/tenant",
{link.href for link in topic.links},
)
self.assertIn(
"/api/v1/files/connectors/credentials", {link.href for link in topic.links}
)
def test_operator_topic_covers_recovery_and_fail_closed_s3_smb(self) -> None:
topic = self.topic(
"files.reference.integrity-recovery-and-fail-closed-transports"
)
self.assertEqual(("admin",), topic.documentation_types)
self.assertEqual("reference", topic.metadata["kind"])
self.assertIn("S3", topic.body)
self.assertIn("SMB", topic.body)
self.assertIn("fail closed", topic.body)
self.assertIn("DFS referrals", topic.body)
self.assertIn("does not remove backend blob objects", topic.body)
self.assertIn("MASTER_KEY_B64", topic.metadata["recovery_unit"])
self.assertTrue(topic.metadata["verification"])
self.assertIn(
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys
)
def test_integrator_topic_exposes_capability_and_provenance_boundary(self) -> None:
topic = self.topic("files.reference.snapshot-provenance-and-capabilities")
self.assertEqual(("admin", "user"), topic.documentation_types)
self.assertEqual("reference", topic.metadata["kind"])
self.assertEqual(
["files.access@0.1.6", "files.campaign_attachments@0.1.6"],
topic.metadata["provided_interfaces"],
)
self.assertIn("revision", topic.metadata["provenance_fields"])
self.assertIn("exact asset, version, blob, checksum", topic.body)
self.assertIn("collaboration", topic.body)
def test_process_and_release_assurance_exposes_gates_evidence_and_limits(
self,
) -> None:
topic = self.topic("files.assurance.process-and-release-readiness")
self.assertEqual(("admin", "user"), topic.documentation_types)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertEqual(["files.list"], topic.metadata["help_contexts"])
self.assertIn("process_owner", topic.audience)
self.assertIn("release_manager", topic.audience)
self.assertIn("versions align", topic.body)
self.assertIn("no general share-management UI or share revocation", topic.body)
self.assertIn("no enforced retention or legal hold", topic.body)
for key in ("prerequisites", "steps", "outcome", "verification"):
self.assertTrue(topic.metadata[key])
self.assertTrue(
any("meta-repository security" in step for step in topic.metadata["steps"])
)
self.assertTrue(any("fails closed" in step for step in topic.metadata["steps"]))
self.assertTrue(any("SHA-256" in step for step in topic.metadata["steps"]))
def test_internal_related_topic_ids_resolve(self) -> None:
known_ids = STATIC_TOPIC_IDS | RUNTIME_TOPIC_IDS
for topic in self.topics.values():
for related_id in topic.metadata.get("related_topic_ids", []):
if related_id.startswith("files."):
self.assertIn(
related_id,
known_ids,
f"{topic.id} refers to missing topic {related_id}",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from govoplan_files.backend.router import router
class FilesRouterContractTests(unittest.TestCase):
def test_connector_routes_keep_existing_api_paths(self) -> None:
routes = {(tuple(sorted(route.methods or ())), route.path) for route in router.routes}
expected = {
(("GET",), "/files/connectors/providers"),
(("GET",), "/files/connectors/settings/delta"),
(("GET",), "/files/connectors/profiles"),
(("POST",), "/files/connectors/profiles"),
(("GET",), "/files/connectors/profiles/{profile_id}/browse"),
(("POST",), "/files/connectors/profiles/{profile_id}/import"),
(("POST",), "/files/connectors/profiles/{profile_id}/sync"),
(("GET",), "/files/connectors/credentials"),
(("POST",), "/files/connectors/credentials"),
(("GET",), "/files/connector-spaces"),
(("POST",), "/files/connector-spaces"),
}
self.assertTrue(expected.issubset(routes))
def test_bulk_organize_routes_keep_existing_api_paths(self) -> None:
routes = {(tuple(sorted(route.methods or ())), route.path) for route in router.routes}
self.assertIn((("POST",), "/files/bulk-rename"), routes)
self.assertIn((("POST",), "/files/transfer"), routes)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import io
import unittest
from unittest.mock import MagicMock, PropertyMock, patch
from govoplan_files.backend.storage.backends import S3StorageBackend, StorageBackendError
def _backend() -> S3StorageBackend:
return S3StorageBackend(
bucket="files",
endpoint_url="https://objects.example.test",
region_name="test",
access_key_id="access",
secret_access_key="secret",
)
class S3StorageBackendTests(unittest.TestCase):
def test_sdk_transport_fails_closed_in_public_and_private_modes(self) -> None:
for allow_private, address in ((False, "93.184.216.34"), (True, "10.0.0.5")):
with self.subTest(allow_private=allow_private), patch.dict(
"os.environ",
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": str(allow_private).lower(),
},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (address, 443))],
), self.assertRaisesRegex(StorageBackendError, "until that transport supports.*DNS/IP pinning"):
_backend().client
def test_get_bytes_rejects_declared_oversize_object_without_reading(self) -> None:
body = MagicMock()
client = MagicMock()
client.get_object.return_value = {"ContentLength": 6, "Body": body}
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "5"}), patch.object(
S3StorageBackend,
"client",
new_callable=PropertyMock,
return_value=client,
), self.assertRaisesRegex(StorageBackendError, "deployment limit"):
_backend().get_bytes("large.bin")
body.read.assert_not_called()
body.close.assert_called_once_with()
def test_iter_bytes_rejects_undeclared_oversize_object(self) -> None:
client = MagicMock()
client.get_object.return_value = {"Body": io.BytesIO(b"123456")}
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "5"}), patch.object(
S3StorageBackend,
"client",
new_callable=PropertyMock,
return_value=client,
), self.assertRaisesRegex(StorageBackendError, "deployment limit"):
list(_backend().iter_bytes("large.bin", chunk_size=3))
def test_iter_bytes_closes_streaming_body_when_consumer_stops_early(self) -> None:
body = MagicMock()
body.read.side_effect = (b"123", b"456", b"")
client = MagicMock()
client.get_object.return_value = {"ContentLength": 6, "Body": body}
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "10"}), patch.object(
S3StorageBackend,
"client",
new_callable=PropertyMock,
return_value=client,
):
chunks = _backend().iter_bytes("object.bin", chunk_size=3)
self.assertEqual(b"123", next(chunks))
chunks.close()
body.close.assert_called_once_with()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import unittest
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError
from govoplan_files.backend.storage.transfers import _normalize_rename_request, _normalize_transfer_request
class TransferHelperTests(unittest.TestCase):
def test_transfer_normalization_deduplicates_folder_paths_and_conflicts(self) -> None:
normalized = _normalize_transfer_request(
operation=" COPY ",
source_owner_type=" USER ",
target_owner_type=" GROUP ",
folder_paths=["Reports", "Reports/2026", "", "./Archive"],
target_folder=" Target ",
conflict_strategy="rename",
conflict_resolutions=[FileConflictResolution(target_path="Target/a.txt", action="skip")],
)
self.assertEqual("copy", normalized.operation)
self.assertEqual("user", normalized.source_owner_type)
self.assertEqual("group", normalized.target_owner_type)
self.assertEqual(["Reports", "Reports/2026", "Archive"], normalized.source_folder_paths)
self.assertEqual("Target", normalized.target_folder)
self.assertEqual("rename", normalized.conflict_strategy)
self.assertEqual("skip", normalized.conflict_resolution_map["Target/a.txt"].action)
def test_transfer_normalization_rejects_unknown_operation(self) -> None:
with self.assertRaisesRegex(FileStorageError, "Unsupported transfer operation"):
_normalize_transfer_request(
operation="link",
source_owner_type="user",
target_owner_type="group",
folder_paths=[],
target_folder="",
conflict_strategy="reject",
conflict_resolutions=None,
)
def test_rename_normalization_collapses_nested_folder_roots(self) -> None:
normalized = _normalize_rename_request(
file_ids=["file-1", "file-1", "file-2"],
folder_paths=["Reports", "Reports/2026", "Archive"],
owner_type=" USER ",
owner_id="owner-1",
mode=" prefix ",
)
self.assertEqual("prefix", normalized.mode)
self.assertEqual(["file-1", "file-2"], normalized.selected_file_ids)
self.assertEqual(["Archive", "Reports"], normalized.selected_folder_roots)
self.assertEqual("user", normalized.owner_type)
def test_rename_normalization_rejects_invalid_direct_multi_select(self) -> None:
with self.assertRaisesRegex(FileStorageError, "Direct rename requires exactly one selected item"):
_normalize_rename_request(
file_ids=["file-1", "file-2"],
folder_paths=[],
owner_type="user",
owner_id="owner-1",
mode="direct",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.1", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,6 +13,9 @@
}, },
"./styles/file-manager.css": "./src/styles/file-manager.css" "./styles/file-manager.css": "./src/styles/file-manager.css"
}, },
"scripts": {
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs"
},
"peerDependencies": { "peerDependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.6", "vite": "^6.0.6",
@@ -20,8 +23,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.1" "@govoplan/core-webui": "^0.1.9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8");
const normalized = source.replace(/\s+/g, " ");
function assertIncludes(fragment, message) {
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
}
assertIncludes(
"const target = options.target ?? currentActionTarget();",
"uploads must prefer the explicit target supplied by the initiating interaction"
);
assertIncludes(
"const targetSpace = target ? findSpace(target.spaceId) : null;",
"the upload owner must be resolved from the selected target"
);
assertIncludes(
"owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: target.folderPath,",
"the upload request must use the selected target owner and folder"
);
assertIncludes(
"async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) { await handleFilesUpload(fileList, { target }); }",
"external drops must pass their concrete target without asynchronous dialog-state mutation"
);
assertIncludes(
"await uploadExternalFilesToTarget(event.dataTransfer.files, target);",
"drop handling must forward the target on which the files were dropped"
);
assertIncludes(
"onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })}",
"the dialog picker/drop zone must snapshot its selected target for upload"
);
console.log("Files upload target routing structure is intact.");

View File

@@ -1,4 +1,10 @@
import { apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui"; import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type DeltaDeletedItem, type FilesManagedFileLinkTarget } from "@govoplan/core-webui";
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationUser as AccessExplanationUser,
ResourceAccessExplanationResponse
} from "@govoplan/core-webui";
export type FileSpace = { export type FileSpace = {
id: string; id: string;
@@ -6,6 +12,14 @@ export type FileSpace = {
owner_type: "user" | "group"; owner_type: "user" | "group";
owner_id: string; owner_id: string;
description?: string | null; description?: string | null;
space_type?: "managed" | "connector";
connector_space_id?: string | null;
connector_profile_id?: string | null;
provider?: string | null;
library_id?: string | null;
remote_path?: string | null;
sync_mode?: string | null;
read_only?: boolean;
}; };
export type FileShare = { export type FileShare = {
@@ -17,6 +31,250 @@ export type FileShare = {
revoked_at?: string | null; revoked_at?: string | null;
}; };
export type FileSourceProvenance = {
source_type?: string | null;
connector_id?: string | null;
provider?: string | null;
external_id?: string | null;
external_path?: string | null;
external_url?: string | null;
revision?: string | null;
revision_label?: string | null;
observed_at?: string | null;
imported_at?: string | null;
metadata?: Record<string, unknown>;
};
export type FileConnectorPolicySource = {
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
label?: string | null;
policy: Record<string, unknown>;
};
export type FileConnectorPolicyDecision = {
allowed: boolean;
reason?: string | null;
source_path: unknown[];
requirements: string[];
details: Record<string, unknown>;
};
export type FileConnectorPolicyStep = {
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
path: string;
label: string;
applied_fields: string[];
policy: Record<string, unknown>;
};
export type FileConnectorPolicyResponse = {
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
policy: Record<string, unknown>;
effective_policy?: Record<string, unknown> | null;
parent_policy?: Record<string, unknown> | null;
effective_policy_sources?: FileConnectorPolicyStep[];
parent_policy_sources?: FileConnectorPolicyStep[];
};
export type FileConnectorProfile = {
id: string;
label: string;
provider: string;
endpoint_url?: string | null;
base_path?: string | null;
enabled: boolean;
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
source_path: string;
credential_profile_id?: string | null;
credential_profile_label?: string | null;
credential_mode: string;
credential_source?: string | null;
credentials_configured: boolean;
username?: string | null;
capabilities: string[];
policy_sources: FileConnectorPolicySource[];
metadata: Record<string, unknown>;
source_kind?: string;
};
export type FileConnectorCredential = {
id: string;
label: string;
provider?: string | null;
enabled: boolean;
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
source_path: string;
credential_mode: string;
credential_secret_source?: string | null;
credentials_configured: boolean;
username?: string | null;
policy_sources: FileConnectorPolicySource[];
metadata: Record<string, unknown>;
source_kind?: string;
};
export type FileConnectorCredentialsPayload = {
username?: string | null;
password?: string | null;
token?: string | null;
secret_ref?: string | null;
};
export type FileConnectorDiscoveryCandidate = {
endpoint_url: string;
status: "failed" | "usable" | "found" | "credentials_rejected";
message: string;
};
export type FileConnectorDiscoveryPayload = {
provider: FileConnectorProfilePayload["provider"];
endpoint_url: string;
base_path?: string | null;
credential_mode?: FileConnectorProfilePayload["credential_mode"];
credentials?: FileConnectorCredentialsPayload;
metadata?: Record<string, unknown>;
require_valid_credentials?: boolean;
};
export type FileConnectorDiscoveryResponse = {
provider: string;
endpoint_url?: string | null;
base_path?: string | null;
status: "usable" | "found" | "credentials_rejected" | "not_found" | "unsupported";
message: string;
candidates: FileConnectorDiscoveryCandidate[];
metadata: Record<string, unknown>;
};
export type FileConnectorProfilePayload = {
id: string;
label: string;
provider: "seafile" | "nextcloud" | "webdav" | "smb" | "s3" | "sharepoint" | "onedrive" | "nfs" | "dms" | "generic";
endpoint_url?: string | null;
base_path?: string | null;
enabled?: boolean;
scope_type?: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
credential_profile_id?: string | null;
credential_mode?: "none" | "anonymous" | "basic" | "token" | "secret_ref";
credentials?: FileConnectorCredentialsPayload;
capabilities?: string[];
policy?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type FileConnectorProfileUpdatePayload = Partial<Omit<FileConnectorProfilePayload, "id" | "scope_type" | "scope_id">> & {
clear_password?: boolean;
clear_token?: boolean;
};
export type FileConnectorCredentialPayload = {
id: string;
label: string;
provider?: "seafile" | "nextcloud" | "webdav" | "smb" | "s3" | "sharepoint" | "onedrive" | "nfs" | "dms" | "generic" | null;
enabled?: boolean;
scope_type?: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
credential_mode?: "none" | "anonymous" | "basic" | "token" | "secret_ref";
credentials?: FileConnectorCredentialsPayload;
policy?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type FileConnectorCredentialUpdatePayload = Partial<Omit<FileConnectorCredentialPayload, "id" | "scope_type" | "scope_id">> & {
clear_password?: boolean;
clear_token?: boolean;
};
export type FileConnectorBrowseItem = {
kind: "library" | "folder" | "file";
name: string;
path: string;
external_id?: string | null;
external_url?: string | null;
size_bytes?: number | null;
content_type?: string | null;
modified_at?: string | null;
etag?: string | null;
metadata: Record<string, unknown>;
};
export type FileConnectorBrowseResponse = {
profile_id: string;
provider: string;
path: string;
library_id?: string | null;
read_only: boolean;
next_continuation_token?: string | null;
has_more: boolean;
decision: FileConnectorPolicyDecision;
items: FileConnectorBrowseItem[];
};
export type FileConnectorProvider = {
provider: string;
label: string;
protocol: string;
implemented: boolean;
installed: boolean;
browse_supported: boolean;
import_supported: boolean;
optional_dependency?: string | null;
permission_model: string;
sync_strategy: string;
conflict_strategy: string;
preview_strategy: string;
audit_events: string[];
notes?: string | null;
};
export type FileConnectorSpace = {
id: string;
tenant_id: string;
owner_type: "user" | "group";
owner_id: string;
label: string;
connector_profile_id: string;
provider: string;
library_id?: string | null;
remote_path: string;
sync_mode: string;
read_only: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata: Record<string, unknown>;
};
export type FileConnectorSettingsDeltaResponse = {
profiles: FileConnectorProfile[];
credentials: FileConnectorCredential[];
spaces: FileConnectorSpace[];
policy?: FileConnectorPolicyResponse | null;
changed_sections?: string[];
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type FileConnectorSpacePayload = {
owner_type?: "user" | "group";
owner_id?: string | null;
label: string;
connector_profile_id: string;
library_id?: string | null;
remote_path?: string;
sync_mode?: "manual";
metadata?: Record<string, unknown>;
};
export type ManagedFile = { export type ManagedFile = {
id: string; id: string;
tenant_id: string; tenant_id: string;
@@ -34,12 +292,43 @@ export type ManagedFile = {
deleted_at?: string | null; deleted_at?: string | null;
audit_relevant: boolean; audit_relevant: boolean;
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
source_provenance?: FileSourceProvenance | null;
source_revision?: string | null;
shares?: FileShare[]; shares?: FileShare[];
}; };
export type FileListResponse = { files: ManagedFile[] }; export type FileListResponse = {files: ManagedFile[];cursor?: string | null;next_cursor?: string | null;watermark?: string | null;};
export type FileSpacesResponse = { spaces: FileSpace[] }; export type FileDeltaDeletedItem = {id: string;resource_type?: string | null;revision?: string | null;deleted_at?: string | null;};
export type FileUploadResponse = { files: ManagedFile[] }; export type FileDeltaResponse = {
files: ManagedFile[];
folders: FileFolder[];
deleted: FileDeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type ManagedFileSnapshotResponse = {files: ManagedFile[];folders: FileFolder[];watermark?: string | null;};
export type FileSpacesResponse = {spaces: FileSpace[];};
export type FileUploadResponse = {files: ManagedFile[];};
export type FileUploadProgress = {loaded: number;total?: number;percentage: number | null;};
export type FileConnectorFilePayload = {
library_id: string;
path: string;
owner_type: "user" | "group";
owner_id?: string | null;
target_folder?: string | null;
target_path?: string | null;
campaign_id?: string | null;
conflict_strategy?: ConflictStrategy;
source_revision?: string | null;
metadata?: Record<string, unknown>;
};
export type FileConnectorSyncResponse = {
file: ManagedFile;
action: "created" | "updated" | "unchanged";
previous_version_id?: string | null;
current_version_id: string;
};
export type FileFolder = { export type FileFolder = {
id: string; id: string;
tenant_id: string; tenant_id: string;
@@ -50,16 +339,18 @@ export type FileFolder = {
updated_at: string; updated_at: string;
deleted_at?: string | null; deleted_at?: string | null;
}; };
export type FileFoldersResponse = { folders: FileFolder[] }; export type FileFoldersResponse = {folders: FileFolder[];cursor?: string | null;next_cursor?: string | null;watermark?: string | null;};
export type FolderDeleteResponse = { deleted_folders: number; deleted_files: number };
export type BulkDeleteResponse = { deleted_count: number }; const DEFAULT_MANAGED_FILE_WINDOW_SIZE = 500;
export type RenameResponse = { dry_run: boolean; items: { kind: "file" | "folder"; id: string; file_id?: string | null; folder_path?: string | null; old_path: string; new_path: string }[] }; export type FolderDeleteResponse = {deleted_folders: number;deleted_files: number;};
export type TransferResponse = { operation: "move" | "copy"; files: number; folders: number }; export type BulkDeleteResponse = {deleted_count: number;};
export type RenameResponse = {dry_run: boolean;items: {kind: "file" | "folder";id: string;file_id?: string | null;folder_path?: string | null;old_path: string;new_path: string;}[];};
export type TransferResponse = {operation: "move" | "copy";files: number;folders: number;};
export type ConflictAction = "overwrite" | "rename" | "skip"; export type ConflictAction = "overwrite" | "rename" | "skip";
export type ConflictStrategy = "reject" | "overwrite" | "rename"; export type ConflictStrategy = "reject" | "overwrite" | "rename";
export type ConflictResolution = { target_path: string; action: ConflictAction; new_path?: string }; export type ConflictResolution = {target_path: string;action: ConflictAction;new_path?: string;};
export type PatternResolveResponse = { export type PatternResolveResponse = {
patterns: { pattern: string; matches: ManagedFile[] }[]; patterns: {pattern: string;matches: ManagedFile[];}[];
unmatched: ManagedFile[]; unmatched: ManagedFile[];
}; };
@@ -69,41 +360,141 @@ export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesRespons
} }
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> { export function listFolders(settings: ApiSettings, params: {owner_type: "user" | "group";owner_id: string;page_size?: number;cursor?: string | null;}): Promise<FileFoldersResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
search.set("owner_type", params.owner_type); search.set("owner_type", params.owner_type);
search.set("owner_id", params.owner_id); search.set("owner_id", params.owner_id);
if (params.page_size) search.set("page_size", String(params.page_size));
if (params.cursor) search.set("cursor", params.cursor);
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`); return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
} }
export function createFolder( export function createFolder(
settings: ApiSettings, settings: ApiSettings,
payload: { owner_type: "user" | "group"; owner_id: string; path: string } payload: {owner_type: "user" | "group";owner_id: string;path: string;})
): Promise<FileFolder> { : Promise<FileFolder> {
return apiFetch<FileFolder>(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) }); return apiFetch<FileFolder>(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) });
} }
export function deleteFolder( export function deleteFolder(
settings: ApiSettings, settings: ApiSettings,
payload: { owner_type: "user" | "group"; owner_id: string; path: string; recursive?: boolean } payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?: boolean;})
): Promise<FolderDeleteResponse> { : Promise<FolderDeleteResponse> {
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) }); return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
} }
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> { export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
if (value) search.set(key, value); if (value) search.set(key, String(value));
} }
const suffix = search.toString() ? `?${search.toString()}` : ""; const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`); return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
} }
export function listFilesDelta(
settings: ApiSettings,
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
: Promise<FileDeltaResponse> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
}
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileDeltaResponse>(settings, `/api/v1/files/delta${suffix}`);
}
function applyDeltaToSnapshot(
files: ManagedFile[],
folders: FileFolder[],
response: FileDeltaResponse)
: {files: ManagedFile[];folders: FileFolder[];} {
if (response.full) return { files: response.files, folders: response.folders };
const deletedFileIds = new Set(response.deleted.filter((item) => !item.resource_type || item.resource_type === "file").map((item) => item.id));
const deletedFolderIds = new Set(response.deleted.filter((item) => item.resource_type === "folder").map((item) => item.id));
const filesById = new Map(files.map((file) => [file.id, file]));
const foldersById = new Map(folders.map((folder) => [folder.id, folder]));
deletedFileIds.forEach((id) => filesById.delete(id));
deletedFolderIds.forEach((id) => foldersById.delete(id));
response.files.forEach((file) => filesById.set(file.id, file));
response.folders.forEach((folder) => foldersById.set(folder.id, folder));
return { files: Array.from(filesById.values()), folders: Array.from(foldersById.values()) };
}
export async function listManagedFileSnapshot(
settings: ApiSettings,
params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page_size?: number;})
: Promise<ManagedFileSnapshotResponse> {
const pageSize = params.page_size ?? DEFAULT_MANAGED_FILE_WINDOW_SIZE;
let watermark: string | null | undefined = null;
let folderCursor: string | null | undefined = null;
let folders: FileFolder[] = [];
do {
const response = await listFolders(settings, {
owner_type: params.owner_type,
owner_id: params.owner_id,
page_size: pageSize,
cursor: folderCursor
});
watermark = watermark || response.watermark;
folders = folders.concat(response.folders);
folderCursor = response.next_cursor;
} while (folderCursor);
let fileCursor: string | null | undefined = null;
let files: ManagedFile[] = [];
do {
const response = await listFiles(settings, {
owner_type: params.owner_type,
owner_id: params.owner_id,
path_prefix: params.path_prefix,
page_size: pageSize,
cursor: fileCursor
});
watermark = watermark || response.watermark;
files = files.concat(response.files);
fileCursor = response.next_cursor;
} while (fileCursor);
if (watermark) {
let since = watermark;
let response: FileDeltaResponse | null = null;
do {
response = await listFilesDelta(settings, {
owner_type: params.owner_type,
owner_id: params.owner_id,
path_prefix: params.path_prefix,
since,
limit: pageSize
});
const snapshot = applyDeltaToSnapshot(files, folders, response);
files = snapshot.files;
folders = snapshot.folders;
since = response.watermark || "";
} while (response.has_more && response.watermark);
watermark = since || watermark;
}
return { files, folders, watermark };
}
export async function uploadFiles( export async function uploadFiles(
settings: ApiSettings, settings: ApiSettings,
files: File[], files: File[],
options: { owner_type: "user" | "group"; owner_id: string; path?: string; campaign_id?: string; unpack_zip?: boolean; conflict_strategy?: ConflictStrategy; conflict_resolutions?: ConflictResolution[] } options: {
): Promise<FileUploadResponse> { owner_type: "user" | "group";
owner_id: string;
path?: string;
campaign_id?: string;
unpack_zip?: boolean;
conflict_strategy?: ConflictStrategy;
conflict_resolutions?: ConflictResolution[];
source_provenance?: FileSourceProvenance;
source_revision?: string;
connector_policy_sources?: FileConnectorPolicySource[];
onProgress?: (progress: FileUploadProgress) => void;
})
: Promise<FileUploadResponse> {
const form = new FormData(); const form = new FormData();
files.forEach((file) => form.append("files", file)); files.forEach((file) => form.append("files", file));
form.append("owner_type", options.owner_type); form.append("owner_type", options.owner_type);
@@ -113,9 +504,57 @@ export async function uploadFiles(
if (options.unpack_zip) form.append("unpack_zip", "true"); if (options.unpack_zip) form.append("unpack_zip", "true");
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy); if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions)); if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
if (options.source_provenance) form.append("source_provenance_json", JSON.stringify(options.source_provenance));
if (options.source_revision) form.append("source_revision", options.source_revision);
if (options.connector_policy_sources?.length) form.append("connector_policy_json", JSON.stringify({ sources: options.connector_policy_sources }));
if (options.onProgress) return uploadFilesWithProgress(settings, form, options.onProgress);
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form }); return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
} }
function uploadFilesWithProgress(
settings: ApiSettings,
form: FormData,
onProgress: (progress: FileUploadProgress) => void)
: Promise<FileUploadResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl(settings, "/api/v1/files/upload"));
xhr.withCredentials = true;
for (const [key, value] of authHeaders(settings)) xhr.setRequestHeader(key, value);
const csrf = csrfToken();
if (csrf) xhr.setRequestHeader("X-CSRF-Token", csrf);
xhr.upload.onprogress = (event) => {
const total = event.lengthComputable ? event.total : undefined;
onProgress({
loaded: event.loaded,
total,
percentage: total && total > 0 ? Math.round(event.loaded / total * 100) : null
});
};
xhr.onerror = () => reject(new Error("i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3"));
xhr.onabort = () => reject(new Error("i18n:govoplan-files.upload_was_cancelled.5bab0f9b"));
xhr.onload = () => {
const responseText = xhr.responseText || "";
if (xhr.status < 200 || xhr.status >= 300) {
reject(new ApiError(xhr.status, xhr.statusText, responseText));
return;
}
onProgress({ loaded: 1, total: 1, percentage: 100 });
if (xhr.status === 204 || !responseText) {
resolve({ files: [] });
return;
}
const contentType = xhr.getResponseHeader("content-type") || "";
resolve(contentType.includes("application/json") ? JSON.parse(responseText) as FileUploadResponse : { files: [] });
};
onProgress({ loaded: 0, total: undefined, percentage: 0 });
xhr.send(form);
});
}
export function deleteFile(settings: ApiSettings, fileId: string): Promise<BulkDeleteResponse> { export function deleteFile(settings: ApiSettings, fileId: string): Promise<BulkDeleteResponse> {
return apiFetch<BulkDeleteResponse>(settings, `/api/v1/files/${fileId}`, { method: "DELETE" }); return apiFetch<BulkDeleteResponse>(settings, `/api/v1/files/${fileId}`, { method: "DELETE" });
} }
@@ -124,17 +563,267 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) }); return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) });
} }
export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> { export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
return apiFetch<FileShare>(settings, `/api/v1/files/${fileId}/shares`, {
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, ""))}`;
}
function base64Url(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST", method: "POST",
body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" }) body: JSON.stringify({ file_ids: fileIds, target_type: target.type, target_id: target.id, permission: target.permission ?? "read" })
}); });
} }
export function evaluateFileConnectorPolicy(
settings: ApiSettings,
payload: {source_provenance: FileSourceProvenance;operation?: string;policy_sources?: FileConnectorPolicySource[];})
: Promise<{decision: FileConnectorPolicyDecision;}> {
return apiFetch<{decision: FileConnectorPolicyDecision;}>(settings, "/api/v1/files/connector-policy/evaluate", {
method: "POST",
body: JSON.stringify({ operation: "access", policy_sources: [], ...payload })
});
}
export function getFileConnectorPolicy(
settings: ApiSettings,
scopeType: "system" | "tenant" | "user" | "group" | "campaign",
scopeId?: string | null)
: Promise<FileConnectorPolicyResponse> {
const search = new URLSearchParams();
if (scopeId) search.set("scope_id", scopeId);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileConnectorPolicyResponse>(settings, `/api/v1/files/connectors/policies/${encodeURIComponent(scopeType)}${suffix}`);
}
export function fetchFileConnectorSettingsDelta(
settings: ApiSettings,
params: {
scope_type?: "system" | "tenant" | "user" | "group" | "campaign";
scope_id?: string | null;
provider?: string;
campaign_id?: string | null;
include_disabled?: boolean;
include_inactive?: boolean;
owner_type?: "user" | "group";
owner_id?: string;
since?: string | null;
limit?: number;
} = {})
: Promise<FileConnectorSettingsDeltaResponse> {
const search = new URLSearchParams();
if (params.scope_type) search.set("scope_type", params.scope_type);
if (params.scope_id) search.set("scope_id", params.scope_id);
if (params.provider) search.set("provider", params.provider);
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
if (params.include_disabled) search.set("include_disabled", "true");
if (params.include_inactive) search.set("include_inactive", "true");
if (params.owner_type) search.set("owner_type", params.owner_type);
if (params.owner_id) search.set("owner_id", params.owner_id);
if (params.since) search.set("since", params.since);
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileConnectorSettingsDeltaResponse>(settings, `/api/v1/files/connectors/settings/delta${suffix}`);
}
export function updateFileConnectorPolicy(
settings: ApiSettings,
scopeType: "system" | "tenant" | "user" | "group" | "campaign",
policy: Record<string, unknown>,
scopeId?: string | null)
: Promise<FileConnectorPolicyResponse> {
const search = new URLSearchParams();
if (scopeId) search.set("scope_id", scopeId);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileConnectorPolicyResponse>(settings, `/api/v1/files/connectors/policies/${encodeURIComponent(scopeType)}${suffix}`, {
method: "PUT",
body: JSON.stringify({ policy })
});
}
export function listFileConnectorProfiles(
settings: ApiSettings,
params: {provider?: string;campaign_id?: string;include_disabled?: boolean;} = {})
: Promise<{profiles: FileConnectorProfile[];}> {
const search = new URLSearchParams();
if (params.provider) search.set("provider", params.provider);
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
if (params.include_disabled) search.set("include_disabled", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<{profiles: FileConnectorProfile[];}>(settings, `/api/v1/files/connectors/profiles${suffix}`);
}
export function listFileConnectorProviders(settings: ApiSettings): Promise<{providers: FileConnectorProvider[];}> {
return apiFetch<{providers: FileConnectorProvider[];}>(settings, "/api/v1/files/connectors/providers");
}
export function discoverFileConnectorEndpoint(settings: ApiSettings, payload: FileConnectorDiscoveryPayload): Promise<FileConnectorDiscoveryResponse> {
return apiFetch<FileConnectorDiscoveryResponse>(settings, "/api/v1/files/connectors/discover", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listFileConnectorCredentials(
settings: ApiSettings,
params: {provider?: string;include_disabled?: boolean;} = {})
: Promise<{credentials: FileConnectorCredential[];}> {
const search = new URLSearchParams();
if (params.provider) search.set("provider", params.provider);
if (params.include_disabled) search.set("include_disabled", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<{credentials: FileConnectorCredential[];}>(settings, `/api/v1/files/connectors/credentials${suffix}`);
}
export function createFileConnectorCredential(settings: ApiSettings, payload: FileConnectorCredentialPayload): Promise<FileConnectorCredential> {
return apiFetch<FileConnectorCredential>(settings, "/api/v1/files/connectors/credentials", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateFileConnectorCredential(
settings: ApiSettings,
credentialId: string,
payload: FileConnectorCredentialUpdatePayload)
: Promise<FileConnectorCredential> {
return apiFetch<FileConnectorCredential>(settings, `/api/v1/files/connectors/credentials/${encodeURIComponent(credentialId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deactivateFileConnectorCredential(settings: ApiSettings, credentialId: string): Promise<FileConnectorCredential> {
return apiFetch<FileConnectorCredential>(settings, `/api/v1/files/connectors/credentials/${encodeURIComponent(credentialId)}`, { method: "DELETE" });
}
export function listFileConnectorSpaces(
settings: ApiSettings,
params: {owner_type?: "user" | "group";owner_id?: string;include_inactive?: boolean;} = {})
: Promise<{spaces: FileConnectorSpace[];}> {
const search = new URLSearchParams();
if (params.owner_type) search.set("owner_type", params.owner_type);
if (params.owner_id) search.set("owner_id", params.owner_id);
if (params.include_inactive) search.set("include_inactive", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<{spaces: FileConnectorSpace[];}>(settings, `/api/v1/files/connector-spaces${suffix}`);
}
export function createFileConnectorSpace(settings: ApiSettings, payload: FileConnectorSpacePayload): Promise<FileConnectorSpace> {
return apiFetch<FileConnectorSpace>(settings, "/api/v1/files/connector-spaces", {
method: "POST",
body: JSON.stringify({ owner_type: "user", remote_path: "", sync_mode: "manual", metadata: {}, ...payload })
});
}
export function updateFileConnectorSpace(
settings: ApiSettings,
spaceId: string,
payload: Partial<Omit<FileConnectorSpacePayload, "connector_profile_id" | "owner_type" | "owner_id">> & {is_active?: boolean;})
: Promise<FileConnectorSpace> {
return apiFetch<FileConnectorSpace>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteFileConnectorSpace(settings: ApiSettings, spaceId: string): Promise<FileConnectorSpace> {
return apiFetch<FileConnectorSpace>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}`, { method: "DELETE" });
}
export function getFileConnectorProfile(
settings: ApiSettings,
profileId: string,
params: {campaign_id?: string;include_disabled?: boolean;} = {})
: Promise<FileConnectorProfile> {
const search = new URLSearchParams();
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
if (params.include_disabled) search.set("include_disabled", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileConnectorProfile>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}${suffix}`);
}
export function createFileConnectorProfile(settings: ApiSettings, payload: FileConnectorProfilePayload): Promise<FileConnectorProfile> {
return apiFetch<FileConnectorProfile>(settings, "/api/v1/files/connectors/profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateFileConnectorProfile(
settings: ApiSettings,
profileId: string,
payload: FileConnectorProfileUpdatePayload)
: Promise<FileConnectorProfile> {
return apiFetch<FileConnectorProfile>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deactivateFileConnectorProfile(settings: ApiSettings, profileId: string): Promise<FileConnectorProfile> {
return apiFetch<FileConnectorProfile>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export function browseFileConnectorProfile(
settings: ApiSettings,
profileId: string,
params: {path?: string;library_id?: string;continuation_token?: string;campaign_id?: string;} = {})
: Promise<FileConnectorBrowseResponse> {
const search = new URLSearchParams();
if (params.path) search.set("path", params.path);
if (params.library_id) search.set("library_id", params.library_id);
if (params.continuation_token) search.set("continuation_token", params.continuation_token);
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<FileConnectorBrowseResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/browse${suffix}`);
}
export function importFileConnectorFile(
settings: ApiSettings,
profileId: string,
payload: FileConnectorFilePayload)
: Promise<FileUploadResponse> {
return apiFetch<FileUploadResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/import`, {
method: "POST",
body: JSON.stringify({ conflict_strategy: "reject", metadata: {}, ...payload })
});
}
export function syncFileConnectorFile(
settings: ApiSettings,
profileId: string,
payload: FileConnectorFilePayload)
: Promise<FileConnectorSyncResponse> {
return apiFetch<FileConnectorSyncResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/sync`, {
method: "POST",
body: JSON.stringify({ conflict_strategy: "rename", metadata: {}, ...payload })
});
}
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
return shareFilesWithTarget(settings, fileIds, { type: "campaign", id: campaignId, label: "campaign" });
}
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
const share = response.shares[0];
if (!share) throw new Error("i18n:govoplan-files.file_share_was_not_created.033ac476");
return share;
}
export function bulkRenameFiles( export function bulkRenameFiles(
settings: ApiSettings, settings: ApiSettings,
payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean } payload: {file_ids: string[];folder_paths?: string[];owner_type: "user" | "group";owner_id: string;mode: "direct" | "prefix" | "suffix" | "replace";new_name?: string;find?: string;replacement?: string;prefix?: string;suffix?: string;recursive?: boolean;dry_run?: boolean;})
): Promise<RenameResponse> { : Promise<RenameResponse> {
return apiFetch<RenameResponse>(settings, "/api/v1/files/bulk-rename", { return apiFetch<RenameResponse>(settings, "/api/v1/files/bulk-rename", {
method: "POST", method: "POST",
body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload }) body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload })
@@ -142,27 +831,27 @@ export function bulkRenameFiles(
} }
export function resolveFilePatterns( export function resolveFilePatterns(
settings: ApiSettings, settings: ApiSettings,
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean } payload: {patterns: string[];owner_type?: "user" | "group";owner_id?: string;campaign_id?: string;path_prefix?: string;include_unmatched?: boolean;case_sensitive?: boolean;})
): Promise<PatternResolveResponse> { : Promise<PatternResolveResponse> {
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) }); return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
} }
export function transferFiles( export function transferFiles(
settings: ApiSettings, settings: ApiSettings,
payload: { payload: {
operation: "move" | "copy"; operation: "move" | "copy";
file_ids: string[]; file_ids: string[];
folder_paths: string[]; folder_paths: string[];
source_owner_type: "user" | "group"; source_owner_type: "user" | "group";
source_owner_id: string; source_owner_id: string;
target_owner_type: "user" | "group"; target_owner_type: "user" | "group";
target_owner_id: string; target_owner_id: string;
target_folder: string; target_folder: string;
conflict_strategy?: ConflictStrategy; conflict_strategy?: ConflictStrategy;
conflict_resolutions?: ConflictResolution[]; conflict_resolutions?: ConflictResolution[];
} })
): Promise<TransferResponse> { : Promise<TransferResponse> {
return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) }); return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) });
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; import { Copy, Download, FolderOpen, Home, KeyRound, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui"; import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } from "@govoplan/core-webui";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files"; import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types"; import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
@@ -11,28 +11,28 @@ export function TransferFolderSelector({
selectedFolder, selectedFolder,
onSelect, onSelect,
disabled disabled
}: {
space: FileSpace | null;
nodes: FolderNode[];
selectedFolder: string;
onSelect: (folderPath: string) => void;
disabled?: boolean;
}) { }: {space: FileSpace | null;nodes: FolderNode[];selectedFolder: string;onSelect: (folderPath: string) => void;disabled?: boolean;}) {
return ( return (
<div className="file-folder-selector" role="tree" aria-label="Destination folder"> <div className="file-folder-selector" role="tree" aria-label="i18n:govoplan-files.destination_folder.f8ccb63b">
<button <button
type="button" type="button"
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`} className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
onClick={() => onSelect("")} onClick={() => onSelect("")}
disabled={disabled || !space} disabled={disabled || !space}>
>
<Home size={15} aria-hidden="true" /> <Home size={15} aria-hidden="true" />
<span>{space?.label || "Root"}</span> <span>{space?.label || "i18n:govoplan-files.root.e96857c5"}</span>
</button> </button>
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">No folders yet. Choose the root folder.</span>} {nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d</span>}
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} /> <TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
</div> </div>);
);
} }
export function TransferFolderSelectorNodes({ export function TransferFolderSelectorNodes({
@@ -41,33 +41,33 @@ export function TransferFolderSelectorNodes({
onSelect, onSelect,
disabled, disabled,
depth = 1 depth = 1
}: {
nodes: FolderNode[];
selectedFolder: string;
onSelect: (folderPath: string) => void;
disabled?: boolean;
depth?: number;
}) { }: {nodes: FolderNode[];selectedFolder: string;onSelect: (folderPath: string) => void;disabled?: boolean;depth?: number;}) {
if (nodes.length === 0) return null; if (nodes.length === 0) return null;
return ( return (
<div className="file-folder-selector-children"> <div className="file-folder-selector-children">
{nodes.map((node) => ( {nodes.map((node) =>
<div key={node.path}> <div key={node.path}>
<button <button
type="button" type="button"
className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`} className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`}
style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }} style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }}
onClick={() => onSelect(node.path)} onClick={() => onSelect(node.path)}
disabled={disabled} disabled={disabled}>
>
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
<span>{node.name}</span> <span>{node.name}</span>
</button> </button>
<TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} /> <TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} />
</div> </div>
))} )}
</div> </div>);
);
} }
export function FolderTree({ export function FolderTree({
@@ -90,27 +90,27 @@ export function FolderTree({
contextMenuEnabled = true, contextMenuEnabled = true,
disabled, disabled,
depth = 1 depth = 1
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
dropTargetKey?: string;
expandedKeys: Set<string>;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
onClearDropState?: () => void;
onRequestDragExpand?: (spaceId: string, path: string) => void;
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
onDragEndFolder?: () => void;
dragDropEnabled?: boolean;
contextMenuEnabled?: boolean;
disabled?: boolean;
depth?: number;
}) { }: {nodes: FolderNode[];activeSpaceId: string;spaceId: string;currentFolder: string;dropTargetKey?: string;expandedKeys: Set<string>;onOpen: (spaceId: string, path: string) => void;onToggle: (spaceId: string, path: string) => void;onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;onClearDropState?: () => void;onRequestDragExpand?: (spaceId: string, path: string) => void;onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;onDragEndFolder?: () => void;dragDropEnabled?: boolean;contextMenuEnabled?: boolean;disabled?: boolean;depth?: number;}) {
return ( return (
<ExplorerTree <ExplorerTree
nodes={nodes} nodes={nodes}
@@ -138,9 +138,9 @@ export function FolderTree({
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path); if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined} } : undefined}
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined} onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined} onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined} />);
/>
);
} }
export function RenamePreviewList({ export function RenamePreviewList({
@@ -151,23 +151,23 @@ export function RenamePreviewList({
visibleCount, visibleCount,
onShowMore, onShowMore,
onShowFewer onShowFewer
}: {
response: RenameResponse;
selectedFileIds: Set<string>;
selectedFolderPaths: Set<string>;
recursive: boolean;
visibleCount: number;
onShowMore: () => void;
onShowFewer: () => void;
}) { }: {response: RenameResponse;selectedFileIds: Set<string>;selectedFolderPaths: Set<string>;recursive: boolean;visibleCount: number;onShowMore: () => void;onShowFewer: () => void;}) {
const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder); const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder);
const hiddenNonRecursiveItems = !recursive const hiddenNonRecursiveItems = !recursive ?
? response.items.filter((item) => { response.items.filter((item) => {
if (item.kind === "file" && selectedFileIds.has(item.id)) return false; if (item.kind === "file" && selectedFileIds.has(item.id)) return false;
if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false; if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false;
return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder)); return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
}).length }).length :
: 0; 0;
const visibleItems = response.items.filter((item) => { const visibleItems = response.items.filter((item) => {
if (recursive) return true; if (recursive) return true;
if (item.kind === "file") { if (item.kind === "file") {
@@ -182,20 +182,20 @@ export function RenamePreviewList({
return ( return (
<div className="rename-preview-panel"> <div className="rename-preview-panel">
<div className="placeholder-stack rename-preview-list"> <div className="placeholder-stack rename-preview-list">
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged.</span>} {hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1</span>}
{shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> <code>{item.new_path}</code></span>)} {shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> <code>{item.new_path}</code></span>)}
{remaining > 0 && ( {remaining > 0 &&
<button type="button" className="rename-preview-more" onClick={onShowMore}> and {remaining} more show next {Math.min(20, remaining)}</button> <button type="button" className="rename-preview-more" onClick={onShowMore}>i18n:govoplan-files.and.a0d93385 {remaining} i18n:govoplan-files.more_show_next.f1912318 {Math.min(20, remaining)}</button>
)} }
{shownItems.length > 20 && ( {shownItems.length > 20 &&
<button type="button" className="rename-preview-more" onClick={onShowFewer}>Show fewer</button> <button type="button" className="rename-preview-more" onClick={onShowFewer}>i18n:govoplan-files.show_fewer.d94dfbe5</button>
)} }
</div> </div>
</div> </div>);
);
} }
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { export function FileDialog({ title, onClose, children }: {title: string;onClose: () => void;children: ReactNode;}) {
return ( return (
<Dialog <Dialog
open open
@@ -205,11 +205,11 @@ export function FileDialog({ title, onClose, children }: { title: string; onClos
className="file-dialog" className="file-dialog"
headerClassName="file-dialog-header" headerClassName="file-dialog-header"
titleClassName="file-dialog-title" titleClassName="file-dialog-title"
bodyClassName="file-dialog-body" bodyClassName="file-dialog-body">
>
{children} {children}
</Dialog> </Dialog>);
);
} }
export function FileContextMenu({ export function FileContextMenu({
@@ -220,29 +220,31 @@ export function FileContextMenu({
canDownload, canDownload,
canOrganize, canOrganize,
canDelete, canDelete,
canExplainAccess,
downloadLabel, downloadLabel,
onCreateFolder, onCreateFolder,
onUpload, onUpload,
onDownload, onDownload,
onMove, onMove,
onCopy, onCopy,
onExplainAccess,
onDelete onDelete
}: {
menu: ContextMenuState;
hasSelection: boolean;
canCreateFolder: boolean;
canUpload: boolean;
canDownload: boolean;
canOrganize: boolean;
canDelete: boolean;
downloadLabel: string;
onCreateFolder: () => void;
onUpload: () => void;
onDownload: () => void;
onMove: () => void;
onCopy: () => void;
onDelete: () => void;
}) { }: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
const showNewFolder = true; const showNewFolder = true;
const showDelete = menu.target !== "empty"; const showDelete = menu.target !== "empty";
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth; const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
@@ -251,19 +253,20 @@ export function FileContextMenu({
const estimatedHeight = 260; const estimatedHeight = 260;
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8)); const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
const openUp = menu.y + estimatedHeight > viewportHeight; const openUp = menu.y + estimatedHeight > viewportHeight;
const style: CSSProperties = openUp const style: CSSProperties = openUp ?
? { left, bottom: Math.max(8, viewportHeight - menu.y) } { left, bottom: Math.max(8, viewportHeight - menu.y) } :
: { left, top: Math.max(8, menu.y) }; { left, top: Math.max(8, menu.y) };
return ( return (
<div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}> <div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}>
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> New folder</button>} {showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> i18n:govoplan-files.new_folder.a711999b</button>}
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> Upload</button> <button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</button>
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button> <button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> Move</button> <button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> Copy</button> <button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> Delete</button>} <button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button>
</div> {showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
); </div>);
} }
export function FileConflictDialog({ export function FileConflictDialog({
@@ -276,60 +279,60 @@ export function FileConflictDialog({
onReview, onReview,
onApplyReview, onApplyReview,
onUpdateItem onUpdateItem
}: {
state: ConflictDialogState;
busy: boolean;
onClose: () => void;
onCancel: () => void;
onOverwrite: () => void;
onRename: () => void;
onReview: () => void;
onApplyReview: () => void;
onUpdateItem: (id: string, patch: Partial<FileConflictItem>) => void;
}) { }: {state: ConflictDialogState;busy: boolean;onClose: () => void;onCancel: () => void;onOverwrite: () => void;onRename: () => void;onReview: () => void;onApplyReview: () => void;onUpdateItem: (id: string, patch: Partial<FileConflictItem>) => void;}) {
return ( return (
<FileDialog title={state.title} onClose={onClose}> <FileDialog title={state.title} onClose={onClose}>
<p className="muted">{state.message}</p> <p className="muted">{state.message}</p>
<div className="file-conflict-summary"> <div className="file-conflict-summary">
<strong>{state.items.length} conflict(s)</strong> <strong>{state.items.length} i18n:govoplan-files.conflict_s.60cea6d5</strong>
<span>{state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}</span> <span>{state.items.length > 1 ? "i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f" : "i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6"}</span>
</div> </div>
{state.review && ( {state.review &&
<div className="file-conflict-list"> <div className="file-conflict-list">
{state.items.map((item) => ( {state.items.map((item) =>
<div className="file-conflict-row" key={item.id}> <div className="file-conflict-row" key={item.id}>
<div> <div>
<strong>{item.label}</strong> <strong>{item.label}</strong>
<small>{item.kind === "folder" ? "Folder" : "File"} conflict at <code>{item.targetPath}</code></small> <small>{item.kind === "folder" ? "i18n:govoplan-files.folder.30baa249" : "i18n:govoplan-files.file.2c3cafa4"} i18n:govoplan-files.conflict_at.0a91052f <code>{item.targetPath}</code></small>
</div> </div>
<select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}> <select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
<option value="overwrite">Overwrite</option> <option value="overwrite">i18n:govoplan-files.overwrite.0625c54e</option>
<option value="rename">Rename</option> <option value="rename">i18n:govoplan-files.rename.d3f4cb89</option>
<option value="skip">Skip</option> <option value="skip">i18n:govoplan-files.skip.3da47453</option>
</select> </select>
<input <input
value={item.newPath} value={item.newPath}
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })} onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
disabled={busy || item.action !== "rename"} disabled={busy || item.action !== "rename"}
aria-label={`New path for ${item.label}`} aria-label={i18nMessage("i18n:govoplan-files.new_path_for_value.a9a30c1c", { value0: item.label })} />
/>
</div> </div>
))} )}
</div> </div>
)} }
{!state.review && ( {!state.review &&
<div className="placeholder-stack"> <div className="placeholder-stack">
{state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)} {state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)}
{state.items.length > 8 && <span> and {state.items.length - 8} more</span>} {state.items.length > 8 && <span>i18n:govoplan-files.and.a0d93385 {state.items.length - 8} more</span>}
</div> </div>
)} }
<div className="button-row compact-actions align-end"> <div className="button-row compact-actions align-end">
<Button onClick={onCancel} disabled={busy}>Cancel</Button> <Button onClick={onCancel} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button onClick={onOverwrite} disabled={busy}>Overwrite all</Button> <Button onClick={onOverwrite} disabled={busy}>i18n:govoplan-files.overwrite_all.a76f2d04</Button>
<Button onClick={onRename} disabled={busy}>Rename all</Button> <Button onClick={onRename} disabled={busy}>i18n:govoplan-files.rename_all.1d6b24dc</Button>
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>Review individually</Button>} {!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>i18n:govoplan-files.review_individually.19abbe58</Button>}
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>Apply reviewed choices</Button>} {state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>i18n:govoplan-files.apply_reviewed_choices.bb55f7b3</Button>}
</div> </div>
</FileDialog> </FileDialog>);
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ export type RenameMode = "prefix" | "suffix" | "replace";
export type SortColumn = "name" | "size" | "modified"; export type SortColumn = "name" | "size" | "modified";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export type TransferMode = "move" | "copy"; export type TransferMode = "move" | "copy";
export type DialogKind = "upload" | "create-folder" | "rename" | "single-rename" | "transfer" | null; export type DialogKind = "upload" | "connector-sync" | "connector-space" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
export type EntrySelectionKey = `file:${string}` | `folder:${string}`; export type EntrySelectionKey = `file:${string}` | `folder:${string}`;
export type ContextMenuState = { export type ContextMenuState = {
x: number; x: number;

View File

@@ -1,7 +1,14 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui"; import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
import type { FileFolder, ManagedFile } from "../../../api/files"; import type { FileFolder, ManagedFile } from "../../../api/files";
import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types"; import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
type FolderStats = {
directFiles: number;
childFolders: Set<string>;
totalSize: number;
updatedAt: string;
};
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] { export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
const byPath = new Map<string, FolderNode>(); const byPath = new Map<string, FolderNode>();
const ensureNode = (path: string, persisted: boolean): FolderNode | null => { const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
@@ -48,7 +55,7 @@ export function treeNodeKey(spaceId: string, folderPath: string): string {
return `${spaceId}:${normalizeFolder(folderPath)}`; return `${spaceId}:${normalizeFolder(folderPath)}`;
} }
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] { export function folderAncestorPaths(folderPath: string, options: {includeSelf?: boolean;} = {}): string[] {
const parts = normalizeFolder(folderPath).split("/").filter(Boolean); const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0); const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
const result: string[] = []; const result: string[] = [];
@@ -71,21 +78,23 @@ export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[]
const prefix = folder ? `${folder}/` : ""; const prefix = folder ? `${folder}/` : "";
const folderMap = new Map<string, FolderEntry>(); const folderMap = new Map<string, FolderEntry>();
const directFiles: FileEntry[] = []; const directFiles: FileEntry[] = [];
const folderStats = buildFolderStats(files, folders);
for (const persisted of folders) { for (const persisted of folders) {
const path = normalizeFolder(persisted.path); const path = normalizeFolder(persisted.path);
if (!path.startsWith(prefix) || path === folder) continue; if (!path.startsWith(prefix) || path === folder) continue;
const relative = prefix ? path.slice(prefix.length) : path; const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || relative.includes("/")) continue; if (!relative || relative.includes("/")) continue;
const stats = folderStats.get(path);
folderMap.set(path, { folderMap.set(path, {
kind: "folder", kind: "folder",
id: `folder:${path}`, id: `folder:${path}`,
name: relative, name: relative,
path, path,
fileCount: 0, fileCount: stats?.directFiles ?? 0,
folderCount: 0, folderCount: stats?.childFolders.size ?? 0,
totalSize: 0, totalSize: stats?.totalSize ?? 0,
updatedAt: persisted.updated_at, updatedAt: latestTimestamp(persisted.updated_at, stats?.updatedAt),
persisted: true persisted: true
}); });
} }
@@ -102,31 +111,22 @@ export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[]
} }
const folderName = relativePath.slice(0, slashIndex); const folderName = relativePath.slice(0, slashIndex);
const folderPath = prefix ? `${folder}/${folderName}` : folderName; const folderPath = prefix ? `${folder}/${folderName}` : folderName;
const existing = folderMap.get(folderPath); if (!folderMap.has(folderPath)) {
if (existing) { const stats = folderStats.get(folderPath);
existing.totalSize += file.size_bytes;
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
} else {
folderMap.set(folderPath, { folderMap.set(folderPath, {
kind: "folder", kind: "folder",
id: `folder:${folderPath}`, id: `folder:${folderPath}`,
name: folderName, name: folderName,
path: folderPath, path: folderPath,
fileCount: 0, fileCount: stats?.directFiles ?? 0,
folderCount: 0, folderCount: stats?.childFolders.size ?? 0,
totalSize: file.size_bytes, totalSize: stats?.totalSize ?? file.size_bytes,
updatedAt: file.updated_at, updatedAt: stats?.updatedAt || file.updated_at,
persisted: false persisted: false
}); });
} }
} }
for (const entry of folderMap.values()) {
const counts = directFolderCounts(files, folders, entry.path);
entry.fileCount = counts.files;
entry.folderCount = counts.folders;
}
return [...Array.from(folderMap.values()), ...directFiles]; return [...Array.from(folderMap.values()), ...directFiles];
} }
@@ -134,7 +134,7 @@ export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn
const factor = direction === "asc" ? 1 : -1; const factor = direction === "asc" ? 1 : -1;
return [...entries].sort((a, b) => { return [...entries].sort((a, b) => {
if (column === "name") { if (column === "name") {
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1); if (a.kind !== b.kind) return a.kind === "folder" ? direction === "asc" ? -1 : 1 : direction === "asc" ? 1 : -1;
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
} }
if (column === "size") { if (column === "size") {
@@ -163,26 +163,56 @@ export function entryModifiedTime(entry: ExplorerEntry): number {
return Number.isNaN(parsed) ? 0 : parsed; return Number.isNaN(parsed) ? 0 : parsed;
} }
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } { export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): {files: number;folders: number;} {
const normalized = normalizeFolder(folderPath); const stats = buildFolderStats(files, folders).get(normalizeFolder(folderPath));
const prefix = normalized ? `${normalized}/` : ""; return { files: stats?.directFiles ?? 0, folders: stats?.childFolders.size ?? 0 };
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length; }
const childFolders = new Set<string>();
function buildFolderStats(files: ManagedFile[], folders: FileFolder[]): Map<string, FolderStats> {
const statsByPath = new Map<string, FolderStats>();
const ensureStats = (path: string): FolderStats => {
const normalized = normalizeFolder(path);
const existing = statsByPath.get(normalized);
if (existing) return existing;
const stats: FolderStats = { directFiles: 0, childFolders: new Set(), totalSize: 0, updatedAt: "" };
statsByPath.set(normalized, stats);
return stats;
};
for (const folder of folders) { for (const folder of folders) {
const path = normalizeFolder(folder.path); const path = normalizeFolder(folder.path);
if (!path.startsWith(prefix) || path === normalized) continue; if (!path) continue;
const relative = prefix ? path.slice(prefix.length) : path; const parts = path.split("/");
if (!relative) continue; const parentPath = normalizeFolder(parts.slice(0, -1).join("/"));
childFolders.add(relative.split("/")[0]); const name = parts[parts.length - 1];
if (name) ensureStats(parentPath).childFolders.add(name);
ensureStats(path);
} }
for (const file of files) { for (const file of files) {
const path = normalizeFilePath(file.display_path); const parts = normalizeFilePath(file.display_path || file.filename).split("/").filter(Boolean);
if (!path.startsWith(prefix)) continue; if (parts.length === 0) continue;
const relative = prefix ? path.slice(prefix.length) : path; const directParentPath = normalizeFolder(parts.slice(0, -1).join("/"));
if (!relative || !relative.includes("/")) continue; ensureStats(directParentPath).directFiles += 1;
childFolders.add(relative.split("/")[0]);
for (let index = 0; index < parts.length - 1; index += 1) {
const folderPath = parts.slice(0, index + 1).join("/");
const stats = ensureStats(folderPath);
stats.totalSize += file.size_bytes;
stats.updatedAt = latestTimestamp(stats.updatedAt, file.updated_at);
const parentPath = normalizeFolder(parts.slice(0, index).join("/"));
ensureStats(parentPath).childFolders.add(parts[index]);
}
} }
return { files: directFiles, folders: childFolders.size };
return statsByPath;
}
function latestTimestamp(current: string, candidate?: string): string {
if (!candidate) return current;
if (!current) return candidate;
return candidate > current ? candidate : current;
} }
export function folderContentLabel(entry: FolderEntry): string { export function folderContentLabel(entry: FolderEntry): string {
@@ -209,13 +239,14 @@ export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileF
const sortedDates = childFiles.map((file) => file.updated_at).sort(); const sortedDates = childFiles.map((file) => file.updated_at).sort();
const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined; const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined;
const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString(); const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString();
const counts = directFolderCounts(files, folders, normalizedPath);
return { return {
kind: "folder", kind: "folder",
id: `folder:${normalizedPath}`, id: `folder:${normalizedPath}`,
name: lastPathSegment(normalizedPath) || "Root", name: lastPathSegment(normalizedPath) || "i18n:govoplan-files.root.e96857c5",
path: normalizedPath, path: normalizedPath,
fileCount: directFolderCounts(files, folders, normalizedPath).files, fileCount: counts.files,
folderCount: directFolderCounts(files, folders, normalizedPath).folders, folderCount: counts.folders,
totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0), totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0),
updatedAt, updatedAt,
persisted: Boolean(persistedFolder) persisted: Boolean(persistedFolder)
@@ -240,7 +271,7 @@ export function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set<s
return Array.from(ids); return Array.from(ids);
} }
export function folderBreadcrumbs(folder: string): { name: string; path: string }[] { export function folderBreadcrumbs(folder: string): {name: string;path: string;}[] {
const parts = normalizeFolder(folder).split("/").filter(Boolean); const parts = normalizeFolder(folder).split("/").filter(Boolean);
return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") })); return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") }));
} }
@@ -287,14 +318,19 @@ export function parseDragState(value: string): DragSelectionState | null {
} }
export function formatBytes(value: number): string { export function formatBytes(value: number): string {
if (value < 1024) return `${value} B`; if (value < 1024) return i18nMessage("i18n:govoplan-files.bytes_b", { value0: value });
const units = ["KB", "MB", "GB", "TB"]; const units = [
"i18n:govoplan-files.bytes_kb",
"i18n:govoplan-files.bytes_mb",
"i18n:govoplan-files.bytes_gb",
"i18n:govoplan-files.bytes_tb"
];
let size = value / 1024; let size = value / 1024;
for (const unit of units) { for (const unit of units) {
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`; if (size < 1024) return i18nMessage(unit, { value0: size.toFixed(size >= 10 ? 0 : 1) });
size /= 1024; size /= 1024;
} }
return `${size.toFixed(1)} PB`; return i18nMessage("i18n:govoplan-files.bytes_pb", { value0: size.toFixed(1) });
} }
export function formatDate(value: string): string { export function formatDate(value: string): string {

View File

@@ -0,0 +1,714 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-files.add_connector_space.aa6bdbd6": "Add connector space",
"i18n:govoplan-files.add_credential_for_value.0fa9c1fe": "Add credential for {value0}",
"i18n:govoplan-files.add_prefix.672452bc": "Add prefix",
"i18n:govoplan-files.add_space.e4d674d4": "Add Space",
"i18n:govoplan-files.add_suffix_before_extension.784381fe": "Add suffix before extension",
"i18n:govoplan-files.added_connector_space_value.a24725b8": "Added connector space {value0}.",
"i18n:govoplan-files.allowed_connection_ids.0df854c8": "Allowed connection IDs",
"i18n:govoplan-files.allowed_credential_ids.efcaba2d": "Allowed credential IDs",
"i18n:govoplan-files.allowed_endpoint_urls.34cfa8e9": "Allowed endpoint URLs",
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
"i18n:govoplan-files.access_explanation.75ee7f62": "Access explanation",
"i18n:govoplan-files.action.97c89a4d": "Action",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
"i18n:govoplan-files.any_provider.b3fc542f": "Any provider",
"i18n:govoplan-files.apply_preview.8692d5eb": "Apply preview",
"i18n:govoplan-files.apply_recursively_to_folder_contents.c7076984": "Apply recursively to folder contents",
"i18n:govoplan-files.apply_reviewed_choices.bb55f7b3": "Apply reviewed choices",
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available",
"i18n:govoplan-files.close.bbfa773e": "Close",
"i18n:govoplan-files.evidence.8487d192": "Evidence",
"i18n:govoplan-files.explain_access.4d5fac37": "Explain access",
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Loading access explanation...",
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
"i18n:govoplan-files.no_source.6dcf9723": "No source",
"i18n:govoplan-files.owner.89ff3122": "Owner",
"i18n:govoplan-files.permission.2f81a22d": "Permission",
"i18n:govoplan-files.policy.0b779a05": "Policy",
"i18n:govoplan-files.resource.d1c626a9": "Resource",
"i18n:govoplan-files.role.b5b4a5a2": "Role",
"i18n:govoplan-files.share.09ca55ca": "Share",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
"i18n:govoplan-files.blocked.99613c74": "Blocked",
"i18n:govoplan-files.bootstrap_settings.05e3df38": " · Bootstrap settings",
"i18n:govoplan-files.browse_ok_value_item_value.921906fd": "Browse OK ({value0} item{value1}).",
"i18n:govoplan-files.browse_protocol.d1f27c90": "Browse protocol",
"i18n:govoplan-files.browse.2f3b5c55": "Browse",
"i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b": "Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.",
"i18n:govoplan-files.bulk_rename_selected_items.5e79d694": "Bulk rename selected items",
"i18n:govoplan-files.bulk_rename.7dcaa624": "Bulk rename",
"i18n:govoplan-files.bytes_b": "{value0} B",
"i18n:govoplan-files.bytes_gb": "{value0} GB",
"i18n:govoplan-files.bytes_kb": "{value0} KB",
"i18n:govoplan-files.bytes_mb": "{value0} MB",
"i18n:govoplan-files.bytes_pb": "{value0} PB",
"i18n:govoplan-files.bytes_tb": "{value0} TB",
"i18n:govoplan-files.can_use_this_connection.5091a74c": "Can use this connection",
"i18n:govoplan-files.can_use_this_credential.f4a41971": "Can use this credential",
"i18n:govoplan-files.cancel.77dfd213": "Cancel",
"i18n:govoplan-files.cannot_move_or_copy_a_folder_into_itself_or_one_.4adbd5ea": "Cannot move or copy a folder into itself or one of its child folders.",
"i18n:govoplan-files.case_sensitive.c73af7bd": "Case sensitive",
"i18n:govoplan-files.checking.494d0f68": "Checking...",
"i18n:govoplan-files.choose_a_connector_file_and_destination_folder.3c6f3ffb": "Choose a connector file and destination folder.",
"i18n:govoplan-files.choose_a_connector_profile_and_owner_space.4c386b00": "Choose a connector profile and owner space.",
"i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643": "Choose a destination space and folder. Folders are transferred recursively.",
"i18n:govoplan-files.choose_how_the_selected_names_should_be_changed.0231854f": "Choose how the selected names should be changed.",
"i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6": "Choose how to resolve this conflict.",
"i18n:govoplan-files.choose_managed_attachment_source.b3709493": "Choose managed attachment source",
"i18n:govoplan-files.choose_managed_file_or_pattern.bcf8e183": "Choose managed file or pattern",
"i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75": "Choose the destination folder before selecting or dropping files.",
"i18n:govoplan-files.campaigns": "Campaigns",
"i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7": "Choose the folder that should act as this campaign attachment source.",
"i18n:govoplan-files.choose_the_parent_folder_for_the_new_subfolder.2fcb6580": "Choose the parent folder for the new subfolder.",
"i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f": "Choose the same action for all conflicts or review the target names individually.",
"i18n:govoplan-files.choose_the_target_folder_folders_are_transferred.f7ab8e9e": "Choose the target folder. Folders are transferred recursively.",
"i18n:govoplan-files.clear_saved_password.7442260d": "Clear saved password",
"i18n:govoplan-files.clear_saved_token.a9c670aa": "Clear saved token",
"i18n:govoplan-files.clear.719ea396": "Clear",
"i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24": "Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.",
"i18n:govoplan-files.collapse.9cf188d3": "Collapse",
"i18n:govoplan-files.conflict_at.0a91052f": "conflict at",
"i18n:govoplan-files.conflict_s.60cea6d5": "conflict(s)",
"i18n:govoplan-files.connection_credential.178babe0": "Connection / credential",
"i18n:govoplan-files.connector_files.8f351f5d": "Connector files",
"i18n:govoplan-files.connector_folders.960cbb03": "Connector folders",
"i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5": "Connector profile is not configured for this space.",
"i18n:govoplan-files.connector_profile.9e9cd317": "Connector profile",
"i18n:govoplan-files.connector_root.9027235c": "Connector root",
"i18n:govoplan-files.connector_source_already_matched.7eaf5be1": "Connector source already matched",
"i18n:govoplan-files.connector_space_files.dbb0ab24": "Connector space files",
"i18n:govoplan-files.connector.ba358306": "Connector",
"i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1": "contained path(s) will move with the selected folder(s), but their own names will stay unchanged.",
"i18n:govoplan-files.copied.8e3df45a": "Copied",
"i18n:govoplan-files.copy.92556c6d": "Copy…",
"i18n:govoplan-files.copy.a69c71d0": " copy",
"i18n:govoplan-files.copy.af74f7c5": "Copy",
"i18n:govoplan-files.copying.43b7de98": "Copying",
"i18n:govoplan-files.copymove.d0fa5904": "copyMove",
"i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76": "Create a folder below the selected destination.",
"i18n:govoplan-files.create_folder.97bafaba": "Create Folder",
"i18n:govoplan-files.create_folder.e59f63fa": "Create folder",
"i18n:govoplan-files.create_space.b50606b4": "Create Space",
"i18n:govoplan-files.created_folder_value.6c3002f9": "Created folder {value0}.",
"i18n:govoplan-files.created_inside.3426a815": "Created inside",
"i18n:govoplan-files.credential_id_and_label_are_required.4cfd1ffd": "Credential id and label are required.",
"i18n:govoplan-files.credential_id.9432a6e1": "Credential id",
"i18n:govoplan-files.credential_mode.23fdd899": "Credential mode",
"i18n:govoplan-files.credential.8bede3ea": "Credential",
"i18n:govoplan-files.current_file.1fbfcf3d": "current file",
"i18n:govoplan-files.current_folder_contents.f9a24fa8": "Current folder contents",
"i18n:govoplan-files.current_folder.5aeab2f0": "Current folder",
"i18n:govoplan-files.delete_selected_item_s.4b6a0023": "Delete selected item(s)?",
"i18n:govoplan-files.delete_value_audit_relevant_blobs_remain_retaine.290af88f": "Delete {value0}? Audit-relevant blobs remain retained.",
"i18n:govoplan-files.delete.f6fdbe48": "Delete",
"i18n:govoplan-files.denied_connection_ids.a95218b4": "Denied connection IDs",
"i18n:govoplan-files.denied_credential_ids.b6838bc2": "Denied credential IDs",
"i18n:govoplan-files.denied_endpoint_urls.1d8d4369": "Denied endpoint URLs",
"i18n:govoplan-files.denied_paths.d25e4315": "Denied paths",
"i18n:govoplan-files.denied_providers.149b29f4": "Denied providers",
"i18n:govoplan-files.deny_listed_paths.fcde62cc": "Deny-listed paths",
"i18n:govoplan-files.destination_folder.f8ccb63b": "Destination folder",
"i18n:govoplan-files.destination_space.92b63970": "Destination space",
"i18n:govoplan-files.disable_file_connection.3fd940ae": "Disable file connection",
"i18n:govoplan-files.disable_file_credential.49bff614": "Disable file credential",
"i18n:govoplan-files.disable_value_connections_using_it_will_stop_aut.fc3a1708": "Disable {value0}? Connections using it will stop authenticating until another credential is selected.",
"i18n:govoplan-files.disable_value_existing_linked_spaces_will_stop_b.8c1b0ac6": "Disable {value0}? Existing linked spaces will stop browsing until the connection is enabled again.",
"i18n:govoplan-files.disable_value.09485f7f": "Disable {value0}",
"i18n:govoplan-files.disable.9a7d4e06": "Disable",
"i18n:govoplan-files.disabled.f4f4473d": "Disabled",
"i18n:govoplan-files.dms.477e5652": "DMS",
"i18n:govoplan-files.download_zip.7bd0e4b0": "Download ZIP",
"i18n:govoplan-files.download.a479c9c3": "Download",
"i18n:govoplan-files.e_g_invoices.77a73bd1": "e.g. invoices",
"i18n:govoplan-files.edit_file_connection.78698154": "Edit file connection",
"i18n:govoplan-files.edit_file_credential.bc49d44f": "Edit file credential",
"i18n:govoplan-files.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-files.effective_sources_none.9a7c407f": "Effective sources: none",
"i18n:govoplan-files.effective_sources.9a576802": "Effective sources:",
"i18n:govoplan-files.enabled.df174a3f": "Enabled",
"i18n:govoplan-files.endpoint_url.65aaaa45": "Endpoint URL",
"i18n:govoplan-files.endpoint.92ec6350": "Endpoint",
"i18n:govoplan-files.expand.9869e506": "Expand",
"i18n:govoplan-files.extracting_files_on_the_server.845a3c1a": "Extracting files on the server…",
"i18n:govoplan-files.file_actions.9e1b94c5": "File actions",
"i18n:govoplan-files.file_connection_created.bdf6e1f6": "File connection created.",
"i18n:govoplan-files.file_connection_disabled_value.059a7de9": "File connection disabled: {value0}.",
"i18n:govoplan-files.file_connection_saved.68c16ee9": "File connection saved.",
"i18n:govoplan-files.file_connections.1e362326": "File connections",
"i18n:govoplan-files.file_credential_created.87c31882": "File credential created.",
"i18n:govoplan-files.file_credential_disabled_value.947538fd": "File credential disabled: {value0}.",
"i18n:govoplan-files.file_credential_saved.d6a1eef8": "File credential saved.",
"i18n:govoplan-files.file_or_pattern_relative_to.0ab4d831": "File or pattern relative to",
"i18n:govoplan-files.file_s.ca61e97f": "file(s),",
"i18n:govoplan-files.file_share_was_not_created.033ac476": "File share was not created.",
"i18n:govoplan-files.file_spaces_and_folders.443d2056": "File spaces and folders",
"i18n:govoplan-files.file.2c3cafa4": "File",
"i18n:govoplan-files.files_with_the_same_visible_name_already_exist_i.1bb487b0": "Files with the same visible name already exist in this folder.",
"i18n:govoplan-files.files.6ce6c512": "Files",
"i18n:govoplan-files.finalizing_upload.bcce936d": "Finalizing upload…",
"i18n:govoplan-files.find_and_replace.2c6b404b": "Find and replace",
"i18n:govoplan-files.find.df251b06": "Find",
"i18n:govoplan-files.first.49ec0838": "first.",
"i18n:govoplan-files.folder_name.b2ce023b": "Folder name",
"i18n:govoplan-files.folder_s.10e54730": "folder(s)",
"i18n:govoplan-files.folder.30baa249": "Folder",
"i18n:govoplan-files.folders_and_files.d107ac99": "Folders and files",
"i18n:govoplan-files.folders.19adc47b": "Folders",
"i18n:govoplan-files.gb.505a44fa": "GB",
"i18n:govoplan-files.generic.ff7613e5": "Generic",
"i18n:govoplan-files.govoplan_files.b20752ed": "GovOPlaN files",
"i18n:govoplan-files.govoplan_webdav_credentials.bc696d69": "GovOPlaN WebDAV credentials",
"i18n:govoplan-files.group.171a0606": "Group",
"i18n:govoplan-files.groups": "Groups",
"i18n:govoplan-files.import.d6fbc9d2": "Import",
"i18n:govoplan-files.inherited.58823af0": "Inherited",
"i18n:govoplan-files.kb.315b39a0": "KB",
"i18n:govoplan-files.kerberos.53c35fb7": "Kerberos",
"i18n:govoplan-files.label.74341e3c": "Label",
"i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e": "Leave empty to keep saved password",
"i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b": "Leave empty to keep saved token",
"i18n:govoplan-files.library.b8100f5b": "Library",
"i18n:govoplan-files.linked_file_space.1a614c7d": "Linked file space",
"i18n:govoplan-files.linked_to_1_campaign.5349694f": "Linked to 1 campaign",
"i18n:govoplan-files.linked_to_value_campaigns.1ad7be94": "Linked to {value0} campaigns",
"i18n:govoplan-files.linked_to.ca188768": "linked to",
"i18n:govoplan-files.linked_to.e7ca9e02": "Linked to",
"i18n:govoplan-files.linking.6f640897": "Linking…",
"i18n:govoplan-files.loading_connector_files.02c876e0": "Loading connector files…",
"i18n:govoplan-files.loading_connector_files.e5b0871d": "Loading connector files",
"i18n:govoplan-files.loading_connector_folders.426de3b6": "Loading connector folders",
"i18n:govoplan-files.loading_connector_folders.dde26f3e": "Loading connector folders...",
"i18n:govoplan-files.loading_connector_policy.2b984eec": "Loading connector policy...",
"i18n:govoplan-files.loading_connector_policy.f12ab285": "Loading connector policy",
"i18n:govoplan-files.loading_connector_space.356d83c6": "Loading connector space…",
"i18n:govoplan-files.loading_connector_space.c176e6b8": "Loading connector space",
"i18n:govoplan-files.loading_file_connections.bd68f224": "Loading file connections",
"i18n:govoplan-files.loading_file_connections.ef22dc81": "Loading file connections...",
"i18n:govoplan-files.loading_files.3b142382": "Loading files",
"i18n:govoplan-files.loading_files.bfd27a7e": "Loading files…",
"i18n:govoplan-files.loading.b04ba49f": "Loading...",
"i18n:govoplan-files.lower_connection_limits.4f9838bc": "Lower connection limits",
"i18n:govoplan-files.lower_credential_limits.ec2b72bc": "Lower credential limits",
"i18n:govoplan-files.lower_endpoint_limits.3fd781d5": "Lower endpoint limits",
"i18n:govoplan-files.lower_path_limits.1abcccb1": "Lower path limits",
"i18n:govoplan-files.lower_provider_limits.5816270a": "Lower provider limits",
"i18n:govoplan-files.managed_files.4dd6ad11": "Managed files",
"i18n:govoplan-files.match.c79af5c2": "match.",
"i18n:govoplan-files.mb.6e979f42": "MB",
"i18n:govoplan-files.metadata_json_must_be_an_object.48629f13": "Metadata JSON must be an object.",
"i18n:govoplan-files.metadata_json.b0e4c283": "Metadata JSON",
"i18n:govoplan-files.mode.a7b93d21": "Mode",
"i18n:govoplan-files.modified.19a532c8": "Modified",
"i18n:govoplan-files.more_show_next.f1912318": "more — show next",
"i18n:govoplan-files.move.76cdb950": "Move",
"i18n:govoplan-files.move.8a74a26e": "Move…",
"i18n:govoplan-files.moved.0fc28db0": "Moved",
"i18n:govoplan-files.moving.65cd4dd8": "Moving",
"i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4": "Must stay in allowed paths",
"i18n:govoplan-files.name.709a2322": "Name",
"i18n:govoplan-files.native_default.ba32f7b6": "Native/default",
"i18n:govoplan-files.new_connection.ac979fe4": "New connection",
"i18n:govoplan-files.new_credential.65b2a9b1": "New credential",
"i18n:govoplan-files.new_file_connection.2ec6eb56": "New file connection",
"i18n:govoplan-files.new_file_credential.4c061082": "New file credential",
"i18n:govoplan-files.new_folder.a711999b": "New folder",
"i18n:govoplan-files.new_name.9e627c7b": "New name",
"i18n:govoplan-files.new_path_for_value.a9a30c1c": "New path for {value0}",
"i18n:govoplan-files.nextcloud.aab73a3d": "Nextcloud",
"i18n:govoplan-files.nfs.db121865": "NFS",
"i18n:govoplan-files.no_accessible_file_spaces_were_found.abcf5003": "No accessible file spaces were found.",
"i18n:govoplan-files.no_campaign_links.4203c2be": "No campaign links",
"i18n:govoplan-files.no_connector_items.d634e023": "No connector items.",
"i18n:govoplan-files.no_connector_profiles_are_available.9be3be28": "No connector profiles are available.",
"i18n:govoplan-files.no_connector_profiles.03c730ee": "No connector profiles",
"i18n:govoplan-files.no_current_files_match_this_pattern.b84c5836": "No current files match this pattern.",
"i18n:govoplan-files.no_endpoint.d0ea68c4": "No endpoint",
"i18n:govoplan-files.no_file_connections_configured.3ef02049": "No file connections configured.",
"i18n:govoplan-files.no_file_selected.f76f1c1c": "No file selected",
"i18n:govoplan-files.no_file_spaces_available.a81fa3d0": "No file spaces available.",
"i18n:govoplan-files.no_value_available": "No {value0} available.",
"i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped.1166f3b5": "No files uploaded; all conflicts were skipped.",
"i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d": "No folders yet. Choose the root folder.",
"i18n:govoplan-files.no_saved_credential.30a5a951": "No saved credential",
"i18n:govoplan-files.no_secret.33c1a339": "No secret",
"i18n:govoplan-files.none.6eef6648": "None",
"i18n:govoplan-files.not_sorted.0abc03c5": "not sorted",
"i18n:govoplan-files.ntlm.026eac85": "NTLM",
"i18n:govoplan-files.only_the_visible_name_changes_immutable_blobs_st.01557e71": "Only the visible name changes. Immutable blobs stay stable for audit.",
"i18n:govoplan-files.open_parent_folder_value.8030a7c7": "Open parent folder {value0}",
"i18n:govoplan-files.overwrite_all.a76f2d04": "Overwrite all",
"i18n:govoplan-files.overwrite.0625c54e": "Overwrite",
"i18n:govoplan-files.owner_space.364c4b62": "Owner space",
"i18n:govoplan-files.parent_folder.d8ed4c2a": "Parent folder",
"i18n:govoplan-files.password.8be3c943": "Password",
"i18n:govoplan-files.path_limited.d32cbef0": "Path-limited",
"i18n:govoplan-files.pattern_preview_results.56cea56c": "Pattern preview results",
"i18n:govoplan-files.policy.bb9cf141": "Policy",
"i18n:govoplan-files.prefix.90eceb01": "Prefix",
"i18n:govoplan-files.preview_rename.bb890b24": "Preview rename",
"i18n:govoplan-files.preview_resolves_to.a8d99432": "Preview resolves to",
"i18n:govoplan-files.preview.f1fbb2b4": "Preview",
"i18n:govoplan-files.profile_id_and_label_are_required.6ad85df1": "Profile id and label are required.",
"i18n:govoplan-files.profile_id.25961d68": "Profile id",
"i18n:govoplan-files.provider.7ceee3f3": "Provider",
"i18n:govoplan-files.read_only.9b19a5a2": "Read-only",
"i18n:govoplan-files.refresh.56e3badc": "Refresh",
"i18n:govoplan-files.reload.cce71553": "Reload",
"i18n:govoplan-files.remote_connector_space.d8956863": "Remote connector space",
"i18n:govoplan-files.rename_all.1d6b24dc": "Rename all",
"i18n:govoplan-files.rename_item.3d21d6ca": "Rename item",
"i18n:govoplan-files.rename.d3f4cb89": "Rename",
"i18n:govoplan-files.renamed_value_item_s.3c4a40fe": "Renamed {value0} item(s).",
"i18n:govoplan-files.replace_pattern.d98e4c92": "Replace pattern",
"i18n:govoplan-files.replace_the_current_pattern.96f80707": "Replace the current pattern?",
"i18n:govoplan-files.replace_value_with_the_exact_file_value.4a63236f": "Replace \"{value0}\" with the exact file \"{value1}\"?",
"i18n:govoplan-files.replacement.c385e347": "Replacement",
"i18n:govoplan-files.require_smb_signing.5168a83d": "Require SMB signing",
"i18n:govoplan-files.review_individually.19abbe58": "Review individually",
"i18n:govoplan-files.root.e96857c5": "Root",
"i18n:govoplan-files.save_connection.07796d38": "Save connection",
"i18n:govoplan-files.save_credential.2c02a6d2": "Save credential",
"i18n:govoplan-files.save_policy.77d67ce3": "Save policy",
"i18n:govoplan-files.saving.56a2285c": "Saving…",
"i18n:govoplan-files.saving.ae7e8875": "Saving...",
"i18n:govoplan-files.seafile.600192cc": "Seafile",
"i18n:govoplan-files.search_this_folder_pdf_pdf_or_2026_pdf.74dec36a": "Search this folder: *.pdf, **/*.pdf or 2026-??-*.pdf",
"i18n:govoplan-files.search.bce06414": "Search",
"i18n:govoplan-files.secret_configured.4dc0f095": "Secret configured",
"i18n:govoplan-files.secret_reference.04ed2221": "Secret reference",
"i18n:govoplan-files.select_a_remote_file_to_sync_into_the_destinatio.80477a47": "Select a remote file to sync into the destination folder.",
"i18n:govoplan-files.select_a_remote_file_to_sync_it_into_this_space_.8e58a5ae": "Select a remote file to sync it into this space owner.",
"i18n:govoplan-files.select_the_space_that_will_receive_the_selected_.0a8bea8f": "Select the space that will receive the selected file(s) or folder(s).",
"i18n:govoplan-files.selected.840fac38": " · Selected",
"i18n:govoplan-files.show_fewer.d94dfbe5": "Show fewer",
"i18n:govoplan-files.size.b7152342": "Size",
"i18n:govoplan-files.skip.3da47453": "Skip",
"i18n:govoplan-files.smb_authentication.96f7cb83": "SMB authentication",
"i18n:govoplan-files.smb.515d2f88": "SMB",
"i18n:govoplan-files.space_label.ec892726": "Space label",
"i18n:govoplan-files.spaces.9b584b52": "Spaces",
"i18n:govoplan-files.status.bae7d5be": "Status",
"i18n:govoplan-files.suffix.885db975": "Suffix",
"i18n:govoplan-files.sync_to_value_value.29c362ea": "Sync to {value0} / {value1}",
"i18n:govoplan-files.sync.905f6309": "Sync",
"i18n:govoplan-files.synced_new_file.d5e8243d": "Synced new file",
"i18n:govoplan-files.synced_new_version.83784264": "Synced new version",
"i18n:govoplan-files.system.bc0792d8": "System",
"i18n:govoplan-files.target.61ad50a9": "Target",
"i18n:govoplan-files.tb.f8a902b0": "TB",
"i18n:govoplan-files.tenant.3ca93c78": "Tenant",
"i18n:govoplan-files.test_value.6a5d10a5": "Test {value0}",
"i18n:govoplan-files.testing.15ccc832": "Testing...",
"i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729": "The configured managed file space is no longer available to this user.",
"i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f": "This attachment base path is not connected to a managed file space. Choose its folder under",
"i18n:govoplan-files.this_file_connection.edcde997": "this file connection",
"i18n:govoplan-files.this_file_credential.695efb90": "this file credential",
"i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a": "This folder is empty. Upload files in the top-level Files module.",
"i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784": "This folder is empty. Use Upload or Create Folder to add content.",
"i18n:govoplan-files.token.a1141eb9": "Token",
"i18n:govoplan-files.unpack_zip_uploads.256fead4": "Unpack ZIP uploads",
"i18n:govoplan-files.unpacking_zip_archive.698095f4": "Unpacking ZIP archive",
"i18n:govoplan-files.unpacking_zip_upload.35019691": "Unpacking ZIP upload…",
"i18n:govoplan-files.up.2038bdec": "Up",
"i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3": "Upload failed because the network request could not be completed.",
"i18n:govoplan-files.upload_to_value_value.b83a34b4": "Upload to {value0} / {value1}",
"i18n:govoplan-files.upload_was_cancelled.5bab0f9b": "Upload was cancelled.",
"i18n:govoplan-files.upload.8bdf057f": "Upload",
"i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b": "Uploaded {value0} file(s) into {value1}.",
"i18n:govoplan-files.uploading_files.6536791d": "Uploading files",
"i18n:govoplan-files.uploading_value_file_s.715ba963": "Uploading {value0} file(s)…",
"i18n:govoplan-files.use_pattern.ce54ba3e": "Use pattern",
"i18n:govoplan-files.use_this_folder.0e77d6d1": "Use this folder",
"i18n:govoplan-files.user.9f8a2389": "User",
"i18n:govoplan-files.users": "Users",
"i18n:govoplan-files.username_password.e8ba8896": "Username/password",
"i18n:govoplan-files.username.84c29015": "Username",
"i18n:govoplan-files.value_conflict_s.b3872a48": "{value0} conflict(s)",
"i18n:govoplan-files.value_connector_policy_saved.430d4eca": "{value0} connector policy saved.",
"i18n:govoplan-files.value_connector_policy.0bf7f53b": "{value0} connector policy",
"i18n:govoplan-files.value_file_shown_for_context.3d6e0acb": "{value0}, file shown for context",
"i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c": "{value0} folder(s) · {value1} file(s)",
"i18n:govoplan-files.value_this_selection_would_create_duplicate_visi.26ae34d4": "{value0} this selection would create duplicate visible names in the destination.",
"i18n:govoplan-files.value_upload_conflict_s.3a04f117": "{value0} upload conflict(s)",
"i18n:govoplan-files.value_value_activate_to_sort.3953ba71": "{value0}: {value1}. Activate to sort.",
"i18n:govoplan-files.value_value_file_s_and_value_folder_s.c160e725": "{value0} {value1} file(s) and {value2} folder(s).",
"i18n:govoplan-files.value_value.36bc3a40": "{value0}: {value1}.",
"i18n:govoplan-files.value_value.598aab59": "{value0} -> {value1}",
"i18n:govoplan-files.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-files.value_value.dca59cc0": "{value0} {value1}",
"i18n:govoplan-files.value.48afe802": "· {value0}",
"i18n:govoplan-files.value_file_connections": "{value0} file connections",
"i18n:govoplan-files.value_scope": "{value0} scope",
"i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90": "visible file(s) were not matched.",
"i18n:govoplan-files.webdav.521b4c8b": "WebDAV",
"i18n:govoplan-files.working.13b7bfca": "Working…",
"i18n:govoplan-files.writable.dd35487a": "Writable"
},
"de": {
"i18n:govoplan-files.add_connector_space.aa6bdbd6": "Add connector space",
"i18n:govoplan-files.add_credential_for_value.0fa9c1fe": "Add credential for {value0}",
"i18n:govoplan-files.add_prefix.672452bc": "Add prefix",
"i18n:govoplan-files.add_space.e4d674d4": "Add Space",
"i18n:govoplan-files.add_suffix_before_extension.784381fe": "Add suffix before extension",
"i18n:govoplan-files.added_connector_space_value.a24725b8": "Added connector space {value0}.",
"i18n:govoplan-files.allowed_connection_ids.0df854c8": "Allowed connection IDs",
"i18n:govoplan-files.allowed_credential_ids.efcaba2d": "Allowed credential IDs",
"i18n:govoplan-files.allowed_endpoint_urls.34cfa8e9": "Allowed endpoint URLs",
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
"i18n:govoplan-files.access_explanation.75ee7f62": "Zugriffserklärung",
"i18n:govoplan-files.action.97c89a4d": "Aktion",
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
"i18n:govoplan-files.and.a0d93385": "… and",
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
"i18n:govoplan-files.any_provider.b3fc542f": "Any provider",
"i18n:govoplan-files.apply_preview.8692d5eb": "Apply preview",
"i18n:govoplan-files.apply_recursively_to_folder_contents.c7076984": "Apply recursively to folder contents",
"i18n:govoplan-files.apply_reviewed_choices.bb55f7b3": "Apply reviewed choices",
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
"i18n:govoplan-files.available.974948f2": " · Available",
"i18n:govoplan-files.close.bbfa773e": "Schließen",
"i18n:govoplan-files.evidence.8487d192": "Nachweise",
"i18n:govoplan-files.explain_access.4d5fac37": "Zugriff erklären",
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
"i18n:govoplan-files.no_source.6dcf9723": "Keine Quelle",
"i18n:govoplan-files.owner.89ff3122": "Eigentümer",
"i18n:govoplan-files.permission.2f81a22d": "Berechtigung",
"i18n:govoplan-files.policy.0b779a05": "Richtlinie",
"i18n:govoplan-files.resource.d1c626a9": "Ressource",
"i18n:govoplan-files.role.b5b4a5a2": "Rolle",
"i18n:govoplan-files.share.09ca55ca": "Freigabe",
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
"i18n:govoplan-files.blocked.99613c74": "Blocked",
"i18n:govoplan-files.bootstrap_settings.05e3df38": " · Bootstrap settings",
"i18n:govoplan-files.browse_ok_value_item_value.921906fd": "Browse OK ({value0} item{value1}).",
"i18n:govoplan-files.browse_protocol.d1f27c90": "Browse protocol",
"i18n:govoplan-files.browse.2f3b5c55": "Durchsuchen",
"i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b": "Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.",
"i18n:govoplan-files.bulk_rename_selected_items.5e79d694": "Bulk rename selected items",
"i18n:govoplan-files.bulk_rename.7dcaa624": "Bulk rename",
"i18n:govoplan-files.bytes_b": "{value0} B",
"i18n:govoplan-files.bytes_gb": "{value0} GB",
"i18n:govoplan-files.bytes_kb": "{value0} KB",
"i18n:govoplan-files.bytes_mb": "{value0} MB",
"i18n:govoplan-files.bytes_pb": "{value0} PB",
"i18n:govoplan-files.bytes_tb": "{value0} TB",
"i18n:govoplan-files.can_use_this_connection.5091a74c": "Can use this connection",
"i18n:govoplan-files.can_use_this_credential.f4a41971": "Can use this credential",
"i18n:govoplan-files.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-files.cannot_move_or_copy_a_folder_into_itself_or_one_.4adbd5ea": "Cannot move or copy a folder into itself or one of its child folders.",
"i18n:govoplan-files.case_sensitive.c73af7bd": "Case sensitive",
"i18n:govoplan-files.checking.494d0f68": "Checking...",
"i18n:govoplan-files.choose_a_connector_file_and_destination_folder.3c6f3ffb": "Choose a connector file and destination folder.",
"i18n:govoplan-files.choose_a_connector_profile_and_owner_space.4c386b00": "Choose a connector profile and owner space.",
"i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643": "Choose a destination space and folder. Folders are transferred recursively.",
"i18n:govoplan-files.choose_how_the_selected_names_should_be_changed.0231854f": "Choose how the selected names should be changed.",
"i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6": "Choose how to resolve this conflict.",
"i18n:govoplan-files.choose_managed_attachment_source.b3709493": "Choose managed attachment source",
"i18n:govoplan-files.choose_managed_file_or_pattern.bcf8e183": "Choose managed file or pattern",
"i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75": "Choose the destination folder before selecting or dropping files.",
"i18n:govoplan-files.campaigns": "Kampagnen",
"i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7": "Choose the folder that should act as this campaign attachment source.",
"i18n:govoplan-files.choose_the_parent_folder_for_the_new_subfolder.2fcb6580": "Choose the parent folder for the new subfolder.",
"i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f": "Choose the same action for all conflicts or review the target names individually.",
"i18n:govoplan-files.choose_the_target_folder_folders_are_transferred.f7ab8e9e": "Choose the target folder. Folders are transferred recursively.",
"i18n:govoplan-files.clear_saved_password.7442260d": "Clear saved password",
"i18n:govoplan-files.clear_saved_token.a9c670aa": "Clear saved token",
"i18n:govoplan-files.clear.719ea396": "Leeren",
"i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24": "Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.",
"i18n:govoplan-files.collapse.9cf188d3": "Collapse",
"i18n:govoplan-files.conflict_at.0a91052f": "conflict at",
"i18n:govoplan-files.conflict_s.60cea6d5": "conflict(s)",
"i18n:govoplan-files.connection_credential.178babe0": "Connection / credential",
"i18n:govoplan-files.connector_files.8f351f5d": "Connector files",
"i18n:govoplan-files.connector_folders.960cbb03": "Connector folders",
"i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5": "Connector profile is not configured for this space.",
"i18n:govoplan-files.connector_profile.9e9cd317": "Connector profile",
"i18n:govoplan-files.connector_root.9027235c": "Connector root",
"i18n:govoplan-files.connector_source_already_matched.7eaf5be1": "Connector source already matched",
"i18n:govoplan-files.connector_space_files.dbb0ab24": "Connector space files",
"i18n:govoplan-files.connector.ba358306": "Verbindung",
"i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1": "contained path(s) will move with the selected folder(s), but their own names will stay unchanged.",
"i18n:govoplan-files.copied.8e3df45a": "Copied",
"i18n:govoplan-files.copy.92556c6d": "Copy…",
"i18n:govoplan-files.copy.a69c71d0": " copy",
"i18n:govoplan-files.copy.af74f7c5": "Copy",
"i18n:govoplan-files.copying.43b7de98": "Copying",
"i18n:govoplan-files.copymove.d0fa5904": "copyMove",
"i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76": "Create a folder below the selected destination.",
"i18n:govoplan-files.create_folder.97bafaba": "Create Folder",
"i18n:govoplan-files.create_folder.e59f63fa": "Ordner erstellen",
"i18n:govoplan-files.create_space.b50606b4": "Create Space",
"i18n:govoplan-files.created_folder_value.6c3002f9": "Created folder {value0}.",
"i18n:govoplan-files.created_inside.3426a815": "Created inside",
"i18n:govoplan-files.credential_id_and_label_are_required.4cfd1ffd": "Credential id and label are required.",
"i18n:govoplan-files.credential_id.9432a6e1": "Credential id",
"i18n:govoplan-files.credential_mode.23fdd899": "Credential mode",
"i18n:govoplan-files.credential.8bede3ea": "Credential",
"i18n:govoplan-files.current_file.1fbfcf3d": "current file",
"i18n:govoplan-files.current_folder_contents.f9a24fa8": "Current folder contents",
"i18n:govoplan-files.current_folder.5aeab2f0": "Current folder",
"i18n:govoplan-files.delete_selected_item_s.4b6a0023": "Delete selected item(s)?",
"i18n:govoplan-files.delete_value_audit_relevant_blobs_remain_retaine.290af88f": "Delete {value0}? Audit-relevant blobs remain retained.",
"i18n:govoplan-files.delete.f6fdbe48": "Löschen",
"i18n:govoplan-files.denied_connection_ids.a95218b4": "Denied connection IDs",
"i18n:govoplan-files.denied_credential_ids.b6838bc2": "Denied credential IDs",
"i18n:govoplan-files.denied_endpoint_urls.1d8d4369": "Denied endpoint URLs",
"i18n:govoplan-files.denied_paths.d25e4315": "Denied paths",
"i18n:govoplan-files.denied_providers.149b29f4": "Denied providers",
"i18n:govoplan-files.deny_listed_paths.fcde62cc": "Deny-listed paths",
"i18n:govoplan-files.destination_folder.f8ccb63b": "Destination folder",
"i18n:govoplan-files.destination_space.92b63970": "Destination space",
"i18n:govoplan-files.disable_file_connection.3fd940ae": "Disable file connection",
"i18n:govoplan-files.disable_file_credential.49bff614": "Disable file credential",
"i18n:govoplan-files.disable_value_connections_using_it_will_stop_aut.fc3a1708": "Disable {value0}? Connections using it will stop authenticating until another credential is selected.",
"i18n:govoplan-files.disable_value_existing_linked_spaces_will_stop_b.8c1b0ac6": "Disable {value0}? Existing linked spaces will stop browsing until the connection is enabled again.",
"i18n:govoplan-files.disable_value.09485f7f": "Disable {value0}",
"i18n:govoplan-files.disable.9a7d4e06": "Disable",
"i18n:govoplan-files.disabled.f4f4473d": "Disabled",
"i18n:govoplan-files.dms.477e5652": "DMS",
"i18n:govoplan-files.download_zip.7bd0e4b0": "Download ZIP",
"i18n:govoplan-files.download.a479c9c3": "Download",
"i18n:govoplan-files.e_g_invoices.77a73bd1": "e.g. invoices",
"i18n:govoplan-files.edit_file_connection.78698154": "Edit file connection",
"i18n:govoplan-files.edit_file_credential.bc49d44f": "Edit file credential",
"i18n:govoplan-files.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-files.effective_sources_none.9a7c407f": "Effective sources: none",
"i18n:govoplan-files.effective_sources.9a576802": "Effective sources:",
"i18n:govoplan-files.enabled.df174a3f": "Aktiviert",
"i18n:govoplan-files.endpoint_url.65aaaa45": "Endpoint URL",
"i18n:govoplan-files.endpoint.92ec6350": "Endpoint",
"i18n:govoplan-files.expand.9869e506": "Expand",
"i18n:govoplan-files.extracting_files_on_the_server.845a3c1a": "Extracting files on the server…",
"i18n:govoplan-files.file_actions.9e1b94c5": "File actions",
"i18n:govoplan-files.file_connection_created.bdf6e1f6": "File connection created.",
"i18n:govoplan-files.file_connection_disabled_value.059a7de9": "File connection disabled: {value0}.",
"i18n:govoplan-files.file_connection_saved.68c16ee9": "File connection saved.",
"i18n:govoplan-files.file_connections.1e362326": "Dateiverbindungen",
"i18n:govoplan-files.file_credential_created.87c31882": "File credential created.",
"i18n:govoplan-files.file_credential_disabled_value.947538fd": "File credential disabled: {value0}.",
"i18n:govoplan-files.file_credential_saved.d6a1eef8": "File credential saved.",
"i18n:govoplan-files.file_or_pattern_relative_to.0ab4d831": "File or pattern relative to",
"i18n:govoplan-files.file_s.ca61e97f": "file(s),",
"i18n:govoplan-files.file_share_was_not_created.033ac476": "File share was not created.",
"i18n:govoplan-files.file_spaces_and_folders.443d2056": "File spaces and folders",
"i18n:govoplan-files.file.2c3cafa4": "Datei",
"i18n:govoplan-files.files_with_the_same_visible_name_already_exist_i.1bb487b0": "Files with the same visible name already exist in this folder.",
"i18n:govoplan-files.files.6ce6c512": "Dateien",
"i18n:govoplan-files.finalizing_upload.bcce936d": "Finalizing upload…",
"i18n:govoplan-files.find_and_replace.2c6b404b": "Find and replace",
"i18n:govoplan-files.find.df251b06": "Find",
"i18n:govoplan-files.first.49ec0838": "first.",
"i18n:govoplan-files.folder_name.b2ce023b": "Ordnername",
"i18n:govoplan-files.folder_s.10e54730": "folder(s)",
"i18n:govoplan-files.folder.30baa249": "Ordner",
"i18n:govoplan-files.folders_and_files.d107ac99": "Folders and files",
"i18n:govoplan-files.folders.19adc47b": "Ordner",
"i18n:govoplan-files.gb.505a44fa": "GB",
"i18n:govoplan-files.generic.ff7613e5": "Generic",
"i18n:govoplan-files.govoplan_files.b20752ed": "GovOPlaN files",
"i18n:govoplan-files.govoplan_webdav_credentials.bc696d69": "GovOPlaN WebDAV credentials",
"i18n:govoplan-files.group.171a0606": "Group",
"i18n:govoplan-files.groups": "Gruppen",
"i18n:govoplan-files.import.d6fbc9d2": "Import",
"i18n:govoplan-files.inherited.58823af0": "Inherited",
"i18n:govoplan-files.kb.315b39a0": "KB",
"i18n:govoplan-files.kerberos.53c35fb7": "Kerberos",
"i18n:govoplan-files.label.74341e3c": "Label",
"i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e": "Leave empty to keep saved password",
"i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b": "Leave empty to keep saved token",
"i18n:govoplan-files.library.b8100f5b": "Library",
"i18n:govoplan-files.linked_file_space.1a614c7d": "Linked file space",
"i18n:govoplan-files.linked_to_1_campaign.5349694f": "Linked to 1 campaign",
"i18n:govoplan-files.linked_to_value_campaigns.1ad7be94": "Linked to {value0} campaigns",
"i18n:govoplan-files.linked_to.ca188768": "linked to",
"i18n:govoplan-files.linked_to.e7ca9e02": "Linked to",
"i18n:govoplan-files.linking.6f640897": "Linking…",
"i18n:govoplan-files.loading_connector_files.02c876e0": "Loading connector files…",
"i18n:govoplan-files.loading_connector_files.e5b0871d": "Loading connector files",
"i18n:govoplan-files.loading_connector_folders.426de3b6": "Loading connector folders",
"i18n:govoplan-files.loading_connector_folders.dde26f3e": "Loading connector folders...",
"i18n:govoplan-files.loading_connector_policy.2b984eec": "Loading connector policy...",
"i18n:govoplan-files.loading_connector_policy.f12ab285": "Loading connector policy",
"i18n:govoplan-files.loading_connector_space.356d83c6": "Loading connector space…",
"i18n:govoplan-files.loading_connector_space.c176e6b8": "Loading connector space",
"i18n:govoplan-files.loading_file_connections.bd68f224": "Loading file connections",
"i18n:govoplan-files.loading_file_connections.ef22dc81": "Loading file connections...",
"i18n:govoplan-files.loading_files.3b142382": "Loading files",
"i18n:govoplan-files.loading_files.bfd27a7e": "Loading files…",
"i18n:govoplan-files.loading.b04ba49f": "Loading...",
"i18n:govoplan-files.lower_connection_limits.4f9838bc": "Lower connection limits",
"i18n:govoplan-files.lower_credential_limits.ec2b72bc": "Lower credential limits",
"i18n:govoplan-files.lower_endpoint_limits.3fd781d5": "Lower endpoint limits",
"i18n:govoplan-files.lower_path_limits.1abcccb1": "Lower path limits",
"i18n:govoplan-files.lower_provider_limits.5816270a": "Lower provider limits",
"i18n:govoplan-files.managed_files.4dd6ad11": "Verwaltete Dateien",
"i18n:govoplan-files.match.c79af5c2": "match.",
"i18n:govoplan-files.mb.6e979f42": "MB",
"i18n:govoplan-files.metadata_json_must_be_an_object.48629f13": "Metadata JSON must be an object.",
"i18n:govoplan-files.metadata_json.b0e4c283": "Metadata JSON",
"i18n:govoplan-files.mode.a7b93d21": "Mode",
"i18n:govoplan-files.modified.19a532c8": "Modified",
"i18n:govoplan-files.more_show_next.f1912318": "more — show next",
"i18n:govoplan-files.move.76cdb950": "Verschieben",
"i18n:govoplan-files.move.8a74a26e": "Move…",
"i18n:govoplan-files.moved.0fc28db0": "Moved",
"i18n:govoplan-files.moving.65cd4dd8": "Moving",
"i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4": "Must stay in allowed paths",
"i18n:govoplan-files.name.709a2322": "Name",
"i18n:govoplan-files.native_default.ba32f7b6": "Native/default",
"i18n:govoplan-files.new_connection.ac979fe4": "New connection",
"i18n:govoplan-files.new_credential.65b2a9b1": "New credential",
"i18n:govoplan-files.new_file_connection.2ec6eb56": "New file connection",
"i18n:govoplan-files.new_file_credential.4c061082": "New file credential",
"i18n:govoplan-files.new_folder.a711999b": "Neuer Ordner",
"i18n:govoplan-files.new_name.9e627c7b": "New name",
"i18n:govoplan-files.new_path_for_value.a9a30c1c": "New path for {value0}",
"i18n:govoplan-files.nextcloud.aab73a3d": "Nextcloud",
"i18n:govoplan-files.nfs.db121865": "NFS",
"i18n:govoplan-files.no_accessible_file_spaces_were_found.abcf5003": "No accessible file spaces were found.",
"i18n:govoplan-files.no_campaign_links.4203c2be": "No campaign links",
"i18n:govoplan-files.no_connector_items.d634e023": "No connector items.",
"i18n:govoplan-files.no_connector_profiles_are_available.9be3be28": "No connector profiles are available.",
"i18n:govoplan-files.no_connector_profiles.03c730ee": "No connector profiles",
"i18n:govoplan-files.no_current_files_match_this_pattern.b84c5836": "No current files match this pattern.",
"i18n:govoplan-files.no_endpoint.d0ea68c4": "No endpoint",
"i18n:govoplan-files.no_file_connections_configured.3ef02049": "No file connections configured.",
"i18n:govoplan-files.no_file_selected.f76f1c1c": "No file selected",
"i18n:govoplan-files.no_file_spaces_available.a81fa3d0": "No file spaces available.",
"i18n:govoplan-files.no_value_available": "Keine {value0} verfügbar.",
"i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped.1166f3b5": "No files uploaded; all conflicts were skipped.",
"i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d": "No folders yet. Choose the root folder.",
"i18n:govoplan-files.no_saved_credential.30a5a951": "No saved credential",
"i18n:govoplan-files.no_secret.33c1a339": "No secret",
"i18n:govoplan-files.none.6eef6648": "Keine",
"i18n:govoplan-files.not_sorted.0abc03c5": "not sorted",
"i18n:govoplan-files.ntlm.026eac85": "NTLM",
"i18n:govoplan-files.only_the_visible_name_changes_immutable_blobs_st.01557e71": "Only the visible name changes. Immutable blobs stay stable for audit.",
"i18n:govoplan-files.open_parent_folder_value.8030a7c7": "Open parent folder {value0}",
"i18n:govoplan-files.overwrite_all.a76f2d04": "Overwrite all",
"i18n:govoplan-files.overwrite.0625c54e": "Overwrite",
"i18n:govoplan-files.owner_space.364c4b62": "Owner space",
"i18n:govoplan-files.parent_folder.d8ed4c2a": "Parent folder",
"i18n:govoplan-files.password.8be3c943": "Passwort",
"i18n:govoplan-files.path_limited.d32cbef0": "Path-limited",
"i18n:govoplan-files.pattern_preview_results.56cea56c": "Pattern preview results",
"i18n:govoplan-files.policy.bb9cf141": "Richtlinie",
"i18n:govoplan-files.prefix.90eceb01": "Prefix",
"i18n:govoplan-files.preview_rename.bb890b24": "Preview rename",
"i18n:govoplan-files.preview_resolves_to.a8d99432": "Preview resolves to",
"i18n:govoplan-files.preview.f1fbb2b4": "Vorschau",
"i18n:govoplan-files.profile_id_and_label_are_required.6ad85df1": "Profile id and label are required.",
"i18n:govoplan-files.profile_id.25961d68": "Profile id",
"i18n:govoplan-files.provider.7ceee3f3": "Provider",
"i18n:govoplan-files.read_only.9b19a5a2": "Read-only",
"i18n:govoplan-files.refresh.56e3badc": "Refresh",
"i18n:govoplan-files.reload.cce71553": "Neu laden",
"i18n:govoplan-files.remote_connector_space.d8956863": "Remote connector space",
"i18n:govoplan-files.rename_all.1d6b24dc": "Rename all",
"i18n:govoplan-files.rename_item.3d21d6ca": "Rename item",
"i18n:govoplan-files.rename.d3f4cb89": "Umbenennen",
"i18n:govoplan-files.renamed_value_item_s.3c4a40fe": "Renamed {value0} item(s).",
"i18n:govoplan-files.replace_pattern.d98e4c92": "Replace pattern",
"i18n:govoplan-files.replace_the_current_pattern.96f80707": "Replace the current pattern?",
"i18n:govoplan-files.replace_value_with_the_exact_file_value.4a63236f": "Replace \"{value0}\" with the exact file \"{value1}\"?",
"i18n:govoplan-files.replacement.c385e347": "Replacement",
"i18n:govoplan-files.require_smb_signing.5168a83d": "Require SMB signing",
"i18n:govoplan-files.review_individually.19abbe58": "Review individually",
"i18n:govoplan-files.root.e96857c5": "Wurzel",
"i18n:govoplan-files.save_connection.07796d38": "Save connection",
"i18n:govoplan-files.save_credential.2c02a6d2": "Save credential",
"i18n:govoplan-files.save_policy.77d67ce3": "Save policy",
"i18n:govoplan-files.saving.56a2285c": "Saving…",
"i18n:govoplan-files.saving.ae7e8875": "Saving...",
"i18n:govoplan-files.seafile.600192cc": "Seafile",
"i18n:govoplan-files.search_this_folder_pdf_pdf_or_2026_pdf.74dec36a": "Search this folder: *.pdf, **/*.pdf or 2026-??-*.pdf",
"i18n:govoplan-files.search.bce06414": "Suchen",
"i18n:govoplan-files.secret_configured.4dc0f095": "Secret configured",
"i18n:govoplan-files.secret_reference.04ed2221": "Secret reference",
"i18n:govoplan-files.select_a_remote_file_to_sync_into_the_destinatio.80477a47": "Select a remote file to sync into the destination folder.",
"i18n:govoplan-files.select_a_remote_file_to_sync_it_into_this_space_.8e58a5ae": "Select a remote file to sync it into this space owner.",
"i18n:govoplan-files.select_the_space_that_will_receive_the_selected_.0a8bea8f": "Select the space that will receive the selected file(s) or folder(s).",
"i18n:govoplan-files.selected.840fac38": " · Selected",
"i18n:govoplan-files.show_fewer.d94dfbe5": "Show fewer",
"i18n:govoplan-files.size.b7152342": "Größe",
"i18n:govoplan-files.skip.3da47453": "Skip",
"i18n:govoplan-files.smb_authentication.96f7cb83": "SMB authentication",
"i18n:govoplan-files.smb.515d2f88": "SMB",
"i18n:govoplan-files.space_label.ec892726": "Space label",
"i18n:govoplan-files.spaces.9b584b52": "Spaces",
"i18n:govoplan-files.status.bae7d5be": "Status",
"i18n:govoplan-files.suffix.885db975": "Suffix",
"i18n:govoplan-files.sync_to_value_value.29c362ea": "Sync to {value0} / {value1}",
"i18n:govoplan-files.sync.905f6309": "Sync",
"i18n:govoplan-files.synced_new_file.d5e8243d": "Synced new file",
"i18n:govoplan-files.synced_new_version.83784264": "Synced new version",
"i18n:govoplan-files.system.bc0792d8": "System",
"i18n:govoplan-files.target.61ad50a9": "Target",
"i18n:govoplan-files.tb.f8a902b0": "TB",
"i18n:govoplan-files.tenant.3ca93c78": "Tenant",
"i18n:govoplan-files.test_value.6a5d10a5": "Test {value0}",
"i18n:govoplan-files.testing.15ccc832": "Testing...",
"i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729": "The configured managed file space is no longer available to this user.",
"i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f": "This attachment base path is not connected to a managed file space. Choose its folder under",
"i18n:govoplan-files.this_file_connection.edcde997": "this file connection",
"i18n:govoplan-files.this_file_credential.695efb90": "this file credential",
"i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a": "This folder is empty. Upload files in the top-level Files module.",
"i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784": "This folder is empty. Use Upload or Create Folder to add content.",
"i18n:govoplan-files.token.a1141eb9": "Token",
"i18n:govoplan-files.unpack_zip_uploads.256fead4": "Unpack ZIP uploads",
"i18n:govoplan-files.unpacking_zip_archive.698095f4": "Unpacking ZIP archive",
"i18n:govoplan-files.unpacking_zip_upload.35019691": "Unpacking ZIP upload…",
"i18n:govoplan-files.up.2038bdec": "Up",
"i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3": "Upload failed because the network request could not be completed.",
"i18n:govoplan-files.upload_to_value_value.b83a34b4": "Upload to {value0} / {value1}",
"i18n:govoplan-files.upload_was_cancelled.5bab0f9b": "Upload was cancelled.",
"i18n:govoplan-files.upload.8bdf057f": "Hochladen",
"i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b": "Uploaded {value0} file(s) into {value1}.",
"i18n:govoplan-files.uploading_files.6536791d": "Uploading files",
"i18n:govoplan-files.uploading_value_file_s.715ba963": "Uploading {value0} file(s)…",
"i18n:govoplan-files.use_pattern.ce54ba3e": "Use pattern",
"i18n:govoplan-files.use_this_folder.0e77d6d1": "Use this folder",
"i18n:govoplan-files.user.9f8a2389": "User",
"i18n:govoplan-files.users": "Benutzer",
"i18n:govoplan-files.username_password.e8ba8896": "Username/password",
"i18n:govoplan-files.username.84c29015": "Benutzername",
"i18n:govoplan-files.value_conflict_s.b3872a48": "{value0} conflict(s)",
"i18n:govoplan-files.value_connector_policy_saved.430d4eca": "{value0} connector policy saved.",
"i18n:govoplan-files.value_connector_policy.0bf7f53b": "{value0} connector policy",
"i18n:govoplan-files.value_file_shown_for_context.3d6e0acb": "{value0}, file shown for context",
"i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c": "{value0} folder(s) · {value1} file(s)",
"i18n:govoplan-files.value_this_selection_would_create_duplicate_visi.26ae34d4": "{value0} this selection would create duplicate visible names in the destination.",
"i18n:govoplan-files.value_upload_conflict_s.3a04f117": "{value0} upload conflict(s)",
"i18n:govoplan-files.value_value_activate_to_sort.3953ba71": "{value0}: {value1}. Activate to sort.",
"i18n:govoplan-files.value_value_file_s_and_value_folder_s.c160e725": "{value0} {value1} file(s) and {value2} folder(s).",
"i18n:govoplan-files.value_value.36bc3a40": "{value0}: {value1}.",
"i18n:govoplan-files.value_value.598aab59": "{value0} -> {value1}",
"i18n:govoplan-files.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-files.value_value.dca59cc0": "{value0} {value1}",
"i18n:govoplan-files.value.48afe802": "· {value0}",
"i18n:govoplan-files.value_file_connections": "{value0}-Dateiverbindungen",
"i18n:govoplan-files.value_scope": "Geltungsbereich: {value0}",
"i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90": "visible file(s) were not matched.",
"i18n:govoplan-files.webdav.521b4c8b": "WebDAV",
"i18n:govoplan-files.working.13b7bfca": "Wird ausgeführt...",
"i18n:govoplan-files.writable.dd35487a": "Writable"
}
};

View File

@@ -1,10 +1,11 @@
import "./styles/file-manager.css";
export { default } from "./module"; export { default } from "./module";
export * from "./module"; export * from "./module";
export { default as FilesPage } from "./features/files/FilesPage"; export { default as FilesPage } from "./features/files/FilesPage";
export * from "./api/files"; export * from "./api/files";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
export { FolderTree } from "./features/files/components/FileManagerComponents"; export { FolderTree } from "./features/files/components/FileManagerComponents";
export { default as ManagedFileChooser } from "./features/files/components/ManagedFileChooser";
export type { ManagedAttachmentSelection, ManagedFolderSelection } from "./features/files/components/ManagedFileChooser";
export { useFileTreeState } from "./features/files/hooks/useFileTreeState"; export { useFileTreeState } from "./features/files/hooks/useFileTreeState";
export type { FolderNode, SortColumn, SortDirection } from "./features/files/types"; export type { FolderNode, SortColumn, SortDirection } from "./features/files/types";
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils"; export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils";

View File

@@ -1,18 +1,83 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import { hasScope, type AdminSectionsUiCapability, type FilesConnectorsUiCapability, type FilesFileExplorerUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import { FolderTree } from "./features/files/components/FileManagerComponents";
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
import { listFileSpaces, listFiles, listFilesDelta, listFolders, resolveFilePatterns, shareFilesWithTarget } from "./api/files";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/file-manager.css";
const FilesPage = lazy(() => import("./features/files/FilesPage")); const FilesPage = lazy(() => import("./features/files/FilesPage"));
const fileRead = ["files:file:read"]; const fileRead = ["files:file:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const fileConnectorAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-file-connectors",
surfaceId: "files.admin.system-connectors",
label: "i18n:govoplan-files.file_connections.1e362326",
group: "SYSTEM",
order: 75,
anyOf: ["system:settings:read", "files:file:admin"],
render: ({ settings, auth }) => createElement(FileConnectorSettingsPanel, {
settings,
scopeType: "system",
canWrite: hasScope(auth, "system:settings:write") || hasScope(auth, "files:file:admin")
})
},
{
id: "tenant-file-connectors",
surfaceId: "files.admin.tenant-connectors",
label: "i18n:govoplan-files.file_connections.1e362326",
group: "TENANT",
order: 65,
anyOf: ["admin:settings:read", "files:file:admin"],
render: ({ settings, auth }) => createElement(FileConnectorSettingsPanel, {
settings,
scopeType: "tenant",
canWrite: hasScope(auth, "admin:settings:write") || hasScope(auth, "files:file:admin")
})
}]
};
export const filesModule: PlatformWebModule = { export const filesModule: PlatformWebModule = {
id: "files", id: "files",
label: "Files", label: "i18n:govoplan-files.files.6ce6c512",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }], translations,
viewSurfaces: [
{ id: "files.admin.system-connectors", moduleId: "files", kind: "section", label: "System file connections", order: 75 },
{ id: "files.admin.tenant-connectors", moduleId: "files", kind: "section", label: "Tenant file connections", order: 65 },
{ id: "files.admin.group-connectors", moduleId: "files", kind: "section", label: "Group file connections", order: 65 },
{ id: "files.admin.user-connectors", moduleId: "files", kind: "section", label: "User file connections", order: 65 },
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 }
],
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
routes: [ routes: [
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) } { path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }],
]
uiCapabilities: {
"files.fileExplorer": {
FolderTree,
ManagedFileChooser,
listFileSpaces,
listFiles,
listFilesDelta,
listFolders,
resolveFilePatterns,
shareFilesWithTarget
} satisfies FilesFileExplorerUiCapability,
"files.connectors": {
FileConnectorScopeManager: FileConnectorSettingsPanel
} satisfies FilesConnectorsUiCapability,
"admin.sections": fileConnectorAdminSections
}
}; };
export default filesModule; export default filesModule;

File diff suppressed because it is too large Load Diff