feat(files): add safe connector space removal

This commit is contained in:
2026-08-19 23:25:43 +02:00
parent e1361427b8
commit a9c7a7a40c
7 changed files with 149 additions and 1 deletions
+12
View File
@@ -724,6 +724,18 @@ All routes below are under `/api/v1/files`.
| Incremental connector settings | `GET /connectors/settings/delta` | | Incremental connector settings | `GET /connectors/settings/delta` |
| Form evidence | `POST /form-evidence/upload` with a short-lived `X-Form-Evidence-Token` issued by Forms Runtime | | Form evidence | `POST /form-evidence/upload` with a short-lived `X-Form-Evidence-Token` issued by Forms Runtime |
The Files workspace exposes **Remove space** only for read-only connector
spaces and only to actors with file-organization authority over the owning user
or group space. Confirmation explains the exact boundary: removal soft-deletes
the local connector-space definition and makes that virtual view disappear. It
does not mutate or delete remote provider content, previously imported managed
files, their metadata or shares, the connector profile, credentials, or remote
object references. Those retained objects therefore do not block removal and
the connector location can be linked again later. User and group managed spaces
are intrinsic ownership scopes rather than removable records, so they never
offer this action. A missing, already removed, cross-tenant, or inaccessible
connector space fails through the backend lookup and owner-access checks.
Consumers should use cursor/watermark contracts instead of assuming an Consumers should use cursor/watermark contracts instead of assuming an
unbounded complete list. The default full-list page size is 500 and public page unbounded complete list. The default full-list page size is 500 and public page
sizes are capped at 1,000. sizes are capped at 1,000.
+3 -1
View File
@@ -1096,7 +1096,8 @@ manifest = ModuleManifest(
summary="Keep endpoint profiles, reusable credentials, and inherited connector policy separate, and understand what DELETE removes immediately.", summary="Keep endpoint profiles, reusable credentials, and inherited connector policy separate, and understand what DELETE removes immediately.",
body=( body=(
"System, tenant, and one user/group/campaign leaf form the effective policy chain: deny rules win and every configured allow rule must match. " "System, tenant, and one user/group/campaign leaf form the effective policy chain: deny rules win and every configured allow rule must match. "
"Responses redact secret values and deployment references. Deleting a database-managed credential or profile immediately scrubs Files-owned encrypted material and private metadata in the same transaction as a non-secret audit event; dependent profiles are disabled, while legacy non-owned references are only detached and audited." "Responses redact secret values and deployment references. Deleting a database-managed credential or profile immediately scrubs Files-owned encrypted material and private metadata in the same transaction as a non-secret audit event; dependent profiles are disabled, while legacy non-owned references are only detached and audited. "
"Removing a connector space is a separate owner-authorized operation: it retires only the local virtual-space link and leaves provider content, imported managed files and shares, profiles, credentials, and remote references untouched. Intrinsic user and group managed spaces cannot be removed."
), ),
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
@@ -1166,6 +1167,7 @@ manifest = ModuleManifest(
"New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.", "New API-managed external secret references fail closed until Files can prove ownership and provider-side deletion.",
"Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.", "Deletion and destructive retirement scrub Files-owned encrypted connector material before completion and emit non-secret audit evidence.",
"Legacy non-owned external references are detached and audited, never sent to an arbitrary provider delete operation.", "Legacy non-owned external references are detached and audited, never sent to an arbitrary provider delete operation.",
"Connector-space removal is local and soft; it never claims to delete remote or previously imported managed content.",
], ],
"related_topic_ids": [ "related_topic_ids": [
"files.workflow.import-managed-snapshot", "files.workflow.import-managed-snapshot",
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.connector_spaces import soft_delete_connector_space
class ConnectorSpaceDeletionTests(unittest.TestCase):
def setUp(self) -> None:
self.session = Mock()
self.space = SimpleNamespace(
tenant_id="tenant-1",
owner_type="user",
owner_user_id="owner-1",
owner_group_id=None,
deleted_at=None,
is_active=True,
)
@patch("govoplan_files.backend.storage.connector_spaces.utcnow")
@patch("govoplan_files.backend.storage.connector_spaces.ensure_owner_access")
def test_owner_can_soft_delete_local_space_definition(
self, ensure_owner_access: Mock, utcnow: Mock
) -> None:
deleted_at = object()
utcnow.return_value = deleted_at
result = soft_delete_connector_space(
self.session, self.space, user_id="owner-1"
)
self.assertIs(result, self.space)
self.assertIs(deleted_at, self.space.deleted_at)
self.assertFalse(self.space.is_active)
ensure_owner_access.assert_called_once_with(
self.session,
tenant_id="tenant-1",
owner_type="user",
owner_id="owner-1",
user_id="owner-1",
is_admin=False,
)
self.session.add.assert_called_once_with(self.space)
self.session.flush.assert_called_once_with()
@patch(
"govoplan_files.backend.storage.connector_spaces.ensure_owner_access",
side_effect=FileStorageError("File space access denied"),
)
def test_inaccessible_space_is_blocked_without_mutation(
self, _ensure_owner_access: Mock
) -> None:
with self.assertRaisesRegex(FileStorageError, "access denied"):
soft_delete_connector_space(
self.session, self.space, user_id="other-user"
)
self.assertIsNone(self.space.deleted_at)
self.assertTrue(self.space.is_active)
self.session.add.assert_not_called()
self.session.flush.assert_not_called()
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -85,6 +85,8 @@ class FilesRouterContractTests(unittest.TestCase):
(("POST",), "/files/connectors/credentials"), (("POST",), "/files/connectors/credentials"),
(("GET",), "/files/connector-spaces"), (("GET",), "/files/connector-spaces"),
(("POST",), "/files/connector-spaces"), (("POST",), "/files/connector-spaces"),
(("PATCH",), "/files/connector-spaces/{space_id}"),
(("DELETE",), "/files/connector-spaces/{space_id}"),
} }
self.assertTrue(expected.issubset(routes)) self.assertTrue(expected.issubset(routes))
+1
View File
@@ -16,6 +16,7 @@
"scripts": { "scripts": {
"test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs", "test:file-drop-target": "node scripts/test-file-drop-target-structure.mjs",
"test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs", "test:file-property-filters": "node scripts/test-file-property-filters-structure.mjs",
"test:connector-space-removal": "node scripts/test-connector-space-removal-structure.mjs",
"test:interface-pattern-language": "node scripts/test-interface-pattern-language.mjs" "test:interface-pattern-language": "node scripts/test-interface-pattern-language.mjs"
}, },
"peerDependencies": { "peerDependencies": {
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/features/files/FilesPage.tsx", import.meta.url), "utf8");
assert.match(source, /deleteFileConnectorSpace\(settings, target\.connector_space_id\)/);
assert.match(source, /activeSpaceIsConnector &&[\s\S]*setConnectorSpaceRemovalTarget\(activeSpace\)/);
assert.match(source, /Remote files and folders remain at the provider/);
assert.match(source, /Imported GovOPlaN files, metadata and shares remain managed/);
assert.match(source, /connector profile, credentials and remote references are not deleted/);
console.log("Connector-space removal structure checks passed.");
+51
View File
@@ -25,6 +25,7 @@ import {
createFolder, createFolder,
confirmArchiveUpload, confirmArchiveUpload,
deleteFolder, deleteFolder,
deleteFileConnectorSpace,
downloadFile, downloadFile,
downloadFilesAsZip, downloadFilesAsZip,
fetchResourceAccessExplanation, fetchResourceAccessExplanation,
@@ -168,6 +169,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [connectorSpaceItemsBySpace, setConnectorSpaceItemsBySpace] = useState<Record<string, FileConnectorBrowseItem[]>>({}); const [connectorSpaceItemsBySpace, setConnectorSpaceItemsBySpace] = useState<Record<string, FileConnectorBrowseItem[]>>({});
const [connectorSpaceLibraryBySpace, setConnectorSpaceLibraryBySpace] = useState<Record<string, string | null>>({}); const [connectorSpaceLibraryBySpace, setConnectorSpaceLibraryBySpace] = useState<Record<string, string | null>>({});
const [connectorSpaceSelectedItem, setConnectorSpaceSelectedItem] = useState<FileConnectorBrowseItem | null>(null); const [connectorSpaceSelectedItem, setConnectorSpaceSelectedItem] = useState<FileConnectorBrowseItem | null>(null);
const [connectorSpaceRemovalTarget, setConnectorSpaceRemovalTarget] = useState<FileSpace | null>(null);
const [connectorSpaceLoading, setConnectorSpaceLoading] = useState(false); const [connectorSpaceLoading, setConnectorSpaceLoading] = useState(false);
const [connectorSpaceError, setConnectorSpaceError] = useState(""); const [connectorSpaceError, setConnectorSpaceError] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@@ -1119,6 +1121,35 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
} }
} }
async function removeConnectorSpace() {
const target = connectorSpaceRemovalTarget;
if (!canOrganize || !target?.connector_space_id || !isConnectorSpace(target)) return;
setBusy(true);
setError("");
setMessage("");
try {
await deleteFileConnectorSpace(settings, target.connector_space_id);
setConnectorSpaceRemovalTarget(null);
setConnectorSpaceItemsBySpace((current) => {
const next = { ...current };
delete next[target.id];
return next;
});
setConnectorSpaceLibraryBySpace((current) => {
const next = { ...current };
delete next[target.id];
return next;
});
setConnectorSpaceSelectedItem(null);
setMessage(`Removed connector space “${target.label}”. Remote content and managed GovOPlaN files were not changed.`);
await loadSpaces();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
function connectorMetadataString(item: FileConnectorBrowseItem, key: string): string | null { function connectorMetadataString(item: FileConnectorBrowseItem, key: string): string | null {
const value = item.metadata[key]; const value = item.metadata[key];
if (typeof value === "string" && value.trim()) return value; if (typeof value === "string" && value.trim()) return value;
@@ -2150,6 +2181,15 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button> <Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={Boolean(accessExplanationBlocker)} disabledReason={accessExplanationBlocker}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
<Button variant="danger" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button> <Button variant="danger" onClick={() => void deleteSelected()} disabled={Boolean(deleteBlocker)} disabledReason={deleteBlocker}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
{activeSpaceIsConnector && {activeSpaceIsConnector &&
<Button
variant="danger"
onClick={() => activeSpace && setConnectorSpaceRemovalTarget(activeSpace)}
disabled={busy || !canOrganize || !activeSpace?.connector_space_id}
disabledReason={workingBlocker || (!canOrganize ? "File organization permission is required to remove a connector space." : !activeSpace?.connector_space_id ? "This space is not a removable connector space." : "")}>
<Trash2 size={16} aria-hidden="true" /> Remove space
</Button>
}
{activeSpaceIsConnector &&
<Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace} disabledReason={workingBlocker || (connectorSpaceLoading ? "This connector space is already refreshing." : !activeSpace ? "Select a connector space first." : "")}> <Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace} disabledReason={workingBlocker || (connectorSpaceLoading ? "This connector space is already refreshing." : !activeSpace ? "Select a connector space first." : "")}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc <RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc
</Button> </Button>
@@ -2520,6 +2560,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
busy={busy} busy={busy}
onConfirm={() => void performConfirmedDelete()} onConfirm={() => void performConfirmedDelete()}
onCancel={() => setDeleteDialog(null)} /> onCancel={() => setDeleteDialog(null)} />
<ConfirmDialog
open={Boolean(connectorSpaceRemovalTarget)}
title="Remove connector space?"
message={connectorSpaceRemovalTarget ? `Remove “${connectorSpaceRemovalTarget.label}” from Files? This retires only the local connector-space link. Remote files and folders remain at the provider. Imported GovOPlaN files, metadata and shares remain managed in their owner spaces. The connector profile, credentials and remote references are not deleted and can be linked again.` : ""}
confirmLabel="Remove space"
cancelLabel="i18n:govoplan-files.cancel.77dfd213"
tone="danger"
busy={busy}
onConfirm={() => void removeConnectorSpace()}
onCancel={() => setConnectorSpaceRemovalTarget(null)} />
{contextMenu && {contextMenu &&