feat: govern file lifecycle and connector writes

This commit is contained in:
2026-08-20 22:06:57 +02:00
parent 95aef18955
commit 6c3cf1c55e
27 changed files with 2721 additions and 65 deletions
+128
View File
@@ -295,6 +295,7 @@ export type FileConnectorProvider = {
installed: boolean;
browse_supported: boolean;
import_supported: boolean;
write_supported: boolean;
optional_dependency?: string | null;
permission_model: string;
sync_strategy: string;
@@ -343,6 +344,7 @@ export type FileConnectorSpacePayload = {
library_id?: string | null;
remote_path?: string;
sync_mode?: "manual";
read_only?: boolean;
metadata?: Record<string, unknown>;
};
@@ -361,6 +363,10 @@ export type ManagedFile = {
created_at: string;
updated_at: string;
deleted_at?: string | null;
retained_until?: string | null;
legal_hold?: boolean;
lifecycle_revision?: number;
lifecycle_reason?: string | null;
audit_relevant: boolean;
metadata?: Record<string, unknown> | null;
source_provenance?: FileSourceProvenance | null;
@@ -420,6 +426,22 @@ export type FileConnectorSyncResponse = {
previous_version_id?: string | null;
current_version_id: string;
};
export type FileConnectorWritePayload = {
file_id: string;
remote_path: string;
idempotency_key: string;
expected_revision?: string | null;
};
export type FileConnectorWriteResponse = {
recovery_operation_id: string;
status: string;
replayed: boolean;
provider: string;
remote_path: string;
revision?: string | null;
checksum_sha256: string;
size_bytes: number;
};
export type FileFolder = {
id: string;
tenant_id: string;
@@ -434,7 +456,49 @@ export type FileFoldersResponse = {folders: FileFolder[];cursor?: string | null;
const DEFAULT_MANAGED_FILE_WINDOW_SIZE = 500;
export type FolderDeleteResponse = {deleted_folders: number;deleted_files: number;};
export type FolderRestoreResponse = {restored_folders: number;restored_files: number;};
export type BulkDeleteResponse = {deleted_count: number;};
export type FileRestoreResponse = {restored_count: number;};
export type FileLifecycleUpdatePayload = {
retained_until?: string | null;
legal_hold: boolean;
reason: string;
expected_revision: number;
};
export type FilePurgePreviewItem = {
file_id: string;
filename: string;
lifecycle_revision: number;
deleted_at?: string | null;
retained_until?: string | null;
legal_hold: boolean;
blockers: string[];
blob_ids: string[];
};
export type FilePurgePreviewResponse = {
preview_sha256: string;
eligible: boolean;
items: FilePurgePreviewItem[];
};
export type FilePurgeExecutePayload = {
file_ids: string[];
preview_sha256: string;
idempotency_key: string;
approval_reference: string;
confirmation: "PURGE";
};
export type FilePurgeResponse = {
recovery_operation_id: string;
status: string;
replayed: boolean;
purged_files: number;
released_blobs: number;
};
export type FileBlobGcResponse = {
inspected_blobs: number;
deleted_blobs: number;
unresolved_operation_ids: string[];
};
export type RenameResponse = {dry_run: boolean;items: {kind: "file" | "folder";id: string;file_id?: string | null;folder_path?: string | null;old_path: string;new_path: string;}[];};
export type TransferResponse = {operation: "move" | "copy";files: number;folders: number;};
export type ConflictAction = "overwrite" | "rename" | "skip";
@@ -748,6 +812,55 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) });
}
export function updateFileLifecycle(
settings: ApiSettings,
fileId: string,
payload: FileLifecycleUpdatePayload)
: Promise<ManagedFile> {
return apiFetch<ManagedFile>(settings, `/api/v1/files/${encodeURIComponent(fileId)}/lifecycle`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function restoreFile(settings: ApiSettings, fileId: string): Promise<FileRestoreResponse> {
return apiFetch<FileRestoreResponse>(settings, `/api/v1/files/assets/${encodeURIComponent(fileId)}/restore`, { method: "POST" });
}
export function restoreFolder(
settings: ApiSettings,
payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?: boolean;})
: Promise<FolderRestoreResponse> {
return apiFetch<FolderRestoreResponse>(settings, "/api/v1/files/folders/restore", {
method: "POST",
body: JSON.stringify({ recursive: true, ...payload })
});
}
export function previewFilePurge(settings: ApiSettings, fileIds: string[]): Promise<FilePurgePreviewResponse> {
return apiFetch<FilePurgePreviewResponse>(settings, "/api/v1/files/purge/preview", {
method: "POST",
body: JSON.stringify({ file_ids: fileIds })
});
}
export function executeFilePurge(settings: ApiSettings, payload: FilePurgeExecutePayload): Promise<FilePurgeResponse> {
return apiFetch<FilePurgeResponse>(settings, "/api/v1/files/purge/execute", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function garbageCollectFileBlobs(
settings: ApiSettings,
payload: {limit?: number;approval_reference: string;})
: Promise<FileBlobGcResponse> {
return apiFetch<FileBlobGcResponse>(settings, "/api/v1/files/purge/blobs", {
method: "POST",
body: JSON.stringify(payload)
});
}
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
export type FileSharePayload = {
target_type: "user" | "group" | "tenant" | "campaign";
@@ -978,6 +1091,21 @@ export function deleteFileConnectorSpace(settings: ApiSettings, spaceId: string)
return apiFetch<FileConnectorSpace>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}`, { method: "DELETE" });
}
export function restoreFileConnectorSpace(settings: ApiSettings, spaceId: string): Promise<FileConnectorSpace> {
return apiFetch<FileConnectorSpace>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}/restore`, { method: "POST" });
}
export function writeBackFileConnectorSpace(
settings: ApiSettings,
spaceId: string,
payload: FileConnectorWritePayload)
: Promise<FileConnectorWriteResponse> {
return apiFetch<FileConnectorWriteResponse>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}/write-back`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function getFileConnectorProfile(
settings: ApiSettings,
profileId: string,
@@ -76,6 +76,7 @@ type ConnectorProfileDraft = {
canBrowse: boolean;
canImport: boolean;
canSync: boolean;
canWriteRemote: boolean;
policyMode: PolicyMode;
allowedPaths: string;
deniedPaths: string;
@@ -509,7 +510,8 @@ export default function FileConnectorSettingsPanel({
const capabilities = [
draft.canBrowse ? "browse" : "",
draft.canImport ? "import" : "",
draft.canSync ? "sync" : ""].
draft.canSync ? "sync" : "",
draft.canWriteRemote ? "write" : ""].
filter(Boolean);
const sharedPayload = {
label,
@@ -1365,6 +1367,7 @@ export default function FileConnectorSettingsPanel({
<ToggleSwitch label="i18n:govoplan-files.browse.2f3b5c55" checked={draft.canBrowse} disabled={saving} onChange={(canBrowse) => patchDraft({ canBrowse })} />
<ToggleSwitch label="i18n:govoplan-files.import.d6fbc9d2" checked={draft.canImport} disabled={saving} onChange={(canImport) => patchDraft({ canImport })} />
<ToggleSwitch label="i18n:govoplan-files.sync.905f6309" checked={draft.canSync} disabled={saving} onChange={(canSync) => patchDraft({ canSync })} />
<ToggleSwitch label="Remote write" checked={draft.canWriteRemote} disabled={saving || draft.provider !== "s3"} onChange={(canWriteRemote) => patchDraft({ canWriteRemote })} />
</div>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="i18n:govoplan-files.policy.bb9cf141" documentation={CONNECTOR_DOCUMENTATION}>
@@ -1528,6 +1531,7 @@ function emptyDraft(scopeType: ConnectorScope): ConnectorProfileDraft {
canBrowse: true,
canImport: true,
canSync: true,
canWriteRemote: false,
policyMode: "allow-provider",
allowedPaths: "",
deniedPaths: "",
@@ -1566,6 +1570,7 @@ function draftFromProfile(profile: FileConnectorProfile, scopeType: ConnectorSco
canBrowse: profile.capabilities.includes("browse"),
canImport: profile.capabilities.includes("import"),
canSync: profile.capabilities.includes("sync"),
canWriteRemote: profile.capabilities.includes("write"),
policyMode: denyProviders.includes("*") || denyProviders.includes(profile.provider) ? "deny-provider" : allowPaths.length > 0 ? "allowed-paths" : "allow-provider",
allowedPaths: allowPaths.join("\n"),
deniedPaths: denyPaths.join("\n"),
+16
View File
@@ -167,6 +167,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
const [connectorLoading, setConnectorLoading] = useState(false);
const [connectorError, setConnectorError] = useState("");
const [connectorSpaceLabel, setConnectorSpaceLabel] = useState("");
const [connectorSpaceReadOnly, setConnectorSpaceReadOnly] = useState(true);
const [connectorSpaceOwnerSpaceId, setConnectorSpaceOwnerSpaceId] = useState("");
const [connectorSpaceItemsBySpace, setConnectorSpaceItemsBySpace] = useState<Record<string, FileConnectorBrowseItem[]>>({});
const [connectorSpaceLibraryBySpace, setConnectorSpaceLibraryBySpace] = useState<Record<string, string | null>>({});
@@ -584,6 +585,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
setConnectorError("");
setConnectorSelectedItem(null);
setConnectorSpaceLabel("");
setConnectorSpaceReadOnly(true);
resetArchiveUploadState();
}
@@ -1103,6 +1105,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
library_id: connectorLibraryId || undefined,
remote_path: remotePath,
sync_mode: "manual",
read_only: connectorSpaceReadOnly,
metadata: {
created_from_ui: true,
source_label: activeConnectorProfile?.label,
@@ -2903,6 +2906,19 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
<FormField label="i18n:govoplan-files.space_label.ec892726">
<input value={connectorSpaceLabel} onChange={(event) => setConnectorSpaceLabel(event.target.value)} disabled={busy} placeholder={connectorSpaceSuggestedLabel()} />
</FormField>
<FormField label="Connector mode" help="Two-way mode permits explicit writes only. Remote deletion, rename, and move propagation remain disabled.">
<select
value={connectorSpaceReadOnly ? "read-only" : "two-way"}
onChange={(event) => setConnectorSpaceReadOnly(event.target.value !== "two-way")}
disabled={busy}>
<option value="read-only">Read-only</option>
<option
value="two-way"
disabled={activeConnectorProfile?.provider !== "s3" || !activeConnectorProfile.capabilities.includes("write")}>
Two-way (S3, explicit writes)
</option>
</select>
</FormField>
</div>
<div className="connector-browser">