security: harden connector policy and add dashboard widget
This commit is contained in:
@@ -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"]) ??
|
||||
|
||||
Reference in New Issue
Block a user