From 54c9881a25a96e2abb93ea5ec2406e3aba1e297c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 00:39:40 +0200 Subject: [PATCH] Add campaign resource access explanations --- src/govoplan_campaign/backend/capabilities.py | 116 ++++++++++++++++++ tests/test_access_provider.py | 95 ++++++++++++++ webui/src/api/campaigns.ts | 35 ++++++ .../features/campaigns/GlobalSettingsPage.tsx | 2 +- .../components/CampaignAccessCard.tsx | 112 ++++++++++++++++- webui/src/i18n/generatedTranslations.ts | 26 ++++ 6 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 tests/test_access_provider.py diff --git a/src/govoplan_campaign/backend/capabilities.py b/src/govoplan_campaign/backend/capabilities.py index a626dfc..f7fc6af 100644 --- a/src/govoplan_campaign/backend/capabilities.py +++ b/src/govoplan_campaign/backend/capabilities.py @@ -4,6 +4,7 @@ from collections.abc import Iterable, Mapping from sqlalchemy import and_, or_ +from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef from govoplan_core.core.campaigns import ( CampaignAccessProvider, CampaignDeliveryTaskProvider, @@ -13,10 +14,14 @@ from govoplan_core.core.campaigns import ( CampaignPolicyContextProvider, CampaignRetentionProvider, ) +from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_campaign.backend.db.models import Campaign, CampaignShare +READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"} + + class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider): def get_campaign_mail_policy_context( self, @@ -105,11 +110,122 @@ class CampaignAccessService(CampaignAccessProvider): ) return share is not None + def explain_resource_provenance( + self, + session: object, + principal: PrincipalRef, + *, + resource_type: str, + resource_id: str, + action: str, + ) -> tuple[AccessDecisionProvenance, ...]: + normalized_type = resource_type.lower().strip() + if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}: + return () + campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] + if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id): + return ( + AccessDecisionProvenance( + kind="resource", + id=resource_id, + tenant_id=principal.tenant_id, + source="campaigns.not_found", + details={"resource_type": "campaign", "found": False}, + ), + ) + items = [ + AccessDecisionProvenance( + kind="resource", + id=campaign.id, + label=campaign.name, + tenant_id=campaign.tenant_id, + source="campaigns.campaign", + details={ + "resource_type": "campaign", + "external_id": campaign.external_id, + "status": campaign.status, + }, + ) + ] + items.extend(_owner_provenance(campaign, principal)) + items.extend(_tenant_admin_provenance(principal)) + permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"} + shares = ( + session.query(CampaignShare) # type: ignore[attr-defined] + .filter( + CampaignShare.tenant_id == campaign.tenant_id, + CampaignShare.campaign_id == campaign.id, + CampaignShare.revoked_at.is_(None), + CampaignShare.permission.in_(sorted(permission_values)), + or_( + (CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id), + (CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))), + ), + ) + .order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc()) + .all() + ) + for share in shares: + items.append( + AccessDecisionProvenance( + kind="share", + id=share.id, + label=share.permission, + tenant_id=share.tenant_id, + source="campaigns.share", + details={ + "target_type": share.target_type, + "target_id": share.target_id, + "permission": share.permission, + }, + ) + ) + return tuple(items) + def access_capability(context: object) -> CampaignAccessService: return CampaignAccessService() +def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: + if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id: + return ( + AccessDecisionProvenance( + kind="owner", + id=campaign.owner_user_id, + tenant_id=campaign.tenant_id, + source="campaigns.owner", + details={"owner_type": "user"}, + ), + ) + if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids: + return ( + AccessDecisionProvenance( + kind="owner", + id=campaign.owner_group_id, + tenant_id=campaign.tenant_id, + source="campaigns.owner", + details={"owner_type": "group"}, + ), + ) + return () + + +def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: + if not scopes_grant_compatible(principal.scopes, "tenant:*"): + return () + return ( + AccessDecisionProvenance( + kind="policy", + id="tenant:*", + label="tenant:*", + tenant_id=principal.tenant_id, + source="campaigns.tenant_admin", + details={"grant": "tenant_admin"}, + ), + ) + + class CampaignPolicyContextService(CampaignPolicyContextProvider): def get_campaign_policy_context( self, diff --git a/tests/test_access_provider.py b/tests/test_access_provider.py new file mode 100644 index 0000000..6dd0def --- /dev/null +++ b/tests/test_access_provider.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db.models import Account, Group, User +from govoplan_campaign.backend.capabilities import CampaignAccessService +from govoplan_campaign.backend.db.models import Campaign, CampaignShare +from govoplan_core.core.access import PrincipalRef +from govoplan_core.db.base import Base + + +TENANT_ID = "tenant-1" +USER_ID = "user-1" +OTHER_USER_ID = "user-2" +GROUP_ID = "group-1" + + +class CampaignAccessProviderTests(unittest.TestCase): + def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None: + session = _session() + _seed_access_subjects(session) + owned = Campaign(id="campaign-owned", tenant_id=TENANT_ID, owner_user_id=USER_ID, external_id="owned", name="Owned campaign") + shared = Campaign(id="campaign-shared", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="shared", name="Shared campaign") + session.add_all([ + owned, + shared, + CampaignShare(id="share-group", tenant_id=TENANT_ID, campaign_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"), + ]) + session.commit() + + service = CampaignAccessService() + owned_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id=owned.id, action="campaigns:campaign:read") + shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read") + admin_items = service.explain_resource_provenance(session, _principal(scopes={"tenant:*"}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read") + missing_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id="missing-campaign", action="campaigns:campaign:read") + + self.assertTrue(any(item.kind == "resource" and item.source == "campaigns.campaign" and item.id == owned.id for item in owned_items)) + self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items)) + self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items)) + self.assertTrue(any(item.kind == "policy" and item.id == "tenant:*" for item in admin_items)) + self.assertEqual("campaigns.not_found", missing_items[0].source) + self.assertIs(missing_items[0].details["found"], False) + + def test_campaign_access_provider_requires_write_share_for_write_actions(self) -> None: + session = _session() + _seed_access_subjects(session) + campaign = Campaign(id="campaign-write", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="write", name="Write campaign") + session.add_all([ + campaign, + CampaignShare(id="share-read", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id=GROUP_ID, permission="read"), + CampaignShare(id="share-write", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="user", target_id=USER_ID, permission="write"), + ]) + session.commit() + + service = CampaignAccessService() + items = service.explain_resource_provenance( + session, + _principal(group_ids={GROUP_ID}), + resource_type="campaign", + resource_id=campaign.id, + action="campaigns:campaign:update", + ) + + self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items)) + self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items)) + + +def _session(): + engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__]) + return sessionmaker(bind=engine, future=True)() + + +def _seed_access_subjects(session) -> None: + session.add_all([ + Account(id="account-1", email="one@example.test", normalized_email="one@example.test"), + Account(id="account-2", email="two@example.test", normalized_email="two@example.test"), + User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"), + User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"), + Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"), + ]) + session.commit() + + +def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef: + return PrincipalRef( + account_id="account-1", + membership_id=USER_ID, + tenant_id=TENANT_ID, + scopes=frozenset(scopes or {"campaigns:campaign:read"}), + group_ids=frozenset(group_ids or set()), + ) diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index d3e9123..2f2653b 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -22,6 +22,27 @@ export type CampaignShare = { export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;}; export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];}; +export type AccessExplanationUser = { + id: string; + account_id?: string | null; + email?: string | null; + display_name?: string | null; +}; +export type AccessDecisionProvenanceItem = { + kind: string; + id?: string | null; + label?: string | null; + tenant_id?: string | null; + source?: string | null; + details?: Record; +}; +export type ResourceAccessExplanationResponse = { + user: AccessExplanationUser; + resource_type: string; + resource_id: string; + action: string; + provenance: AccessDecisionProvenanceItem[]; +}; export type CampaignUpdatePayload = { external_id?: string | null; @@ -834,6 +855,20 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId: return apiFetch(settings, `/api/v1/campaigns/${campaignId}/share-targets`); } +export function fetchResourceAccessExplanation( +settings: ApiSettings, +options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;}) +: Promise { + const params = new URLSearchParams({ + user_id: options.userId, + resource_type: options.resourceType, + resource_id: options.resourceId, + action: options.action + }); + if (options.tenantId) params.set("tenant_id", options.tenantId); + return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`); +} + export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise { const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`); return response.shares; diff --git a/webui/src/features/campaigns/GlobalSettingsPage.tsx b/webui/src/features/campaigns/GlobalSettingsPage.tsx index 2e49bc2..ab26555 100644 --- a/webui/src/features/campaigns/GlobalSettingsPage.tsx +++ b/webui/src/features/campaigns/GlobalSettingsPage.tsx @@ -217,7 +217,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view = : <> - {data.campaign && } + {data.campaign && }
diff --git a/webui/src/features/campaigns/components/CampaignAccessCard.tsx b/webui/src/features/campaigns/components/CampaignAccessCard.tsx index c915f22..5c922d8 100644 --- a/webui/src/features/campaigns/components/CampaignAccessCard.tsx +++ b/webui/src/features/campaigns/components/CampaignAccessCard.tsx @@ -1,13 +1,16 @@ import { useEffect, useMemo, useState } from "react"; -import type { ApiSettings, CampaignListItem } from "../../../types"; +import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types"; +import { KeyRound } from "lucide-react"; import { + fetchResourceAccessExplanation, getCampaignShares, getCampaignShareTargets, revokeCampaignShare, updateCampaignOwner, upsertCampaignShare, type CampaignShare, - type CampaignShareTargets } from + type CampaignShareTargets, + type ResourceAccessExplanationResponse } from "../../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; @@ -15,11 +18,12 @@ import { ConfirmDialog } from "@govoplan/core-webui"; import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid"; import { Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; -import { StatusBadge, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; type TargetType = "user" | "group"; export default function CampaignAccessCard({ settings, + auth, campaign, onChanged, onError @@ -28,7 +32,7 @@ export default function CampaignAccessCard({ -}: {settings: ApiSettings;campaign: CampaignListItem;onChanged: () => Promise;onError: (message: string) => void;}) { +}: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise;onError: (message: string) => void;}) { const [available, setAvailable] = useState(null); const [targets, setTargets] = useState({ users: [], groups: [] }); const [shares, setShares] = useState([]); @@ -40,11 +44,19 @@ export default function CampaignAccessCard({ const [shareType, setShareType] = useState("user"); const [shareTargetId, setShareTargetId] = useState(""); const [sharePermission, setSharePermission] = useState<"read" | "write">("read"); + const [accessExplanationOpen, setAccessExplanationOpen] = useState(false); + const [accessExplanation, setAccessExplanation] = useState(null); + const [accessExplanationLoading, setAccessExplanationLoading] = useState(false); const [busy, setBusy] = useState(false); const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user"; const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || ""; const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId); const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read"); + const canExplainResourceAccess = + hasScope(auth, "admin:users:read") || + hasScope(auth, "admin:roles:read") || + hasScope(auth, "access:membership:read") || + hasScope(auth, "access:role:read"); useUnsavedDraftGuard({ dirty: ownerDirty || shareDirty, @@ -146,6 +158,27 @@ export default function CampaignAccessCard({ {setBusy(false);} } + async function openAccessExplanation() { + if (!auth.user?.id) return; + setAccessExplanationOpen(true); + setAccessExplanation(null); + setAccessExplanationLoading(true); + try { + setAccessExplanation(await fetchResourceAccessExplanation(settings, { + userId: auth.user.id, + resourceType: "campaign", + resourceId: campaign.id, + action: "campaigns:campaign:read", + tenantId: (auth.active_tenant ?? auth.tenant)?.id + })); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + setAccessExplanationOpen(false); + } finally { + setAccessExplanationLoading(false); + } + } + const columns = useMemo[]>(() => [ { id: "target", @@ -170,7 +203,7 @@ export default function CampaignAccessCard({ return ( <> -
}> + }>

