feat(files): add safe connector space removal
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -1097,6 +1097,7 @@ manifest = ModuleManifest(
|
|||||||
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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.");
|
||||||
@@ -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>
|
||||||
@@ -2521,6 +2561,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
|||||||
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 &&
|
||||||
<FileContextMenu
|
<FileContextMenu
|
||||||
|
|||||||
Reference in New Issue
Block a user