security: harden connector policy and add dashboard widget

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 93eea839c8
commit 5b868272b9
9 changed files with 416 additions and 32 deletions

View File

@@ -22,7 +22,7 @@
"typescript": "^5.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"lucide-react": "^1.23.0",
"@govoplan/core-webui": "^0.1.9"
},

View File

@@ -14,16 +14,21 @@ import {
AdvancedOptionsPanel,
LoadingFrame,
mergeDeltaRows,
ReferenceMultiSelect,
SegmentedControl,
staticReferenceOptionProvider,
StatusBadge,
TableActionGroup,
ToggleSwitch,
useDeltaWatermarks,
usePlatformLanguage,
useUnsavedDraftGuard,
wildcardReferenceOption,
type ApiSettings,
type ConnectionTreeColumn,
type FileConnectorScope,
type FileConnectorTargetOption,
type ReferenceOption,
i18nMessage } from
"@govoplan/core-webui";
import {
@@ -186,6 +191,7 @@ export default function FileConnectorSettingsPanel({
const [credentialLoginResult, setCredentialLoginResult] = useState<{ok: boolean;message: string;} | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const { translateText } = usePlatformLanguage();
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const requiresTarget = scopeType === "user" || scopeType === "group";
@@ -198,6 +204,10 @@ export default function FileConnectorSettingsPanel({
const profileDirty = Boolean(draft) && connectorProfileDraftKey(draft) !== savedDraftKey;
const credentialDirty = Boolean(credentialDraft) && connectorCredentialDraftKey(credentialDraft) !== savedCredentialDraftKey;
const policyDirty = scopeReady && centralPolicyDraftKey(policyDraft) !== savedPolicyDraftKey;
const policyConflicts = useMemo(
() => policyReferenceConflicts(policyDraft),
[policyDraft]
);
useUnsavedDraftGuard({
dirty: profileDirty || credentialDirty || policyDirty,
@@ -233,6 +243,71 @@ export default function FileConnectorSettingsPanel({
),
[activeScopeId, credentials, scopeType]
);
const connectorReferenceOptions = useMemo<ReferenceOption[]>(
() => profiles.map((profile) => ({
value: profile.id,
label: profile.label || profile.id,
description: [
profile.provider,
profile.source_path,
profile.enabled ? null : "Inactive"
].filter(Boolean).join(" · "),
kind: "file_connection",
availability: profile.enabled ? "available" : "inactive",
disabled: !profile.enabled,
sourceModule: "files",
provenance: {
scopeType: profile.scope_type,
scopeId: profile.scope_id,
sourcePath: profile.source_path
}
})),
[profiles]
);
const credentialReferenceOptions = useMemo<ReferenceOption[]>(
() => credentials.map((credential) => ({
value: credential.id,
label: credential.label || credential.id,
description: [
credential.provider || "shared",
credential.source_path,
credential.enabled ? null : "Inactive"
].filter(Boolean).join(" · "),
kind: "file_credential",
availability: credential.enabled ? "available" : "inactive",
disabled: !credential.enabled,
sourceModule: credential.source_kind === "credential_envelope" ? "core" : "files",
provenance: {
scopeType: credential.scope_type,
scopeId: credential.scope_id,
sourcePath: credential.source_path,
sourceKind: credential.source_kind
}
})),
[credentials]
);
const providerReferenceOptions = useMemo<ReferenceOption[]>(
() => PROVIDERS.map((provider) => ({
value: provider.id,
label: translateText(provider.label),
description: provider.id,
kind: "file_provider",
sourceModule: "files"
})),
[translateText]
);
const connectorReferenceProvider = useMemo(
() => staticReferenceOptionProvider(connectorReferenceOptions),
[connectorReferenceOptions]
);
const credentialReferenceProvider = useMemo(
() => staticReferenceOptionProvider(credentialReferenceOptions),
[credentialReferenceOptions]
);
const providerReferenceProvider = useMemo(
() => staticReferenceOptionProvider(providerReferenceOptions),
[providerReferenceOptions]
);
useEffect(() => {
resetDeltaWatermark(deltaKey);
@@ -865,22 +940,70 @@ export default function FileConnectorSettingsPanel({
<>
<div className="form-grid two">
<FormField label="i18n:govoplan-files.allowed_connection_ids.0df854c8">
<textarea value={policyDraft.allowedConnectors} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedConnectors: event.target.value })} rows={3} placeholder={"tenant-webdav\nseafile-*"} />
<ReferenceMultiSelect
values={lines(policyDraft.allowedConnectors)}
onChange={(values) => patchPolicyDraft({ allowedConnectors: values.join("\n") })}
provider={connectorReferenceProvider}
createCustomOption={(value) => wildcardReferenceOption(value)}
aria-label="Allowed file connections"
placeholder="Add a connection or wildcard pattern"
disabled={saving || !canWrite}
/>
</FormField>
<FormField label="i18n:govoplan-files.denied_connection_ids.a95218b4">
<textarea value={policyDraft.deniedConnectors} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedConnectors: event.target.value })} rows={3} placeholder={"legacy-*\nblocked-smb"} />
<ReferenceMultiSelect
values={lines(policyDraft.deniedConnectors)}
onChange={(values) => patchPolicyDraft({ deniedConnectors: values.join("\n") })}
provider={connectorReferenceProvider}
createCustomOption={(value) => wildcardReferenceOption(value)}
aria-label="Denied file connections"
placeholder="Add a connection or wildcard pattern"
disabled={saving || !canWrite}
/>
</FormField>
<FormField label="i18n:govoplan-files.allowed_credential_ids.efcaba2d">
<textarea value={policyDraft.allowedCredentials} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedCredentials: event.target.value })} rows={3} placeholder={"tenant-webdav-credentials\nshared-*"} />
<ReferenceMultiSelect
values={lines(policyDraft.allowedCredentials)}
onChange={(values) => patchPolicyDraft({ allowedCredentials: values.join("\n") })}
provider={credentialReferenceProvider}
createCustomOption={(value) => wildcardReferenceOption(value)}
aria-label="Allowed file credentials"
placeholder="Add a credential or wildcard pattern"
disabled={saving || !canWrite}
/>
</FormField>
<FormField label="i18n:govoplan-files.denied_credential_ids.b6838bc2">
<textarea value={policyDraft.deniedCredentials} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedCredentials: event.target.value })} rows={3} placeholder={"personal-*\nblocked-*"} />
<ReferenceMultiSelect
values={lines(policyDraft.deniedCredentials)}
onChange={(values) => patchPolicyDraft({ deniedCredentials: values.join("\n") })}
provider={credentialReferenceProvider}
createCustomOption={(value) => wildcardReferenceOption(value)}
aria-label="Denied file credentials"
placeholder="Add a credential or wildcard pattern"
disabled={saving || !canWrite}
/>
</FormField>
<FormField label="i18n:govoplan-files.allowed_providers.82a9c23e">
<textarea value={policyDraft.allowedProviders} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedProviders: event.target.value })} rows={3} placeholder={"webdav\nnextcloud\nsmb"} />
<ReferenceMultiSelect
values={lines(policyDraft.allowedProviders)}
onChange={(values) => patchPolicyDraft({ allowedProviders: values.join("\n") })}
provider={providerReferenceProvider}
createCustomOption={(value) => wildcardReferenceOption(value)}
aria-label="Allowed file providers"
placeholder="Add a provider or wildcard pattern"
disabled={saving || !canWrite}
/>
</FormField>
<FormField label="i18n:govoplan-files.denied_providers.149b29f4">
<textarea value={policyDraft.deniedProviders} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedProviders: event.target.value })} rows={3} placeholder={"generic\nnfs"} />
<ReferenceMultiSelect
values={lines(policyDraft.deniedProviders)}
onChange={(values) => patchPolicyDraft({ deniedProviders: values.join("\n") })}
provider={providerReferenceProvider}
createCustomOption={(value) => wildcardReferenceOption(value)}
aria-label="Denied file providers"
placeholder="Add a provider or wildcard pattern"
disabled={saving || !canWrite}
/>
</FormField>
<FormField label="i18n:govoplan-files.allowed_paths.c953b4be">
<textarea value={policyDraft.allowedPaths} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ allowedPaths: event.target.value })} rows={3} placeholder={"GovOPlaN\nreports/*"} />
@@ -895,6 +1018,20 @@ export default function FileConnectorSettingsPanel({
<textarea value={policyDraft.deniedUrls} disabled={saving || !canWrite} onChange={(event) => patchPolicyDraft({ deniedUrls: event.target.value })} rows={3} placeholder={"http://*\n*://legacy.example.test/*"} />
</FormField>
</div>
{policyConflicts.length ? (
<DismissibleAlert
tone="warning"
resetKey={policyConflicts.join("\u0000")}
>
Allow and deny rules contain the same values for{" "}
{policyConflicts.join("; ")}. Deny rules take precedence.
</DismissibleAlert>
) : (
<p className="muted small-note">
When an allow and deny rule both match, the deny rule takes
precedence.
</p>
)}
<div className="file-connector-settings-section file-connector-policy-locks">
<ToggleSwitch label="i18n:govoplan-files.lower_connection_limits.4f9838bc" checked={policyDraft.allowLowerConnectionLimits} disabled={saving || !canWrite} onChange={(allowLowerConnectionLimits) => patchPolicyDraft({ allowLowerConnectionLimits })} />
<ToggleSwitch label="i18n:govoplan-files.lower_credential_limits.ec2b72bc" checked={policyDraft.allowLowerCredentialLimits} disabled={saving || !canWrite} onChange={(allowLowerCredentialLimits) => patchPolicyDraft({ allowLowerCredentialLimits })} />
@@ -1693,6 +1830,26 @@ function lines(value: string): string[] {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
function policyReferenceConflicts(
draft: CentralConnectorPolicyDraft
): string[] {
return [
["connections", draft.allowedConnectors, draft.deniedConnectors],
["credentials", draft.allowedCredentials, draft.deniedCredentials],
["providers", draft.allowedProviders, draft.deniedProviders],
["paths", draft.allowedPaths, draft.deniedPaths],
["endpoint URLs", draft.allowedUrls, draft.deniedUrls]
].flatMap(([label, allowed, denied]) => {
const deniedValues = new Set(
lines(denied).map((value) => value.toLocaleLowerCase())
);
const overlaps = lines(allowed).filter((value) =>
deniedValues.has(value.toLocaleLowerCase())
);
return overlaps.length ? [`${label}: ${overlaps.join(", ")}`] : [];
});
}
function policyRuleList(policy: Record<string, unknown>, kind: "allow" | "deny", field: "connectors" | "providers" | "credentials" | "external_paths" | "external_urls"): string[] {
const rule = recordValue(policy[kind]) ??
recordValue(policy[kind === "allow" ? "allowlist" : "denylist"]) ??

View File

@@ -0,0 +1,98 @@
import { useCallback } from "react";
import { Folder, Network } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listFileSpaces,
type FileSpace
} from "../../api/files";
export default function FileSpacesWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 6, 1, 16);
const includeConnectors = configuration.includeConnectors !== false;
const load = useCallback(async () => {
const response = await listFileSpaces(settings);
return response.spaces
.filter(
(space) =>
includeConnectors || (space.space_type ?? "managed") === "managed"
)
.sort((left, right) => left.label.localeCompare(right.label))
.slice(0, maxItems);
}, [includeConnectors, maxItems, settings]);
const { data: spaces, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading file spaces">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No file spaces are available."
items={(spaces ?? []).map((space) => ({
id: space.id,
title: space.label,
detail: spaceDescription(space),
meta: space.owner_type === "group" ? "Group" : "Personal",
leading:
space.space_type === "connector" ? (
<Network size={17} aria-hidden="true" />
) : (
<Folder size={17} aria-hidden="true" />
),
trailing: space.read_only ? (
<StatusBadge status="locked" label="Read only" />
) : undefined,
to: "/files"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/files">
Open files
</Link>
</div>
</LoadingFrame>
);
}
function spaceDescription(space: FileSpace): string {
if (space.space_type !== "connector") {
return space.description || "Managed storage";
}
return [space.provider, space.library_id, space.remote_path]
.filter(Boolean)
.join(" · ") || "Connected storage";
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -1,8 +1,16 @@
import { createElement, lazy } from "react";
import { hasScope, type AdminSectionsUiCapability, type FilesConnectorsUiCapability, type FilesFileExplorerUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import {
hasScope,
type AdminSectionsUiCapability,
type DashboardWidgetsUiCapability,
type FilesConnectorsUiCapability,
type FilesFileExplorerUiCapability,
type PlatformWebModule
} from "@govoplan/core-webui";
import { FolderTree } from "./features/files/components/FileManagerComponents";
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
import FileSpacesWidget from "./features/files/FileSpacesWidget";
import { listFileSpaces, listFiles, listFilesDelta, listFolders, resolveFilePatterns, shareFilesWithTarget } from "./api/files";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/file-manager.css";
@@ -14,6 +22,50 @@ const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const fileDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "files.spaces",
surfaceId: "files.widget.spaces",
title: "File spaces",
description: "Managed and connected file spaces available to you.",
moduleId: "files",
category: "Content",
order: 35,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: fileRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 6,
includeConnectors: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum spaces",
kind: "number",
min: 1,
max: 16,
step: 1,
required: true
},
{
id: "includeConnectors",
label: "Include connected storage",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(FileSpacesWidget, {
settings,
refreshKey,
configuration
})
}
]
};
const fileConnectorAdminSections: AdminSectionsUiCapability = {
sections: [
{
@@ -56,7 +108,8 @@ export const filesModule: PlatformWebModule = {
{ id: "files.admin.tenant-connectors", moduleId: "files", kind: "section", label: "Tenant file connections", order: 65 },
{ id: "files.admin.group-connectors", moduleId: "files", kind: "section", label: "Group file connections", order: 65 },
{ id: "files.admin.user-connectors", moduleId: "files", kind: "section", label: "User file connections", order: 65 },
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 }
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 },
{ id: "files.widget.spaces", moduleId: "files", kind: "section", label: "File spaces widget", order: 35 }
],
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
routes: [
@@ -76,7 +129,8 @@ export const filesModule: PlatformWebModule = {
"files.connectors": {
FileConnectorScopeManager: FileConnectorSettingsPanel
} satisfies FilesConnectorsUiCapability,
"admin.sections": fileConnectorAdminSections
"admin.sections": fileConnectorAdminSections,
"dashboard.widgets": fileDashboardWidgets
}
};