i18n:govoplan-campaign.owner.719379ae {ownerLabel}

i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2

row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} /> @@ -188,7 +221,76 @@ export default function CampaignAccessCard({ + { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={}> + + + setRevokeTarget(null)} onConfirm={() => void revoke()} /> ); } + +function ResourceAccessExplanationContent({ + loading, + explanation, + fallbackResourceLabel +}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) { + if (loading) return

i18n:govoplan-campaign.loading_access_explanation.04a7c934

; + if (!explanation) return null; + const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id; + const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id; + return ( + <> +
+
i18n:govoplan-campaign.user.9f8a2389

{userLabel}

+
i18n:govoplan-campaign.resource.d1c626a9

{resourceLabel}

+
i18n:govoplan-campaign.action.97c89a4d

{explanation.action}

+
i18n:govoplan-campaign.evidence.8487d192

{explanation.provenance.length}

+
+ {explanation.provenance.length === 0 ? +

i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e

: +
+ {explanation.provenance.map((item, index) => +
+ {provenanceKindLabel(item.kind)} +
+ {item.source || "i18n:govoplan-campaign.no_source.6dcf9723"} + {item.id && <> · {item.id}} +
+ {item.label &&

{item.label}

} + {Object.keys(item.details ?? {}).length > 0 &&

{formatProvenanceDetails(item.details ?? {})}

} +
+ )} +
+ } + ); +} + +function provenanceKindLabel(kind: string): string { + switch (kind) { + case "resource": + return "i18n:govoplan-campaign.resource.d1c626a9"; + case "owner": + return "i18n:govoplan-campaign.owner.89ff3122"; + case "share": + return "i18n:govoplan-campaign.share.09ca55ca"; + case "policy": + return "i18n:govoplan-campaign.policy.0b779a05"; + case "role": + return "i18n:govoplan-campaign.role.b5b4a5a2"; + case "right": + return "i18n:govoplan-campaign.permission.2f81a22d"; + default: + return kind; + } +} + +function formatProvenanceDetails(details: Record): string { + return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; "); +} + +function formatProvenanceValue(value: unknown): string { + if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 96346f2..7d17a3b 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -14,6 +14,19 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.accept_non_blocking_review_conditions.47895387": "Accept non-blocking review conditions?", "i18n:govoplan-campaign.accepted.61a0572c": "Accepted", "i18n:govoplan-campaign.access.2f81a22d": "Access", + "i18n:govoplan-campaign.access_explanation.75ee7f62": "Access explanation", + "i18n:govoplan-campaign.action.97c89a4d": "Action", + "i18n:govoplan-campaign.evidence.8487d192": "Evidence", + "i18n:govoplan-campaign.explain_access.4d5fac37": "Explain access", + "i18n:govoplan-campaign.loading_access_explanation.04a7c934": "Loading access explanation...", + "i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.", + "i18n:govoplan-campaign.no_source.6dcf9723": "No source", + "i18n:govoplan-campaign.none.6eef6648": "None", + "i18n:govoplan-campaign.permission.2f81a22d": "Permission", + "i18n:govoplan-campaign.policy.0b779a05": "Policy", + "i18n:govoplan-campaign.resource.d1c626a9": "Resource", + "i18n:govoplan-campaign.role.b5b4a5a2": "Role", + "i18n:govoplan-campaign.share.09ca55ca": "Share", "i18n:govoplan-campaign.actions.c3cd636a": "Actions", "i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients", "i18n:govoplan-campaign.active.a733b809": "Active", @@ -1128,6 +1141,19 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.accept_non_blocking_review_conditions.47895387": "Accept non-blocking review conditions?", "i18n:govoplan-campaign.accepted.61a0572c": "Accepted", "i18n:govoplan-campaign.access.2f81a22d": "Zugriff", + "i18n:govoplan-campaign.access_explanation.75ee7f62": "Zugriffserklärung", + "i18n:govoplan-campaign.action.97c89a4d": "Aktion", + "i18n:govoplan-campaign.evidence.8487d192": "Nachweise", + "i18n:govoplan-campaign.explain_access.4d5fac37": "Zugriff erklären", + "i18n:govoplan-campaign.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...", + "i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.", + "i18n:govoplan-campaign.no_source.6dcf9723": "Keine Quelle", + "i18n:govoplan-campaign.none.6eef6648": "Keine", + "i18n:govoplan-campaign.permission.2f81a22d": "Berechtigung", + "i18n:govoplan-campaign.policy.0b779a05": "Richtlinie", + "i18n:govoplan-campaign.resource.d1c626a9": "Ressource", + "i18n:govoplan-campaign.role.b5b4a5a2": "Rolle", + "i18n:govoplan-campaign.share.09ca55ca": "Freigabe", "i18n:govoplan-campaign.actions.c3cd636a": "Aktionen", "i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients", "i18n:govoplan-campaign.active.a733b809": "Aktiv",