Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3210234d3 | |||
| b8b395e8b5 | |||
| 8bf7296bf5 | |||
| 620fdda2fc | |||
| 58c0441763 |
@@ -18,8 +18,8 @@ cd /mnt/DATA/git/govoplan-core
|
||||
For combined checks, run:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/check-focused.sh
|
||||
cd /mnt/DATA/git/govoplan
|
||||
tools/checks/check-focused.sh
|
||||
```
|
||||
|
||||
For WebUI permutation checks that include files:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -17,3 +17,9 @@ 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
|
||||
|
||||
@@ -9,7 +9,8 @@ binds services to localhost high ports.
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-files/dev/connectors
|
||||
cp .env.example .env
|
||||
docker compose up -d nextcloud nextcloud-db webdav smb
|
||||
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
|
||||
```
|
||||
|
||||
@@ -29,6 +30,8 @@ Endpoints:
|
||||
`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.
|
||||
|
||||
@@ -92,6 +95,25 @@ Example local profile file:
|
||||
"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"] } }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -114,9 +136,11 @@ cd /mnt/DATA/git/govoplan-files/dev/connectors
|
||||
```
|
||||
|
||||
The script reads `.env` from this directory when present, seeds tiny WebDAV,
|
||||
Nextcloud, and SMB fixtures, then browses and imports them through the connector
|
||||
helper layer. Pass `--require-smb` after recreating the SMB container to fail on
|
||||
SMB access errors instead of reporting them as an optional skip.
|
||||
Nextcloud, SMB, and MinIO fixtures, then browses and imports them through the
|
||||
connector helper layer. Pass `--require-smb` after recreating the SMB container
|
||||
to fail on SMB access errors instead of reporting them as an optional skip. Pass
|
||||
`--require-s3` after starting MinIO and installing `govoplan-files[s3]` to fail
|
||||
on S3 access errors instead of reporting them as an optional skip.
|
||||
|
||||
SMB smoke checks need the optional Python dependency in the environment running
|
||||
the script:
|
||||
@@ -125,6 +149,20 @@ the script:
|
||||
/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`.
|
||||
|
||||
@@ -86,21 +86,35 @@ services:
|
||||
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_PASSWORD: ${SMB_PASSWORD:-govoplan-smb}
|
||||
SMB_UID: ${SMB_UID:-1000}
|
||||
SMB_GID: ${SMB_GID:-1000}
|
||||
SMB_PORT: ${SMB_PORT:-1445}
|
||||
ports:
|
||||
- "127.0.0.1:${SMB_HOST_PORT:-1445}:445"
|
||||
- "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:
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache samba-server samba-common-tools
|
||||
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 445
|
||||
EXPOSE 1445
|
||||
|
||||
USER govoplan:govoplan
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/govoplan-samba-entrypoint"]
|
||||
|
||||
@@ -3,21 +3,42 @@ set -eu
|
||||
|
||||
share_name="${SMB_SHARE_NAME:-files}"
|
||||
user_name="${SMB_USER:-govoplan}"
|
||||
password="${SMB_PASSWORD:-govoplan-smb}"
|
||||
uid="${SMB_UID:-1000}"
|
||||
gid="${SMB_GID:-1000}"
|
||||
smb_port="${SMB_PORT:-1445}"
|
||||
runtime_user="$(id -un)"
|
||||
runtime_group="$(id -gn)"
|
||||
|
||||
mkdir -p /storage /var/lib/samba/private /run/samba
|
||||
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
|
||||
|
||||
if ! getent group "$user_name" >/dev/null 2>&1; then
|
||||
addgroup -g "$gid" "$user_name"
|
||||
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 ! id "$user_name" >/dev/null 2>&1; then
|
||||
adduser -D -H -s /sbin/nologin -u "$uid" -G "$user_name" "$user_name"
|
||||
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
|
||||
|
||||
chown -R "$user_name:$user_name" /storage
|
||||
mkdir -p /storage /var/lib/samba/private /var/cache/samba /run/samba /etc/samba
|
||||
|
||||
cat > /etc/samba/smb.conf <<EOF
|
||||
[global]
|
||||
@@ -25,12 +46,20 @@ cat > /etc/samba/smb.conf <<EOF
|
||||
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
|
||||
@@ -38,13 +67,16 @@ cat > /etc/samba/smb.conf <<EOF
|
||||
read only = no
|
||||
guest ok = no
|
||||
valid users = $user_name
|
||||
force user = $user_name
|
||||
force group = $user_name
|
||||
force user = $runtime_user
|
||||
force group = $runtime_group
|
||||
create mask = 0664
|
||||
directory mask = 0775
|
||||
EOF
|
||||
|
||||
printf '%s\n%s\n' "$password" "$password" | smbpasswd -s -a "$user_name" >/dev/null
|
||||
smbpasswd -e "$user_name" >/dev/null
|
||||
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
|
||||
exec smbd --foreground --no-process-group --debug-stdout -p "$smb_port"
|
||||
|
||||
@@ -18,16 +18,17 @@ 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)
|
||||
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")
|
||||
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()
|
||||
@@ -36,6 +37,13 @@ def main() -> int:
|
||||
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:
|
||||
@@ -59,6 +67,15 @@ def main() -> int:
|
||||
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}")
|
||||
@@ -76,6 +93,9 @@ def _default_env() -> None:
|
||||
"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)
|
||||
@@ -96,7 +116,7 @@ def _load_dotenv() -> None:
|
||||
os.environ[key] = value.strip().strip("\"'")
|
||||
|
||||
|
||||
def _preflight_services(*, require_smb: bool) -> list[str]:
|
||||
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:
|
||||
@@ -121,6 +141,14 @@ def _preflight_services(*, require_smb: bool) -> list[str]:
|
||||
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
|
||||
|
||||
|
||||
@@ -150,6 +178,20 @@ def _profile_payloads() -> list[dict[str, object]]:
|
||||
"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,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -211,5 +253,30 @@ def _seed_nextcloud_fixture() -> None:
|
||||
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())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,18 +4,22 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-files"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN files module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.6",
|
||||
"govoplan-core>=0.1.8",
|
||||
"defusedxml>=0.7,<1",
|
||||
"python-multipart>=0.0.31,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
s3 = [
|
||||
"boto3>=1.34,<2",
|
||||
]
|
||||
smb = [
|
||||
"smbprotocol>=1.13",
|
||||
]
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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,
|
||||
@@ -12,6 +20,21 @@ from govoplan_files.backend.storage.campaign_attachments import (
|
||||
)
|
||||
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
|
||||
from govoplan_files.backend.storage.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:
|
||||
@@ -32,3 +55,247 @@ class FilesCampaignCapability:
|
||||
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
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event, inspect
|
||||
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"
|
||||
@@ -39,7 +45,7 @@ def _record_files_changes(session: OrmSession, _flush_context: object, _instance
|
||||
|
||||
|
||||
def _record_asset_change(session: OrmSession, asset: FileAsset) -> None:
|
||||
operation = _operation_for_soft_deletable(
|
||||
operation = operation_for_soft_deletable(
|
||||
asset,
|
||||
changed_attrs=(
|
||||
"owner_type",
|
||||
@@ -70,7 +76,7 @@ def _record_asset_change(session: OrmSession, asset: FileAsset) -> None:
|
||||
"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"),
|
||||
"previous_path": previous_value(asset, "display_path"),
|
||||
"filename": asset.filename,
|
||||
"deleted_at": _isoformat(asset.deleted_at),
|
||||
},
|
||||
@@ -78,7 +84,7 @@ def _record_asset_change(session: OrmSession, asset: FileAsset) -> None:
|
||||
|
||||
|
||||
def _record_folder_change(session: OrmSession, folder: FileFolder) -> None:
|
||||
operation = _operation_for_soft_deletable(
|
||||
operation = operation_for_soft_deletable(
|
||||
folder,
|
||||
changed_attrs=("owner_type", "owner_user_id", "owner_group_id", "path", "deleted_at", "metadata_"),
|
||||
)
|
||||
@@ -99,17 +105,17 @@ def _record_folder_change(session: OrmSession, folder: FileFolder) -> None:
|
||||
"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"),
|
||||
"previous_path": previous_value(folder, "path"),
|
||||
"deleted_at": _isoformat(folder.deleted_at),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_share_visibility_change(session: OrmSession, share: FileShare) -> None:
|
||||
state = inspect(share)
|
||||
state = object_state(share)
|
||||
if not share.file_asset_id:
|
||||
return
|
||||
if not state.pending and not _has_attr_changes(
|
||||
if not state.pending and not has_attr_changes(
|
||||
state,
|
||||
("file_asset_id", "target_type", "target_id", "permission", "revoked_at"),
|
||||
):
|
||||
@@ -135,44 +141,8 @@ def _record_share_visibility_change(session: OrmSession, share: FileShare) -> No
|
||||
)
|
||||
|
||||
|
||||
def _operation_for_soft_deletable(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
|
||||
state = inspect(obj)
|
||||
if state.pending:
|
||||
return "created"
|
||||
if not _has_attr_changes(state, changed_attrs):
|
||||
return None
|
||||
if "deleted_at" in state.attrs:
|
||||
history = state.attrs.deleted_at.history
|
||||
if history.has_changes():
|
||||
if any(value is not None for value in history.added):
|
||||
return "deleted"
|
||||
if any(value is not None for value in history.deleted):
|
||||
return "created"
|
||||
return "updated"
|
||||
|
||||
|
||||
def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
||||
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
||||
|
||||
|
||||
def _previous_value(obj: object, attr_name: str) -> str | None:
|
||||
state = inspect(obj)
|
||||
if attr_name not in state.attrs:
|
||||
return None
|
||||
history = state.attrs[attr_name].history
|
||||
if not history.has_changes() or not history.deleted:
|
||||
return None
|
||||
value = history.deleted[0]
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def _ensure_id(obj: object) -> str:
|
||||
resource_id = getattr(obj, "id", None)
|
||||
if resource_id:
|
||||
return str(resource_id)
|
||||
resource_id = new_uuid()
|
||||
setattr(obj, "id", resource_id)
|
||||
return resource_id
|
||||
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:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
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 (
|
||||
FrontendModule,
|
||||
@@ -111,10 +112,11 @@ def _files_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="files",
|
||||
name="Files",
|
||||
version="0.1.6",
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns",),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="files.access", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
@@ -172,6 +174,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
@@ -105,6 +105,7 @@ from govoplan_files.backend.storage.access import ensure_group_access, group_ref
|
||||
from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
from govoplan_files.backend.storage.connector_credential_store import (
|
||||
ConnectorCredential,
|
||||
connector_credential_from_row,
|
||||
create_connector_credential_row,
|
||||
deactivate_connector_credential_row,
|
||||
@@ -802,6 +803,14 @@ def _download_connector_payload(
|
||||
return source_path, downloaded, metadata or {}
|
||||
|
||||
|
||||
def _connector_browse_next_token(items: list[Any]) -> str | None:
|
||||
for item in items:
|
||||
token = item.metadata.get("next_continuation_token") if hasattr(item, "metadata") else None
|
||||
if token:
|
||||
return str(token)
|
||||
return None
|
||||
|
||||
|
||||
def _connector_audit_details(asset: FileAsset, version: FileVersion, blob: FileBlob, *, operation: str) -> dict[str, object] | None:
|
||||
metadata = asset.metadata_ or {}
|
||||
provenance = source_provenance_from_metadata(metadata)
|
||||
@@ -2148,6 +2157,148 @@ def _full_file_connector_settings_delta_response(
|
||||
)
|
||||
|
||||
|
||||
def _changed_file_connector_setting_ids(entries: list[ChangeSequenceEntry]) -> tuple[set[str], set[str], set[str], bool]:
|
||||
changed_profile_ids = {
|
||||
entry.resource_id
|
||||
for entry in entries
|
||||
if entry.collection == FILES_CONNECTOR_PROFILES_COLLECTION and entry.resource_type == FILES_CONNECTOR_PROFILE_RESOURCE
|
||||
}
|
||||
changed_credential_ids = {
|
||||
entry.resource_id
|
||||
for entry in entries
|
||||
if entry.collection == FILES_CONNECTOR_CREDENTIALS_COLLECTION and entry.resource_type == FILES_CONNECTOR_CREDENTIAL_RESOURCE
|
||||
}
|
||||
changed_space_ids = {
|
||||
entry.resource_id
|
||||
for entry in entries
|
||||
if entry.collection == FILES_CONNECTOR_SPACES_COLLECTION and entry.resource_type == FILES_CONNECTOR_SPACE_RESOURCE
|
||||
}
|
||||
policy_changed = any(entry.collection == FILES_CONNECTOR_POLICIES_COLLECTION for entry in entries)
|
||||
return changed_profile_ids, changed_credential_ids, changed_space_ids, policy_changed
|
||||
|
||||
|
||||
def _file_connector_settings_changed_sections(
|
||||
*,
|
||||
changed_profile_ids: set[str],
|
||||
changed_credential_ids: set[str],
|
||||
changed_space_ids: set[str],
|
||||
policy_changed: bool,
|
||||
profiles: list[ConnectorProfile],
|
||||
) -> list[str]:
|
||||
changed_sections = []
|
||||
if changed_profile_ids or any(profile.credential_profile_id and profile.credential_profile_id in changed_credential_ids for profile in profiles):
|
||||
changed_sections.append("profiles")
|
||||
if changed_credential_ids:
|
||||
changed_sections.append("credentials")
|
||||
if changed_space_ids:
|
||||
changed_sections.append("spaces")
|
||||
if policy_changed:
|
||||
changed_sections.append("policy")
|
||||
return changed_sections
|
||||
|
||||
|
||||
def _file_connector_settings_deleted_items(
|
||||
entries: list[ChangeSequenceEntry],
|
||||
*,
|
||||
visible_profiles: dict[str, ConnectorProfile],
|
||||
visible_credentials: dict[str, ConnectorCredential],
|
||||
visible_spaces: dict[str, FileConnectorSpace],
|
||||
) -> list[DeltaDeletedItem]:
|
||||
return [
|
||||
_connector_deleted_item(entry)
|
||||
for entry in entries
|
||||
if (
|
||||
entry.resource_type == FILES_CONNECTOR_PROFILE_RESOURCE
|
||||
and entry.resource_id not in visible_profiles
|
||||
)
|
||||
or (
|
||||
entry.resource_type == FILES_CONNECTOR_CREDENTIAL_RESOURCE
|
||||
and entry.resource_id not in visible_credentials
|
||||
)
|
||||
or (
|
||||
entry.resource_type == FILES_CONNECTOR_SPACE_RESOURCE
|
||||
and entry.resource_id not in visible_spaces
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _incremental_file_connector_settings_delta_response(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
entries: list[ChangeSequenceEntry],
|
||||
has_more: bool,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
provider: str | None,
|
||||
campaign_id: str | None,
|
||||
include_disabled: bool,
|
||||
include_inactive: bool,
|
||||
owner_type: Literal["user", "group"] | None,
|
||||
owner_id: str | None,
|
||||
) -> FileConnectorSettingsDeltaResponse:
|
||||
changed_profile_ids, changed_credential_ids, changed_space_ids, policy_changed = _changed_file_connector_setting_ids(entries)
|
||||
profiles = _visible_connector_profiles(
|
||||
session,
|
||||
principal,
|
||||
provider=provider,
|
||||
campaign_id=campaign_id,
|
||||
include_disabled=include_disabled and _can_read_disabled_connector_profiles(principal),
|
||||
include_admin_scopes=_can_read_disabled_connector_profiles(principal),
|
||||
include_effective_policy=False,
|
||||
)
|
||||
visible_profiles = {
|
||||
profile.id: profile
|
||||
for profile in profiles
|
||||
if profile.id in changed_profile_ids or (profile.credential_profile_id and profile.credential_profile_id in changed_credential_ids)
|
||||
}
|
||||
credentials = _visible_connector_credentials(
|
||||
session,
|
||||
principal,
|
||||
provider=provider,
|
||||
include_disabled=include_disabled,
|
||||
)
|
||||
visible_credentials = {
|
||||
credential.id: credential
|
||||
for credential in credentials
|
||||
if credential.id in changed_credential_ids
|
||||
}
|
||||
spaces = _visible_connector_spaces(
|
||||
session,
|
||||
principal,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
visible_spaces = {
|
||||
space.id: space
|
||||
for space in spaces
|
||||
if space.id in changed_space_ids
|
||||
}
|
||||
return FileConnectorSettingsDeltaResponse(
|
||||
profiles=[FileConnectorProfileResponse(**profile.to_response()) for profile in visible_profiles.values()],
|
||||
credentials=[FileConnectorCredentialResponse(**credential.to_response()) for credential in visible_credentials.values()],
|
||||
spaces=[_connector_space_response(space) for space in visible_spaces.values()],
|
||||
policy=FileConnectorPolicyResponse(**connector_policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)) if policy_changed else None,
|
||||
changed_sections=_file_connector_settings_changed_sections(
|
||||
changed_profile_ids=changed_profile_ids,
|
||||
changed_credential_ids=changed_credential_ids,
|
||||
changed_space_ids=changed_space_ids,
|
||||
policy_changed=policy_changed,
|
||||
profiles=profiles,
|
||||
),
|
||||
deleted=_file_connector_settings_deleted_items(
|
||||
entries,
|
||||
visible_profiles=visible_profiles,
|
||||
visible_credentials=visible_credentials,
|
||||
visible_spaces=visible_spaces,
|
||||
),
|
||||
watermark=_file_connector_settings_response_watermark(session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/connectors/settings/delta", response_model=FileConnectorSettingsDeltaResponse)
|
||||
def connector_settings_delta(
|
||||
scope_type: str = Query(default="tenant"),
|
||||
@@ -2193,94 +2344,19 @@ def connector_settings_delta(
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
changed_profile_ids = {
|
||||
entry.resource_id
|
||||
for entry in entries
|
||||
if entry.collection == FILES_CONNECTOR_PROFILES_COLLECTION and entry.resource_type == FILES_CONNECTOR_PROFILE_RESOURCE
|
||||
}
|
||||
changed_credential_ids = {
|
||||
entry.resource_id
|
||||
for entry in entries
|
||||
if entry.collection == FILES_CONNECTOR_CREDENTIALS_COLLECTION and entry.resource_type == FILES_CONNECTOR_CREDENTIAL_RESOURCE
|
||||
}
|
||||
changed_space_ids = {
|
||||
entry.resource_id
|
||||
for entry in entries
|
||||
if entry.collection == FILES_CONNECTOR_SPACES_COLLECTION and entry.resource_type == FILES_CONNECTOR_SPACE_RESOURCE
|
||||
}
|
||||
policy_changed = any(entry.collection == FILES_CONNECTOR_POLICIES_COLLECTION for entry in entries)
|
||||
profiles = _visible_connector_profiles(
|
||||
return _incremental_file_connector_settings_delta_response(
|
||||
session,
|
||||
principal,
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
provider=provider,
|
||||
campaign_id=campaign_id,
|
||||
include_disabled=include_disabled and _can_read_disabled_connector_profiles(principal),
|
||||
include_admin_scopes=_can_read_disabled_connector_profiles(principal),
|
||||
include_effective_policy=False,
|
||||
)
|
||||
visible_profiles = {
|
||||
profile.id: profile
|
||||
for profile in profiles
|
||||
if profile.id in changed_profile_ids or (profile.credential_profile_id and profile.credential_profile_id in changed_credential_ids)
|
||||
}
|
||||
credentials = _visible_connector_credentials(
|
||||
session,
|
||||
principal,
|
||||
provider=provider,
|
||||
include_disabled=include_disabled,
|
||||
)
|
||||
visible_credentials = {
|
||||
credential.id: credential
|
||||
for credential in credentials
|
||||
if credential.id in changed_credential_ids
|
||||
}
|
||||
spaces = _visible_connector_spaces(
|
||||
session,
|
||||
principal,
|
||||
include_inactive=include_inactive,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
visible_spaces = {
|
||||
space.id: space
|
||||
for space in spaces
|
||||
if space.id in changed_space_ids
|
||||
}
|
||||
changed_sections = []
|
||||
if changed_profile_ids or any(profile.credential_profile_id and profile.credential_profile_id in changed_credential_ids for profile in profiles):
|
||||
changed_sections.append("profiles")
|
||||
if changed_credential_ids:
|
||||
changed_sections.append("credentials")
|
||||
if changed_space_ids:
|
||||
changed_sections.append("spaces")
|
||||
if policy_changed:
|
||||
changed_sections.append("policy")
|
||||
deleted = [
|
||||
_connector_deleted_item(entry)
|
||||
for entry in entries
|
||||
if (
|
||||
entry.resource_type == FILES_CONNECTOR_PROFILE_RESOURCE
|
||||
and entry.resource_id not in visible_profiles
|
||||
)
|
||||
or (
|
||||
entry.resource_type == FILES_CONNECTOR_CREDENTIAL_RESOURCE
|
||||
and entry.resource_id not in visible_credentials
|
||||
)
|
||||
or (
|
||||
entry.resource_type == FILES_CONNECTOR_SPACE_RESOURCE
|
||||
and entry.resource_id not in visible_spaces
|
||||
)
|
||||
]
|
||||
return FileConnectorSettingsDeltaResponse(
|
||||
profiles=[FileConnectorProfileResponse(**profile.to_response()) for profile in visible_profiles.values()],
|
||||
credentials=[FileConnectorCredentialResponse(**credential.to_response()) for credential in visible_credentials.values()],
|
||||
spaces=[_connector_space_response(space) for space in visible_spaces.values()],
|
||||
policy=FileConnectorPolicyResponse(**connector_policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)) if policy_changed else None,
|
||||
changed_sections=changed_sections,
|
||||
deleted=deleted,
|
||||
watermark=_file_connector_settings_response_watermark(session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
except (FileStorageError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
@@ -2819,6 +2895,7 @@ def browse_connector_profile_items(
|
||||
profile_id: str,
|
||||
path: str | None = None,
|
||||
library_id: str | None = None,
|
||||
continuation_token: str | None = None,
|
||||
campaign_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("files:file:read", "files:file:upload", "files:file:download", "files:file:admin", "system:settings:read", "admin:settings:read")),
|
||||
@@ -2839,7 +2916,7 @@ def browse_connector_profile_items(
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise ConnectorPolicyDenied(decision)
|
||||
items = browse_connector_profile(profile, path=browse_path, library_id=library_id)
|
||||
items = browse_connector_profile(profile, path=browse_path, library_id=library_id, continuation_token=continuation_token)
|
||||
except ConnectorPolicyDenied as exc:
|
||||
raise _connector_policy_error(exc) from exc
|
||||
except ConnectorBrowseUnsupported as exc:
|
||||
@@ -2851,6 +2928,8 @@ def browse_connector_profile_items(
|
||||
provider=profile.provider,
|
||||
path=browse_path,
|
||||
library_id=library_id,
|
||||
next_continuation_token=_connector_browse_next_token(items),
|
||||
has_more=any(bool(item.metadata.get("listing_truncated")) for item in items),
|
||||
decision=decision.to_dict(),
|
||||
items=[FileConnectorBrowseItem(**item.to_response()) for item in items],
|
||||
)
|
||||
@@ -3230,7 +3309,7 @@ def download_archive(
|
||||
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal))
|
||||
for file_id in payload.file_ids
|
||||
]
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="multimailer-files-", suffix=".zip", delete=False)
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="govoplan-files-", suffix=".zip", delete=False)
|
||||
tmp_path = tmp.name
|
||||
tmp.close()
|
||||
try:
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from govoplan_core.core.runtime import ModuleRuntimeState
|
||||
|
||||
_runtime_registry: object | None = None
|
||||
_runtime_settings: object | None = None
|
||||
_runtime = ModuleRuntimeState("Files")
|
||||
|
||||
|
||||
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
||||
global _runtime_registry, _runtime_settings
|
||||
if registry is not None:
|
||||
_runtime_registry = registry
|
||||
if settings is not None:
|
||||
_runtime_settings = settings
|
||||
|
||||
|
||||
def get_registry() -> object | None:
|
||||
return _runtime_registry
|
||||
|
||||
|
||||
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()
|
||||
configure_runtime = _runtime.configure_runtime
|
||||
get_registry = _runtime.get_registry
|
||||
get_settings = _runtime.get_settings
|
||||
settings = _runtime.settings
|
||||
|
||||
@@ -287,7 +287,7 @@ class FileConnectorDiscoveryCandidate(BaseModel):
|
||||
|
||||
|
||||
class FileConnectorDiscoveryRequest(BaseModel):
|
||||
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"]
|
||||
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"
|
||||
@@ -309,7 +309,7 @@ class FileConnectorDiscoveryResponse(BaseModel):
|
||||
class FileConnectorProfileCreateRequest(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"]
|
||||
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
|
||||
@@ -325,7 +325,7 @@ class FileConnectorProfileCreateRequest(BaseModel):
|
||||
|
||||
class FileConnectorProfileUpdateRequest(BaseModel):
|
||||
label: str | None = None
|
||||
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"] | 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
|
||||
@@ -342,7 +342,7 @@ class FileConnectorProfileUpdateRequest(BaseModel):
|
||||
class FileConnectorCredentialCreateRequest(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"] | None = None
|
||||
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
|
||||
@@ -354,7 +354,7 @@ class FileConnectorCredentialCreateRequest(BaseModel):
|
||||
|
||||
class FileConnectorCredentialUpdateRequest(BaseModel):
|
||||
label: str | None = None
|
||||
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"] | 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
|
||||
@@ -404,6 +404,8 @@ class FileConnectorBrowseResponse(BaseModel):
|
||||
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]
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ def prepared_campaign_snapshot(
|
||||
campaign_id: str,
|
||||
raw_json: dict[str, Any],
|
||||
include_bytes: bool,
|
||||
prefix: str = "multimailer-managed-campaign-",
|
||||
prefix: str = "govoplan-managed-campaign-",
|
||||
) -> Iterator[PreparedCampaignSnapshot]:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -24,6 +25,15 @@ def _candidate_match_keys(raw_match: str) -> set[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:
|
||||
return job_id, file_version_id, filename_used, stage
|
||||
|
||||
@@ -134,22 +144,48 @@ def record_campaign_attachment_uses_for_jobs(
|
||||
if not job_list:
|
||||
return
|
||||
|
||||
managed_by_job: dict[str, list[dict[str, object]]] = defaultdict(list)
|
||||
fallback_attachments_by_job: dict[str, list[dict[str, object]]] = defaultdict(list)
|
||||
refs = _collect_attachment_batch_refs(job_list)
|
||||
job_by_id = {job.id: job for job in job_list}
|
||||
asset_ids: set[str] = set()
|
||||
version_ids: set[str] = set()
|
||||
blob_ids: set[str] = set()
|
||||
known_keys = _known_use_keys_for_jobs(session, job_list, stage=stage)
|
||||
assets_by_id, versions_by_id, blobs_by_id = _load_managed_attachment_entities(session, refs)
|
||||
|
||||
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 []
|
||||
if not isinstance(attachments, list):
|
||||
continue
|
||||
for attachment in attachments:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
if not _collect_managed_attachment_refs(refs, job.id, attachment):
|
||||
refs.fallback_attachments_by_job[job.id].append(attachment)
|
||||
return refs
|
||||
|
||||
|
||||
def _collect_managed_attachment_refs(refs: _AttachmentBatchRefs, job_id: str, attachment: dict[str, object]) -> bool:
|
||||
managed_matches = attachment.get("managed_matches")
|
||||
if isinstance(managed_matches, list) and 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
|
||||
@@ -158,38 +194,41 @@ def record_campaign_attachment_uses_for_jobs(
|
||||
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)
|
||||
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
|
||||
|
||||
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 _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():
|
||||
job = job_by_id[job_id]
|
||||
for item in items:
|
||||
asset = assets_by_id.get(str(item.get("asset_id") or ""))
|
||||
version = versions_by_id.get(str(item.get("version_id") or ""))
|
||||
blob = blobs_by_id.get(str(item.get("blob_id") or ""))
|
||||
if not asset or not version or not 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:
|
||||
if not _managed_attachment_entities_match_job(job, asset, version, blob):
|
||||
continue
|
||||
_add_use(
|
||||
session,
|
||||
@@ -202,29 +241,79 @@ def record_campaign_attachment_uses_for_jobs(
|
||||
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]] = {}
|
||||
version_blobs_by_campaign: dict[tuple[str, str], dict[str, tuple[FileVersion, FileBlob]]] = {}
|
||||
for job_id, attachments in fallback_attachments_by_job.items():
|
||||
job = job_by_id[job_id]
|
||||
campaign_key = (job.tenant_id, job.campaign_id)
|
||||
by_key, version_blobs = _fallback_campaign_assets(
|
||||
session,
|
||||
job,
|
||||
campaign_key=campaign_key,
|
||||
assets_by_campaign=assets_by_campaign,
|
||||
version_blobs_by_campaign=version_blobs_by_campaign,
|
||||
)
|
||||
for attachment in attachments:
|
||||
_record_fallback_attachment(session, job, attachment, by_key=by_key, version_blobs=version_blobs, stage=stage, known_keys=known_keys)
|
||||
|
||||
|
||||
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 = {}
|
||||
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)
|
||||
assets_by_campaign[campaign_key] = by_key
|
||||
version_blobs_by_campaign[campaign_key] = current_versions_and_blobs(session, assets)
|
||||
version_blobs = version_blobs_by_campaign[campaign_key]
|
||||
return by_key
|
||||
|
||||
for attachment in attachments:
|
||||
|
||||
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):
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any
|
||||
from urllib.parse import quote, unquote, urljoin, urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
import httpx
|
||||
|
||||
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||
@@ -52,7 +53,7 @@ class ConnectorBrowseItem:
|
||||
}
|
||||
|
||||
|
||||
def browse_connector_profile(profile: ConnectorProfile, *, path: str | None = None, library_id: str | None = None) -> list[ConnectorBrowseItem]:
|
||||
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:
|
||||
@@ -65,13 +66,15 @@ def browse_connector_profile(profile: ConnectorProfile, *, path: str | None = No
|
||||
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 = ET.fromstring(payload)
|
||||
except ET.ParseError as exc:
|
||||
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)
|
||||
@@ -221,6 +224,227 @@ def _browse_smb(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowse
|
||||
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")
|
||||
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] = {}
|
||||
if profile.endpoint_url:
|
||||
kwargs["endpoint_url"] = profile.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:
|
||||
ca_bundle = _metadata_string(profile, "ca_bundle")
|
||||
if ca_bundle:
|
||||
return ca_bundle
|
||||
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"}
|
||||
@@ -443,6 +667,13 @@ def _metadata_bool(profile: ConnectorProfile, key: str, *, default: bool = False
|
||||
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.id)
|
||||
|
||||
|
||||
def _env_required(name: str, profile_id: str) -> str:
|
||||
value = _clean(os.environ.get(name))
|
||||
if not value:
|
||||
|
||||
@@ -21,6 +21,9 @@ from govoplan_files.backend.storage.connector_browse import (
|
||||
_smb_stat_size,
|
||||
_smb_unc_path,
|
||||
_smbclient_module,
|
||||
_s3_bucket,
|
||||
_s3_client,
|
||||
_s3_object_key,
|
||||
_seafile_headers,
|
||||
_seafile_url,
|
||||
_webdav_url,
|
||||
@@ -64,6 +67,8 @@ def read_connector_file(
|
||||
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")
|
||||
|
||||
|
||||
@@ -213,6 +218,63 @@ def _read_smb_file(profile: ConnectorProfile, *, path: str, max_bytes: int) -> C
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
try:
|
||||
client = _s3_client(profile)
|
||||
except ConnectorBrowseUnsupported as exc:
|
||||
raise ConnectorImportUnsupported(str(exc)) from exc
|
||||
except ConnectorBrowseError as exc:
|
||||
raise ConnectorImportError(str(exc)) from exc
|
||||
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")
|
||||
version_id = _clean(detail.get("VersionId"))
|
||||
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")
|
||||
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={
|
||||
"bucket": bucket,
|
||||
"key": key,
|
||||
"version_id": version_id,
|
||||
"etag": etag,
|
||||
"size": len(data),
|
||||
**({"checksum_sha256": detail["ChecksumSHA256"]} if "ChecksumSHA256" in detail else {}),
|
||||
**({"checksum_crc32": detail["ChecksumCRC32"]} if "ChecksumCRC32" in detail else {}),
|
||||
**({"storage_class": detail["StorageClass"]} if "StorageClass" in detail else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _int(value: object) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
|
||||
@@ -12,8 +12,17 @@ from govoplan_files.backend.storage.connector_policy import ConnectorPolicySourc
|
||||
|
||||
_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", "nfs", "dms", "generic"}
|
||||
_INLINE_SECRET_FIELDS = ("password", "token", "api_key", "access_key", "secret_key")
|
||||
_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]:
|
||||
@@ -123,43 +132,19 @@ def connector_profiles_from_payload(value: object) -> list[ConnectorProfile]:
|
||||
|
||||
|
||||
def _profile_from_mapping(value: Mapping[str, Any]) -> ConnectorProfile:
|
||||
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")
|
||||
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}")
|
||||
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 profiles require scope_id")
|
||||
|
||||
credentials = value.get("credentials")
|
||||
credentials = credentials if isinstance(credentials, Mapping) else {}
|
||||
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 = ConnectorProfile(
|
||||
id=profile_id,
|
||||
label=_clean(value.get("label") or value.get("name")) or profile_id,
|
||||
profile = _profile_from_normalized_mapping(
|
||||
value,
|
||||
profile_id=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=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")),
|
||||
credentials=credentials,
|
||||
)
|
||||
return ConnectorProfile(
|
||||
id=profile.id,
|
||||
@@ -187,6 +172,69 @@ def _profile_from_mapping(value: Mapping[str, Any]) -> ConnectorProfile:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -112,6 +112,51 @@ def connector_provider_descriptors() -> tuple[ConnectorProviderDescriptor, ...]:
|
||||
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||
notes="Requires the optional smbprotocol dependency and an smb://server[:port]/share[/path] profile endpoint.",
|
||||
),
|
||||
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="Configure endpoint_url for MinIO or other S3-compatible stores; use profile metadata for bucket, region, addressing_style, verify_tls, and ca_bundle.",
|
||||
),
|
||||
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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
101
tests/test_access_provider.py
Normal file
101
tests/test_access_provider.py
Normal file
@@ -0,0 +1,101 @@
|
||||
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()
|
||||
_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()
|
||||
_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 _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()),
|
||||
)
|
||||
157
tests/test_connector_providers.py
Normal file
157
tests/test_connector_providers.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_files.backend.storage.connector_browse import browse_connector_profile
|
||||
from govoplan_files.backend.storage.connector_browse import ConnectorBrowseUnsupported
|
||||
from govoplan_files.backend.storage.connector_imports import 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_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_browse_reports_missing_optional_dependency(self) -> None:
|
||||
with patch("govoplan_files.backend.storage.connector_browse.import_module", side_effect=ImportError):
|
||||
with self.assertRaisesRegex(ConnectorBrowseUnsupported, "boto3"):
|
||||
browse_connector_profile(s3_profile(), path="")
|
||||
|
||||
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()
|
||||
36
tests/test_router_contract.py
Normal file
36
tests/test_router_contract.py
Normal 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()
|
||||
67
tests/test_transfer_helpers.py
Normal file
67
tests/test_transfer_helpers.py
Normal 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()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -21,7 +21,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"@govoplan/core-webui": "^0.1.6"
|
||||
"@govoplan/core-webui": "^0.1.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -150,7 +150,7 @@ export type FileConnectorDiscoveryResponse = {
|
||||
export type FileConnectorProfilePayload = {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: "seafile" | "nextcloud" | "webdav" | "smb" | "nfs" | "dms" | "generic";
|
||||
provider: "seafile" | "nextcloud" | "webdav" | "smb" | "s3" | "sharepoint" | "onedrive" | "nfs" | "dms" | "generic";
|
||||
endpoint_url?: string | null;
|
||||
base_path?: string | null;
|
||||
enabled?: boolean;
|
||||
@@ -172,7 +172,7 @@ export type FileConnectorProfileUpdatePayload = Partial<Omit<FileConnectorProfil
|
||||
export type FileConnectorCredentialPayload = {
|
||||
id: string;
|
||||
label: string;
|
||||
provider?: "seafile" | "nextcloud" | "webdav" | "smb" | "nfs" | "dms" | "generic" | null;
|
||||
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;
|
||||
@@ -206,6 +206,8 @@ export type FileConnectorBrowseResponse = {
|
||||
path: string;
|
||||
library_id?: string | null;
|
||||
read_only: boolean;
|
||||
next_continuation_token?: string | null;
|
||||
has_more: boolean;
|
||||
decision: FileConnectorPolicyDecision;
|
||||
items: FileConnectorBrowseItem[];
|
||||
};
|
||||
@@ -559,6 +561,55 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
|
||||
|
||||
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
|
||||
|
||||
export type AccessExplanationUser = {
|
||||
id: string;
|
||||
account_id?: string | null;
|
||||
email?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type AccessDecisionProvenanceItem = {
|
||||
kind: string;
|
||||
id?: string | null;
|
||||
label?: string | null;
|
||||
tenant_id?: string | null;
|
||||
source?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResourceAccessExplanationResponse = {
|
||||
user: AccessExplanationUser;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
provenance: AccessDecisionProvenanceItem[];
|
||||
};
|
||||
|
||||
export function fetchResourceAccessExplanation(
|
||||
settings: ApiSettings,
|
||||
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
|
||||
: Promise<ResourceAccessExplanationResponse> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: options.userId,
|
||||
resource_type: options.resourceType,
|
||||
resource_id: options.resourceId,
|
||||
action: options.action
|
||||
});
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -759,11 +810,12 @@ export function deactivateFileConnectorProfile(settings: ApiSettings, profileId:
|
||||
export function browseFileConnectorProfile(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
params: {path?: string;library_id?: string;campaign_id?: 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}`);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Card,
|
||||
ConnectionTree,
|
||||
ConfirmDialog,
|
||||
CredentialPanel,
|
||||
DisabledActionTooltip,
|
||||
DismissibleAlert,
|
||||
Dialog,
|
||||
FormField,
|
||||
@@ -123,6 +125,9 @@ const PROVIDERS: Array<{id: Provider;label: string;}> = [
|
||||
{ id: "nextcloud", label: "i18n:govoplan-files.nextcloud.aab73a3d" },
|
||||
{ id: "seafile", label: "i18n:govoplan-files.seafile.600192cc" },
|
||||
{ id: "smb", label: "i18n:govoplan-files.smb.515d2f88" },
|
||||
{ id: "s3", label: "S3" },
|
||||
{ id: "sharepoint", label: "SharePoint" },
|
||||
{ id: "onedrive", label: "OneDrive" },
|
||||
{ id: "nfs", label: "i18n:govoplan-files.nfs.db121865" },
|
||||
{ id: "dms", label: "i18n:govoplan-files.dms.477e5652" },
|
||||
{ id: "generic", label: "i18n:govoplan-files.generic.ff7613e5" }];
|
||||
@@ -133,6 +138,9 @@ const ENDPOINT_PLACEHOLDERS: Record<Provider, string> = {
|
||||
nextcloud: "http://127.0.0.1:9081/remote.php/dav/files/admin/",
|
||||
seafile: "http://127.0.0.1:9082/",
|
||||
smb: "smb://127.0.0.1:1445/files",
|
||||
s3: "http://127.0.0.1:9000",
|
||||
sharepoint: "https://graph.microsoft.com/v1.0/sites/{site-id}/drives/{drive-id}",
|
||||
onedrive: "https://graph.microsoft.com/v1.0/drives/{drive-id}",
|
||||
nfs: "nfs://127.0.0.1/export",
|
||||
dms: "https://dms.example.test",
|
||||
generic: "https://files.example.test"
|
||||
@@ -340,6 +348,17 @@ export default function FileConnectorSettingsPanel({
|
||||
setCredentialDraft((current) => current ? { ...current, ...patch } : current);
|
||||
}
|
||||
|
||||
function patchCredentialValues(patch: {username?: string | null;password?: string | null;}) {
|
||||
const next: Partial<ConnectorCredentialDraft> = {};
|
||||
if (patch.username !== undefined) next.username = String(patch.username ?? "");
|
||||
if (patch.password !== undefined) next.password = String(patch.password ?? "");
|
||||
patchCredentialDraft(next);
|
||||
}
|
||||
|
||||
function patchCredentialToken(patch: {password?: string | null;}) {
|
||||
if (patch.password !== undefined) patchCredentialDraft({ token: String(patch.password ?? "") });
|
||||
}
|
||||
|
||||
function patchPolicyDraft(patch: Partial<CentralConnectorPolicyDraft>) {
|
||||
setPolicyDraft((current) => ({ ...current, ...patch }));
|
||||
}
|
||||
@@ -906,11 +925,11 @@ export default function FileConnectorSettingsPanel({
|
||||
footer={credentialDraft ?
|
||||
<>
|
||||
<Button onClick={closeCredentialDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||
<span className="disabled-action-tooltip" data-tooltip={credentialSaveTooltip}>
|
||||
<DisabledActionTooltip reason={credentialSaveTooltip}>
|
||||
<Button variant="primary" onClick={() => void saveCredentialDraft()} disabled={credentialSaveDisabled}>
|
||||
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_credential.2c02a6d2"}
|
||||
</Button>
|
||||
</span>
|
||||
</DisabledActionTooltip>
|
||||
</> :
|
||||
null}>
|
||||
|
||||
@@ -983,35 +1002,47 @@ export default function FileConnectorSettingsPanel({
|
||||
actor: "Connector administrator",
|
||||
target: "Credential mode"
|
||||
}} /> :
|
||||
<div className="form-grid two-column-form-grid">
|
||||
{(credentialDraft.credentialMode === "basic" || credentialDraft.credentialMode === "secret_ref") &&
|
||||
<FormField label="i18n:govoplan-files.username.84c29015">
|
||||
<input value={credentialDraft.username} disabled={saving} onChange={(event) => patchCredentialDraft({ username: event.target.value })} autoComplete="username" />
|
||||
</FormField>
|
||||
}
|
||||
<div className="file-connector-secret-fields">
|
||||
{credentialDraft.credentialMode === "basic" &&
|
||||
<FormField label="i18n:govoplan-files.password.8be3c943">
|
||||
<input value={credentialDraft.password} disabled={saving} onChange={(event) => patchCredentialDraft({ password: event.target.value })} type="password" autoComplete="new-password" placeholder={editingCredentialId ? "i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e" : ""} />
|
||||
</FormField>
|
||||
}
|
||||
{credentialDraft.credentialMode === "token" &&
|
||||
<FormField label="i18n:govoplan-files.token.a1141eb9">
|
||||
<input value={credentialDraft.token} disabled={saving} onChange={(event) => patchCredentialDraft({ token: event.target.value })} type="password" placeholder={editingCredentialId ? "i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b" : ""} />
|
||||
</FormField>
|
||||
<CredentialPanel
|
||||
values={{ username: credentialDraft.username, password: credentialDraft.password }}
|
||||
onChange={patchCredentialValues}
|
||||
disabled={saving}
|
||||
savedPassword={Boolean(editingCredentialId) && !credentialDraft.clearPassword}
|
||||
savedPasswordPlaceholder="i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e" />
|
||||
}
|
||||
{credentialDraft.credentialMode === "secret_ref" &&
|
||||
<>
|
||||
<CredentialPanel
|
||||
values={{ username: credentialDraft.username }}
|
||||
onChange={patchCredentialValues}
|
||||
disabled={saving}
|
||||
showPassword={false} />
|
||||
<div className="form-grid two-column-form-grid">
|
||||
<FormField label="i18n:govoplan-files.secret_reference.04ed2221">
|
||||
<input value={credentialDraft.secretRef} disabled={saving} onChange={(event) => patchCredentialDraft({ secretRef: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{credentialDraft.credentialMode === "token" &&
|
||||
<CredentialPanel
|
||||
values={{ password: credentialDraft.token }}
|
||||
onChange={patchCredentialToken}
|
||||
disabled={saving}
|
||||
showUsername={false}
|
||||
passwordLabel="i18n:govoplan-files.token.a1141eb9"
|
||||
savedPassword={Boolean(editingCredentialId) && !credentialDraft.clearToken}
|
||||
savedPasswordPlaceholder="i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div className="button-row compact-actions">
|
||||
<span className="disabled-action-tooltip" data-tooltip={credentialTestDisabled ? credentialTestTooltip : ""}>
|
||||
<DisabledActionTooltip reason={credentialTestDisabled ? credentialTestTooltip : ""}>
|
||||
<Button type="button" onClick={() => void testCredentialLogin()} disabled={credentialTestDisabled}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> {testingCredentialLogin ? "Testing login" : "Test login"}
|
||||
</Button>
|
||||
</span>
|
||||
</DisabledActionTooltip>
|
||||
</div>
|
||||
{credentialLoginResult &&
|
||||
<DismissibleAlert tone={credentialLoginResult.ok ? "success" : "warning"} resetKey={credentialLoginResult.message}>
|
||||
@@ -1072,11 +1103,11 @@ export default function FileConnectorSettingsPanel({
|
||||
footer={draft ?
|
||||
<>
|
||||
<Button onClick={closeProfileDialog} disabled={saving}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||
<span className="disabled-action-tooltip" data-tooltip={profileSaveTooltip}>
|
||||
<DisabledActionTooltip reason={profileSaveTooltip}>
|
||||
<Button variant="primary" onClick={() => void saveDraft()} disabled={profileSaveDisabled}>
|
||||
<Save size={16} aria-hidden="true" /> {saving ? "i18n:govoplan-files.saving.ae7e8875" : "i18n:govoplan-files.save_connection.07796d38"}
|
||||
</Button>
|
||||
</span>
|
||||
</DisabledActionTooltip>
|
||||
</> :
|
||||
null}>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
|
||||
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
deleteFolder,
|
||||
downloadFile,
|
||||
downloadFilesAsZip,
|
||||
fetchResourceAccessExplanation,
|
||||
listFilesDelta,
|
||||
listFileConnectorProfiles,
|
||||
listFileSpaces,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
syncFileConnectorFile,
|
||||
transferFiles,
|
||||
uploadFiles,
|
||||
virtualFolderResourceId,
|
||||
type ConflictResolution,
|
||||
type ConflictStrategy,
|
||||
type FileConnectorBrowseItem,
|
||||
@@ -38,7 +40,8 @@ import {
|
||||
type FileFolder,
|
||||
type FileSpace,
|
||||
type ManagedFile,
|
||||
type RenameResponse } from
|
||||
type RenameResponse,
|
||||
type ResourceAccessExplanationResponse } from
|
||||
"../../api/files";
|
||||
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
|
||||
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
|
||||
@@ -84,6 +87,12 @@ import { useFileDialogs } from "./hooks/useFileDialogs";
|
||||
import { useFileDragDropState } from "./hooks/useFileDragDropState";
|
||||
|
||||
type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing";
|
||||
type FileAccessExplanationTarget = {
|
||||
resourceType: "file" | "folder";
|
||||
resourceId: string;
|
||||
label: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
|
||||
const FILE_LIST_OVERSCAN_ROWS = 8;
|
||||
@@ -133,6 +142,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const [connectorSpaceError, setConnectorSpaceError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
|
||||
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
||||
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
|
||||
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
|
||||
const {
|
||||
dialog,
|
||||
@@ -170,6 +182,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
}, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]);
|
||||
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
|
||||
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(new Set());
|
||||
const [fileListViewportHeight, setFileListViewportHeight] = useState(0);
|
||||
const [fileListScrollTop, setFileListScrollTop] = useState(0);
|
||||
const [fileListRowHeight, setFileListRowHeight] = useState(DEFAULT_FILE_LIST_ROW_HEIGHT);
|
||||
@@ -216,6 +229,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
});
|
||||
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
|
||||
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
|
||||
const accessExplainableTarget = useMemo(
|
||||
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
|
||||
[activeSpaceId, filesBySpace, foldersBySpace, selectedFileIds, selectedFolderPaths]
|
||||
);
|
||||
const canExplainResourceAccess =
|
||||
hasScope(auth, "admin:users:read") ||
|
||||
hasScope(auth, "admin:roles:read") ||
|
||||
hasScope(auth, "access:membership:read") ||
|
||||
hasScope(auth, "access:role:read");
|
||||
const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]);
|
||||
const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null);
|
||||
const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null;
|
||||
@@ -270,8 +292,9 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
try {
|
||||
const response = await listFileSpaces(settings);
|
||||
setSpaces(response.spaces);
|
||||
setActiveSpaceId((current) => current || response.spaces[0]?.id || "");
|
||||
await Promise.all(response.spaces.filter((space) => !isConnectorSpace(space)).map((space) => loadSpaceContents(space, { silent: true })));
|
||||
const nextActiveSpace = response.spaces.find((space) => space.id === activeSpaceId) ?? response.spaces[0] ?? null;
|
||||
setActiveSpaceId(nextActiveSpace?.id || "");
|
||||
if (nextActiveSpace && !isConnectorSpace(nextActiveSpace)) await loadSpaceContents(nextActiveSpace, { silent: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -310,6 +333,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
await loadConnectorSpaceContents(space, { silent: options.silent, folderPath: "" });
|
||||
return;
|
||||
}
|
||||
if (managedSpaceLoadsInFlightRef.current.has(space.id)) return;
|
||||
managedSpaceLoadsInFlightRef.current.add(space.id);
|
||||
if (!options.silent) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
@@ -351,6 +376,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
managedSpaceLoadsInFlightRef.current.delete(space.id);
|
||||
if (!options.silent) setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +416,8 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const nextSpace = spaces.find((space) => space.id === activeSpaceId);
|
||||
if (nextSpace && isConnectorSpace(nextSpace)) {
|
||||
void loadConnectorSpaceContents(nextSpace, { folderPath: "", silent: true });
|
||||
} else if (nextSpace && filesBySpace[nextSpace.id] === undefined && foldersBySpace[nextSpace.id] === undefined) {
|
||||
void loadSpaceContents(nextSpace, { silent: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSpaceId, spaces]);
|
||||
@@ -1456,6 +1484,74 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
await deleteSelected(fileIds, folderPaths, space);
|
||||
}
|
||||
|
||||
function accessTargetForSelection(fileIds: Set<string>, folderPaths: Set<string>, spaceId: string): FileAccessExplanationTarget | null {
|
||||
if (fileIds.size === 1 && folderPaths.size === 0) {
|
||||
const fileId = Array.from(fileIds)[0];
|
||||
const file = filesInSpace(spaceId).find((item) => item.id === fileId);
|
||||
return file ? {
|
||||
resourceType: "file",
|
||||
resourceId: file.id,
|
||||
label: file.display_path || file.filename,
|
||||
action: "files:file:read"
|
||||
} : null;
|
||||
}
|
||||
if (fileIds.size === 0 && folderPaths.size === 1) {
|
||||
const folderPath = normalizeFolder(Array.from(folderPaths)[0]);
|
||||
const folder = foldersInSpace(spaceId).find((item) => normalizeFolder(item.path) === folderPath);
|
||||
if (folder) {
|
||||
return {
|
||||
resourceType: "folder",
|
||||
resourceId: folder.id,
|
||||
label: folder.path,
|
||||
action: "files:file:read"
|
||||
};
|
||||
}
|
||||
const space = findSpace(spaceId);
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant)?.id;
|
||||
if (!space || !tenantId || !folderPath) return null;
|
||||
return {
|
||||
resourceType: "folder",
|
||||
resourceId: virtualFolderResourceId({ tenantId, ownerType: space.owner_type, ownerId: space.owner_id, path: folderPath }),
|
||||
label: folderPath,
|
||||
action: "files:file:read"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function accessTargetForContext(menu: ContextMenuState | null): FileAccessExplanationTarget | null {
|
||||
const { fileIds, folderPaths } = selectedSetsForContext(menu);
|
||||
return accessTargetForSelection(fileIds, folderPaths, menu?.spaceId ?? activeSpaceId);
|
||||
}
|
||||
|
||||
function openAccessExplanationForContext(menu: ContextMenuState | null) {
|
||||
const target = accessTargetForContext(menu);
|
||||
setContextMenu(null);
|
||||
if (target) void openAccessExplanation(target);
|
||||
}
|
||||
|
||||
async function openAccessExplanation(target: FileAccessExplanationTarget): Promise<void> {
|
||||
if (!auth.user?.id) return;
|
||||
setAccessExplanationTarget(target);
|
||||
setResourceAccessExplanation(null);
|
||||
setResourceAccessLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, {
|
||||
userId: auth.user.id,
|
||||
resourceType: target.resourceType,
|
||||
resourceId: target.resourceId,
|
||||
action: target.action,
|
||||
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setAccessExplanationTarget(null);
|
||||
} finally {
|
||||
setResourceAccessLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) {
|
||||
const sets = selectedSetsForContext(menu);
|
||||
const space = spaceForContext(menu);
|
||||
@@ -1688,6 +1784,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
|
||||
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
|
||||
{hasSelection && <Button onClick={openRenameDialog} disabled={busy || activeSpaceIsConnector || !canOrganize}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>}
|
||||
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={busy || activeSpaceIsConnector || !canExplainResourceAccess || !accessExplainableTarget}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
|
||||
<Button variant="danger" onClick={() => void deleteSelected()} disabled={busy || activeSpaceIsConnector || !canDelete || !hasSelection}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
|
||||
{activeSpaceIsConnector &&
|
||||
<Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace}>
|
||||
@@ -2055,16 +2152,30 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
canDownload={canDownload}
|
||||
canOrganize={canOrganize}
|
||||
canDelete={canDelete}
|
||||
canExplainAccess={canExplainResourceAccess && Boolean(accessTargetForContext(contextMenu))}
|
||||
downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)}
|
||||
onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)}
|
||||
onUpload={() => openUploadDialogForContext(contextMenu)}
|
||||
onDownload={() => void downloadContextSelection(contextMenu)}
|
||||
onMove={() => openTransferDialogForContext(contextMenu, "move")}
|
||||
onCopy={() => openTransferDialogForContext(contextMenu, "copy")}
|
||||
onExplainAccess={() => openAccessExplanationForContext(contextMenu)}
|
||||
onDelete={() => void deleteContextSelection(contextMenu)} />
|
||||
|
||||
}
|
||||
|
||||
{accessExplanationTarget &&
|
||||
<FileDialog title="i18n:govoplan-files.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}>
|
||||
<ResourceAccessExplanationContent
|
||||
loading={resourceAccessLoading}
|
||||
explanation={resourceAccessExplanation}
|
||||
fallbackResourceLabel={accessExplanationTarget.label} />
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button>
|
||||
</div>
|
||||
</FileDialog>
|
||||
}
|
||||
|
||||
{dialog === "upload" &&
|
||||
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
|
||||
<FileDropZone
|
||||
@@ -2350,6 +2461,71 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
|
||||
}
|
||||
|
||||
function ResourceAccessExplanationContent({
|
||||
loading,
|
||||
explanation,
|
||||
fallbackResourceLabel
|
||||
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
|
||||
if (loading) return <p className="muted small-note">i18n:govoplan-files.loading_access_explanation.04a7c934</p>;
|
||||
if (!explanation) return null;
|
||||
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
|
||||
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
|
||||
return (
|
||||
<>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<div><span className="form-label">i18n:govoplan-files.user.9f8a2389</span><p>{userLabel}</p></div>
|
||||
<div><span className="form-label">i18n:govoplan-files.resource.d1c626a9</span><p>{resourceLabel}</p></div>
|
||||
<div><span className="form-label">i18n:govoplan-files.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
|
||||
<div><span className="form-label">i18n:govoplan-files.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
|
||||
</div>
|
||||
{explanation.provenance.length === 0 ?
|
||||
<p className="muted small-note">i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e</p> :
|
||||
<div className="admin-assignment-grid">
|
||||
{explanation.provenance.map((item, index) =>
|
||||
<div key={`${item.kind}:${item.id ?? index}`}>
|
||||
<strong>{provenanceKindLabel(item.kind)}</strong>
|
||||
<div className="muted small-note">
|
||||
{item.source || "i18n:govoplan-files.no_source.6dcf9723"}
|
||||
{item.id && <> · <code>{item.id}</code></>}
|
||||
</div>
|
||||
{item.label && <p>{item.label}</p>}
|
||||
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</>);
|
||||
}
|
||||
|
||||
function provenanceKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "resource":
|
||||
return "i18n:govoplan-files.resource.d1c626a9";
|
||||
case "owner":
|
||||
return "i18n:govoplan-files.owner.89ff3122";
|
||||
case "share":
|
||||
return "i18n:govoplan-files.share.09ca55ca";
|
||||
case "policy":
|
||||
return "i18n:govoplan-files.policy.0b779a05";
|
||||
case "role":
|
||||
return "i18n:govoplan-files.role.b5b4a5a2";
|
||||
case "right":
|
||||
return "i18n:govoplan-files.permission.2f81a22d";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
function formatProvenanceDetails(details: Record<string, unknown>): string {
|
||||
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
|
||||
}
|
||||
|
||||
function formatProvenanceValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "i18n:govoplan-files.none.6eef6648";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function FileSearchRow({
|
||||
value,
|
||||
caseSensitive,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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, i18nMessage } from "@govoplan/core-webui";
|
||||
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
|
||||
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
|
||||
@@ -220,12 +220,14 @@ export function FileContextMenu({
|
||||
canDownload,
|
||||
canOrganize,
|
||||
canDelete,
|
||||
canExplainAccess,
|
||||
downloadLabel,
|
||||
onCreateFolder,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onMove,
|
||||
onCopy,
|
||||
onExplainAccess,
|
||||
onDelete
|
||||
|
||||
|
||||
@@ -242,7 +244,7 @@ export function FileContextMenu({
|
||||
|
||||
|
||||
|
||||
}: {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 showDelete = menu.target !== "empty";
|
||||
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
|
||||
@@ -261,6 +263,7 @@ export function FileContextMenu({
|
||||
<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" /> i18n:govoplan-files.move.8a74a26e</button>
|
||||
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
|
||||
<button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button>
|
||||
{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>);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"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",
|
||||
@@ -24,6 +26,18 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"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",
|
||||
@@ -357,6 +371,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"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",
|
||||
@@ -367,6 +383,18 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"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",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
@@ -46,7 +46,7 @@
|
||||
.file-list-sticky {
|
||||
flex: 0 0 auto;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
.file-list-table-head {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -161,7 +161,7 @@
|
||||
.file-list-row {
|
||||
min-height: 58px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -244,7 +244,7 @@
|
||||
}
|
||||
|
||||
.folder-row .file-row-icon {
|
||||
color: #b6791d;
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.file-row-tail {
|
||||
@@ -274,7 +274,7 @@
|
||||
display: grid;
|
||||
min-width: 190px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
@@ -299,7 +299,7 @@
|
||||
|
||||
.file-context-menu button:hover,
|
||||
.file-context-menu button:focus-visible {
|
||||
background: rgba(13, 110, 253, .08);
|
||||
background: var(--primary-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -315,14 +315,14 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 22px;
|
||||
background: rgba(28, 25, 22, .38);
|
||||
background: var(--dialog-backdrop);
|
||||
}
|
||||
|
||||
.file-dialog {
|
||||
width: min(620px, 100%);
|
||||
max-height: min(720px, calc(100vh - 44px));
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-dark);
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
@@ -334,7 +334,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -428,13 +428,13 @@
|
||||
.file-folder-selector-node:hover:not(:disabled),
|
||||
.file-folder-selector-node:focus-visible,
|
||||
.file-folder-selector-node.is-selected {
|
||||
background: rgba(13, 110, 253, .09);
|
||||
background: var(--primary-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-folder-selector-node.is-selected {
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .25);
|
||||
box-shadow: var(--primary-inset-ring);
|
||||
}
|
||||
|
||||
.file-folder-selector-empty {
|
||||
@@ -462,7 +462,7 @@
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@@ -473,7 +473,7 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -625,7 +625,7 @@
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -698,6 +698,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-connector-secret-fields {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.file-connector-profile-row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -726,7 +731,7 @@
|
||||
.file-tree-panel {
|
||||
max-height: 260px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.file-list-table-head {
|
||||
@@ -760,7 +765,7 @@
|
||||
z-index: 35;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
background: var(--panel-glass);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
@@ -768,7 +773,7 @@
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -791,7 +796,7 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -842,8 +847,8 @@
|
||||
|
||||
.rename-preview-more:hover,
|
||||
.rename-preview-more:focus-visible {
|
||||
border-color: rgba(13, 110, 253, .45);
|
||||
background: rgba(13, 110, 253, .08);
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-soft);
|
||||
color: var(--text-strong);
|
||||
outline: none;
|
||||
}
|
||||
@@ -860,7 +865,7 @@
|
||||
|
||||
.managed-file-chooser-dialog > .dialog-header {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
@@ -891,9 +896,9 @@
|
||||
.managed-file-chooser-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
|
||||
box-shadow: var(--shadow-footer);
|
||||
}
|
||||
|
||||
.managed-file-chooser-layout {
|
||||
@@ -911,7 +916,7 @@
|
||||
.managed-file-chooser-spaces {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1014,7 +1019,7 @@
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.managed-file-breadcrumb {
|
||||
@@ -1046,7 +1051,7 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -1075,7 +1080,7 @@
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -1206,7 +1211,7 @@
|
||||
.managed-file-chooser-note {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -1313,7 +1318,7 @@
|
||||
|
||||
.managed-file-chooser-spaces {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: var(--border-line);
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user