Skip to content

feat(AF-626): result-export governance & DLP — policies, watermarked exports, RESULT_EXPORTED audit - #726

Merged
babltiga merged 2 commits into
mainfrom
feature/AF-626-result-export-governance
Aug 19, 2026
Merged

feat(AF-626): result-export governance & DLP — policies, watermarked exports, RESULT_EXPORTED audit#726
babltiga merged 2 commits into
mainfrom
feature/AF-626-result-export-governance

Conversation

@babltiga

Copy link
Copy Markdown
Contributor

Closes #626

What

Result-export governance & DLP: masking and row-level security govern what a user sees; this adds per-datasource policies governing what leaves.

  • export_policy (V145): per-datasource rows with a mode (ALLOW < WATERMARK < ROW_CAP < DENY_CLASSIFIED, most-restrictive-wins), optional row_cap / deny_classifications, and the row-security applies_to_* polarity (all empty ⇒ every exporter, no admin bypass). CRUD at /api/v1/datasources/{id}/export-policies behind the new EXPORT_POLICY_MANAGE permission (V146 seeds ADMIN).
  • Signed result export: GET /api/v1/queries/{id}/results/export?format=CSV|PDF renders the persisted result snapshot governed by the caller's effective decision — deny ⇒ 403 RESULT_EXPORT_DENIED; watermark/cap ⇒ the ResultExportWatermark stamp (exporter email, UTC timestamp, query id) baked into the bytes before RS256 signing, so stripping it invalidates the signature. Classified detection runs at export time: persisted result columns + snapshot referenced_tables matched against the AF-447 classification tags (table-level tags classify all returned columns; column-level match by name).
  • RESULT_EXPORTED audit rows: fail-hard on the download path (no audit row ⇒ no bytes), best-effort (trigger=email_attachment, actor = recipient) on the email path.
  • Email attachment governance: the recurring-run results CSV (workflow: recurring approved queries with result subscriptions (approve query + cadence + expiry once, deliver via notification channels) #627) is now decided per recipient — deny suppresses the attachment (mail still delivers), caps truncate, watermark stamps; each sent attachment is audited.
  • SENSITIVE_RESULT_EXPORTED notification: fires only when an export actually moved data AND classified columns were present; advisory fan-out to org admins on all active channels (GRANT_STALE shape — never pages, never opens a ticket). Full 16-site fan-out incl. Thymeleaf template, 4 chat factories, email subject/template, in-app bell, 14 locale files.
  • Frontend: policy-driven Export button on the results table (server decision endpoint; disabled with the denial reason as tooltip + aria-label when denied; CSV/PDF dropdown otherwise), and an Export policy tab on datasource settings with a live watermark preview (utils/watermarkPreview.ts mirrors the backend template byte-for-byte).

Deliberately out of scope: MCP get_query_result stays view-parity (same data the in-app table shows).

Design notes

  • The export endpoints live in the compliance module (pathed under /queries/...): compliance already depends on workflow.api (snapshots) + core.api and owns the signing pipeline; the reverse edge from workflow would cycle. New module edge notifications → compliance.api/events is acyclic (ApplicationModulesTest green).
  • SensitiveResultExportedEvent is published outside any transaction ⇒ consumed with a plain @EventListener (the QuerySnapshotListener trap).
  • deny_classifications stores enum names as TEXT[] (the applies_to_roles precedent) rather than a PG enum array — Hibernate's enum-array binding is unreliable; the service validates values.

Docs & website (same commit set)

docs/03-data-model.md (export_policy, audit actions), docs/04-api-spec.md (CRUD + export + decision endpoints), docs/05-backend.md (new section), docs/06-frontend.md, docs/07-security.md, docs/08-notifications.md, docs/09-deployment.md (ACCESSFLOW_COMPLIANCE_RESULT_EXPORT_MAX[_PDF]_ROWS), docs/12-roadmap.md (v2.2 entry), README.md, website/index.html + website/docs/index.html + sitemap.xml + website/README.md.

Testing

  • Backend: unit + IT coverage for every new concrete class (policy admin/resolution, governance combine/classification matching, export service incl. fail-hard audit + sign-input identity, CSV/PDF writers, watermark, both controllers, notification fan-out incl. the never-pages/never-tickets guards and all-locale template rendering); ApplicationModulesTest, ApiPackageDependencyTest, MessagesParityTest green; full mvn verify -Pcoverage green.
  • Frontend: lint (0 errors), typecheck, build, 196 test files / 94.3% lines / 84.4% branches — incl. new tests for the export button states, ExportPolicyTab-adjacent utils/api modules, bell case, permission catalog.
  • E2E (ran locally against the built stack): new export-policies.spec.ts (deny 403 + decision, signed & watermarked download, disabled button with reason, admin tab UI) plus the drift-risk specs (query-execute, datasource-settings, masking-policies, query-list, admin-audit-log, custom-roles, auth-guard-roles) — 35/35 green. Local note: host port 8080 is occupied on this machine, so the stack ran via a local-only compose override (backend on 18080 + bind-mounted runtime-config.js); no repo files affected.

Review notes

Four independent review agents ran pre-PR (af-verifier, af-reviewer, af-java-reviewer, af-frontend-reviewer). Everything each of them flagged was either fixed in this branch or is recorded below.

Fixed in response to review:

  • (af-java-reviewer, Blocker) The email-attachment governance could fail open: NotificationListener.onQueryExecuted and QuerySnapshotListener.onQueryExecuted are unordered peer listeners on the same event, and decide() returned allow-all when the snapshot was missing. Fixed twice over: QuerySnapshotListener now carries @Order(HIGHEST_PRECEDENCE) so the snapshot exists before any peer consumes the event, and decide() now fails closed (deny ⇒ attachment suppressed, mail still delivers) when no snapshot is resolvable — with tests for both.
  • (af-java-reviewer, Concern) Watermark footer cap provenance: the footer now names the cap that actually truncated the file (policy cap or format cap, whichever bound) and only when it truncated — in both the export service and the attachment renderer, with new tests for the format-cap-binding and uncapped cases.
  • (af-java-reviewer, nits) @Access(AccessType.FIELD) on ExportPolicyEntity; WARN log on unparseable stored rows before the 404; debug log + Collectors import in the context builder; 400 documented on the CRUD @ApiResponses.
  • (af-frontend-reviewer, Concerns 1+3) The e2e UI test now authors a ROW_CAP policy through the modal (mode switch, conditional row-cap field, preview cap provenance, saved-row assertion) instead of duplicating the seeded WATERMARK policy; component tests added for the PDF menu item, the truncated-export warning toast, and the export-failure toast.
  • (af-reviewer, Concern 2 + nit) The website docs/configuration/datasources/ chapter gained an "Export policies" paragraph (with dateModified/sitemap bumps) so the hub card's promise is backed by content; CLAUDE.md's module map now mentions compliance: result-export governance & DLP — per-role export policies, watermarked exports, RESULT_EXPORTED audit rows #626 under compliance/.

Surviving concerns (acknowledged, not changed):

  • (af-frontend-reviewer, nit) The "Applies to users" select loads the first 100 users — the same ceiling as the sibling RowSecurityTab; fixing it is a cross-tab change out of scope here.
  • (af-frontend-reviewer, nit) Audit-action filter options render raw enum identifiers — the page's established convention; the four new values follow it.
  • (af-reviewer, open question) A multi-recipient recurring email produces one SENSITIVE_RESULT_EXPORTED notification per recipient attachment. Intentional: each attachment is a distinct egress with its own audit row; docs/08 treats each egress as a distinct event.
  • (af-java-reviewer, note) PagerDuty/Ticketing deliberately have no trigger for the new event (advisory oversight, not an incident) — asserted by SensitiveResultExportNotificationTest.neverPages/neverOpensATicket and documented in docs/08.

Per-datasource export policies (ALLOW/WATERMARK/ROW_CAP/DENY_CLASSIFIED,
most-restrictive-wins, no admin bypass) gate a new signed CSV/PDF result
export endpoint and the recurring-email results attachment. Classified
detection reuses AF-447 tags at export time; watermarks (exporter, UTC
timestamp, query id) are baked into the signed bytes; every export
writes a RESULT_EXPORTED audit row and classified exports raise the new
SENSITIVE_RESULT_EXPORTED notification to org admins.

Closes #626
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Frontend Test Results

1 530 tests  +23   1 530 ✅ +23   5m 16s ⏱️ + 1m 11s
  196 suites + 3       0 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit 2fefc95. ± Comparison against base commit ec3a0be.

This pull request removes 1 and adds 24 tests. Note that renamed tests count towards both.
src/utils/permissions.test.ts ‑ permissions > exposes the full 39-entry catalog mirror
src/api/exportPolicies.test.ts ‑ api/exportPolicies > builds hierarchical query keys
src/api/exportPolicies.test.ts ‑ api/exportPolicies > createExportPolicy POSTs the snake_case body
src/api/exportPolicies.test.ts ‑ api/exportPolicies > deleteExportPolicy DELETEs the policy path
src/api/exportPolicies.test.ts ‑ api/exportPolicies > listExportPolicies GETs the datasource-scoped path and unwraps content
src/api/exportPolicies.test.ts ‑ api/exportPolicies > updateExportPolicy PUTs to the policy path
src/api/resultExport.test.ts ‑ api/resultExport > builds hierarchical query keys
src/api/resultExport.test.ts ‑ api/resultExport > downloadResultExport requests a blob with the format param and parses headers
src/api/resultExport.test.ts ‑ api/resultExport > falls back to a synthetic filename and null headers when absent
src/api/resultExport.test.ts ‑ api/resultExport > fetchExportDecision GETs the decision endpoint
src/components/common/__tests__/NotificationBell.test.tsx ‑ NotificationBell > renders SENSITIVE_RESULT_EXPORTED with classifications and navigates to the query
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Frontend Coverage (frontend)

Status Category Percentage Covered / Total
🟢 Lines 94.26% (🎯 90%) 2267 / 2405
🟢 Statements 92.4% (🎯 90%) 2518 / 2725
🟢 Functions 91.56% (🎯 90%) 695 / 759
🟢 Branches 84.42% (🎯 80%) 1404 / 1663
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
frontend/src/api/exportPolicies.ts 100% 100% 100% 100%
frontend/src/api/resultExport.ts 100% 87.5% 100% 100%
frontend/src/utils/enumLabels.ts 96.96% 100% 91.66% 96.95% 209, 217, 338, 484, 542
frontend/src/utils/permissions.ts 100% 100% 100% 100%
frontend/src/utils/watermarkPreview.ts 100% 100% 100% 100%
Generated in workflow #959 for commit 2fefc95 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Backend Test Results

6 481 tests  +140   6 481 ✅ +140   20m 24s ⏱️ +24s
  775 suites + 12       0 💤 ±  0 
  775 files   + 12       0 ❌ ±  0 

Results for commit 2fefc95. ± Comparison against base commit ec3a0be.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Backend Code Coverage

Overall Project 93.23% -0.11% 🍏
Files changed 95.02% 🍏

File Coverage
DefaultResultExportGovernanceService.java 100% 🍏
ResultExportCsvWriter.java 100% 🍏
QuerySnapshotListener.java 100% 🍏
DefaultExportPolicyResolutionService.java 100% 🍏
NotificationEventType.java 100% 🍏
ResultExportNotFoundException.java 100% 🍏
ResultExportWatermark.java 100% 🍏
ResultExportUnavailableException.java 100% 🍏
ResultExportException.java 100% 🍏
NotificationContext.java 100% 🍏
ComplianceProperties.java 100% 🍏
ExportPolicyEntity.java 100% 🍏
CreateExportPolicyRequest.java 100% 🍏
ExportPolicyListResponse.java 100% 🍏
UpdateExportPolicyRequest.java 100% 🍏
ExportPolicyResponse.java 100% 🍏
ExportPolicyMode.java 100% 🍏
IllegalExportPolicyException.java 100% 🍏
ExportPolicyNotFoundException.java 100% 🍏
CreateExportPolicyCommand.java 100% 🍏
ExportPolicyView.java 100% 🍏
Permission.java 100% 🍏
ExportPolicyException.java 100% 🍏
UpdateExportPolicyCommand.java 100% 🍏
ResultExportDecisionResponse.java 100% 🍏
AuditAction.java 100% 🍏
AuditResourceType.java 100% 🍏
DefaultExportPolicyAdminService.java 99.65% -0.35% 🍏
QueryResultCsvRenderer.java 99.24% 🍏
DefaultResultExportService.java 97.98% -2.02% 🍏
ResultExportController.java 97.79% -2.21% 🍏
SensitiveResultExportedEvent.java 95% -5% 🍏
ExportPolicyController.java 90.65% -9.35% 🍏
ExportDecision.java 88.57% -11.43% 🍏
NotificationContextBuilder.java 87.36% -0.07% 🍏
ResultExportDeniedException.java 86.67% -13.33% 🍏
GlobalExceptionHandler.java 86.3% 🍏
ResultExportPdfWriter.java 85.49% -14.51% 🍏
MsTeamsPayloadFactory.java 84.38% -0.31% 🍏
SlackBlockKitFactory.java 83.63% -0.39% 🍏
TelegramMessageFactory.java 83.27% -0.41% 🍏
DiscordPayloadFactory.java 82.55% -0.43% 🍏
EmailNotificationStrategy.java 78.44% -1.18% 🍏
NotificationDispatcher.java 77.97% -5.86% 🍏
NotificationListener.java 70.82% 🍏

The tenth datasource-settings tab overflows a 1280px viewport, putting
AntD Tabs into transform-scrolled mode where a physical Playwright click
on a scrolled-out tab is silently swallowed (classification-tags spec:
clicking Masking while Classification is active). Shorten the export tab
label in all seven locales and switch the affected specs to a shared
dispatch-based clickTab helper. Full main e2e suite green on a fresh
stack: 283 passed.
@babltiga
babltiga merged commit 30ff1ef into main Aug 19, 2026
53 of 55 checks passed
@babltiga
babltiga deleted the feature/AF-626-result-export-governance branch August 19, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compliance: result-export governance & DLP — per-role export policies, watermarked exports, RESULT_EXPORTED audit rows

1 participant