From 106e956617dbfaa77d5356a94fbfdfab93f8dc96 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 24 Sep 2026 18:45:14 +0400 Subject: [PATCH] feat(AF-939): table and schema deny-lists alongside allow-lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add denied_schemas / denied_tables to user and group datasource grants (V190). A denial always beats the allow-list, so an admin can allow a whole schema and carve out a few tables; new tables in the schema are allowed automatically unless denied. Denials merge by union across a user's grants — the inverse of the allow-list merge — so a permissive group grant can never lift one, and a JIT approval carries the replaced row's denials over. One fail-closed matcher (core.api.DeniedTables) backs submission, the recurring recheck, break-glass, dry-run, the access simulator, effective access, query suggestions, table preview, schema introspection and request-group members. Unqualified references are refused under any schema denial; db..table, @dblink suffixes and index patterns cannot slip past. --- README.md | 2 +- .../internal/AccessGrantMaterializer.java | 15 +- .../DefaultAttestationLifecycleService.java | 2 + ...reateDatasourceGroupPermissionCommand.java | 2 + .../core/api/CreatePermissionCommand.java | 2 + .../api/DatasourceGroupPermissionView.java | 2 + .../api/DatasourcePermissionContribution.java | 4 + .../core/api/DatasourcePermissionView.java | 2 + ...DatasourceUserPermissionLookupService.java | 10 +- .../api/DatasourceUserPermissionView.java | 2 + .../accessflow/core/api/DeniedTables.java | 170 ++++++++++++++ .../internal/DatasourceAdminServiceImpl.java | 30 +++ ...DatasourceUserPermissionLookupService.java | 25 ++- .../internal/SchemaViewPermissionFilter.java | 25 ++- .../DatasourceGroupPermissionEntity.java | 8 + .../DatasourceUserPermissionEntity.java | 8 + .../internal/DefaultQueryDryRunService.java | 9 + .../internal/DefaultSampleDataService.java | 6 + .../internal/DefaultRequestGroupService.java | 29 ++- .../internal/web/DatasourceController.java | 16 +- .../model/CreateGroupPermissionRequest.java | 9 + .../web/model/CreatePermissionRequest.java | 9 + .../web/model/GroupPermissionResponse.java | 4 + .../web/model/PermissionResponse.java | 4 + .../internal/DatasourcePermissionChecker.java | 27 ++- .../DatasourcePermissionVerifier.java | 14 +- .../DefaultAccessSimulationService.java | 10 + .../internal/DefaultBreakGlassService.java | 4 + .../DefaultEffectiveAccessService.java | 7 +- .../DefaultQuerySuggestionService.java | 2 +- ...V190__add_denied_tables_to_permissions.sql | 7 + .../main/resources/i18n/messages.properties | 8 + .../resources/i18n/messages_de.properties | 8 + .../resources/i18n/messages_es.properties | 8 + .../resources/i18n/messages_fr.properties | 8 + .../resources/i18n/messages_hy.properties | 8 + .../resources/i18n/messages_ru.properties | 8 + .../resources/i18n/messages_zh_CN.properties | 8 + .../internal/AccessGrantMaterializerTest.java | 46 +++- ...faultGrantUsageAggregationServiceTest.java | 2 +- .../DefaultPrivilegedAccessServiceTest.java | 4 +- .../DefaultAiAnalyzerServiceTest.java | 2 +- .../internal/DefaultTextToSqlServiceTest.java | 2 +- .../AttestationLifecycleIntegrationTest.java | 2 +- ...efaultAttestationLifecycleServiceTest.java | 49 +++- .../accessflow/core/api/DeniedTablesTest.java | 161 +++++++++++++ .../DatasourceAdminServiceImplTest.java | 125 ++++++++++- ...sourceUserPermissionLookupServiceTest.java | 116 ++++++++++ .../SchemaViewPermissionFilterTest.java | 62 ++++- .../DefaultQueryDryRunServiceTest.java | 41 +++- .../DefaultSampleDataServiceTest.java | 63 +++++- .../DefaultRequestGroupServiceCrudTest.java | 61 ++++- .../DefaultRequestGroupServiceTest.java | 2 +- .../internal/GroupExecutionServiceTest.java | 4 +- ...faultSchemaChangePromotionServiceTest.java | 2 +- .../DatasourceControllerIntegrationTest.java | 169 ++++++++++++++ .../DatasourcePermissionCheckerTest.java | 72 +++++- .../DatasourcePermissionVerifierTest.java | 74 +++++- .../DefaultAccessSimulationServiceTest.java | 54 ++++- ...faultBreakGlassEligibilityServiceTest.java | 2 +- .../DefaultBreakGlassServiceTest.java | 42 +++- .../DefaultEffectiveAccessServiceTest.java | 54 ++++- .../DefaultQueryLifecycleServiceTest.java | 4 +- .../DefaultQuerySubmissionServiceTest.java | 2 +- .../DefaultQuerySuggestionServiceTest.java | 19 +- ...eniedTablesEnforcementIntegrationTest.java | 212 ++++++++++++++++++ .../EffectiveAccessEnforcementParityTest.java | 4 +- docs/03-data-model.md | 14 +- docs/04-api-spec.md | 41 +++- docs/05-backend.md | 88 +++++++- docs/06-frontend.md | 4 +- docs/07-security.md | 89 ++++++-- e2e/helpers/datasources.ts | 7 + e2e/tests/datasource-denied-tables.spec.ts | 124 ++++++++++ .../policies/decisionTraceDetails.ts | 1 + frontend/src/locales/de.json | 18 +- frontend/src/locales/en.json | 18 +- frontend/src/locales/es.json | 18 +- frontend/src/locales/fr.json | 18 +- frontend/src/locales/hy.json | 18 +- frontend/src/locales/ru.json | 18 +- frontend/src/locales/zh-CN.json | 18 +- frontend/src/mocks/data.ts | 4 + .../datasources/DatasourceSettingsPage.tsx | 125 +++++++++++ .../__tests__/DatasourceSettingsPage.test.tsx | 187 +++++++++++++++ frontend/src/types/api.ts | 8 + .../src/utils/__tests__/deniedTables.test.ts | 44 ++++ frontend/src/utils/deniedTables.ts | 28 +++ help-corpus/corpus.jsonl | 27 +-- help-corpus/manifest.json | 22 +- .../docs/configuration/datasources/index.html | 22 +- .../docs/configuration/users-roles/index.html | 12 +- website/docs/guides/datasource/index.html | 23 +- website/docs/guides/team/index.html | 10 +- website/sitemap.xml | 2 +- 95 files changed, 2811 insertions(+), 171 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedTables.java create mode 100644 backend/src/main/resources/db/migration/V190__add_denied_tables_to_permissions.sql create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedTablesTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedTablesEnforcementIntegrationTest.java create mode 100644 e2e/tests/datasource-denied-tables.spec.ts create mode 100644 frontend/src/utils/__tests__/deniedTables.test.ts create mode 100644 frontend/src/utils/deniedTables.ts diff --git a/README.md b/README.md index f4af77b37..e3f6082d7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ A glance at the day-to-day flows engineers and approvers actually use. - **Just-in-time (JIT) access requests** — users self-request temporary, scoped access to a datasource (read/write/DDL, optional schema/table scope) or an API connection (read/write, optional operation allow-list) for an ISO-8601 duration. Requests flow through the same approval engine, a time-boxed permission is granted on approval, and a clustered scheduler auto-revokes it on expiry (admins can also revoke early). A grant can opt into **query pre-approval**: while it is active, queries it covers skip human review and are auto-approved with the grant recorded as the approval provenance — routing policies, high-risk AI verdicts, and behavioural anomalies still override. - **Break-glass / emergency access** — a gated emergency path for when production is on fire and approvers are asleep. A per-user/per-datasource `can_break_glass` permission (required for everyone, including admins; time-boxed) lets a query **execute immediately, bypassing review** — still through every proxy guard (allow-list, masking, row-level security, row caps). Compensating controls: a mandatory justification, instant fanout to all org admins (incl. PagerDuty), a prominently-tagged audit row, and a **mandatory retro-review** an admin (never the submitter) must acknowledge on the `/admin/break-glass` log. - **Dynamic data masking** — per-column masking policies (full, partial last-N, stable hash, email-preserving, format-preserving) with role / group / user **reveal** conditions evaluated per requester. Masking is applied at result-read time before results are serialized or stored, so unmasked values never persist; applied policy ids are recorded in the audit log. Extends the static `restricted_columns` masking; for a column that must never be read at all, a grant's `denied_columns` rejects any query that references it — including through `SELECT *` — before it runs (relational engines). -- **Row-level security** — per-table row predicates the proxy injects into the parsed SQL so a scoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for. Admins author a structured `column operator value` predicate where the value is a fixed literal or a `:user.*` variable (built-in id / email / role / groups, or an admin-set per-user attribute). Values are bound as JDBC parameters — never concatenated; predicates that can't be safely applied are rejected, never run unfiltered. Composes with column masking and the schema/table allow-list. +- **Row-level security** — per-table row predicates the proxy injects into the parsed SQL so a scoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for. Admins author a structured `column operator value` predicate where the value is a fixed literal or a `:user.*` variable (built-in id / email / role / groups, or an admin-set per-user attribute). Values are bound as JDBC parameters — never concatenated; predicates that can't be safely applied are rejected, never run unfiltered. Composes with column masking and the schema/table allow-list — and with table/schema **deny-lists** that always beat it ("all of `crm` except `crm.salary`", tables created later included). - **Per-table row limits** — cap how many rows a SELECT may return from a specific table, for everyone or for chosen roles, groups or users, so two tables on one datasource (or two teams on one table) get different limits. A limit only ever lowers the cap set by the datasource and the access grant; a query across several limited tables takes the lowest one, and an unqualified table name still matches a schema-qualified policy. The policies that applied are recorded on the execution's audit entry. - **Data classification tagging** — tag tables and columns as PII, PCI, PHI, GDPR, FINANCIAL, or SENSITIVE right in the schema explorer. Tagging a column auto-applies a masking policy, the AI analyzer raises a query's risk score when it touches a tagged object, and a derivation preview suggests a stricter review posture. Tags are audited and queryable org-wide as the evidence base for compliance reporting. - **Automated sensitive-data discovery** — an opt-in per-datasource scanner samples column data through the same governed sampling path, detects sensitive values with local regex + checksum detectors (emails, credit-card PANs with Luhn, SSNs, IBANs, phone numbers) and optionally your bound AI analyzer (which only ever sees column names, types, and redacted samples), and **proposes** classification tags in a review worklist. Confirming a finding applies the tag — deriving masking automatically — while dismissing suppresses it permanently; scans and decisions are audited, and an on-demand "Scan now" complements the scheduled cadence. Proposals the scanner keeps sampling but no longer finds — the column was dropped, the data cleaned up, or masking added by hand — are marked **stale** after a few consecutive misses and leave the active worklist for a filtered bulk dismissal, reversibly: re-detection returns them to pending, and a run cut short by its table cap or time budget never ages proposals it did not look at. diff --git a/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java b/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java index 447492053..db7adad21 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java +++ b/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java @@ -18,6 +18,7 @@ import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; /** @@ -52,7 +53,9 @@ void materialize(UUID accessRequestId, UUID approvedByUserId) { materializeConnectorGrant(entity, approvedByUserId, expiresAt); return; } - replaceExistingTimeBoxedPermission(entity); + // A replaced row's denials carry over (#939): a JIT approval widens capabilities and expiry, + // never lifts a denial an admin set — JIT requests cannot even ask for deny-lists. + var replaced = replaceExistingTimeBoxedPermission(entity); var command = new CreatePermissionCommand( entity.getRequesterId(), entity.isCanRead(), @@ -63,7 +66,9 @@ void materialize(UUID accessRequestId, UUID approvedByUserId) { toList(entity.getAllowedSchemas()), toList(entity.getAllowedTables()), null, - null, + replaced.map(DatasourceUserPermissionView::deniedColumns).orElse(null), + replaced.map(DatasourceUserPermissionView::deniedSchemas).orElse(null), + replaced.map(DatasourceUserPermissionView::deniedTables).orElse(null), expiresAt, entity.getId()); var granted = datasourceAdminService.grantPermission(entity.getDatasourceId(), @@ -108,13 +113,14 @@ private void requireNoStandingConnectorPermission(AccessGrantRequestEntity entit }); } - private void replaceExistingTimeBoxedPermission(AccessGrantRequestEntity entity) { + private Optional replaceExistingTimeBoxedPermission( + AccessGrantRequestEntity entity) { // JIT access manages the per-user datasource_user_permissions row specifically, so it must // look at the direct grant only — never a group grant (whose id it could not revoke here). var existing = permissionLookupService.findDirectFor(entity.getRequesterId(), entity.getDatasourceId()); if (existing.isEmpty()) { - return; + return Optional.empty(); } DatasourceUserPermissionView permission = existing.get(); if (permission.expiresAt() == null) { @@ -126,6 +132,7 @@ private void replaceExistingTimeBoxedPermission(AccessGrantRequestEntity entity) permission.id(), entity.getRequesterId(), entity.getDatasourceId(), entity.getId()); datasourceAdminService.revokePermission(entity.getDatasourceId(), entity.getOrganizationId(), permission.id()); + return existing; } private static List toList(String[] values) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleService.java b/backend/src/main/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleService.java index 9b3e3ece5..e2777a9bc 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleService.java @@ -215,6 +215,8 @@ private String toSnapshotJson(DatasourcePermissionView view) { putStringArray(node, "allowed_tables", view.allowedTables()); putStringArray(node, "restricted_columns", view.restrictedColumns()); putStringArray(node, "denied_columns", view.deniedColumns()); + putStringArray(node, "denied_schemas", view.deniedSchemas()); + putStringArray(node, "denied_tables", view.deniedTables()); node.put("expires_at", view.expiresAt() != null ? view.expiresAt().toString() : null); node.put("created_by", view.createdBy() != null ? view.createdBy().toString() : null); node.put("created_at", view.createdAt() != null ? view.createdAt().toString() : null); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateDatasourceGroupPermissionCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateDatasourceGroupPermissionCommand.java index 215aecebe..4afcfa0ae 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateDatasourceGroupPermissionCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateDatasourceGroupPermissionCommand.java @@ -15,5 +15,7 @@ public record CreateDatasourceGroupPermissionCommand( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Instant expiresAt) { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreatePermissionCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreatePermissionCommand.java index 34d817c2f..b553bf043 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreatePermissionCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreatePermissionCommand.java @@ -19,6 +19,8 @@ public record CreatePermissionCommand( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Instant expiresAt, UUID accessGrantRequestId ) {} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceGroupPermissionView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceGroupPermissionView.java index 64942bf84..f2bf3a9b0 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceGroupPermissionView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceGroupPermissionView.java @@ -19,6 +19,8 @@ public record DatasourceGroupPermissionView( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Instant expiresAt, UUID createdBy, Instant createdAt) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionContribution.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionContribution.java index bf4b7dd9c..1ec9965c2 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionContribution.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionContribution.java @@ -37,6 +37,8 @@ public record DatasourcePermissionContribution( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Integer rowLimitOverride, Instant expiresAt, UUID accessGrantRequestId) { @@ -46,5 +48,7 @@ public record DatasourcePermissionContribution( allowedTables = allowedTables == null ? List.of() : List.copyOf(allowedTables); restrictedColumns = restrictedColumns == null ? List.of() : List.copyOf(restrictedColumns); deniedColumns = deniedColumns == null ? List.of() : List.copyOf(deniedColumns); + deniedSchemas = deniedSchemas == null ? List.of() : List.copyOf(deniedSchemas); + deniedTables = deniedTables == null ? List.of() : List.copyOf(deniedTables); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionView.java index 18e9cd129..f91ec8dcc 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourcePermissionView.java @@ -19,6 +19,8 @@ public record DatasourcePermissionView( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Instant expiresAt, UUID createdBy, Instant createdAt diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionLookupService.java index fb4ecdaec..3373e0ae5 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionLookupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionLookupService.java @@ -11,10 +11,12 @@ public interface DatasourceUserPermissionLookupService { * direct grant (if any) and every unexpired group grant for a group they belong to (AF-530). * Boolean flags are OR-ed; allow-lists (allowed schemas/tables) merge to their union (any * contributor with no restriction ⇒ all allowed); the restricted-columns mask merges to the - * intersection (a column is masked only when every contributor masks it). The row-limit - * override is the one deliberate inversion: it merges to the smallest non-null value - * (most restrictive), so a wide group grant can never raise a tight per-user cap; it is - * {@code null} only when no contributor sets one (#933). Expired grants contribute nothing; + * intersection (a column is masked only when every contributor masks it), and so do denied + * columns (#935). Two fields deliberately merge the other way: the row-limit override merges + * to the smallest non-null value (most restrictive), so a wide group grant can never + * raise a tight per-user cap, and is {@code null} only when no contributor sets one (#933); + * denied schemas and tables merge to their union, so no contributor can lift another's + * denial (#939). Expired grants contribute nothing; * returns empty when no unexpired grant applies. */ Optional findFor(UUID userId, UUID datasourceId); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionView.java index 80e738256..c70527023 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceUserPermissionView.java @@ -16,6 +16,8 @@ public record DatasourceUserPermissionView( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Integer rowLimitOverride, Instant expiresAt) { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedTables.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedTables.java new file mode 100644 index 000000000..e64c305da --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedTables.java @@ -0,0 +1,170 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * The one matcher behind a permission's {@code denied_schemas} / {@code denied_tables} (#939), shared + * by the query gates, the dry-run, the table preview, request groups and the schema view, so they + * never disagree about a schema-qualified name. A denial always beats the allow-list. + * + *

Every rule fails closed, because the gate cannot know where the database resolves a name: + * a {@code denied_tables} entry matches a reference when either name is a dot-aligned suffix of the + * other ({@code salary} denies {@code crm.salary}; {@code crm.salary} denies a bare {@code salary}), + * and a {@code denied_schemas} entry matches any qualified reference carrying it as a non-final + * segment, and every unqualified reference — so the schema view can list {@code public.orders} while + * a bare {@code FROM orders} is refused: under a schema denial, users must schema-qualify. + */ +public final class DeniedTables { + + /** One name, no dots and no wildcards: a pattern here could never match a segment. */ + public static final String SCHEMA_ENTRY_PATTERN = "^[^.*?]*[^.*?\\s][^.*?]*$"; + + /** {@code table} or {@code schema.table} (any depth), or a whole schema as {@code schema.*}. */ + public static final String TABLE_ENTRY_PATTERN = + "^[^.*?]*[^.*?\\s][^.*?]*(\\.[^.*?]*[^.*?\\s][^.*?]*)*(\\.\\*)?$"; + + private DeniedTables() { + } + + /** Whether {@code entry} is a well-formed {@code denied_schemas} entry. */ + public static boolean isValidSchemaEntry(String entry) { + return entry != null && entry.matches(SCHEMA_ENTRY_PATTERN); + } + + /** Whether {@code entry} is a well-formed {@code denied_tables} entry. */ + public static boolean isValidTableEntry(String entry) { + return entry != null && entry.matches(TABLE_ENTRY_PATTERN); + } + + /** {@link AllowedTables#normalize}, with duplicates dropped. */ + public static List normalize(List raw) { + var out = new ArrayList(); + for (String entry : AllowedTables.normalize(raw)) { + if (!out.contains(entry)) { + out.add(entry); + } + } + return List.copyOf(out); + } + + /** + * Which entry denies {@code table}, or {@code null} when none does. Both lists and {@code table} + * must already be {@link #normalize}d. + * + *

Names are compared segment by segment from the right. A reference segment the database + * would fill in itself — the empty schema of SQL Server's {@code db..table} — matches anything, + * an Oracle {@code @dblink} suffix is ignored, and a reference that is a pattern rather than a + * name (an Elasticsearch {@code sal*} index) is denied by any entry at all, since it may expand + * to a denied object. + */ + public static String denyingEntry(List deniedSchemas, List deniedTables, + String table) { + if (table == null || (deniedSchemas.isEmpty() && deniedTables.isEmpty())) { + return null; + } + var reference = segments(table); + if (isPattern(table)) { + return deniedTables.isEmpty() ? deniedSchemas.get(0) : deniedTables.get(0); + } + var schemas = new ArrayList<>(deniedSchemas); + for (String entry : deniedTables) { + if (entry.endsWith(".*")) { + // "crm.*" in the table list means the schema — the way the UI displays one. + schemas.add(entry.substring(0, entry.length() - 2)); + } else if (alignedFromTheRight(segments(entry), reference)) { + return entry; + } + } + return schemaDenial(schemas, reference); + } + + private static String schemaDenial(List deniedSchemas, String[] reference) { + if (deniedSchemas.isEmpty()) { + return null; + } + if (reference.length == 1) { + // Unqualified: the gate cannot tell which schema it resolves to. + return deniedSchemas.get(0); + } + for (int i = 0; i < reference.length - 1; i++) { + if (reference[i].isEmpty()) { + return deniedSchemas.get(0); + } + if (deniedSchemas.contains(reference[i])) { + return reference[i]; + } + } + return null; + } + + /** + * Whether the shorter name is the tail of the longer one: {@code salary} and {@code crm.salary} + * align, so do {@code crm.salary} and {@code db.crm.salary}. An empty reference segment matches + * any entry segment. + */ + private static boolean alignedFromTheRight(String[] entry, String[] reference) { + int overlap = Math.min(entry.length, reference.length); + for (int i = 1; i <= overlap; i++) { + var referenceSegment = reference[reference.length - i]; + if (!referenceSegment.isEmpty() && !referenceSegment.equals(entry[entry.length - i])) { + return false; + } + } + return true; + } + + private static String[] segments(String name) { + var at = name.indexOf('@'); + var withoutLink = at > 0 ? name.substring(0, at) : name; + return withoutLink.split("\\.", -1); + } + + private static boolean isPattern(String reference) { + return reference.indexOf('*') >= 0 || reference.indexOf('?') >= 0; + } + + /** @return the referenced tables a denial reaches, sorted; empty when none is denied. */ + public static SortedSet rejected(List rawDeniedSchemas, + List rawDeniedTables, + Collection referencedTables) { + var out = new TreeSet(); + var deniedSchemas = normalize(rawDeniedSchemas); + var deniedTables = normalize(rawDeniedTables); + if ((deniedSchemas.isEmpty() && deniedTables.isEmpty()) || referencedTables == null) { + return out; + } + for (String table : referencedTables) { + if (denyingEntry(deniedSchemas, deniedTables, AllowedTables.normalizeEntry(table)) + != null) { + out.add(table); + } + } + return out; + } + + /** + * @return whether the introspected {@code schema.table} is denied — the schema view and the + * table preview. {@code schema} may be null, which reads as an unqualified reference. + */ + public static boolean deniesTable(List rawDeniedSchemas, List rawDeniedTables, + String schema, String table) { + var bare = AllowedTables.normalizeEntry(table); + if (bare == null) { + return false; + } + var normalizedSchema = AllowedTables.normalizeEntry(schema); + var qualified = normalizedSchema == null ? bare : normalizedSchema + "." + bare; + return denyingEntry(normalize(rawDeniedSchemas), normalize(rawDeniedTables), qualified) + != null; + } + + /** @return whether a whole schema is denied, so the schema view never lists it. */ + public static boolean deniesSchema(List rawDeniedSchemas, String schema) { + var normalized = AllowedTables.normalizeEntry(schema); + return normalized != null && normalize(rawDeniedSchemas).contains(normalized); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java index 819f21871..d9e58f1bf 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImpl.java @@ -21,6 +21,7 @@ import com.bablsoft.accessflow.core.api.DbType; import com.bablsoft.accessflow.core.api.DeniedColumns; import com.bablsoft.accessflow.core.api.DeniedColumnsNotSupportedException; +import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.DriverCatalogService; import com.bablsoft.accessflow.core.api.QueryEngineCatalog; import com.bablsoft.accessflow.core.api.IllegalDatasourcePermissionException; @@ -78,6 +79,7 @@ import java.util.Properties; import java.util.Set; import java.util.UUID; +import java.util.function.Predicate; @Service @RequiredArgsConstructor @@ -653,6 +655,10 @@ public DatasourcePermissionView grantPermission(UUID datasourceId, UUID organiza entity.setAllowedTables(toArray(command.allowedTables())); entity.setRestrictedColumns(toArray(command.restrictedColumns())); entity.setDeniedColumns(toArray(deniedColumns(datasource, command.deniedColumns()))); + entity.setDeniedSchemas(toArray(deniedTables(command.deniedSchemas(), + DeniedTables::isValidSchemaEntry, "denied_schemas"))); + entity.setDeniedTables(toArray(deniedTables(command.deniedTables(), + DeniedTables::isValidTableEntry, "denied_tables"))); entity.setExpiresAt(command.expiresAt()); entity.setAccessGrantRequestId(command.accessGrantRequestId()); entity.setCreatedBy(grantedBy); @@ -708,6 +714,10 @@ public DatasourceGroupPermissionView grantGroupPermission( entity.setAllowedTables(toArray(command.allowedTables())); entity.setRestrictedColumns(toArray(command.restrictedColumns())); entity.setDeniedColumns(toArray(deniedColumns(datasource, command.deniedColumns()))); + entity.setDeniedSchemas(toArray(deniedTables(command.deniedSchemas(), + DeniedTables::isValidSchemaEntry, "denied_schemas"))); + entity.setDeniedTables(toArray(deniedTables(command.deniedTables(), + DeniedTables::isValidTableEntry, "denied_tables"))); entity.setExpiresAt(command.expiresAt()); entity.setCreatedBy(grantedBy); return toGroupPermissionView(groupPermissionRepository.save(entity)); @@ -1086,6 +1096,22 @@ private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } + /** + * Normalises a grant's {@code denied_schemas} / {@code denied_tables} (#939), refusing an entry + * that could never match a reference (so would silently deny nothing); empty → null. + */ + private static List deniedTables(List raw, Predicate valid, + String field) { + var denied = DeniedTables.normalize(raw); + for (String entry : denied) { + if (!valid.test(entry)) { + throw new IllegalDatasourcePermissionException( + field + " entry is not a valid name: " + entry); + } + } + return denied.isEmpty() ? null : denied; + } + /** * Normalises a grant's {@code denied_columns} (#935), refusing an entry that does not name its * table and any entry on an engine that cannot resolve column references. @@ -1124,6 +1150,8 @@ private DatasourcePermissionView toPermissionView(DatasourceUserPermissionEntity toList(entity.getAllowedTables()), toList(entity.getRestrictedColumns()), toList(entity.getDeniedColumns()), + toList(entity.getDeniedSchemas()), + toList(entity.getDeniedTables()), entity.getExpiresAt(), entity.getCreatedBy() != null ? entity.getCreatedBy().getId() : null, entity.getCreatedAt()); @@ -1147,6 +1175,8 @@ private DatasourceGroupPermissionView toGroupPermissionView( toList(entity.getAllowedTables()), toList(entity.getRestrictedColumns()), toList(entity.getDeniedColumns()), + toList(entity.getDeniedSchemas()), + toList(entity.getDeniedTables()), entity.getExpiresAt(), entity.getCreatedBy() != null ? entity.getCreatedBy().getId() : null, entity.getCreatedAt()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java index 687a05a23..0d940e41e 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java @@ -5,6 +5,7 @@ import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DeniedColumns; +import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceGroupPermissionEntity; import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceUserPermissionEntity; import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceGroupPermissionRepository; @@ -174,7 +175,8 @@ private static boolean isActive(Instant expiresAt, Instant now) { * allow-lists union (null wins = all allowed); restricted-columns intersect (empty wins = * nothing masked), and so do denied-columns (#935 — empty wins = nothing denied); expiry is * the latest among contributors (null wins = never expires). The row limit is the inversion: - * the smallest non-null override wins (#933). + * the smallest non-null override wins (#933). Denied schemas and tables are the other + * inversion: they union, so no contributing grant can lift another's denial (#939). */ private static DatasourceUserPermissionView merge(UUID userId, UUID datasourceId, List parts) { @@ -207,10 +209,27 @@ private static DatasourceUserPermissionView merge(UUID userId, UUID datasourceId unionAllowList(parts, DatasourcePermissionContribution::allowedTables), intersect(parts, DatasourcePermissionContribution::restrictedColumns), intersectDenied(parts), + unionDenied(parts, DatasourcePermissionContribution::deniedSchemas), + unionDenied(parts, DatasourcePermissionContribution::deniedTables), minRowLimit(parts), anyNeverExpires ? null : expiresAt); } + /** + * Deny-list union (#939): a table or schema stays denied when any contribution denies + * it. This is the inverse of the allow-list merge on purpose — a second, more permissive grant + * can widen what is allowed but can never dissolve a denial. + */ + private static List unionDenied( + List parts, + Function> field) { + var union = new LinkedHashSet(); + for (var p : parts) { + union.addAll(DeniedTables.normalize(field.apply(p))); + } + return List.copyOf(union); + } + /** Most restrictive non-null override; {@code null} when no contribution sets one. */ private static Integer minRowLimit(List parts) { Integer min = null; @@ -290,6 +309,8 @@ private static DatasourceUserPermissionView toDirectView(DatasourceUserPermissio toList(entity.getAllowedTables()), toList(entity.getRestrictedColumns()), DeniedColumns.normalize(toList(entity.getDeniedColumns())), + DeniedTables.normalize(toList(entity.getDeniedSchemas())), + DeniedTables.normalize(toList(entity.getDeniedTables())), entity.getRowLimitOverride(), entity.getExpiresAt()); } @@ -301,6 +322,7 @@ private static DatasourcePermissionContribution toContribution( e.isCanRead(), e.isCanWrite(), e.isCanDdl(), e.isCanBreakGlass(), toList(e.getAllowedSchemas()), toList(e.getAllowedTables()), toList(e.getRestrictedColumns()), toList(e.getDeniedColumns()), + toList(e.getDeniedSchemas()), toList(e.getDeniedTables()), e.getRowLimitOverride(), e.getExpiresAt(), e.getAccessGrantRequestId()); } @@ -311,6 +333,7 @@ private static DatasourcePermissionContribution toContribution( e.getGroup().getName(), e.isCanRead(), e.isCanWrite(), e.isCanDdl(), e.isCanBreakGlass(), toList(e.getAllowedSchemas()), toList(e.getAllowedTables()), toList(e.getRestrictedColumns()), toList(e.getDeniedColumns()), + toList(e.getDeniedSchemas()), toList(e.getDeniedTables()), e.getRowLimitOverride(), e.getExpiresAt(), null); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java index ff4fbc8cb..0e845bc00 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilter.java @@ -4,6 +4,7 @@ import com.bablsoft.accessflow.core.api.DatabaseSchemaView; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DeniedColumns; +import com.bablsoft.accessflow.core.api.DeniedTables; import java.util.ArrayList; import java.util.HashSet; @@ -12,9 +13,9 @@ /** * Narrows an introspected schema to what a restricted caller may query (#936): tables outside the - * allow-list, columns on the deny list, and foreign keys that would name a hidden table or column - * are dropped. Matching goes through {@link AllowedTables} and {@link DeniedColumns}, the rules the - * query gates enforce. Where the view cannot tell what the gate would allow it fails closed: a bare + * allow-list, tables and schemas on the deny-lists (#939), columns on the deny list, and foreign keys + * that would name a hidden table or column are dropped. Matching goes through {@link AllowedTables}, + * {@link DeniedTables} and {@link DeniedColumns}, the rules the query gates enforce. Where the view cannot tell what the gate would allow it fails closed: a bare * {@code allowed_tables} entry names whatever table the database resolves the unqualified name to, * so it only shows a table whose name is unique in the view, and a foreign key whose bare target name * also belongs to a hidden table is dropped. @@ -30,7 +31,10 @@ static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissio } var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas()); var allowedTables = AllowedTables.normalize(permission.allowedTables()); - var restricted = !allowedSchemas.isEmpty() || !allowedTables.isEmpty(); + var deniedSchemas = DeniedTables.normalize(permission.deniedSchemas()); + var deniedTables = DeniedTables.normalize(permission.deniedTables()); + var allowListed = !allowedSchemas.isEmpty() || !allowedTables.isEmpty(); + var restricted = allowListed || !deniedSchemas.isEmpty() || !deniedTables.isEmpty(); var denied = permission.deniedColumns(); var ambiguousNames = ambiguousTableNames(view); @@ -42,8 +46,10 @@ static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissio var tables = new ArrayList(); for (var table : nullSafe(schema.tables())) { var bare = AllowedTables.normalizeEntry(table.name()); - if (!restricted || tableAllowed(allowedSchemas, allowedTables, normalizedSchema, - bare, ambiguousNames)) { + var allowed = !allowListed || tableAllowed(allowedSchemas, allowedTables, + normalizedSchema, bare, ambiguousNames); + if (allowed && !DeniedTables.deniesTable(deniedSchemas, deniedTables, + schema.name(), table.name())) { tables.add(table); if (bare != null) { visibleTableNames.add(bare); @@ -52,9 +58,10 @@ static DatabaseSchemaView apply(DatabaseSchemaView view, DatasourceUserPermissio hiddenTableNames.add(bare); } } - if (!tables.isEmpty() - || (normalizedSchema != null && allowedSchemas.contains(normalizedSchema)) - || !restricted) { + var schemaDenied = DeniedTables.deniesSchema(deniedSchemas, schema.name()); + // An empty schema stays listed when the caller could still query it, never when denied. + if (!tables.isEmpty() || (!schemaDenied && (!allowListed + || (normalizedSchema != null && allowedSchemas.contains(normalizedSchema))))) { visible.add(new DatabaseSchemaView.Schema(schema.name(), tables)); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceGroupPermissionEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceGroupPermissionEntity.java index e5d00f5ac..b2f52944c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceGroupPermissionEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceGroupPermissionEntity.java @@ -69,6 +69,14 @@ public class DatasourceGroupPermissionEntity { @Column(name = "denied_columns", columnDefinition = "text[]") private String[] deniedColumns; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "denied_schemas", columnDefinition = "text[]") + private String[] deniedSchemas; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "denied_tables", columnDefinition = "text[]") + private String[] deniedTables; + @Column(name = "expires_at") private Instant expiresAt; diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceUserPermissionEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceUserPermissionEntity.java index 45c955d24..67e51db4f 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceUserPermissionEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/DatasourceUserPermissionEntity.java @@ -65,6 +65,14 @@ public class DatasourceUserPermissionEntity { @Column(name = "denied_columns", columnDefinition = "text[]") private String[] deniedColumns; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "denied_schemas", columnDefinition = "text[]") + private String[] deniedSchemas; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "denied_tables", columnDefinition = "text[]") + private String[] deniedTables; + @Column(name = "expires_at") private Instant expiresAt; diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java index 0545b068a..e4c8aed8b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunService.java @@ -5,6 +5,7 @@ import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DeniedColumns; +import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.SqlParseResult; import com.bablsoft.accessflow.core.api.QueryDryRunResult; import com.bablsoft.accessflow.core.api.QueryExecutionRequest; @@ -94,6 +95,14 @@ private void verifyPermission(UUID userId, UUID datasourceId, SqlParseResult par "Insufficient permission for " + queryType + " on datasource: " + datasourceId); } verifyAllowedTables(permission, datasourceId, parsed.referencedTables()); + var deniedTables = DeniedTables.rejected(permission.deniedSchemas(), + permission.deniedTables(), parsed.referencedTables()); + if (!deniedTables.isEmpty()) { + log.warn("Dry-run table deny rejection on datasource {} for user {}: tables {}", + datasourceId, permission.userId(), deniedTables); + throw new AccessDeniedException(msg("error.permission.table_denied", + new Object[]{String.join(", ", deniedTables)})); + } var deniedColumns = DeniedColumns.rejected(permission.deniedColumns(), parsed); if (!deniedColumns.isEmpty()) { log.warn("Dry-run column deny rejection on datasource {} for user {}: columns {}", diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java index 808dcc1dc..d4d4727dd 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataService.java @@ -7,6 +7,7 @@ import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DeniedColumns; +import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.MaskingPolicyResolutionService; import com.bablsoft.accessflow.core.api.RowLimitPolicyResolutionService; import com.bablsoft.accessflow.core.api.RowSecurityDirective; @@ -134,9 +135,14 @@ private Optional resolveTarget(DatabaseSchemaView view, String schema, S * (the fail-closed rule {@code SchemaViewPermissionFilter} applies to the view, #936), counted * over the unfiltered catalog, fetched only for this fallback, since the caller's filtered view * may already hide the other table. A target without a schema is covered by a bare entry only. + * A denied table or schema (#939) is refused before any of that, whatever the allow-list says. */ private static boolean targetAllowed(DatasourceUserPermissionView permission, Target target, Supplier fullCatalog) { + if (DeniedTables.deniesTable(permission.deniedSchemas(), permission.deniedTables(), + target.schema(), target.table())) { + return false; + } var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas()); var allowedTables = AllowedTables.normalize(permission.allowedTables()); if (allowedSchemas.isEmpty() && allowedTables.isEmpty()) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupService.java b/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupService.java index 4f8236871..e75f2954c 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupService.java @@ -14,6 +14,7 @@ import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DbType; import com.bablsoft.accessflow.core.api.DeniedColumns; +import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.PageRequest; import com.bablsoft.accessflow.core.api.PageResponse; import com.bablsoft.accessflow.core.api.QueryDetailView; @@ -302,14 +303,26 @@ private SqlParseResult parseQuery(UUID datasourceId, String sql) { return queryParser.parse(sql, dbType); } - /** A member may not reach a column its submitter is denied (#935), break-glass included. */ - private void verifyDeniedColumns(RequestGroupItemEntity item, - DatasourceUserPermissionView permission) { - if (DeniedColumns.normalize(permission.deniedColumns()).isEmpty()) { + /** + * A member may not reach a table or schema (#939) or a column (#935) its submitter is denied, + * break-glass included. + */ + private void verifyDenials(RequestGroupItemEntity item, + DatasourceUserPermissionView permission) { + var tablesDenied = !DeniedTables.normalize(permission.deniedSchemas()).isEmpty() + || !DeniedTables.normalize(permission.deniedTables()).isEmpty(); + var columnsDenied = !DeniedColumns.normalize(permission.deniedColumns()).isEmpty(); + if (!tablesDenied && !columnsDenied) { return; } - var rejected = DeniedColumns.rejected(permission.deniedColumns(), - parseQuery(item.getDatasourceId(), item.getSqlText())); + var parsed = parseQuery(item.getDatasourceId(), item.getSqlText()); + var rejectedTables = DeniedTables.rejected(permission.deniedSchemas(), + permission.deniedTables(), parsed.referencedTables()); + if (!rejectedTables.isEmpty()) { + throw new RequestGroupPermissionException( + "Denied tables referenced: " + String.join(", ", rejectedTables)); + } + var rejected = DeniedColumns.rejected(permission.deniedColumns(), parsed); if (!rejected.isEmpty()) { throw new RequestGroupPermissionException( "Denied columns referenced: " + String.join(", ", rejected)); @@ -325,7 +338,7 @@ private void validatePermission(RequestGroupItemEntity item, UUID submitterId, b throw new RequestGroupPermissionException( "Break-glass requires can_break_glass on every member target"); } - verifyDeniedColumns(item, perm.get()); + verifyDenials(item, perm.get()); return; } if (admin) { @@ -338,7 +351,7 @@ private void validatePermission(RequestGroupItemEntity item, UUID submitterId, b throw new RequestGroupPermissionException( "You are not permitted to run this query on the selected datasource"); } - verifyDeniedColumns(item, perm.get()); + verifyDenials(item, perm.get()); } else { var perm = apiConnectorPermissionLookupService.findFor(item.getApiConnectorId(), submitterId); if (breakGlass) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DatasourceController.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DatasourceController.java index 80cafa831..f42fb462d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DatasourceController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/DatasourceController.java @@ -344,7 +344,7 @@ ResponseEntity grantPermission( request.allowedSchemas(), request.allowedTables(), request.restrictedColumns(), - request.deniedColumns(), + request.deniedColumns(), request.deniedSchemas(), request.deniedTables(), request.expiresAt(), // Admin-created: no originating JIT request (#969). null); @@ -360,6 +360,12 @@ ResponseEntity grantPermission( if (view.deniedColumns() != null && !view.deniedColumns().isEmpty()) { metadata.put("denied_columns", view.deniedColumns()); } + if (view.deniedSchemas() != null && !view.deniedSchemas().isEmpty()) { + metadata.put("denied_schemas", view.deniedSchemas()); + } + if (view.deniedTables() != null && !view.deniedTables().isEmpty()) { + metadata.put("denied_tables", view.deniedTables()); + } recordAudit(AuditAction.PERMISSION_GRANTED, AuditResourceType.PERMISSION, view.id(), caller, auditContext, metadata); URI location = ServletUriComponentsBuilder.fromCurrentRequest() @@ -424,7 +430,7 @@ ResponseEntity grantGroupPermission( request.allowedSchemas(), request.allowedTables(), request.restrictedColumns(), - request.deniedColumns(), + request.deniedColumns(), request.deniedSchemas(), request.deniedTables(), request.expiresAt()); var view = datasourceAdminService.grantGroupPermission(id, caller.organizationId(), caller.userId(), command); @@ -438,6 +444,12 @@ ResponseEntity grantGroupPermission( if (view.deniedColumns() != null && !view.deniedColumns().isEmpty()) { metadata.put("denied_columns", view.deniedColumns()); } + if (view.deniedSchemas() != null && !view.deniedSchemas().isEmpty()) { + metadata.put("denied_schemas", view.deniedSchemas()); + } + if (view.deniedTables() != null && !view.deniedTables().isEmpty()) { + metadata.put("denied_tables", view.deniedTables()); + } recordAudit(AuditAction.PERMISSION_GROUP_GRANTED, AuditResourceType.PERMISSION, view.id(), caller, auditContext, metadata); URI location = ServletUriComponentsBuilder.fromCurrentRequest() diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreateGroupPermissionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreateGroupPermissionRequest.java index aeaa28332..8f6c47ab9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreateGroupPermissionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreateGroupPermissionRequest.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.security.internal.web.model; +import com.bablsoft.accessflow.core.api.DeniedTables; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -24,6 +25,14 @@ public record CreateGroupPermissionRequest( List<@NotBlank(message = "{validation.denied_columns.item_blank}") @Pattern(regexp = CreatePermissionRequest.DENIED_COLUMN_PATTERN, message = "{validation.denied_columns.item_unqualified}") String> deniedColumns, + @Size(max = 50, message = "{validation.denied_schemas.too_many}") + List<@NotBlank(message = "{validation.denied_schemas.item_blank}") + @Pattern(regexp = DeniedTables.SCHEMA_ENTRY_PATTERN, + message = "{validation.denied_schemas.item_invalid}") String> deniedSchemas, + @Size(max = 200, message = "{validation.denied_tables.too_many}") + List<@NotBlank(message = "{validation.denied_tables.item_blank}") + @Pattern(regexp = DeniedTables.TABLE_ENTRY_PATTERN, + message = "{validation.denied_tables.item_invalid}") String> deniedTables, Instant expiresAt ) { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreatePermissionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreatePermissionRequest.java index 3a56eec72..40c98e50f 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreatePermissionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/CreatePermissionRequest.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.security.internal.web.model; +import com.bablsoft.accessflow.core.api.DeniedTables; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -24,6 +25,14 @@ public record CreatePermissionRequest( List<@NotBlank(message = "{validation.denied_columns.item_blank}") @Pattern(regexp = DENIED_COLUMN_PATTERN, message = "{validation.denied_columns.item_unqualified}") String> deniedColumns, + @Size(max = 50, message = "{validation.denied_schemas.too_many}") + List<@NotBlank(message = "{validation.denied_schemas.item_blank}") + @Pattern(regexp = DeniedTables.SCHEMA_ENTRY_PATTERN, + message = "{validation.denied_schemas.item_invalid}") String> deniedSchemas, + @Size(max = 200, message = "{validation.denied_tables.too_many}") + List<@NotBlank(message = "{validation.denied_tables.item_blank}") + @Pattern(regexp = DeniedTables.TABLE_ENTRY_PATTERN, + message = "{validation.denied_tables.item_invalid}") String> deniedTables, Instant expiresAt ) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/GroupPermissionResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/GroupPermissionResponse.java index 22746b6e7..f1425fcb9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/GroupPermissionResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/GroupPermissionResponse.java @@ -21,6 +21,8 @@ public record GroupPermissionResponse( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Instant expiresAt, UUID createdBy, Instant createdAt @@ -41,6 +43,8 @@ public static GroupPermissionResponse from(DatasourceGroupPermissionView view) { view.allowedTables(), view.restrictedColumns(), view.deniedColumns(), + view.deniedSchemas(), + view.deniedTables(), view.expiresAt(), view.createdBy(), view.createdAt()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/PermissionResponse.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/PermissionResponse.java index 6a6993004..43fb229cd 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/PermissionResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/model/PermissionResponse.java @@ -21,6 +21,8 @@ public record PermissionResponse( List allowedTables, List restrictedColumns, List deniedColumns, + List deniedSchemas, + List deniedTables, Instant expiresAt, UUID createdBy, Instant createdAt @@ -41,6 +43,8 @@ public static PermissionResponse from(DatasourcePermissionView view) { view.allowedTables(), view.restrictedColumns(), view.deniedColumns(), + view.deniedSchemas(), + view.deniedTables(), view.expiresAt(), view.createdBy(), view.createdAt()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java index 72ede973c..750421622 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionChecker.java @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.core.api.AllowedTables; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; import com.bablsoft.accessflow.core.api.DeniedColumns; +import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.SqlParseResult; @@ -13,8 +14,9 @@ /** * Shared capability + allow-list checks for the standard query-submission gate and the break-glass * gate (AF-385). Both verify that a permission grants the capability for the parsed query type and - * that every referenced table is within the permission's schema/table allow-list, and that no - * referenced column is on the permission's deny list (#935). + * that every referenced table is within the permission's schema/table allow-list and outside its + * schema/table deny-lists (#939), and that no referenced column is on the permission's deny list + * (#935). */ final class DatasourcePermissionChecker { @@ -66,6 +68,27 @@ static Set rejectedTables(List rawAllowedSchemas, return rejected; } + /** + * @return the referenced tables the permission's {@code denied_schemas} / {@code denied_tables} + * reach (#939), sorted; a denial applies whether or not an allow-list is set. + */ + static Set deniedTables(DatasourceUserPermissionView permission, + Set referencedTables) { + return DeniedTables.rejected(permission.deniedSchemas(), permission.deniedTables(), + referencedTables); + } + + /** + * @return every referenced table the permission does not let through — outside the allow-list + * or denied — sorted. The one answer for callers that only need "may this user reach it". + */ + static Set blockedTables(DatasourceUserPermissionView permission, + Set referencedTables) { + var blocked = new TreeSet<>(rejectedTables(permission, referencedTables)); + blocked.addAll(deniedTables(permission, referencedTables)); + return blocked; + } + /** * @return the permission's {@code denied_columns} entries the parsed query reaches, sorted; * empty when it reaches none. Fails closed over a parse that did not analyze columns. diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifier.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifier.java index 16c79080d..1c57ee4da 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifier.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifier.java @@ -39,7 +39,7 @@ private String msg(String key, Object[] args) { /** * @throws AccessDeniedException when the user has no active permission on the datasource, the * permission is expired, lacks the capability for {@code queryType}, any referenced - * table falls outside the allow-list, or any referenced column is denied. + * table falls outside the allow-list or is denied, or any referenced column is denied. */ void verify(UUID userId, UUID datasourceId, QueryType queryType, SqlParseResult parsed) { var permission = permissionLookupService.findFor(userId, datasourceId) @@ -53,9 +53,21 @@ void verify(UUID userId, UUID datasourceId, QueryType queryType, SqlParseResult "Insufficient permission for " + queryType + " on datasource: " + datasourceId); } verifyAllowedTables(permission, datasourceId, parsed.referencedTables()); + verifyDeniedTables(permission, datasourceId, parsed.referencedTables()); verifyDeniedColumns(permission, datasourceId, parsed); } + private void verifyDeniedTables(DatasourceUserPermissionView permission, UUID datasourceId, + Set referencedTables) { + var rejected = DatasourcePermissionChecker.deniedTables(permission, referencedTables); + if (!rejected.isEmpty()) { + log.warn("Table deny rejection on datasource {} for user {}: tables {} denied", + datasourceId, permission.userId(), rejected); + throw new AccessDeniedException(msg("error.permission.table_denied", + new Object[]{String.join(", ", rejected)})); + } + } + private void verifyDeniedColumns(DatasourceUserPermissionView permission, UUID datasourceId, SqlParseResult parsed) { var rejected = DatasourcePermissionChecker.rejectedColumns(permission, parsed); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java index af57a9b1a..84fce217b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationService.java @@ -245,6 +245,7 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, // QUERY_ADMIN holders skip the per-datasource gate outright, so they pass here with no // permission row at all. Saying so is the point: it is invisible on every other screen. details.put("rejected_tables", List.of()); + details.put("denied_tables", List.of()); details.put("rejected_columns", List.of()); details.put("expires_at", null); steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.ALLOW, @@ -256,6 +257,7 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, .orElse(null); if (permission == null) { details.put("rejected_tables", List.of()); + details.put("denied_tables", List.of()); details.put("rejected_columns", List.of()); details.put("expires_at", null); steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.DENY, @@ -267,6 +269,9 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, var rejected = DatasourcePermissionChecker.rejectedTables(permission, parsed.referencedTables()); details.put("rejected_tables", List.copyOf(rejected)); + var deniedTables = DatasourcePermissionChecker.deniedTables(permission, + parsed.referencedTables()); + details.put("denied_tables", List.copyOf(deniedTables)); var rejectedColumns = DatasourcePermissionChecker.rejectedColumns(permission, parsed); details.put("rejected_columns", List.copyOf(rejectedColumns)); if (!capable) { @@ -279,6 +284,11 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, "workflow.access_simulation.permission.table_not_allowed", details)); return false; } + if (!deniedTables.isEmpty()) { + steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.DENY, + "workflow.access_simulation.permission.table_denied", details)); + return false; + } if (!rejectedColumns.isEmpty()) { steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.DENY, "workflow.access_simulation.permission.column_denied", details)); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java index 9110cba7c..47f2acccb 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassService.java @@ -178,6 +178,10 @@ private void verifyBreakGlassPermission(UUID userId, UUID datasourceId, QueryTyp .isEmpty()) { throw denied(datasourceId, userId, "tables outside allow-list"); } + if (!DatasourcePermissionChecker.deniedTables(permission, parsed.referencedTables()) + .isEmpty()) { + throw denied(datasourceId, userId, "denied tables referenced"); + } if (!DatasourcePermissionChecker.rejectedColumns(permission, parsed).isEmpty()) { throw denied(datasourceId, userId, "denied columns referenced"); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessService.java index 2a81b6607..fbc555be2 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessService.java @@ -170,7 +170,10 @@ private static AccessSource toSource(DatasourcePermissionContribution contributi contribution.expiresAt(), preApproveQueries); } - /** Both the capability and the table coverage are read off the merged permission, never a part. */ + /** + * Both the capability and the table coverage are read off the merged permission, never a part — + * the merge is what unions every grant's denials (#939). + */ private static boolean grants(DatasourceUserPermissionView merged, String table, QueryType queryType) { if (merged == null) { @@ -179,7 +182,7 @@ private static boolean grants(DatasourceUserPermissionView merged, String table, if (!DatasourcePermissionChecker.hasCapability(merged, queryType)) { return false; } - return DatasourcePermissionChecker.rejectedTables(merged, Set.of(table)).isEmpty(); + return DatasourcePermissionChecker.blockedTables(merged, Set.of(table)).isEmpty(); } private static TableScope scopeOf(List allowedSchemas, List allowedTables) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionService.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionService.java index cd3870917..d88c735ef 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionService.java @@ -124,7 +124,7 @@ private boolean isExpired(DatasourceUserPermissionView permission) { private boolean isReachable(DatasourceUserPermissionView permission, QuerySuggestionEntity row, List tables) { return DatasourcePermissionChecker.hasCapability(permission, row.getQueryType()) - && DatasourcePermissionChecker.rejectedTables(permission, new HashSet<>(tables)) + && DatasourcePermissionChecker.blockedTables(permission, new HashSet<>(tables)) .isEmpty(); } diff --git a/backend/src/main/resources/db/migration/V190__add_denied_tables_to_permissions.sql b/backend/src/main/resources/db/migration/V190__add_denied_tables_to_permissions.sql new file mode 100644 index 000000000..056e907ad --- /dev/null +++ b/backend/src/main/resources/db/migration/V190__add_denied_tables_to_permissions.sql @@ -0,0 +1,7 @@ +-- Table and schema deny-lists (#939): a denial always beats the allow-list, and denials merge as a +-- union across a user's grants. Entries are 'schema', 'table' or 'schema.table'. Nullable: NULL/empty +-- denies nothing. +ALTER TABLE datasource_user_permissions ADD COLUMN denied_schemas TEXT[]; +ALTER TABLE datasource_user_permissions ADD COLUMN denied_tables TEXT[]; +ALTER TABLE datasource_group_permissions ADD COLUMN denied_schemas TEXT[]; +ALTER TABLE datasource_group_permissions ADD COLUMN denied_tables TEXT[]; diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 37064527a..d19ff6a16 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -48,6 +48,12 @@ validation.restricted_columns.item_blank=Restricted column entries must not be b validation.denied_columns.item_blank=Denied column entries must not be blank validation.denied_columns.item_unqualified=Denied column entries must be table.column or schema.table.column validation.denied_columns.too_many=At most 200 denied columns may be listed +validation.denied_schemas.item_blank=Denied schema entries must not be blank +validation.denied_schemas.item_invalid=Denied schema entries must be a single schema name without dots or wildcards +validation.denied_schemas.too_many=At most 50 denied schemas may be listed +validation.denied_tables.item_blank=Denied table entries must not be blank +validation.denied_tables.item_invalid=Denied table entries must be table, schema.table or schema.* +validation.denied_tables.too_many=At most 200 denied tables may be listed validation.schemas.max=Schemas list must be at most 4,000 characters validation.review_plan_name.required=Review plan name is required validation.review_plan_name.max=Review plan name must be between 1 and 255 characters @@ -104,6 +110,7 @@ error.unauthorized=Authentication required error.forbidden=Access denied error.permission.table_not_allowed=Query references one or more tables that are not in the user''s allow-list: {0} error.permission.column_not_allowed=Query references one or more columns the user is denied on this datasource: {0} +error.permission.table_denied=Query references one or more tables the user is denied on this datasource: {0} error.dry_run.unsupported=Dry-run is not supported for the {0} engine error.dry_run.mssql_row_security_unsupported=Dry-run is not available for this query because a row-security policy applies: SQL Server can only return an execution plan for a statement without bound parameters error.dry_run.transactional_unsupported=Dry-run is not available for transactional batch envelopes @@ -1287,6 +1294,7 @@ workflow.access_simulation.permission.none=The user holds no active permission o workflow.access_simulation.permission.capability_missing=The effective permission does not grant this statement type workflow.access_simulation.permission.table_not_allowed=The effective permission does not cover every referenced table workflow.access_simulation.permission.column_denied=The effective permission denies a referenced column +workflow.access_simulation.permission.table_denied=The effective permission denies a referenced table workflow.access_simulation.permission.query_admin_bypass=The user holds QUERY_ADMIN, which skips the per-datasource permission gate entirely workflow.access_simulation.reviewers.assigned=Reviewers are assigned to this datasource and could act on the request workflow.access_simulation.reviewers.none=No reviewer other than the submitter could act on the request diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties index 2d690c607..0ac80ce6c 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -43,6 +43,12 @@ validation.restricted_columns.item_blank=Eingeschränkte Spalten dürfen nicht l validation.denied_columns.item_blank=Gesperrte Spalten dürfen nicht leer sein validation.denied_columns.item_unqualified=Gesperrte Spalten müssen als tabelle.spalte oder schema.tabelle.spalte angegeben werden validation.denied_columns.too_many=Es dürfen höchstens 200 gesperrte Spalten angegeben werden +validation.denied_schemas.item_blank=Gesperrte Schemas dürfen nicht leer sein +validation.denied_schemas.item_invalid=Gesperrte Schemas müssen ein einzelner Schemaname ohne Punkte oder Platzhalter sein +validation.denied_schemas.too_many=Es dürfen höchstens 50 gesperrte Schemas angegeben werden +validation.denied_tables.item_blank=Gesperrte Tabellen dürfen nicht leer sein +validation.denied_tables.item_invalid=Gesperrte Tabellen müssen als tabelle, schema.tabelle oder schema.* angegeben werden +validation.denied_tables.too_many=Es dürfen höchstens 200 gesperrte Tabellen angegeben werden validation.schemas.max=Die Schema-Liste darf maximal 4.000 Zeichen lang sein validation.review_plan_name.required=Der Name des Prüfplans ist erforderlich validation.review_plan_name.max=Der Name des Prüfplans muss zwischen 1 und 255 Zeichen lang sein @@ -94,6 +100,7 @@ error.unauthorized=Authentifizierung erforderlich error.forbidden=Zugriff verweigert error.permission.table_not_allowed=Die Abfrage verweist auf eine oder mehrere Tabellen, die nicht in der Zulassungsliste des Benutzers stehen: {0} error.permission.column_not_allowed=Die Abfrage verweist auf eine oder mehrere Spalten, die für den Benutzer auf dieser Datenquelle gesperrt sind: {0} +error.permission.table_denied=Die Abfrage verweist auf eine oder mehrere Tabellen, die für den Benutzer auf dieser Datenquelle gesperrt sind: {0} error.dry_run.unsupported=Probelauf wird für die {0}-Engine nicht unterstützt error.dry_run.mssql_row_security_unsupported=Der Probelauf ist für diese Abfrage nicht verfügbar, da eine Zeilensicherheitsrichtlinie greift: SQL Server kann einen Ausführungsplan nur für Anweisungen ohne gebundene Parameter zurückgeben error.dry_run.transactional_unsupported=Der Probelauf ist für transaktionale Batch-Umschläge nicht verfügbar @@ -1266,6 +1273,7 @@ workflow.access_simulation.permission.none=Der Benutzer hat keine aktive Berecht workflow.access_simulation.permission.capability_missing=Die effektive Berechtigung gewährt diesen Anweisungstyp nicht workflow.access_simulation.permission.table_not_allowed=Die effektive Berechtigung deckt nicht alle referenzierten Tabellen ab workflow.access_simulation.permission.column_denied=Die effektive Berechtigung sperrt eine referenzierte Spalte +workflow.access_simulation.permission.table_denied=Die effektive Berechtigung sperrt eine referenzierte Tabelle workflow.access_simulation.permission.query_admin_bypass=Der Benutzer hat QUERY_ADMIN und umgeht damit die datenquellenbezogene Berechtigungsprüfung vollständig workflow.access_simulation.reviewers.assigned=Dieser Datenquelle sind Prüfer zugewiesen, die auf die Anfrage reagieren könnten workflow.access_simulation.reviewers.none=Außer dem Einreicher könnte kein Prüfer auf die Anfrage reagieren diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties index e50c5c381..f6a688353 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -43,6 +43,12 @@ validation.restricted_columns.item_blank=Las columnas restringidas no pueden est validation.denied_columns.item_blank=Las columnas denegadas no pueden estar en blanco validation.denied_columns.item_unqualified=Las columnas denegadas deben ser tabla.columna o esquema.tabla.columna validation.denied_columns.too_many=Se pueden indicar como máximo 200 columnas denegadas +validation.denied_schemas.item_blank=Los esquemas denegados no pueden estar en blanco +validation.denied_schemas.item_invalid=Los esquemas denegados deben ser un único nombre de esquema sin puntos ni comodines +validation.denied_schemas.too_many=Se pueden indicar como máximo 50 esquemas denegados +validation.denied_tables.item_blank=Las tablas denegadas no pueden estar en blanco +validation.denied_tables.item_invalid=Las tablas denegadas deben ser tabla, esquema.tabla o esquema.* +validation.denied_tables.too_many=Se pueden indicar como máximo 200 tablas denegadas validation.schemas.max=La lista de esquemas debe tener como máximo 4.000 caracteres validation.review_plan_name.required=El nombre del plan de revisión es obligatorio validation.review_plan_name.max=El nombre del plan de revisión debe tener entre 1 y 255 caracteres @@ -94,6 +100,7 @@ error.unauthorized=Se requiere autenticación error.forbidden=Acceso denegado error.permission.table_not_allowed=La consulta hace referencia a una o más tablas que no están en la lista de permitidos del usuario: {0} error.permission.column_not_allowed=La consulta hace referencia a una o más columnas denegadas al usuario en esta fuente de datos: {0} +error.permission.table_denied=La consulta hace referencia a una o más tablas denegadas al usuario en esta fuente de datos: {0} error.dry_run.unsupported=La simulación no es compatible con el motor {0} error.dry_run.mssql_row_security_unsupported=La simulación no está disponible para esta consulta porque se aplica una política de seguridad de filas: SQL Server solo puede devolver un plan de ejecución para sentencias sin parámetros vinculados error.dry_run.transactional_unsupported=La simulación no está disponible para envoltorios de lotes transaccionales @@ -1266,6 +1273,7 @@ workflow.access_simulation.permission.none=El usuario no tiene ningún permiso a workflow.access_simulation.permission.capability_missing=El permiso efectivo no concede este tipo de sentencia workflow.access_simulation.permission.table_not_allowed=El permiso efectivo no cubre todas las tablas referenciadas workflow.access_simulation.permission.column_denied=El permiso efectivo deniega una columna referenciada +workflow.access_simulation.permission.table_denied=El permiso efectivo deniega una tabla referenciada workflow.access_simulation.permission.query_admin_bypass=El usuario tiene QUERY_ADMIN, lo que omite por completo el control de permisos por fuente de datos workflow.access_simulation.reviewers.assigned=Hay revisores asignados a esta fuente de datos que podrían actuar sobre la solicitud workflow.access_simulation.reviewers.none=Ningún revisor distinto del solicitante podría actuar sobre la solicitud diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties index 568eb190a..9d5259a6c 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -43,6 +43,12 @@ validation.restricted_columns.item_blank=Les colonnes restreintes ne peuvent pas validation.denied_columns.item_blank=Les colonnes refusées ne peuvent pas être vides validation.denied_columns.item_unqualified=Les colonnes refusées doivent être au format table.colonne ou schema.table.colonne validation.denied_columns.too_many=Au plus 200 colonnes refusées peuvent être indiquées +validation.denied_schemas.item_blank=Les schémas refusés ne peuvent pas être vides +validation.denied_schemas.item_invalid=Les schémas refusés doivent être un seul nom de schéma sans point ni caractère générique +validation.denied_schemas.too_many=Au plus 50 schémas refusés peuvent être indiqués +validation.denied_tables.item_blank=Les tables refusées ne peuvent pas être vides +validation.denied_tables.item_invalid=Les tables refusées doivent être au format table, schema.table ou schema.* +validation.denied_tables.too_many=Au plus 200 tables refusées peuvent être indiquées validation.schemas.max=La liste des schémas ne doit pas dépasser 4 000 caractères validation.review_plan_name.required=Le nom du plan d'examen est obligatoire validation.review_plan_name.max=Le nom du plan d'examen doit contenir entre 1 et 255 caractères @@ -94,6 +100,7 @@ error.unauthorized=Authentification requise error.forbidden=Accès refusé error.permission.table_not_allowed=La requête référence une ou plusieurs tables absentes de la liste d''autorisation de l''utilisateur : {0} error.permission.column_not_allowed=La requête référence une ou plusieurs colonnes refusées à l''utilisateur sur cette source de données : {0} +error.permission.table_denied=La requête référence une ou plusieurs tables refusées à l''utilisateur sur cette source de données : {0} error.dry_run.unsupported=La simulation n''est pas prise en charge pour le moteur {0} # Les deux clés suivantes sont résolues sans arguments : Spring saute alors MessageFormat, # donc l'apostrophe simple est correcte ici (la doubler l'afficherait littéralement). @@ -1272,6 +1279,7 @@ workflow.access_simulation.permission.none=L’utilisateur n’a aucune permissi workflow.access_simulation.permission.capability_missing=La permission effective n’accorde pas ce type d’instruction workflow.access_simulation.permission.table_not_allowed=La permission effective ne couvre pas toutes les tables référencées workflow.access_simulation.permission.column_denied=La permission effective refuse une colonne référencée +workflow.access_simulation.permission.table_denied=La permission effective refuse une table référencée workflow.access_simulation.permission.query_admin_bypass=L’utilisateur détient QUERY_ADMIN, ce qui contourne entièrement le contrôle de permission par source de données workflow.access_simulation.reviewers.assigned=Des relecteurs sont affectés à cette source de données et pourraient traiter la demande workflow.access_simulation.reviewers.none=Aucun relecteur autre que le demandeur ne pourrait traiter la demande diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties index 22def1099..8892a3e4f 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -43,6 +43,12 @@ validation.restricted_columns.item_blank=Սահմանափակ սյունակնե validation.denied_columns.item_blank=Արգելված սյունակների գրառումները չեն կարող լինել դատարկ validation.denied_columns.item_unqualified=Արգելված սյունակները պետք է լինեն աղյուսակ.սյունակ կամ սխեմա.աղյուսակ.սյունակ ձևաչափով validation.denied_columns.too_many=Կարելի է նշել առավելագույնը 200 արգելված սյունակ +validation.denied_schemas.item_blank=Արգելված սխեմաների գրառումները չեն կարող լինել դատարկ +validation.denied_schemas.item_invalid=Արգելված սխեման պետք է լինի մեկ սխեմայի անուն՝ առանց կետերի կամ նշանների +validation.denied_schemas.too_many=Կարելի է նշել առավելագույնը 50 արգելված սխեմա +validation.denied_tables.item_blank=Արգելված աղյուսակների գրառումները չեն կարող լինել դատարկ +validation.denied_tables.item_invalid=Արգելված աղյուսակները պետք է լինեն աղյուսակ, սխեմա.աղյուսակ կամ սխեմա.* ձևաչափով +validation.denied_tables.too_many=Կարելի է նշել առավելագույնը 200 արգելված աղյուսակ validation.schemas.max=Սխեմաների ցանկը պետք է լինի առավելագույնը 4 000 նիշ validation.review_plan_name.required=Վերանայման պլանի անունը պարտադիր է validation.review_plan_name.max=Վերանայման պլանի անունը պետք է լինի 1-ից 255 նիշ միջակայքում @@ -94,6 +100,7 @@ error.unauthorized=Անհրաժեշտ է վավերացում error.forbidden=Մուտքն արգելված է error.permission.table_not_allowed=Հարցումը հղում է կատարում մեկ կամ մի քանի աղյուսակների, որոնք օգտատիրոջ թույլատրված ցանկում չեն՝ {0} error.permission.column_not_allowed=Հարցումը հղում է կատարում մեկ կամ մի քանի սյունակների, որոնք այս տվյալների աղբյուրում արգելված են օգտատիրոջ համար՝ {0} +error.permission.table_denied=Հարցումը հղում է կատարում մեկ կամ մի քանի աղյուսակների, որոնք այս տվյալների աղբյուրում արգելված են օգտատիրոջ համար՝ {0} error.dry_run.unsupported={0} շարժիչի համար փորձնական գործարկումը չի աջակցվում error.dry_run.mssql_row_security_unsupported=Փորձնական գործարկումը հասանելի չէ այս հարցման համար, քանի որ կիրառվում է տողերի անվտանգության քաղաքականություն. SQL Server-ը կարող է կատարման պլան վերադարձնել միայն առանց կապված պարամետրերի հրահանգի համար error.dry_run.transactional_unsupported=Փորձնական գործարկումը հասանելի չէ գործարքային փաթեթային հարցումների համար @@ -1266,6 +1273,7 @@ workflow.access_simulation.permission.none=Օգտատերը չունի ակտի workflow.access_simulation.permission.capability_missing=Գործող թույլտվությունը չի տալիս այս տեսակի հրահանգի իրավունք workflow.access_simulation.permission.table_not_allowed=Գործող թույլտվությունը չի ընդգրկում բոլոր հղում աղյուսակները workflow.access_simulation.permission.column_denied=Գործող թույլտվությունը արգելում է հղված սյունակը +workflow.access_simulation.permission.table_denied=Գործող թույլտվությունը արգելում է հղված աղյուսակը workflow.access_simulation.permission.query_admin_bypass=Օգտատերը ունի QUERY_ADMIN, ինչը լիովին շրջանցում է տվյալների աղբյուրի թույլտվության ստուգումը workflow.access_simulation.reviewers.assigned=Այս տվյալների աղբյուրին նշանակված են վերանայողներ, որոնք կարող են գործել հարցման վրա workflow.access_simulation.reviewers.none=Ներկայացնողից բացի ոչ մի վերանայող չէր կարող գործել հարցման վրա diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties index 6f298024b..2580e8314 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -43,6 +43,12 @@ validation.restricted_columns.item_blank=Записи ограниченных validation.denied_columns.item_blank=Записи запрещённых столбцов не должны быть пустыми validation.denied_columns.item_unqualified=Запрещённые столбцы указываются как таблица.столбец или схема.таблица.столбец validation.denied_columns.too_many=Можно указать не более 200 запрещённых столбцов +validation.denied_schemas.item_blank=Записи запрещённых схем не должны быть пустыми +validation.denied_schemas.item_invalid=Запрещённая схема должна быть одним именем схемы без точек и подстановочных знаков +validation.denied_schemas.too_many=Можно указать не более 50 запрещённых схем +validation.denied_tables.item_blank=Записи запрещённых таблиц не должны быть пустыми +validation.denied_tables.item_invalid=Запрещённые таблицы указываются как таблица, схема.таблица или схема.* +validation.denied_tables.too_many=Можно указать не более 200 запрещённых таблиц validation.schemas.max=Список схем не должен превышать 4 000 символов validation.review_plan_name.required=Укажите название плана проверки validation.review_plan_name.max=Название плана проверки должно содержать от 1 до 255 символов @@ -94,6 +100,7 @@ error.unauthorized=Требуется аутентификация error.forbidden=Доступ запрещён error.permission.table_not_allowed=Запрос обращается к одной или нескольким таблицам, отсутствующим в списке разрешённых пользователя: {0} error.permission.column_not_allowed=Запрос обращается к одному или нескольким столбцам, запрещённым пользователю в этом источнике данных: {0} +error.permission.table_denied=Запрос обращается к одной или нескольким таблицам, запрещённым пользователю в этом источнике данных: {0} error.dry_run.unsupported=Пробный запуск не поддерживается для движка {0} error.dry_run.mssql_row_security_unsupported=Пробный запуск недоступен для этого запроса, так как применяется политика безопасности на уровне строк: SQL Server может вернуть план выполнения только для инструкции без связанных параметров error.dry_run.transactional_unsupported=Пробный запуск недоступен для транзакционных пакетных конвертов @@ -1266,6 +1273,7 @@ workflow.access_simulation.permission.none=У пользователя нет а workflow.access_simulation.permission.capability_missing=Действующее разрешение не даёт права на этот тип выражения workflow.access_simulation.permission.table_not_allowed=Действующее разрешение покрывает не все упомянутые таблицы workflow.access_simulation.permission.column_denied=Действующее разрешение запрещает упомянутый столбец +workflow.access_simulation.permission.table_denied=Действующее разрешение запрещает упомянутую таблицу workflow.access_simulation.permission.query_admin_bypass=У пользователя есть QUERY_ADMIN, который полностью обходит проверку разрешений по источнику данных workflow.access_simulation.reviewers.assigned=К этому источнику данных назначены проверяющие, которые могли бы обработать запрос workflow.access_simulation.reviewers.none=Ни один проверяющий, кроме автора запроса, не смог бы его обработать diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 2018be11f..8b603c75e 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -43,6 +43,12 @@ validation.restricted_columns.item_blank=受限列条目不能为空 validation.denied_columns.item_blank=禁止列条目不能为空 validation.denied_columns.item_unqualified=禁止列必须为 table.column 或 schema.table.column 格式 validation.denied_columns.too_many=最多可列出 200 个禁止列 +validation.denied_schemas.item_blank=禁止模式条目不能为空 +validation.denied_schemas.item_invalid=禁止模式必须是单个模式名称,不能包含点号或通配符 +validation.denied_schemas.too_many=最多可列出 50 个禁止模式 +validation.denied_tables.item_blank=禁止表条目不能为空 +validation.denied_tables.item_invalid=禁止表必须为 table、schema.table 或 schema.* 格式 +validation.denied_tables.too_many=最多可列出 200 个禁止表 validation.schemas.max=模式列表最多 4,000 个字符 validation.review_plan_name.required=请输入审核计划名称 validation.review_plan_name.max=审核计划名称必须在 1 到 255 个字符之间 @@ -94,6 +100,7 @@ error.unauthorized=需要身份验证 error.forbidden=访问被拒绝 error.permission.table_not_allowed=查询引用了一个或多个不在用户允许列表中的表:{0} error.permission.column_not_allowed=查询引用了一个或多个在此数据源上对该用户禁止的列:{0} +error.permission.table_denied=查询引用了一个或多个在此数据源上对该用户禁止的表:{0} error.dry_run.unsupported={0} 引擎不支持试运行 error.dry_run.mssql_row_security_unsupported=该查询适用行级安全策略,因此无法试运行:SQL Server 仅能为不含绑定参数的语句返回执行计划 error.dry_run.transactional_unsupported=事务批处理语句不支持试运行 @@ -1266,6 +1273,7 @@ workflow.access_simulation.permission.none=该用户在此数据源上没有有 workflow.access_simulation.permission.capability_missing=有效权限不包含该语句类型 workflow.access_simulation.permission.table_not_allowed=有效权限未覆盖全部引用表 workflow.access_simulation.permission.column_denied=有效权限禁止了某个引用列 +workflow.access_simulation.permission.table_denied=有效权限禁止了某个引用表 workflow.access_simulation.permission.query_admin_bypass=该用户持有 QUERY_ADMIN,会完全跳过按数据源的权限校验 workflow.access_simulation.reviewers.assigned=此数据源已指派审核人,他们可以处理该请求 workflow.access_simulation.reviewers.none=除提交者之外没有审核人可以处理该请求 diff --git a/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java b/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java index 65d283bab..4690a2452 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java @@ -69,7 +69,7 @@ private AccessGrantRequestEntity approved() { private DatasourcePermissionView granted() { return new DatasourcePermissionView(newPermissionId, datasourceId, requesterId, "u@x.io", - "U", true, false, false, false, null, List.of("public"), null, null, null, + "U", true, false, false, false, null, List.of("public"), null, null, null, List.of(), List.of(), Instant.now().plusSeconds(3600), approverId, Instant.now()); } @@ -103,7 +103,7 @@ void materialiseGrantsPermissionAndAttaches() { void materialiseThrowsWhenStandingPermissionExists() { when(requestRepository.findById(requestId)).thenReturn(Optional.of(approved())); var standing = new DatasourceUserPermissionView(UUID.randomUUID(), requesterId, datasourceId, - true, false, false, false, null, null, null, null, null, null /* no expiry = standing */); + true, false, false, false, null, null, null, null, List.of(), List.of(), null, null /* no expiry = standing */); when(permissionLookupService.findDirectFor(requesterId, datasourceId)) .thenReturn(Optional.of(standing)); @@ -117,7 +117,7 @@ void materialiseReplacesExistingTimeBoxedPermission() { when(requestRepository.findById(requestId)).thenReturn(Optional.of(approved())); var existingPermId = UUID.randomUUID(); var jit = new DatasourceUserPermissionView(existingPermId, requesterId, datasourceId, - true, false, false, false, null, null, null, null, null, Instant.now().plusSeconds(60)); + true, false, false, false, null, null, null, null, List.of(), List.of(), null, Instant.now().plusSeconds(60)); when(permissionLookupService.findDirectFor(requesterId, datasourceId)).thenReturn(Optional.of(jit)); when(datasourceAdminService.grantPermission(any(), any(), any(), any())) .thenReturn(granted()); @@ -129,6 +129,46 @@ void materialiseReplacesExistingTimeBoxedPermission() { eq(approverId), any()); } + @Test + void materialiseCarriesTheReplacedRowsDenialsOntoTheNewGrant() { + when(requestRepository.findById(requestId)).thenReturn(Optional.of(approved())); + var existing = new DatasourceUserPermissionView(UUID.randomUUID(), requesterId, + datasourceId, true, false, false, false, List.of("crm"), null, null, + List.of("crm.customer.ssn"), List.of("hr"), List.of("crm.salary"), null, + Instant.now().plusSeconds(60)); + when(permissionLookupService.findDirectFor(requesterId, datasourceId)) + .thenReturn(Optional.of(existing)); + when(datasourceAdminService.grantPermission(any(), any(), any(), any())) + .thenReturn(granted()); + + materializer.materialize(requestId, approverId); + + var captor = ArgumentCaptor.forClass(CreatePermissionCommand.class); + verify(datasourceAdminService).grantPermission(eq(datasourceId), eq(organizationId), + eq(approverId), captor.capture()); + assertThat(captor.getValue().deniedColumns()).containsExactly("crm.customer.ssn"); + assertThat(captor.getValue().deniedSchemas()).containsExactly("hr"); + assertThat(captor.getValue().deniedTables()).containsExactly("crm.salary"); + } + + @Test + void materialiseWithoutAnExistingRowCarriesNoDenials() { + when(requestRepository.findById(requestId)).thenReturn(Optional.of(approved())); + when(permissionLookupService.findDirectFor(requesterId, datasourceId)) + .thenReturn(Optional.empty()); + when(datasourceAdminService.grantPermission(any(), any(), any(), any())) + .thenReturn(granted()); + + materializer.materialize(requestId, approverId); + + var captor = ArgumentCaptor.forClass(CreatePermissionCommand.class); + verify(datasourceAdminService).grantPermission(eq(datasourceId), eq(organizationId), + eq(approverId), captor.capture()); + assertThat(captor.getValue().deniedSchemas()).isNull(); + assertThat(captor.getValue().deniedTables()).isNull(); + assertThat(captor.getValue().deniedColumns()).isNull(); + } + // --- AF-567: connector-targeted requests ---------------------------------------------------- private AccessGrantRequestEntity approvedConnector() { diff --git a/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultGrantUsageAggregationServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultGrantUsageAggregationServiceTest.java index 16e518c2b..107b65077 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultGrantUsageAggregationServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultGrantUsageAggregationServiceTest.java @@ -109,7 +109,7 @@ private void givenDatasourceGrant(List allowedTables, Instant grantedAt) .thenReturn(List.of(new DatasourceRef(DATASOURCE, "analytics"))); when(datasourceAdminService.listPermissions(DATASOURCE, ORG)).thenReturn(List.of( new DatasourcePermissionView(PERMISSION, DATASOURCE, USER, "dev@example.test", - "Dev", true, false, false, false, null, List.of(), allowedTables, List.of(), null, + "Dev", true, false, false, false, null, List.of(), allowedTables, List.of(), null, List.of(), List.of(), null, UUID.randomUUID(), grantedAt))); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultPrivilegedAccessServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultPrivilegedAccessServiceTest.java index 1a2f5e197..2148deaed 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultPrivilegedAccessServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/access/internal/DefaultPrivilegedAccessServiceTest.java @@ -352,7 +352,7 @@ private static DatasourcePermissionContribution direct(UUID userId, UUID datasou UUID rowId, Instant expiresAt) { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.DIRECT, rowId, userId, datasourceId, null, null, true, false, false, true, List.of(), List.of(), - List.of(), null, null, expiresAt, null); + List.of(), null, List.of(), List.of(), null, expiresAt, null); } private static DatasourcePermissionContribution group(UUID userId, UUID datasourceId, @@ -360,6 +360,6 @@ private static DatasourcePermissionContribution group(UUID userId, UUID datasour Instant expiresAt) { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.GROUP, rowId, userId, datasourceId, groupId, groupName, true, false, false, true, List.of(), - List.of(), List.of(), null, null, expiresAt, null); + List.of(), List.of(), null, List.of(), List.of(), null, expiresAt, null); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultAiAnalyzerServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultAiAnalyzerServiceTest.java index 68be2f54d..c39eccd66 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultAiAnalyzerServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultAiAnalyzerServiceTest.java @@ -198,7 +198,7 @@ void analyzePreviewIncludesRestrictedColumnMarkerInSchemaContext() { List.of())))))); var permission = new com.bablsoft.accessflow.core.api.DatasourceUserPermissionView( UUID.randomUUID(), userId, datasourceId, true, false, false, false, - List.of(), List.of(), List.of("public.users.ssn"), null, null, null); + List.of(), List.of(), List.of("public.users.ssn"), null, List.of(), List.of(), null, null); when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.of(permission)); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(String.class); when(strategy.analyze(eq("SELECT ssn FROM users"), eq(DbType.POSTGRESQL), contextCaptor.capture(), diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultTextToSqlServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultTextToSqlServiceTest.java index 32ec11647..826e49f60 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultTextToSqlServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/DefaultTextToSqlServiceTest.java @@ -128,7 +128,7 @@ void generateSqlPassesRestrictedColumnsIntoSchemaContext() { when(datasourceAdminService.introspectSchema(datasourceId, organizationId, userId, false)) .thenReturn(schemaView()); var permission = new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, - true, false, false, false, List.of(), List.of(), List.of("public.orders.created_at"), null, null, null); + true, false, false, false, List.of(), List.of(), List.of("public.orders.created_at"), null, List.of(), List.of(), null, null); when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.of(permission)); ArgumentCaptor ctx = ArgumentCaptor.forClass(String.class); when(strategy.generateSql(any(), eq(DbType.POSTGRESQL), ctx.capture(), eq("en"), eq(aiConfigId))) diff --git a/backend/src/test/java/com/bablsoft/accessflow/attestation/AttestationLifecycleIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/attestation/AttestationLifecycleIntegrationTest.java index 0879e5c71..681aa5c99 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/attestation/AttestationLifecycleIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/attestation/AttestationLifecycleIntegrationTest.java @@ -130,7 +130,7 @@ private UserEntity user(String name, UserRoleType role) { private void grant(UserEntity subject) { datasourceAdminService.grantPermission(datasource.getId(), organization.getId(), admin.getId(), new CreatePermissionCommand(subject.getId(), true, false, false, - false, null, List.of("public"), null, null, null, null, null)); + false, null, List.of("public"), null, null, null, null, null, null, null)); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleServiceTest.java index ab911983b..d94cc6730 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/attestation/internal/DefaultAttestationLifecycleServiceTest.java @@ -79,7 +79,7 @@ private AttestationCampaignEntity scheduledDatasourceCampaign() { private DatasourcePermissionView permission(UUID userId) { return new DatasourcePermissionView(UUID.randomUUID(), datasourceId, userId, userId + "@example.com", "User", true, false, false, false, null, - List.of("public"), List.of(), List.of(), null, null, UUID.randomUUID(), Instant.now()); + List.of("public"), List.of(), List.of(), null, List.of(), List.of(), null, UUID.randomUUID(), Instant.now()); } @Test @@ -103,6 +103,53 @@ void openSnapshotsGrantsAndPublishesEvent() { .isEqualTo(AuditAction.ATTESTATION_CAMPAIGN_OPENED); } + @Test + void openSnapshotsTheGrantsTableAndSchemaDenials() { + var userId = UUID.randomUUID(); + when(campaignRepository.findByIdForUpdate(campaignId)) + .thenReturn(Optional.of(scheduledDatasourceCampaign())); + when(datasourceLookupService.findRef(datasourceId)) + .thenReturn(Optional.of(new DatasourceRef(datasourceId, "Production"))); + when(datasourceAdminService.listPermissions(datasourceId, orgId)) + .thenReturn(List.of(new DatasourcePermissionView(UUID.randomUUID(), datasourceId, + userId, "u@example.com", "User", true, false, false, false, null, + List.of("crm"), List.of(), List.of(), List.of(), List.of("hr"), + List.of("crm.salary"), null, UUID.randomUUID(), Instant.now()))); + when(itemRepository.existsByCampaignIdAndPermissionId(any(), any())).thenReturn(false); + + service.openCampaign(campaignId); + + var item = ArgumentCaptor.forClass(AttestationItemEntity.class); + verify(itemRepository).save(item.capture()); + var snapshot = new ObjectMapper().readTree(item.getValue().getPermissionSnapshot()); + assertThat(snapshot.get("denied_schemas").get(0).asString()).isEqualTo("hr"); + assertThat(snapshot.get("denied_schemas")).hasSize(1); + assertThat(snapshot.get("denied_tables").get(0).asString()).isEqualTo("crm.salary"); + assertThat(snapshot.get("denied_tables")).hasSize(1); + } + + @Test + void openSnapshotsEmptyDenialArraysForAGrantWithoutDenials() { + when(campaignRepository.findByIdForUpdate(campaignId)) + .thenReturn(Optional.of(scheduledDatasourceCampaign())); + when(datasourceLookupService.findRef(datasourceId)) + .thenReturn(Optional.of(new DatasourceRef(datasourceId, "Production"))); + var view = new DatasourcePermissionView(UUID.randomUUID(), datasourceId, UUID.randomUUID(), + "u@example.com", "User", true, false, false, false, null, null, null, null, null, + null, null, null, null, null); + when(datasourceAdminService.listPermissions(datasourceId, orgId)).thenReturn(List.of(view)); + when(itemRepository.existsByCampaignIdAndPermissionId(any(), any())).thenReturn(false); + + service.openCampaign(campaignId); + + var item = ArgumentCaptor.forClass(AttestationItemEntity.class); + verify(itemRepository).save(item.capture()); + var snapshot = new ObjectMapper().readTree(item.getValue().getPermissionSnapshot()); + assertThat(snapshot.get("denied_schemas").isArray()).isTrue(); + assertThat(snapshot.get("denied_schemas")).isEmpty(); + assertThat(snapshot.get("denied_tables")).isEmpty(); + } + /** * The reviewer's whole reason for existing here: the item carries the usage evidence as it stood * at campaign open, so certifying is an informed decision rather than a rubber stamp. diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedTablesTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedTablesTest.java new file mode 100644 index 000000000..bb4d54e48 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedTablesTest.java @@ -0,0 +1,161 @@ +package com.bablsoft.accessflow.core.api; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeniedTablesTest { + + @Test + void normalizeStripsQuotesLowercasesDropsBlanksAndDedupes() { + var input = new ArrayList(); + input.add(" \"CRM\".[Salary] "); + input.add("crm.salary"); + input.add(" "); + input.add(null); + + assertThat(DeniedTables.normalize(input)).containsExactly("crm.salary"); + assertThat(DeniedTables.normalize(null)).isEmpty(); + } + + @Test + void qualifiedTableEntryDeniesItselfBareAndCatalogQualifiedReferences() { + var tables = List.of("crm.salary"); + + assertThat(DeniedTables.denyingEntry(List.of(), tables, "crm.salary")) + .isEqualTo("crm.salary"); + // Unqualified: could resolve to crm.salary, so it fails closed. + assertThat(DeniedTables.denyingEntry(List.of(), tables, "salary")).isEqualTo("crm.salary"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "db.crm.salary")) + .isEqualTo("crm.salary"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "crm.customer")).isNull(); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "hr.salary")).isNull(); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "crm.salary_history")).isNull(); + } + + @Test + void bareTableEntryDeniesTheNameInEverySchema() { + var tables = List.of("salary"); + + assertThat(DeniedTables.denyingEntry(List.of(), tables, "salary")).isEqualTo("salary"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "crm.salary")).isEqualTo("salary"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "hr.salary")).isEqualTo("salary"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "crm.salaryx")).isNull(); + } + + @Test + void schemaEntryDeniesQualifiedReferencesAndEveryUnqualifiedOne() { + var schemas = List.of("hr"); + + assertThat(DeniedTables.denyingEntry(schemas, List.of(), "hr.employee")).isEqualTo("hr"); + assertThat(DeniedTables.denyingEntry(schemas, List.of(), "db.hr.employee")) + .isEqualTo("hr"); + assertThat(DeniedTables.denyingEntry(schemas, List.of(), "crm.customer")).isNull(); + // A table named like the schema is not in it. + assertThat(DeniedTables.denyingEntry(schemas, List.of(), "crm.hr")).isNull(); + // Unqualified: the gate cannot tell which schema it resolves to — fail closed. + assertThat(DeniedTables.denyingEntry(schemas, List.of(), "employee")).isEqualTo("hr"); + } + + @Test + void aSchemaWildcardInTheTableListDeniesTheSchema() { + var tables = List.of("hr.*"); + + assertThat(DeniedTables.denyingEntry(List.of(), tables, "hr.employee")).isEqualTo("hr"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "employee")).isEqualTo("hr"); + assertThat(DeniedTables.denyingEntry(List.of(), tables, "crm.customer")).isNull(); + } + + @Test + void anEmptySchemaSegmentMatchesAnyDenial() { + // SQL Server db..table resolves to the caller's default schema. + assertThat(DeniedTables.denyingEntry(List.of(), List.of("dbo.salary"), "mydb..salary")) + .isEqualTo("dbo.salary"); + assertThat(DeniedTables.denyingEntry(List.of("dbo"), List.of(), "mydb..salary")) + .isEqualTo("dbo"); + assertThat(DeniedTables.denyingEntry(List.of(), List.of("dbo.salary"), "mydb..orders")) + .isNull(); + } + + @Test + void anOracleDatabaseLinkSuffixIsIgnored() { + assertThat(DeniedTables.denyingEntry(List.of(), List.of("hr.salary"), "hr.salary@loop")) + .isEqualTo("hr.salary"); + assertThat(DeniedTables.denyingEntry(List.of("hr"), List.of(), "hr.salary@loop")) + .isEqualTo("hr"); + } + + @Test + void aPatternReferenceIsDeniedByAnyEntry() { + assertThat(DeniedTables.denyingEntry(List.of(), List.of("salary"), "sal*")) + .isEqualTo("salary"); + assertThat(DeniedTables.denyingEntry(List.of("hr"), List.of(), "logs-?")) + .isEqualTo("hr"); + assertThat(DeniedTables.denyingEntry(List.of(), List.of(), "sal*")).isNull(); + } + + @Test + void denyingEntryIsNullForANullTableOrEmptyLists() { + assertThat(DeniedTables.denyingEntry(List.of("hr"), List.of("x"), null)).isNull(); + assertThat(DeniedTables.denyingEntry(List.of(), List.of(), "hr.x")).isNull(); + } + + @Test + void rejectedReturnsTheDeniedReferencesSortedInTheirOriginalSpelling() { + var rejected = DeniedTables.rejected(List.of("HR"), List.of("\"crm\".\"salary\""), + Set.of("crm.customer", "crm.salary", "hr.employee")); + + assertThat(rejected).containsExactly("crm.salary", "hr.employee"); + } + + @Test + void rejectedIsEmptyWithoutDenialsOrReferences() { + assertThat(DeniedTables.rejected(null, null, Set.of("crm.salary"))).isEmpty(); + assertThat(DeniedTables.rejected(List.of("hr"), List.of(), null)).isEmpty(); + assertThat(DeniedTables.rejected(List.of("hr"), List.of(), Set.of())).isEmpty(); + } + + @Test + void deniesTableMatchesTheIntrospectedSchemaAndTable() { + assertThat(DeniedTables.deniesTable(List.of(), List.of("crm.salary"), "CRM", "Salary")) + .isTrue(); + assertThat(DeniedTables.deniesTable(List.of(), List.of("crm.salary"), "crm", "customer")) + .isFalse(); + assertThat(DeniedTables.deniesTable(List.of("hr"), List.of(), "hr", "employee")).isTrue(); + assertThat(DeniedTables.deniesTable(List.of("hr"), List.of(), "crm", "employee")).isFalse(); + // No schema reads as unqualified, which a schema denial refuses. + assertThat(DeniedTables.deniesTable(List.of("hr"), List.of(), null, "employee")).isTrue(); + assertThat(DeniedTables.deniesTable(List.of("hr"), List.of(), "hr", " ")).isFalse(); + } + + @Test + void entryValidationRefusesNamesThatCouldNeverMatch() { + assertThat(DeniedTables.isValidSchemaEntry("hr")).isTrue(); + assertThat(DeniedTables.isValidSchemaEntry("\"HR Data\"")).isTrue(); + assertThat(DeniedTables.isValidSchemaEntry("analytics.hr")).isFalse(); + assertThat(DeniedTables.isValidSchemaEntry("h*")).isFalse(); + assertThat(DeniedTables.isValidSchemaEntry(" ")).isFalse(); + assertThat(DeniedTables.isValidSchemaEntry(null)).isFalse(); + + assertThat(DeniedTables.isValidTableEntry("salary")).isTrue(); + assertThat(DeniedTables.isValidTableEntry("crm.salary")).isTrue(); + assertThat(DeniedTables.isValidTableEntry("db.crm.salary")).isTrue(); + assertThat(DeniedTables.isValidTableEntry("crm.*")).isTrue(); + assertThat(DeniedTables.isValidTableEntry("*")).isFalse(); + assertThat(DeniedTables.isValidTableEntry("sal*")).isFalse(); + assertThat(DeniedTables.isValidTableEntry("crm..salary")).isFalse(); + assertThat(DeniedTables.isValidTableEntry("crm.")).isFalse(); + assertThat(DeniedTables.isValidTableEntry(null)).isFalse(); + } + + @Test + void deniesSchemaMatchesNormalizedNames() { + assertThat(DeniedTables.deniesSchema(List.of("\"HR\""), "hr")).isTrue(); + assertThat(DeniedTables.deniesSchema(List.of("hr"), "crm")).isFalse(); + assertThat(DeniedTables.deniesSchema(List.of("hr"), null)).isFalse(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java index d36e57586..ae4c5b59e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DatasourceAdminServiceImplTest.java @@ -1075,7 +1075,7 @@ void grantPermissionRejectsUserFromDifferentOrg() { when(userRepository.findById(userId)).thenReturn(Optional.of(user)); var command = new CreatePermissionCommand(userId, true, false, false, false, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null); assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(IllegalDatasourcePermissionException.class); } @@ -1087,7 +1087,7 @@ void grantPermissionRejectsUnknownUser() { when(userRepository.findById(userId)).thenReturn(Optional.empty()); var command = new CreatePermissionCommand(userId, true, false, false, false, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null); assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(IllegalDatasourcePermissionException.class); } @@ -1106,7 +1106,7 @@ void grantPermissionRejectsDuplicate() { .thenReturn(true); var command = new CreatePermissionCommand(userId, true, false, false, false, null, null, - null, null, null, null, null); + null, null, null, null, null, null, null); assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(DatasourcePermissionAlreadyExistsException.class); } @@ -1133,7 +1133,7 @@ void grantPermissionPersistsAndReturnsView() { .thenAnswer(inv -> inv.getArgument(0)); var command = new CreatePermissionCommand(userId, true, true, false, true, 500, - List.of("public"), List.of("orders"), List.of("public.orders.ssn"), null, null, null); + List.of("public"), List.of("orders"), List.of("public.orders.ssn"), null, null, null, null, null); var view = service.grantPermission(datasourceId, orgId, adminId, command); // An admin-created row has no originating JIT request (#969). @@ -1172,7 +1172,7 @@ void grantPermissionStampsTheOriginatingAccessRequest() { var accessGrantRequestId = UUID.randomUUID(); service.grantPermission(datasourceId, orgId, adminId, new CreatePermissionCommand(userId, - true, false, false, false, null, null, null, null, null, + true, false, false, false, null, null, null, null, null, null, null, Instant.now().plusSeconds(3600), accessGrantRequestId)); assertThat(saved.getValue().getAccessGrantRequestId()).isEqualTo(accessGrantRequestId); @@ -1229,6 +1229,56 @@ void grantPermissionRejectsDeniedColumnsOnAnEngineManagedDatasource() { verify(permissionRepository, never()).save(any()); } + @Test + void grantPermissionPersistsNormalizedDeniedSchemasAndTables() { + stubGrantableUser(DbType.POSTGRESQL); + var saved = ArgumentCaptor.forClass(DatasourceUserPermissionEntity.class); + when(permissionRepository.save(saved.capture())).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.grantPermission(datasourceId, orgId, adminId, + new CreatePermissionCommand(userId, true, false, false, false, null, + List.of("crm"), null, null, null, List.of(" \"HR\" ", "hr"), + List.of("CRM.Salary", "`crm`.`salary`", "bonus"), null, null)); + + assertThat(saved.getValue().getDeniedSchemas()).containsExactly("hr"); + assertThat(saved.getValue().getDeniedTables()).containsExactly("crm.salary", "bonus"); + assertThat(view.deniedSchemas()).containsExactly("hr"); + assertThat(view.deniedTables()).containsExactly("crm.salary", "bonus"); + } + + @Test + void grantPermissionRefusesADeniedEntryThatCouldNeverMatch() { + stubGrantableUser(DbType.POSTGRESQL); + + assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, + new CreatePermissionCommand(userId, true, false, false, false, null, null, null, + null, null, List.of("analytics.hr"), null, null, null))) + .isInstanceOf(IllegalDatasourcePermissionException.class) + .hasMessageContaining("denied_schemas"); + assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, + new CreatePermissionCommand(userId, true, false, false, false, null, null, null, + null, null, null, List.of("sal*"), null, null))) + .isInstanceOf(IllegalDatasourcePermissionException.class) + .hasMessageContaining("denied_tables"); + verify(permissionRepository, never()).save(any()); + } + + @Test + void grantPermissionStoresNoDeniedSchemasOrTablesWhenEmptyOrBlank() { + stubGrantableUser(DbType.POSTGRESQL); + var saved = ArgumentCaptor.forClass(DatasourceUserPermissionEntity.class); + when(permissionRepository.save(saved.capture())).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.grantPermission(datasourceId, orgId, adminId, + new CreatePermissionCommand(userId, true, false, false, false, null, null, null, + null, null, List.of(), List.of(" "), null, null)); + + assertThat(saved.getValue().getDeniedSchemas()).isNull(); + assertThat(saved.getValue().getDeniedTables()).isNull(); + assertThat(view.deniedSchemas()).isNullOrEmpty(); + assertThat(view.deniedTables()).isNullOrEmpty(); + } + private void stubGrantableUser(DbType dbType) { var entity = buildDatasource(datasourceId, orgId, "Prod"); entity.setDbType(dbType); @@ -1245,7 +1295,7 @@ private void stubGrantableUser(DbType dbType) { private CreatePermissionCommand deniedCommand(List deniedColumns) { return new CreatePermissionCommand(userId, true, false, false, false, null, null, null, - null, deniedColumns, null, null); + null, deniedColumns, null, null, null, null); } @Test @@ -1312,7 +1362,7 @@ void grantGroupPermissionPersistsAndReturnsView() { .thenAnswer(inv -> inv.getArgument(0)); var command = new com.bablsoft.accessflow.core.api.CreateDatasourceGroupPermissionCommand( - groupId, true, true, false, false, null, List.of("public"), null, null, null, null); + groupId, true, true, false, false, null, List.of("public"), null, null, null, null, null, null); var view = service.grantGroupPermission(datasourceId, orgId, adminId, command); assertThat(view.groupId()).isEqualTo(groupId); @@ -1324,6 +1374,61 @@ void grantGroupPermissionPersistsAndReturnsView() { assertThat(view.createdBy()).isEqualTo(adminId); } + @Test + void grantGroupPermissionPersistsNormalizedDeniedSchemasAndTables() { + var groupId = UUID.randomUUID(); + var entity = buildDatasource(datasourceId, orgId, "Prod"); + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.of(entity)); + when(userGroupService.getGroup(groupId, orgId)).thenReturn(new com.bablsoft.accessflow.core.api.UserGroupView( + groupId, orgId, "Analysts", null, 4, java.time.Instant.now(), java.time.Instant.now())); + when(groupPermissionRepository.existsByGroup_IdAndDatasource_Id(groupId, datasourceId)) + .thenReturn(false); + var group = new com.bablsoft.accessflow.core.internal.persistence.entity.UserGroupEntity(); + group.setId(groupId); + group.setName("Analysts"); + when(userGroupRepository.getReferenceById(groupId)).thenReturn(group); + var saved = ArgumentCaptor.forClass( + com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceGroupPermissionEntity.class); + when(groupPermissionRepository.save(saved.capture())).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.grantGroupPermission(datasourceId, orgId, adminId, + new com.bablsoft.accessflow.core.api.CreateDatasourceGroupPermissionCommand( + groupId, true, false, false, false, null, null, null, null, null, + List.of("Audit"), List.of("CRM.Salary", " "), null)); + + assertThat(saved.getValue().getDeniedSchemas()).containsExactly("audit"); + assertThat(saved.getValue().getDeniedTables()).containsExactly("crm.salary"); + assertThat(view.deniedSchemas()).containsExactly("audit"); + assertThat(view.deniedTables()).containsExactly("crm.salary"); + } + + @Test + void grantGroupPermissionStoresNoDeniedSchemasOrTablesWhenAbsent() { + var groupId = UUID.randomUUID(); + var entity = buildDatasource(datasourceId, orgId, "Prod"); + when(datasourceRepository.findById(datasourceId)).thenReturn(Optional.of(entity)); + when(userGroupService.getGroup(groupId, orgId)).thenReturn(new com.bablsoft.accessflow.core.api.UserGroupView( + groupId, orgId, "Analysts", null, 4, java.time.Instant.now(), java.time.Instant.now())); + when(groupPermissionRepository.existsByGroup_IdAndDatasource_Id(groupId, datasourceId)) + .thenReturn(false); + var group = new com.bablsoft.accessflow.core.internal.persistence.entity.UserGroupEntity(); + group.setId(groupId); + when(userGroupRepository.getReferenceById(groupId)).thenReturn(group); + var saved = ArgumentCaptor.forClass( + com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceGroupPermissionEntity.class); + when(groupPermissionRepository.save(saved.capture())).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.grantGroupPermission(datasourceId, orgId, adminId, + new com.bablsoft.accessflow.core.api.CreateDatasourceGroupPermissionCommand( + groupId, true, false, false, false, null, null, null, null, null, + List.of(), null, null)); + + assertThat(saved.getValue().getDeniedSchemas()).isNull(); + assertThat(saved.getValue().getDeniedTables()).isNull(); + assertThat(view.deniedSchemas()).isNullOrEmpty(); + assertThat(view.deniedTables()).isNullOrEmpty(); + } + @Test void grantGroupPermissionRejectsDuplicate() { var groupId = UUID.randomUUID(); @@ -1335,7 +1440,7 @@ void grantGroupPermissionRejectsDuplicate() { .thenReturn(true); var command = new com.bablsoft.accessflow.core.api.CreateDatasourceGroupPermissionCommand( - groupId, true, false, false, false, null, null, null, null, null, null); + groupId, true, false, false, false, null, null, null, null, null, null, null, null); assertThatThrownBy(() -> service.grantGroupPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(com.bablsoft.accessflow.core.api.DatasourceGroupPermissionAlreadyExistsException.class); } @@ -1349,7 +1454,7 @@ void grantGroupPermissionRejectsUnknownGroup() { .thenThrow(new com.bablsoft.accessflow.core.api.UserGroupNotFoundException(groupId)); var command = new com.bablsoft.accessflow.core.api.CreateDatasourceGroupPermissionCommand( - groupId, true, false, false, false, null, null, null, null, null, null); + groupId, true, false, false, false, null, null, null, null, null, null, null, null); assertThatThrownBy(() -> service.grantGroupPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(com.bablsoft.accessflow.core.api.UserGroupNotFoundException.class); } @@ -1536,7 +1641,7 @@ private static com.bablsoft.accessflow.core.api.DatabaseSchemaView fourTableSche private com.bablsoft.accessflow.core.api.DatasourceUserPermissionView permission( java.util.List tables) { return new com.bablsoft.accessflow.core.api.DatasourceUserPermissionView(UUID.randomUUID(), - userId, datasourceId, true, false, false, false, null, tables, null, null, null, + userId, datasourceId, true, false, false, false, null, tables, null, null, List.of(), List.of(), null, null); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java index 35931ed8e..de64f94d4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java @@ -224,6 +224,122 @@ void findForDeniesNothingWhenOneGrantDeniesNothing() { assertThat(service.findFor(userId, datasourceId).orElseThrow().deniedColumns()).isEmpty(); } + // ── Table / schema deny-lists (#939) ───────────────────────────────────── + + @Test + void permissiveGroupGrantCannotLiftADirectTableDenial() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var groupId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setCanRead(true); + direct.setDeniedTables(new String[] {"crm.salary"}); + var group = newGroupPermission(groupId, datasourceId); + group.setCanRead(true); + group.setCanWrite(true); + group.setCanDdl(true); + group.setCanBreakGlass(true); + group.setAllowedSchemas(new String[0]); + group.setAllowedTables(new String[0]); + group.setDeniedSchemas(new String[0]); + group.setDeniedTables(new String[0]); + when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId)) + .thenReturn(Optional.of(direct)); + when(membershipRepository.findGroupIdsForUser(userId)).thenReturn(List.of(groupId)); + when(groupPermissionRepository.findAllByGroup_IdIn(List.of(groupId))) + .thenReturn(List.of(group)); + + var view = service.findFor(userId, datasourceId).orElseThrow(); + + assertThat(view.canWrite()).isTrue(); + assertThat(view.allowedSchemas()).isEmpty(); + assertThat(view.allowedTables()).isEmpty(); + assertThat(view.deniedTables()).containsExactly("crm.salary"); + assertThat(com.bablsoft.accessflow.core.api.DeniedTables.rejected(view.deniedSchemas(), + view.deniedTables(), java.util.Set.of("crm.salary", "crm.customer"))) + .containsExactly("crm.salary"); + } + + @Test + void findForUnionsDenialsAcrossGrantsNormalisedAndDeduplicated() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var groupId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setCanRead(true); + direct.setDeniedSchemas(new String[] {"\"HR\""}); + direct.setDeniedTables(new String[] {"CRM.Salary", " crm.salary "}); + var group = newGroupPermission(groupId, datasourceId); + group.setCanRead(true); + group.setDeniedSchemas(new String[] {"hr", "audit"}); + group.setDeniedTables(new String[] {"crm.bonus", "crm.salary"}); + when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId)) + .thenReturn(Optional.of(direct)); + when(membershipRepository.findGroupIdsForUser(userId)).thenReturn(List.of(groupId)); + when(groupPermissionRepository.findAllByGroup_IdIn(List.of(groupId))) + .thenReturn(List.of(group)); + + var view = service.findFor(userId, datasourceId).orElseThrow(); + + assertThat(view.deniedSchemas()).containsExactly("hr", "audit"); + assertThat(view.deniedTables()).containsExactly("crm.salary", "crm.bonus"); + } + + @Test + void findForDeniesNothingWhenNoGrantDeniesATable() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setCanRead(true); + when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId)) + .thenReturn(Optional.of(direct)); + + var view = service.findFor(userId, datasourceId).orElseThrow(); + + assertThat(view.deniedSchemas()).isEmpty(); + assertThat(view.deniedTables()).isEmpty(); + } + + @Test + void findDirectForNormalisesTheDenyLists() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setDeniedSchemas(new String[] {"HR", "hr"}); + direct.setDeniedTables(new String[] {"`CRM`.`Salary`"}); + when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId)) + .thenReturn(Optional.of(direct)); + + var view = service.findDirectFor(userId, datasourceId).orElseThrow(); + + assertThat(view.deniedSchemas()).containsExactly("hr"); + assertThat(view.deniedTables()).containsExactly("crm.salary"); + } + + @Test + void contributionsCarryEachGrantsOwnDenyLists() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var groupId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setDeniedTables(new String[] {"crm.salary"}); + var group = newGroupPermission(groupId, datasourceId); + group.setDeniedSchemas(new String[] {"hr"}); + when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId)) + .thenReturn(Optional.of(direct)); + when(membershipRepository.findGroupIdsForUser(userId)).thenReturn(List.of(groupId)); + when(groupPermissionRepository.findAllByGroup_IdIn(List.of(groupId))) + .thenReturn(List.of(group)); + + var contributions = service.findContributions(userId, datasourceId); + + assertThat(contributions).hasSize(2); + assertThat(contributions.get(0).deniedTables()).containsExactly("crm.salary"); + assertThat(contributions.get(0).deniedSchemas()).isEmpty(); + assertThat(contributions.get(1).deniedSchemas()).containsExactly("hr"); + assertThat(contributions.get(1).deniedTables()).isEmpty(); + } + @Test void findForAllowListWideOpenWhenOneGrantHasNoRestriction() { var userId = UUID.randomUUID(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java index 5810c112d..5b0d51d0e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/SchemaViewPermissionFilterTest.java @@ -36,7 +36,7 @@ private static DatasourceUserPermissionView permission(List schemas, List tables, List denied) { return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), - UUID.randomUUID(), true, false, false, false, schemas, tables, null, denied, null, + UUID.randomUUID(), true, false, false, false, schemas, tables, null, denied, List.of(), List.of(), null, null); } @@ -162,6 +162,66 @@ void aForeignKeyWhoseTargetNameAlsoBelongsToAHiddenTableIsDropped() { assertThat(result.schemas().getFirst().tables().getFirst().foreignKeys()).isEmpty(); } + private static DatasourceUserPermissionView denying(List allowedSchemas, + List deniedSchemas, + List deniedTables) { + return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), true, false, false, false, allowedSchemas, null, null, null, + deniedSchemas, deniedTables, null, null); + } + + @Test + void aDeniedTableIsHiddenAndADenyOnlyGrantKeepsEverythingElse() { + var result = SchemaViewPermissionFilter.apply(view(), + denying(null, null, List.of("PUBLIC.Salary"))); + + assertThat(tableNames(result)).containsExactly("public.customer", "public.service", + "public.employee", "HR.payroll"); + // A deny-only grant leaves the rest queryable, so an empty schema stays listed. + assertThat(result.schemas()).extracting(Schema::name) + .containsExactly("public", "HR", "empty"); + } + + @Test + void aDeniedTableIsHiddenInsideAnAllowedSchema() { + var result = SchemaViewPermissionFilter.apply(view(), + denying(List.of("public"), null, List.of("salary"))); + + assertThat(tableNames(result)).containsExactly("public.customer", "public.service", + "public.employee"); + } + + @Test + void aDeniedSchemaIsNotListedAtAll() { + var result = SchemaViewPermissionFilter.apply(view(), + denying(null, List.of("hr", "empty"), null)); + + assertThat(result.schemas()).extracting(Schema::name).containsExactly("public"); + assertThat(tableNames(result)).hasSize(4); + } + + @Test + void aDeniedSchemaBeatsTheSameSchemaOnTheAllowList() { + var result = SchemaViewPermissionFilter.apply(view(), + denying(List.of("hr", "empty"), List.of("empty"), null)); + + assertThat(result.schemas()).extracting(Schema::name).containsExactly("HR"); + assertThat(tableNames(result)).containsExactly("HR.payroll"); + } + + @Test + void aForeignKeyToADeniedTableIsDropped() { + var result = SchemaViewPermissionFilter.apply(view(), + denying(null, null, List.of("public.employee"))); + + var tables = result.schemas().getFirst().tables(); + assertThat(tables).extracting(Table::name) + .containsExactly("customer", "service", "salary"); + assertThat(tables.get(0).foreignKeys()).isEmpty(); + assertThat(tables.get(1).foreignKeys()) + .containsExactly(new ForeignKey("id", "customer", "id")); + } + @Test void nullViewsAndNullListsAreTolerated() { assertThat(SchemaViewPermissionFilter.apply(null, permission(null, null, null))).isNull(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java index 6e39d7bb8..3e7834d0e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultQueryDryRunServiceTest.java @@ -66,7 +66,7 @@ private SqlParseResult parse(QueryType type, Set tables) { private DatasourceUserPermissionView permission(boolean read, List schemas, List tables) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, read, - false, false, false, schemas, tables, List.of(), null, null, null); + false, false, false, schemas, tables, List.of(), null, List.of(), List.of(), null, null); } @Test @@ -180,7 +180,7 @@ void nonAdminReferencingDeniedColumnIsDeniedBeforePlanning() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(java.util.Optional.of(new DatasourceUserPermissionView( UUID.randomUUID(), userId, datasourceId, true, false, false, false, - List.of(), List.of(), List.of(), List.of("users.ssn"), null, null))); + List.of(), List.of(), List.of(), List.of("users.ssn"), List.of(), List.of(), null, null))); when(messageSource.getMessage(eq("error.permission.column_not_allowed"), any(), any())) .thenReturn("column denied"); @@ -190,6 +190,43 @@ void nonAdminReferencingDeniedColumnIsDeniedBeforePlanning() { verify(queryExecutor, never()).dryRun(any()); } + @Test + void nonAdminReferencingDeniedTableIsDeniedBeforePlanning() { + when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); + when(queryParser.parse(anyString(), any())).thenReturn(parse(QueryType.SELECT, + Set.of("crm.salary"))); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(java.util.Optional.of(new DatasourceUserPermissionView( + UUID.randomUUID(), userId, datasourceId, true, false, false, false, + List.of("crm"), List.of(), List.of(), null, List.of(), + List.of("crm.salary"), null, null))); + when(messageSource.getMessage(eq("error.permission.table_denied"), any(), any())) + .thenReturn("table denied"); + + assertThatThrownBy(() -> service.dryRun(datasourceId, "SELECT * FROM crm.salary", userId, + orgId, false)) + .isInstanceOf(AccessDeniedException.class) + .hasMessage("table denied"); + verify(queryExecutor, never()).dryRun(any()); + } + + @Test + void nonAdminReferencingATableInADeniedSchemaIsDeniedWithoutAnAllowList() { + when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); + when(queryParser.parse(anyString(), any())).thenReturn(parse(QueryType.SELECT, + Set.of("hr.payroll"))); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(java.util.Optional.of(new DatasourceUserPermissionView( + UUID.randomUUID(), userId, datasourceId, true, false, false, false, + List.of(), List.of(), List.of(), null, List.of("hr"), List.of(), null, + null))); + + assertThatThrownBy(() -> service.dryRun(datasourceId, "SELECT * FROM hr.payroll", userId, + orgId, false)) + .isInstanceOf(AccessDeniedException.class); + verify(queryExecutor, never()).dryRun(any()); + } + @Test void unsupportedResultGetsLocalizedReason() { when(datasourceAdminService.getForAdmin(datasourceId, orgId)).thenReturn(view()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java index 591b19147..60db69a7f 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/DefaultSampleDataServiceTest.java @@ -87,7 +87,7 @@ void nonAdminIsRefusedAPreviewOfATableWithADeniedColumn() { when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.of( new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, false, false, List.of(), List.of(), List.of(), - List.of("users.ssn"), null, null))); + List.of("users.ssn"), List.of(), List.of(), null, null))); when(messageSource.getMessage(eq("error.permission.column_not_allowed"), any(), any())) .thenReturn("column denied"); @@ -103,7 +103,7 @@ void nonAdminPreviewIsAllowedWhenTheDeniedColumnIsOnAnotherTable() { when(permissionLookupService.findFor(userId, datasourceId)).thenReturn(Optional.of( new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, false, false, List.of(), List.of(), List.of(), - List.of("public.orders.card"), null, null))); + List.of("public.orders.card"), List.of(), List.of(), null, null))); assertThat(service.sample(datasourceId, organizationId, userId, false, "public", "users", 10)).isSameAs(result); @@ -238,11 +238,54 @@ void nonAdminSchemaWithinAllowedSchemasIsPermitted() { assertThat(out).isSameAs(result); } + @Test + void nonAdminDeniedTableIsNotFoundWithoutAnyAllowList() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denying(List.of(), List.of(), List.of("public.users")))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "public", "users", 50)) + .isInstanceOf(TableNotFoundException.class); + verify(queryExecutor, never()).sampleTable(any()); + } + + @Test + void nonAdminDeniedTableIsNotFoundEvenInsideAnAllowedSchema() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denying(List.of("public"), List.of(), List.of("users")))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "public", "users", 50)) + .isInstanceOf(TableNotFoundException.class); + verify(queryExecutor, never()).sampleTable(any()); + } + + @Test + void nonAdminTableInADeniedSchemaIsNotFound() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denying(List.of(), List.of("public"), List.of()))); + + assertThatThrownBy(() -> service.sample(datasourceId, organizationId, userId, false, + "public", "users", 50)) + .isInstanceOf(TableNotFoundException.class); + verify(queryExecutor, never()).sampleTable(any()); + } + + @Test + void nonAdminPreviewIsAllowedWhenTheDeniedTableIsAnother() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denying(List.of("public"), List.of("hr"), + List.of("public.salary")))); + + assertThat(service.sample(datasourceId, organizationId, userId, false, "public", "users", + 50)).isSameAs(result); + } + @Test void rowLimitOverrideBelowTheRequestedLimitCapsThePreview() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, - datasourceId, true, false, false, false, List.of(), List.of(), List.of(), null, + datasourceId, true, false, false, false, List.of(), List.of(), List.of(), null, List.of(), List.of(), 5, null))); service.sample(datasourceId, organizationId, userId, false, "public", "users", 50); @@ -256,7 +299,7 @@ void rowLimitOverrideBelowTheRequestedLimitCapsThePreview() { void rowLimitOverrideAboveTheRequestedLimitLeavesTheLimit() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, - datasourceId, true, false, false, false, List.of(), List.of(), List.of(), null, + datasourceId, true, false, false, false, List.of(), List.of(), List.of(), null, List.of(), List.of(), 500, null))); service.sample(datasourceId, organizationId, userId, false, "public", "users", 50); @@ -270,7 +313,7 @@ void rowLimitOverrideAboveTheRequestedLimitLeavesTheLimit() { void rowLimitPolicyOnTheSampledTableCapsThePreview() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, - datasourceId, true, false, false, false, List.of(), List.of(), List.of(), null, + datasourceId, true, false, false, false, List.of(), List.of(), List.of(), null, List.of(), List.of(), 20, null))); when(rowLimitPolicyResolutionService.resolve(organizationId, datasourceId, userId, java.util.Set.of("public.users"))) @@ -419,6 +462,14 @@ private DatasourceUserPermissionView permission(boolean canRead, List re List allowedSchemas, List allowedTables) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, canRead, - false, false, false, allowedSchemas, allowedTables, restrictedColumns, null, null, null); + false, false, false, allowedSchemas, allowedTables, restrictedColumns, null, List.of(), List.of(), null, null); + } + + private DatasourceUserPermissionView denying(List allowedSchemas, + List deniedSchemas, + List deniedTables) { + return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, + false, false, false, allowedSchemas, List.of(), List.of(), null, deniedSchemas, + deniedTables, null, null); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java index 5c520f839..340cf261c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java @@ -104,7 +104,7 @@ void setUp() { private DatasourceUserPermissionView dsPerm(boolean read, boolean write, boolean bg) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, read, write, - false, bg, List.of(), List.of(), List.of(), null, null, null); + false, bg, List.of(), List.of(), List.of(), null, List.of(), List.of(), null, null); } private ApiConnectorPermissionLookupView apiPerm(boolean read, boolean write, boolean bg) { @@ -251,6 +251,63 @@ void breakGlassSubmitAlsoRejectsADeniedColumn() { .isInstanceOf(RequestGroupPermissionException.class); } + @Test + void submitRejectsAMemberThatReferencesADeniedTable() { + var group = draftGroup(); + when(groupRepository.findByIdAndOrganizationId(group.getId(), orgId)).thenReturn(Optional.of(group)); + when(itemRepository.findByGroupIdOrderBySequenceOrderAsc(group.getId())) + .thenReturn(List.of(deniedTableItem())); + when(datasourcePermissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTablePerm(false))); + stubDeniedTableParse(); + + assertThatThrownBy(() -> service.submit(new SubmitRequestGroupCommand(group.getId(), orgId, + userId, false, false, null, "1.2.3.4", "ua"))) + .isInstanceOf(RequestGroupPermissionException.class) + .hasMessageContaining("crm.salary"); + verify(stateService, org.mockito.Mockito.never()).apply(any(), any()); + } + + @Test + void breakGlassSubmitAlsoRejectsADeniedTable() { + var group = draftGroup(); + when(groupRepository.findByIdAndOrganizationId(group.getId(), orgId)).thenReturn(Optional.of(group)); + when(itemRepository.findByGroupIdOrderBySequenceOrderAsc(group.getId())) + .thenReturn(List.of(deniedTableItem())); + when(datasourcePermissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTablePerm(true))); + stubDeniedTableParse(); + + assertThatThrownBy(() -> service.submit(new SubmitRequestGroupCommand(group.getId(), orgId, + userId, false, true, null, "1.2.3.4", "ua"))) + .isInstanceOf(RequestGroupPermissionException.class) + .hasMessageContaining("crm.salary"); + verify(stateService, org.mockito.Mockito.never()).apply(any(), any()); + } + + private RequestGroupItemEntity deniedTableItem() { + var item = new RequestGroupItemEntity(); + item.setTargetKind(com.bablsoft.accessflow.requestgroups.api.RequestGroupTargetKind.QUERY); + item.setDatasourceId(datasourceId); + item.setQueryType(QueryType.SELECT); + item.setSqlText("SELECT * FROM crm.salary"); + return item; + } + + private DatasourceUserPermissionView denyingTablePerm(boolean breakGlass) { + return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, + false, breakGlass, List.of("crm"), List.of(), List.of(), List.of(), List.of(), + List.of("crm.salary"), null, null); + } + + private void stubDeniedTableParse() { + when(datasourceLookupService.findById(datasourceId)).thenReturn(Optional.empty()); + when(queryParser.parse(any(), any())).thenReturn(new SqlParseResult(QueryType.SELECT, false, + List.of("SELECT * FROM crm.salary"), java.util.Set.of("crm.salary"), false, false, + java.util.Set.of(com.bablsoft.accessflow.core.api.ColumnReference.wildcard( + java.util.Set.of("crm.salary"))), true)); + } + private RequestGroupItemEntity deniedColumnItem() { var item = new RequestGroupItemEntity(); item.setTargetKind(com.bablsoft.accessflow.requestgroups.api.RequestGroupTargetKind.QUERY); @@ -262,7 +319,7 @@ private RequestGroupItemEntity deniedColumnItem() { private DatasourceUserPermissionView denyingPerm(boolean breakGlass) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, - false, breakGlass, List.of(), List.of(), List.of(), List.of("customer.ssn"), null, null); + false, breakGlass, List.of(), List.of(), List.of(), List.of("customer.ssn"), List.of(), List.of(), null, null); } private void stubDeniedColumnParse() { diff --git a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceTest.java index e5a4e519e..e483c94ec 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceTest.java @@ -80,7 +80,7 @@ private RequestGroupItemInput queryInput() { private DatasourceUserPermissionView perm(boolean read, boolean breakGlass) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, read, false, - false, breakGlass, List.of(), List.of(), List.of(), null, null, null); + false, breakGlass, List.of(), List.of(), List.of(), null, List.of(), List.of(), null, null); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java index 05298046e..7917cf097 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/GroupExecutionServiceTest.java @@ -195,7 +195,7 @@ void queryMemberHonoursTheSubmittersRowLimitOverride() { new com.bablsoft.accessflow.core.api.DatasourceUserPermissionView( UUID.randomUUID(), group.getSubmittedBy(), item.getDatasourceId(), true, false, false, false, List.of(), List.of(), - List.of("public.users.ssn"), null, 100, null))); + List.of("public.users.ssn"), null, List.of(), List.of(), 100, null))); when(maskingPolicyResolutionService.resolveApplicable(any(), any(), any())).thenReturn(List.of()); when(rowSecurityResolutionService.resolveApplicable(any(), any(), any())).thenReturn(List.of()); when(datasourceLookupService.findById(any())).thenReturn(java.util.Optional.empty()); @@ -232,7 +232,7 @@ void queryMemberTightensTheOverrideByRowLimitPolicies() { new com.bablsoft.accessflow.core.api.DatasourceUserPermissionView( UUID.randomUUID(), group.getSubmittedBy(), item.getDatasourceId(), true, false, false, false, List.of(), List.of(), - List.of("public.users.ssn"), null, 100, null))); + List.of("public.users.ssn"), null, List.of(), List.of(), 100, null))); when(maskingPolicyResolutionService.resolveApplicable(any(), any(), any())).thenReturn(List.of()); when(rowSecurityResolutionService.resolveApplicable(any(), any(), any())).thenReturn(List.of()); when(datasourceLookupService.findById(any())).thenReturn(java.util.Optional.empty()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/schemachange/internal/DefaultSchemaChangePromotionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/schemachange/internal/DefaultSchemaChangePromotionServiceTest.java index 2c71024fd..2a3610f81 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/schemachange/internal/DefaultSchemaChangePromotionServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/schemachange/internal/DefaultSchemaChangePromotionServiceTest.java @@ -678,7 +678,7 @@ private void givenDatasource(UUID datasourceId) { private void givenDdl(UUID datasourceId, boolean canDdl) { lenient().when(permissionLookupService.findFor(actorId, datasourceId)).thenReturn(Optional.of( new DatasourceUserPermissionView(UUID.randomUUID(), actorId, datasourceId, true, true, canDdl, false, - List.of(), List.of(), List.of(), null, null, null))); + List.of(), List.of(), List.of(), null, List.of(), List.of(), null, null))); } private DeploymentEnvironmentView environment(UUID id, String name, int sortOrder, UUID datasourceId, diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceControllerIntegrationTest.java index 8382d0b73..9359f9327 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/DatasourceControllerIntegrationTest.java @@ -659,6 +659,175 @@ void grantPermissionRejectsUnqualifiedDeniedColumn() { assertThat(result).hasStatus(400); } + @Test + void grantPermissionRoundTripsNormalizedDeniedSchemasAndTables() { + var ds = saveDatasource(primaryOrg, "DS"); + + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"user_id":"%s","can_read":true,"allowed_schemas":["crm"], + "denied_schemas":["HR","hr"], + "denied_tables":["CRM.Salary","bonus"]} + """.formatted(analyst.getId())) + .exchange(); + + assertThat(result).hasStatus(201); + assertThat(result).bodyJson().extractingPath("$.denied_schemas").asArray() + .containsExactly("hr"); + assertThat(result).bodyJson().extractingPath("$.denied_tables").asArray() + .containsExactly("crm.salary", "bonus"); + + var listed = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(listed).hasStatus(200); + assertThat(listed).bodyJson().extractingPath("$.content[0].denied_tables").asArray() + .containsExactly("crm.salary", "bonus"); + assertThat(listed).bodyJson().extractingPath("$.content[0].denied_schemas").asArray() + .containsExactly("hr"); + } + + @Test + void grantPermissionRejectsBlankDeniedTable() { + var ds = saveDatasource(primaryOrg, "DS"); + + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"user_id":"%s","can_read":true,"denied_tables":["crm.salary"," "]} + """.formatted(analyst.getId())) + .exchange(); + + assertThat(result).hasStatus(400); + } + + @Test + void grantPermissionRejectsDeniedEntriesThatCouldNeverMatch() { + var ds = saveDatasource(primaryOrg, "DS"); + + for (var body : List.of("\"denied_schemas\":[\"analytics.hr\"]", + "\"denied_tables\":[\"sal*\"]", "\"denied_tables\":[\"crm..salary\"]")) { + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"user_id\":\"%s\",\"can_read\":true,%s}" + .formatted(analyst.getId(), body)) + .exchange(); + + assertThat(result).hasStatus(400); + } + } + + @Test + void grantPermissionRejectsBlankDeniedSchema() { + var ds = saveDatasource(primaryOrg, "DS"); + + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"user_id":"%s","can_read":true,"denied_schemas":[""]} + """.formatted(analyst.getId())) + .exchange(); + + assertThat(result).hasStatus(400); + } + + @Test + void grantPermissionRejectsTooManyDeniedSchemas() { + var ds = saveDatasource(primaryOrg, "DS"); + + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"user_id":"%s","can_read":true,"denied_schemas":%s} + """.formatted(analyst.getId(), jsonNames("s", 51))) + .exchange(); + + assertThat(result).hasStatus(400); + assertThat(permissionRepository.existsByUser_IdAndDatasource_Id(analyst.getId(), + ds.getId())).isFalse(); + } + + @Test + void grantPermissionRejectsTooManyDeniedTables() { + var ds = saveDatasource(primaryOrg, "DS"); + + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"user_id":"%s","can_read":true,"denied_tables":%s} + """.formatted(analyst.getId(), jsonNames("t", 201))) + .exchange(); + + assertThat(result).hasStatus(400); + } + + @Test + void grantGroupPermissionRoundTripsDeniedSchemasAndTables() { + var ds = saveDatasource(primaryOrg, "DS"); + var group = saveGroup(primaryOrg, "Analysts"); + + var result = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions/groups") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"group_id":"%s","can_read":true, + "denied_schemas":["Audit"],"denied_tables":["crm.salary"]} + """.formatted(group.getId())) + .exchange(); + + assertThat(result).hasStatus(201); + assertThat(result).bodyJson().extractingPath("$.denied_schemas").asArray() + .containsExactly("audit"); + assertThat(result).bodyJson().extractingPath("$.denied_tables").asArray() + .containsExactly("crm.salary"); + + var listed = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/permissions/groups") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(listed).hasStatus(200); + assertThat(listed).bodyJson().extractingPath("$.content[0].denied_tables").asArray() + .containsExactly("crm.salary"); + } + + @Test + void grantGroupPermissionRejectsBlankDeniedTableAndOversizeDeniedSchemas() { + var ds = saveDatasource(primaryOrg, "DS"); + var group = saveGroup(primaryOrg, "Analysts"); + + var blank = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions/groups") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"group_id":"%s","can_read":true,"denied_tables":[" "]} + """.formatted(group.getId())) + .exchange(); + assertThat(blank).hasStatus(400); + + var oversize = mvc.post().uri("/api/v1/datasources/" + ds.getId() + "/permissions/groups") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"group_id":"%s","can_read":true,"denied_schemas":%s} + """.formatted(group.getId(), jsonNames("s", 51))) + .exchange(); + assertThat(oversize).hasStatus(400); + } + + private static String jsonNames(String prefix, int count) { + var names = new java.util.ArrayList(); + for (int i = 0; i < count; i++) { + names.add("\"" + prefix + i + "\""); + } + return "[" + String.join(",", names) + "]"; + } + @Test void grantDuplicatePermissionReturns409() { var ds = saveDatasource(primaryOrg, "DS"); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionCheckerTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionCheckerTest.java index 7dfdb5f9f..6b3273d46 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionCheckerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionCheckerTest.java @@ -93,13 +93,13 @@ void listRejectedTablesOverloadMatchesViewOverload() { private DatasourceUserPermissionView perm(boolean canRead, boolean canWrite, boolean canDdl) { return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), canRead, canWrite, canDdl, false, - List.of(), List.of(), List.of(), null, null, null); + List.of(), List.of(), List.of(), null, List.of(), List.of(), null, null); } private DatasourceUserPermissionView perm(List allowedSchemas, List allowedTables) { return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), true, true, true, true, - allowedSchemas, allowedTables, List.of(), null, null, null); + allowedSchemas, allowedTables, List.of(), null, List.of(), List.of(), null, null); } @Test @@ -144,7 +144,7 @@ void rejectedColumnsReadsThePermissionDenyList() { var permission = new com.bablsoft.accessflow.core.api.DatasourceUserPermissionView( java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), true, false, false, false, null, null, null, - List.of("users.ssn"), null, null); + List.of("users.ssn"), List.of(), List.of(), null, null); var parsed = new com.bablsoft.accessflow.core.api.SqlParseResult( com.bablsoft.accessflow.core.api.QueryType.SELECT, false, List.of("sql"), Set.of("users"), false, false, Set.of( @@ -154,4 +154,70 @@ void rejectedColumnsReadsThePermissionDenyList() { assertThat(DatasourcePermissionChecker.rejectedColumns(permission, parsed)) .containsExactly("users.ssn"); } + + @Test + void deniedTableCarvesAnExceptionOutOfASchemaAllowList() { + var permission = denying(List.of("crm"), List.of(), List.of(), List.of("crm.salary")); + + assertThat(DatasourcePermissionChecker.blockedTables(permission, Set.of("crm.customer"))) + .isEmpty(); + assertThat(DatasourcePermissionChecker.blockedTables(permission, Set.of("crm.salary"))) + .containsExactly("crm.salary"); + assertThat(DatasourcePermissionChecker.deniedTables(permission, + Set.of("crm.customer", "crm.salary"))).containsExactly("crm.salary"); + } + + @Test + void tableAddedLaterUnderAnAllowedSchemaIsPermitted() { + var permission = denying(List.of("crm"), List.of(), List.of(), List.of("crm.salary")); + + assertThat(DatasourcePermissionChecker.blockedTables(permission, Set.of("crm.new_table"))) + .isEmpty(); + } + + @Test + void denialAppliesWithoutAnyAllowList() { + var permission = denying(List.of(), List.of(), List.of(), List.of("crm.salary")); + + assertThat(DatasourcePermissionChecker.rejectedTables(permission, Set.of("crm.salary"))) + .isEmpty(); + assertThat(DatasourcePermissionChecker.deniedTables(permission, Set.of("crm.salary"))) + .containsExactly("crm.salary"); + assertThat(DatasourcePermissionChecker.blockedTables(permission, + Set.of("crm.salary", "crm.customer"))).containsExactly("crm.salary"); + } + + @Test + void deniedSchemaRejectsEveryTableInIt() { + var permission = denying(List.of(), List.of(), List.of("hr"), List.of()); + + assertThat(DatasourcePermissionChecker.deniedTables(permission, + Set.of("hr.salary", "crm.customer"))).containsExactly("hr.salary"); + } + + @Test + void blockedTablesUnionsTheAllowListAndTheDenyList() { + var permission = denying(List.of("crm"), List.of(), List.of(), List.of("crm.salary")); + + assertThat(DatasourcePermissionChecker.blockedTables(permission, + Set.of("crm.salary", "hr.payroll", "crm.customer"))) + .containsExactly("crm.salary", "hr.payroll"); + } + + @Test + void noDenyListDeniesNothing() { + var permission = denying(List.of(), List.of(), null, null); + + assertThat(DatasourcePermissionChecker.deniedTables(permission, Set.of("crm.salary"))) + .isEmpty(); + } + + private DatasourceUserPermissionView denying(List allowedSchemas, + List allowedTables, + List deniedSchemas, + List deniedTables) { + return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), true, true, true, false, allowedSchemas, allowedTables, + List.of(), null, deniedSchemas, deniedTables, null, null); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifierTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifierTest.java index dce30195f..26109f2a8 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifierTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DatasourcePermissionVerifierTest.java @@ -52,7 +52,7 @@ private DatasourceUserPermissionView permission(boolean canRead, boolean canWrit List allowedTables, Instant expiresAt) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, - canRead, canWrite, false, false, null, allowedTables, null, null, null, expiresAt); + canRead, canWrite, false, false, null, allowedTables, null, null, List.of(), List.of(), null, expiresAt); } @Test @@ -195,9 +195,79 @@ void verifyPassesWhenDeniedColumnIsNotReferenced() { .doesNotThrowAnyException(); } + @Test + void verifyThrowsLocalizedMessageWhenDeniedTableReferenced() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTables(List.of("crm"), List.of(), + List.of("crm.salary")))); + when(messageSource.getMessage(eq("error.permission.table_denied"), + eq(new Object[]{"crm.salary"}), any(Locale.class))) + .thenReturn("TABLE_DENIED_MARKER"); + + assertThatThrownBy(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + parsed(Set.of("crm.salary", "crm.customer")))) + .isInstanceOf(AccessDeniedException.class) + .hasMessage("TABLE_DENIED_MARKER"); + } + + @Test + void verifyPassesForAnAllowedSiblingOfADeniedTable() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTables(List.of("crm"), List.of(), + List.of("crm.salary")))); + + assertThatCode(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + parsed(Set.of("crm.customer", "crm.new_table")))) + .doesNotThrowAnyException(); + } + + @Test + void verifyRejectsADeniedTableWithoutAnyAllowList() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTables(List.of(), List.of(), + List.of("crm.salary")))); + + assertThatThrownBy(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + parsed(Set.of("crm.salary")))) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void verifyRejectsATableInADeniedSchema() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTables(List.of(), List.of("hr"), List.of()))); + + assertThatThrownBy(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + parsed(Set.of("hr.payroll")))) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void verifyReportsTheAllowListBeforeTheDenyList() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingTables(List.of("crm"), List.of(), + List.of("crm.salary")))); + when(messageSource.getMessage(eq("error.permission.table_not_allowed"), any(), + any(Locale.class))) + .thenReturn("TABLE_NOT_ALLOWED_MARKER"); + + assertThatThrownBy(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + parsed(Set.of("crm.salary", "hr.payroll")))) + .isInstanceOf(AccessDeniedException.class) + .hasMessage("TABLE_NOT_ALLOWED_MARKER"); + } + + private DatasourceUserPermissionView denyingTables(List allowedSchemas, + List deniedSchemas, + List deniedTables) { + return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, + true, false, false, false, allowedSchemas, null, null, null, deniedSchemas, + deniedTables, null, null); + } + private DatasourceUserPermissionView denying(List deniedColumns) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, - true, false, false, false, null, null, null, deniedColumns, null, null); + true, false, false, false, null, null, null, deniedColumns, List.of(), List.of(), null, null); } private static SqlParseResult parsed(Set tables) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java index c6fec8f60..f85314ca6 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultAccessSimulationServiceTest.java @@ -371,6 +371,54 @@ void aTableOutsideTheAllowListStopsTheRequestAndNamesIt() { assertThat(permission.details()).containsEntry("rejected_tables", List.of("public.payments")); } + @Test + void aDeniedTableStopsTheRequestAndNamesIt() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, + datasourceId, true, false, false, false, List.of("public"), List.of(), + List.of(), List.of(), List.of(), List.of("public.payments"), null, null))); + + var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); + + var permission = step(result.steps(), QueryDecisionStepKind.EFFECTIVE_PERMISSION); + assertThat(permission.outcome()).isEqualTo(StepOutcome.DENY); + assertThat(permission.reasonKey()) + .isEqualTo("workflow.access_simulation.permission.table_denied"); + assertThat(permission.details()) + .containsEntry("rejected_tables", List.of()) + .containsEntry("denied_tables", List.of("public.payments")); + } + + @Test + void aTableOutsideTheAllowListIsReportedBeforeADenial() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, + datasourceId, true, false, false, false, List.of("reporting"), List.of(), + List.of(), List.of(), List.of("public"), List.of(), null, null))); + + var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); + + var permission = step(result.steps(), QueryDecisionStepKind.EFFECTIVE_PERMISSION); + assertThat(permission.reasonKey()) + .isEqualTo("workflow.access_simulation.permission.table_not_allowed"); + assertThat(permission.details()) + .containsEntry("denied_tables", List.of("public.payments")); + } + + @Test + void anAllowedTableCarriesAnEmptyDeniedTablesDetail() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, + datasourceId, true, false, false, false, List.of("public"), List.of(), + List.of(), List.of(), List.of(), List.of("public.salary"), null, null))); + + var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); + + var permission = step(result.steps(), QueryDecisionStepKind.EFFECTIVE_PERMISSION); + assertThat(permission.outcome()).isEqualTo(StepOutcome.ALLOW); + assertThat(permission.details()).containsEntry("denied_tables", List.of()); + } + @Test void aDeniedColumnStopsTheRequestAndNamesIt() { when(queryParser.parse(any(), any())).thenReturn(new SqlParseResult(QueryType.SELECT, @@ -380,7 +428,7 @@ void aDeniedColumnStopsTheRequestAndNamesIt() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, false, false, List.of(), List.of(), List.of(), - List.of("public.payments.card"), null, null))); + List.of("public.payments.card"), List.of(), List.of(), null, null))); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -705,13 +753,13 @@ private DatasourceView datasource(boolean active) { private DatasourcePermissionContribution contribution() { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.GROUP, UUID.randomUUID(), userId, datasourceId, UUID.randomUUID(), "payments-oncall", - true, false, false, false, List.of("public"), List.of(), List.of(), null, null, null, null); + true, false, false, false, List.of("public"), List.of(), List.of(), null, List.of(), List.of(), null, null, null); } private DatasourceUserPermissionView permission(boolean canRead, boolean canWrite, boolean canDdl, List allowedSchemas) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, canRead, - canWrite, canDdl, false, allowedSchemas, List.of(), List.of(), null, null, null); + canWrite, canDdl, false, allowedSchemas, List.of(), List.of(), null, List.of(), List.of(), null, null); } private ReviewPlanSnapshot plan() { diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassEligibilityServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassEligibilityServiceTest.java index 86cc350df..71463ea84 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassEligibilityServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassEligibilityServiceTest.java @@ -57,6 +57,6 @@ void returnsEmptyWhenNoGrants() { private DatasourceUserPermissionView view(UUID datasourceId, Instant expiresAt) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, - true, false, false, true, List.of(), List.of(), List.of(), null, null, expiresAt); + true, false, false, true, List.of(), List.of(), List.of(), null, List.of(), List.of(), null, expiresAt); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java index 87f78136c..6fd7f9e6c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultBreakGlassServiceTest.java @@ -226,7 +226,7 @@ void deniesWhenDeniedColumnReferenced() { when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(new DatasourceUserPermissionView( UUID.randomUUID(), userId, datasourceId, true, false, false, true, - List.of(), List.of(), List.of(), List.of("customer.ssn"), null, null))); + List.of(), List.of(), List.of(), List.of("customer.ssn"), List.of(), List.of(), null, null))); assertThatThrownBy(() -> service.breakGlassExecute( input("SELECT ssn FROM customer", false))) @@ -234,6 +234,44 @@ void deniesWhenDeniedColumnReferenced() { verify(queryRequestPersistenceService, never()).submit(any()); } + @Test + void deniesWhenDeniedTableReferencedEvenInsideAnAllowedSchema() { + stubDatasourceForUser(true); + when(queryParser.parse(eq("SELECT * FROM crm.salary"), any())) + .thenReturn(new SqlParseResult(QueryType.SELECT, false, + List.of("SELECT * FROM crm.salary"), Set.of("crm.salary"), false, false, + Set.of(ColumnReference.wildcard(Set.of("crm.salary"))), true)); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(new DatasourceUserPermissionView( + UUID.randomUUID(), userId, datasourceId, true, false, false, true, + List.of("crm"), List.of(), List.of(), List.of(), List.of(), + List.of("crm.salary"), null, null))); + + assertThatThrownBy(() -> service.breakGlassExecute( + input("SELECT * FROM crm.salary", false))) + .isInstanceOf(BreakGlassNotPermittedException.class); + verify(queryRequestPersistenceService, never()).submit(any()); + } + + @Test + void deniesWhenTableInADeniedSchemaReferenced() { + stubDatasourceForUser(true); + when(queryParser.parse(eq("SELECT * FROM hr.payroll"), any())) + .thenReturn(new SqlParseResult(QueryType.SELECT, false, + List.of("SELECT * FROM hr.payroll"), Set.of("hr.payroll"), false, false, + Set.of(ColumnReference.wildcard(Set.of("hr.payroll"))), true)); + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(new DatasourceUserPermissionView( + UUID.randomUUID(), userId, datasourceId, true, false, false, true, + List.of(), List.of(), List.of(), List.of(), List.of("hr"), List.of(), + null, null))); + + assertThatThrownBy(() -> service.breakGlassExecute( + input("SELECT * FROM hr.payroll", false))) + .isInstanceOf(BreakGlassNotPermittedException.class); + verify(queryRequestPersistenceService, never()).submit(any()); + } + @Test void rejectsQueryTypeOther() { stubDatasourceForUser(true); @@ -325,7 +363,7 @@ private void stubPermission(boolean canRead, boolean canWrite, boolean canDdl, .thenReturn(Optional.of(new DatasourceUserPermissionView( UUID.randomUUID(), userId, datasourceId, canRead, canWrite, canDdl, canBreakGlass, - allowedSchemas, allowedTables, List.of(), null, null, expiresAt))); + allowedSchemas, allowedTables, List.of(), null, List.of(), List.of(), null, expiresAt))); } private DatasourceView datasourceView(boolean active) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessServiceTest.java index 6b0e2fa39..cc1472a96 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultEffectiveAccessServiceTest.java @@ -363,6 +363,34 @@ void ddlIsAnswersFromCanDdl() { assertThat(report(StatementCapability.DDL, "public.payments").content()).hasSize(1); } + @Test + void aDeniedTableIsNotListedEvenUnderAnUnrestrictedGrant() { + givenContributions(denying(analystId, List.of(), List.of("public.payments"))); + givenUsers(user(analystId, "dana@example.com")); + + assertThat(report(StatementCapability.READ, "public.payments").content()).isEmpty(); + assertThat(report(StatementCapability.READ, "public.orders").content()).hasSize(1); + } + + @Test + void aPermissiveGroupGrantCannotLiftADirectDenial() { + givenContributions( + denying(analystId, List.of(), List.of("public.payments")), + group(analystId, true, true, List.of(), List.of(), "everything")); + givenUsers(user(analystId, "dana@example.com")); + + assertThat(report(StatementCapability.READ, "public.payments").content()).isEmpty(); + } + + @Test + void aDeniedSchemaHidesEveryTableInIt() { + givenContributions(denying(analystId, List.of("public"), List.of())); + givenUsers(user(analystId, "dana@example.com")); + + assertThat(report(StatementCapability.READ, "public.payments").content()).isEmpty(); + assertThat(report(StatementCapability.READ, "reporting.summary").content()).hasSize(1); + } + // ── Fixtures ────────────────────────────────────────────────────────────── private com.bablsoft.accessflow.core.api.PageResponse< @@ -386,7 +414,15 @@ private DatasourcePermissionContribution direct(UUID userId, boolean canRead, bo Instant expiresAt, boolean breakGlass) { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.DIRECT, UUID.randomUUID(), userId, datasourceId, null, null, canRead, canWrite, false, - breakGlass, schemas, tables, List.of(), null, null, expiresAt, null); + breakGlass, schemas, tables, List.of(), null, List.of(), List.of(), null, expiresAt, null); + } + + private DatasourcePermissionContribution denying(UUID userId, List deniedSchemas, + List deniedTables) { + return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.DIRECT, + UUID.randomUUID(), userId, datasourceId, null, null, true, false, false, false, + List.of(), List.of(), List.of(), null, deniedSchemas, deniedTables, null, null, + null); } /** A time-boxed direct row materialised from the given JIT request (#969). */ @@ -394,13 +430,13 @@ private DatasourcePermissionContribution jit(UUID userId, Instant expiresAt, UUID accessGrantRequestId) { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.DIRECT, UUID.randomUUID(), userId, datasourceId, null, null, true, false, false, false, - List.of("public"), List.of(), List.of(), null, null, expiresAt, accessGrantRequestId); + List.of("public"), List.of(), List.of(), null, List.of(), List.of(), null, expiresAt, accessGrantRequestId); } private DatasourcePermissionContribution ddlGrant(UUID userId) { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.DIRECT, UUID.randomUUID(), userId, datasourceId, null, null, false, false, true, false, - List.of(), List.of(), List.of(), null, null, null, null); + List.of(), List.of(), List.of(), null, List.of(), List.of(), null, null, null); } private DatasourcePermissionContribution group(UUID userId, boolean canRead, boolean canWrite, @@ -408,7 +444,7 @@ private DatasourcePermissionContribution group(UUID userId, boolean canRead, boo String groupName) { return new DatasourcePermissionContribution(DatasourcePermissionSourceKind.GROUP, UUID.randomUUID(), userId, datasourceId, UUID.randomUUID(), groupName, canRead, - canWrite, false, false, schemas, tables, List.of(), null, null, null, null); + canWrite, false, false, schemas, tables, List.of(), null, List.of(), List.of(), null, null, null); } private AccessGrantView grant(UUID grantId, UUID requesterId) { @@ -423,7 +459,10 @@ private UserView user(UUID id, String email) { Instant.now(), null, Instant.now()); } - /** Mirrors core's merge: booleans OR, allow-lists union with unrestricted winning. */ + /** + * Mirrors core's merge: booleans OR, allow-lists union with unrestricted winning, deny-lists + * union (#939). + */ private DatasourceUserPermissionView merge(List parts) { boolean canRead = false; boolean canWrite = false; @@ -433,6 +472,8 @@ private DatasourceUserPermissionView merge(List(); var tables = new LinkedHashSet(); + var deniedSchemas = new LinkedHashSet(); + var deniedTables = new LinkedHashSet(); boolean unrestrictedSchemas = false; boolean unrestrictedTables = false; for (var part : parts) { @@ -444,6 +485,8 @@ private DatasourceUserPermissionView merge(List(schemas), unrestrictedTables ? List.of() : new ArrayList<>(tables), List.of(), null, + new ArrayList<>(deniedSchemas), new ArrayList<>(deniedTables), null, anyNeverExpires ? null : expiresAt); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java index 1caba45f3..eb18c651c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQueryLifecycleServiceTest.java @@ -301,7 +301,7 @@ void executePassesRestrictedColumnsFromPermissionToExecutor() { .thenReturn(Optional.of(snapshot(QueryStatus.APPROVED, QueryType.SELECT))); var permissionView = new DatasourceUserPermissionView( UUID.randomUUID(), submitterId, datasourceId, true, false, false, false, - List.of(), List.of(), List.of("public.users.ssn", "public.users.email"), null, null, null); + List.of(), List.of(), List.of("public.users.ssn", "public.users.email"), null, List.of(), List.of(), null, null); when(permissionLookupService.findFor(submitterId, datasourceId)) .thenReturn(Optional.of(permissionView)); when(queryExecutor.execute(any())).thenReturn(new SelectExecutionResult( @@ -543,7 +543,7 @@ void executeUpdateNeverAuditsRowLimitPolicies() { private DatasourceUserPermissionView permissionWithRowLimit(Integer rowLimitOverride) { return new DatasourceUserPermissionView( UUID.randomUUID(), submitterId, datasourceId, true, false, false, false, - List.of(), List.of(), List.of(), null, rowLimitOverride, null); + List.of(), List.of(), List.of(), null, List.of(), List.of(), rowLimitOverride, null); } @Test diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java index ded8e60ca..c1e1a7c21 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySubmissionServiceTest.java @@ -644,7 +644,7 @@ private void stubPermission(boolean canRead, boolean canWrite, boolean canDdl, .thenReturn(Optional.of(new DatasourceUserPermissionView( UUID.randomUUID(), userId, datasourceId, canRead, canWrite, canDdl, false, - allowedSchemas, allowedTables, List.of(), null, null, expiresAt))); + allowedSchemas, allowedTables, List.of(), null, List.of(), List.of(), null, expiresAt))); } private void stubAllowListMessageSource() { diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionServiceTest.java index eb17190a3..a76d1eb56 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DefaultQuerySuggestionServiceTest.java @@ -155,6 +155,23 @@ void aSuggestionIsDroppedWhenAnyOneOfItsTablesIsOutsideTheAllowList() { assertThat(service.findForViewer(DATASOURCE, ORG, USER, false, 10)).isEmpty(); } + @Test + void suggestionsReferencingADeniedTableAreDroppedEvenWithoutAnAllowList() { + givenRows(row("select 1 from crm.customer", QueryType.SELECT, + new String[]{"crm.customer"}, 5), + row("select 1 from crm.salary", QueryType.SELECT, new String[]{"crm.salary"}, 9), + row("select 1 from hr.payroll", QueryType.SELECT, new String[]{"hr.payroll"}, 7)); + when(permissionLookupService.findFor(eq(USER), eq(DATASOURCE))) + .thenReturn(Optional.of(new DatasourceUserPermissionView(UUID.randomUUID(), USER, + DATASOURCE, true, false, false, false, List.of(), List.of(), List.of(), + null, List.of("hr"), List.of("crm.salary"), null, null))); + + var railed = service.findForViewer(DATASOURCE, ORG, USER, false, 10); + + assertThat(railed).extracting(QuerySuggestionView::sqlText) + .containsExactly("select 1 from crm.customer"); + } + @Test void aRowWithNoResolvedTablesIsNeverServedToAnyone() { // The aggregation should never persist one, but rejectedTables() reports "nothing rejected" @@ -274,6 +291,6 @@ private static DatasourceUserPermissionView permission(boolean read, boolean wri boolean ddl, List schemas, List tables, Instant expiresAt) { return new DatasourceUserPermissionView(UUID.randomUUID(), USER, DATASOURCE, read, write, - ddl, false, schemas, tables, List.of(), null, null, expiresAt); + ddl, false, schemas, tables, List.of(), null, List.of(), List.of(), null, expiresAt); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedTablesEnforcementIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedTablesEnforcementIntegrationTest.java new file mode 100644 index 000000000..0dbbccea4 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedTablesEnforcementIntegrationTest.java @@ -0,0 +1,212 @@ +package com.bablsoft.accessflow.workflow.internal; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import com.bablsoft.accessflow.core.api.DbType; +import com.bablsoft.accessflow.core.api.SslMode; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceGroupPermissionEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.DatasourceUserPermissionEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserGroupEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserGroupMembershipEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceGroupPermissionRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.DatasourceUserPermissionRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserGroupMembershipRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserGroupRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.proxy.api.QueryParser; +import com.bablsoft.accessflow.workflow.api.QuerySubmissionService; +import com.bablsoft.accessflow.workflow.api.QuerySubmissionService.SubmissionInput; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.access.AccessDeniedException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * #939 acceptance: a user granted {@code allowed_schemas=[crm]} with {@code denied_tables=[crm.salary]} + * can query every other table in {@code crm} — including one created after the grant — but a query + * reaching {@code crm.salary} is refused with 403 before anything is persisted, and a more permissive + * group grant can never lift that denial. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class DeniedTablesEnforcementIntegrationTest { + + @Autowired QuerySubmissionService querySubmissionService; + @Autowired DatasourcePermissionVerifier permissionVerifier; + @Autowired QueryParser queryParser; + @Autowired OrganizationRepository organizationRepository; + @Autowired UserRepository userRepository; + @Autowired DatasourceRepository datasourceRepository; + @Autowired DatasourceUserPermissionRepository permissionRepository; + @Autowired DatasourceGroupPermissionRepository groupPermissionRepository; + @Autowired UserGroupRepository userGroupRepository; + @Autowired UserGroupMembershipRepository membershipRepository; + @Autowired CredentialEncryptionService encryptionService; + @Autowired JdbcTemplate jdbcTemplate; + + private OrganizationEntity organization; + private UserEntity analyst; + private DatasourceEntity datasource; + + @BeforeEach + void setUp() { + organization = new OrganizationEntity(); + organization.setId(UUID.randomUUID()); + organization.setName("Denied tables"); + organization.setSlug("denied-tables-" + UUID.randomUUID()); + organizationRepository.save(organization); + analyst = persistUser(); + + datasource = new DatasourceEntity(); + datasource.setId(UUID.randomUUID()); + datasource.setOrganization(organization); + datasource.setName("DS-" + UUID.randomUUID()); + datasource.setDbType(DbType.POSTGRESQL); + datasource.setHost("nope.invalid"); + datasource.setPort(65000); + datasource.setDatabaseName("db"); + datasource.setUsername("u"); + datasource.setPasswordEncrypted(encryptionService.encrypt("p")); + datasource.setSslMode(SslMode.DISABLE); + datasource.setConnectionPoolSize(5); + datasource.setMaxRowsPerQuery(1000); + datasource.setActive(true); + datasourceRepository.save(datasource); + + var permission = new DatasourceUserPermissionEntity(); + permission.setId(UUID.randomUUID()); + permission.setDatasource(datasource); + permission.setUser(analyst); + permission.setCanRead(true); + permission.setCreatedBy(analyst); + permission.setAllowedSchemas(new String[] {"crm"}); + permission.setDeniedTables(new String[] {"crm.salary"}); + permissionRepository.save(permission); + } + + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM query_requests WHERE datasource_id = ?", datasource.getId()); + jdbcTemplate.update("DELETE FROM datasource_user_permissions WHERE datasource_id = ?", + datasource.getId()); + jdbcTemplate.update("DELETE FROM datasource_group_permissions WHERE datasource_id = ?", + datasource.getId()); + jdbcTemplate.update("DELETE FROM user_group_memberships WHERE user_id = ?", analyst.getId()); + jdbcTemplate.update("DELETE FROM user_groups WHERE organization_id = ?", + organization.getId()); + jdbcTemplate.update("DELETE FROM datasources WHERE id = ?", datasource.getId()); + jdbcTemplate.update("DELETE FROM users WHERE id = ?", analyst.getId()); + jdbcTemplate.update("DELETE FROM organizations WHERE id = ?", organization.getId()); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * FROM crm.salary", + "SELECT c.id FROM crm.customer c JOIN crm.salary s ON s.customer_id = c.id", + "SELECT id FROM crm.customer WHERE id IN (SELECT customer_id FROM crm.salary)", + "UPDATE crm.salary SET amount = 0"}) + void aQueryReachingADeniedTableIsRefusedBeforeAnythingIsPersisted(String sql) { + assertThatThrownBy(() -> submit(sql)) + .isInstanceOf(AccessDeniedException.class); + + assertThat(persistedQueries()).isZero(); + } + + @Test + void theRefusalNamesTheDeniedTable() { + assertThatThrownBy(() -> submit("SELECT * FROM crm.salary")) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("crm.salary"); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * FROM crm.customer", + "SELECT * FROM crm.table_created_after_the_grant", + "SELECT c.id FROM crm.customer c JOIN crm.orders o ON o.customer_id = c.id"}) + void anAllowedSiblingOfTheDeniedTablePassesTheGate(String sql) { + var parsed = queryParser.parse(sql, DbType.POSTGRESQL); + + assertThatCode(() -> permissionVerifier.verify(analyst.getId(), datasource.getId(), + parsed.type(), parsed)).doesNotThrowAnyException(); + } + + @Test + void aPermissiveGroupGrantCannotLiftTheDirectDenial() { + var group = new UserGroupEntity(); + group.setId(UUID.randomUUID()); + group.setOrganization(organization); + group.setName("everything-" + UUID.randomUUID()); + userGroupRepository.save(group); + var membership = new UserGroupMembershipEntity(); + membership.setId(new UserGroupMembershipEntity.Id(analyst.getId(), group.getId())); + membership.setUser(analyst); + membership.setGroup(group); + membershipRepository.save(membership); + var groupPermission = new DatasourceGroupPermissionEntity(); + groupPermission.setId(UUID.randomUUID()); + groupPermission.setOrganizationId(organization.getId()); + groupPermission.setDatasource(datasource); + groupPermission.setGroup(group); + groupPermission.setCreatedBy(analyst); + groupPermission.setCanRead(true); + groupPermission.setCanWrite(true); + groupPermission.setCanDdl(true); + groupPermissionRepository.save(groupPermission); + + // The group's empty allow-list widens the merged grant to every schema... + var outsideCrm = queryParser.parse("SELECT * FROM hr.payroll", DbType.POSTGRESQL); + assertThatCode(() -> permissionVerifier.verify(analyst.getId(), datasource.getId(), + outsideCrm.type(), outsideCrm)).doesNotThrowAnyException(); + + // ...but the direct grant's denial survives the merge. + assertThatThrownBy(() -> submit("SELECT * FROM crm.salary")) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("crm.salary"); + assertThat(persistedQueries()).isZero(); + } + + private int persistedQueries() { + Integer rows = jdbcTemplate.queryForObject( + "SELECT count(*) FROM query_requests WHERE datasource_id = ?", Integer.class, + datasource.getId()); + return rows == null ? 0 : rows; + } + + private void submit(String sql) { + querySubmissionService.submit(new SubmissionInput(datasource.getId(), sql, "j", + analyst.getId(), organization.getId(), false, null, null, null, null, false)); + } + + private UserEntity persistUser() { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail("analyst-" + UUID.randomUUID() + "@example.com"); + user.setDisplayName("analyst"); + user.setPasswordHash("hash"); + user.setRole(UserRoleType.ANALYST); + user.setAuthProvider(AuthProviderType.LOCAL); + user.setActive(true); + user.setOrganization(organization); + return userRepository.save(user); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/EffectiveAccessEnforcementParityTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/EffectiveAccessEnforcementParityTest.java index 5e645f97f..bc77ee9a4 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/EffectiveAccessEnforcementParityTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/EffectiveAccessEnforcementParityTest.java @@ -108,14 +108,14 @@ void theReportAgreesWithTheGateOnEveryTableSpelling(String allowedSchemas, Strin var schemas = split(allowedSchemas); var tables = split(allowedTables); var permission = new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, - true, false, false, false, schemas, tables, List.of(), null, null, null); + true, false, false, false, schemas, tables, List.of(), null, List.of(), List.of(), null, null); when(permissionLookupService.findFor(userId, datasourceId)) .thenReturn(Optional.of(permission)); when(permissionLookupService.findContributionsForDatasource(datasourceId)) .thenReturn(List.of(new DatasourcePermissionContribution( DatasourcePermissionSourceKind.DIRECT, permission.id(), userId, datasourceId, null, null, true, false, false, false, schemas, tables, - List.of(), null, null, null, null))); + List.of(), null, List.of(), List.of(), null, null, null))); when(permissionLookupService.mergeContributions(any())) .thenReturn(Optional.of(permission)); diff --git a/docs/03-data-model.md b/docs/03-data-model.md index a69c6f76a..dc836d55b 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -311,6 +311,8 @@ Grants a specific user access to a specific datasource with granular controls. | `allowed_tables` | TEXT[] — null means all tables permitted | | `restricted_columns` | TEXT[] nullable — fully-qualified `schema.table.column` entries whose values are masked in SELECT results before persistence and surfaced to the AI analyzer; null/empty means no column restrictions. A column listed here with no matching `masking_policy` row uses the static `FULL` mask (`***`); a `masking_policy` for the same column overrides it with the configured strategy. | | `denied_columns` | TEXT[] nullable (#935, Flyway V186) — `table.column` / `schema.table.column` entries, stored normalised (unquoted, lowercase). A query that references one (including through `*`, `t.*` or a column-list-less `INSERT` on its table) is rejected with 403 before it is persisted. Relational engines only: a non-empty list is refused at grant time for an engine-managed datasource. Deny beats mask when a column is in both lists. Null/empty means nothing denied. | +| `denied_schemas` | TEXT[] nullable (#939, Flyway V190) — schemas the grantee may not touch, stored normalised (unquoted, lowercase). A reference qualified with a denied schema (as any non-final segment) is rejected with 403, and so is **every unqualified reference** while any schema is denied, because the gate cannot know which schema the database resolves it to — grantees must schema-qualify table names. A denial always beats `allowed_schemas` / `allowed_tables`. Null/empty means nothing denied. | +| `denied_tables` | TEXT[] nullable (#939, Flyway V190) — `table` or `schema.table` entries, stored normalised. An entry denies a reference when either name is a dot-aligned suffix of the other: bare `salary` denies `salary` in every schema; `crm.salary` denies `crm.salary`, `db.crm.salary` and an unqualified `salary`. Evaluated after the allow-list, so `allowed_schemas=[crm]` + `denied_tables=[crm.salary]` means "all of `crm` except `crm.salary`", including tables created later. Null/empty means nothing denied. | | `expires_at` | TIMESTAMPTZ nullable — time-limited access grants | | `access_grant_request_id` | UUID nullable, FK → `access_grant_request` `ON DELETE SET NULL` (#969, Flyway V169) — the JIT request this row materialises; null on an admin-created row. Read by the effective-access report (#859) to label a source `JIT_GRANT`. Partial index on `(access_grant_request_id) WHERE access_grant_request_id IS NOT NULL`. Backfilled once by V169 from `access_grant_request.granted_permission_id` (datasource requests only) | | `created_by` | FK → `users` | @@ -326,9 +328,13 @@ constraint and restriction columns clean), keyed on `group_id` instead of `user_ **effective** permission is the most-permissive union of their direct grant and every unexpired group grant they belong to — resolved in `DefaultDatasourceUserPermissionLookupService` (flags OR-ed; allow-lists unioned; `restricted_columns` and `denied_columns` intersected so a column is masked — or -denied — only when every contributing grant masks or denies it; each grant's `expires_at` honoured independently). The one deliberate inversion is -`row_limit_override`, which merges to the **smallest** non-null value so a wide group grant can never -raise a tight per-user cap (#933). Mirrors how groups already drive +denied — only when every contributing grant masks or denies it; each grant's `expires_at` honoured independently). Two deliberate inversions: +`row_limit_override` merges to the **smallest** non-null value so a wide group grant can never +raise a tight per-user cap (#933), and `denied_schemas` / `denied_tables` merge to their **union** +(#939), so a permissive grant can never lift another grant's denial and a group grant's denial binds +every member. (`denied_columns` still intersects — an intentional, documented asymmetry.) A denial +lives on its row, so revoking or expiring that row (including an attestation revoke) drops the denial +and can widen the user's effective access through their remaining grants. Mirrors how groups already drive masking-reveal and row-security. | Column | Type / Notes | @@ -339,7 +345,7 @@ masking-reveal and row-security. | `group_id` | FK → `user_groups` ON DELETE CASCADE | | `can_read` / `can_write` / `can_ddl` / `can_break_glass` | BOOLEAN NOT NULL DEFAULT false — same semantics as the per-user table | | `row_limit_override` | INTEGER nullable — same semantics as the per-user table; merged most-restrictive (smallest non-null wins) | -| `allowed_schemas` / `allowed_tables` / `restricted_columns` / `denied_columns` | TEXT[] nullable — same semantics as the per-user table (`denied_columns` added by V186, #935) | +| `allowed_schemas` / `allowed_tables` / `restricted_columns` / `denied_columns` / `denied_schemas` / `denied_tables` | TEXT[] nullable — same semantics as the per-user table (`denied_columns` added by V186, #935; `denied_schemas` / `denied_tables` by V190, #939 — merged as a union across a user's grants) | | `expires_at` | TIMESTAMPTZ nullable — honoured per grant (an expired grant contributes nothing) | | `created_by` | FK → `users` | | `created_at` | TIMESTAMPTZ | diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index d80269377..95a9abb02 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -535,7 +535,7 @@ Opens a transient JDBC connection to a candidate read-replica using the values s ### GET /datasources/{id}/schema — Response -Introspects tables and columns from the customer database via JDBC `DatabaseMetaData`. System schemas (`pg_catalog`, `information_schema`, `pg_toast`, `mysql`, `performance_schema`, `sys`) are filtered out. ADMINs may introspect any datasource in their organization and see every table. Non-ADMINs require an effective permission (direct or group grant; none → `404 DATASOURCE_NOT_FOUND`), and the response is scoped to it (#936): only tables their `allowed_schemas` / `allowed_tables` cover are returned (a table is shown when its `schema.table` name — or a catalog-qualified `catalog.schema.table` ending in it — is listed, when its schema is listed, or when its bare name is listed **and no other schema has a table of that name**; the qualified rules are the query gate's own matcher, and the ambiguous bare case fails closed because the view cannot know which table the database resolves an unqualified name to — qualify the entry. Both lists empty means no restriction), schemas left with no visible table are dropped unless the schema itself is allow-listed, columns on the grant's `denied_columns` are omitted, and a foreign key is omitted when it starts from a denied column, points at a denied column, or references a table the caller cannot see (foreign-key targets are reported by bare name, so one whose name is shared with a hidden table is omitted too). The same scoping applies wherever this user-facing introspection feeds another surface — editor autocomplete, the table preview, the AI analyze-preview and text-to-SQL schema context, and the MCP `get_datasource_schema` / `validate_sql` tools. System paths (async AI analysis at submission, discovery scans, schema drift and promotion snapshots, query snapshots, query-replay compatibility) introspect unfiltered, and the JIT request form keeps its own name-only, unfiltered endpoint (`GET /access-requests/datasources/{id}/schema`). The matcher follows relational qualification; for engine plugins with their own naming (Couchbase `bucket.scope.collection` grants, Elasticsearch index patterns) the view can hide objects the engine's gate allows — admins see everything, and the query gate is unchanged. +Introspects tables and columns from the customer database via JDBC `DatabaseMetaData`. System schemas (`pg_catalog`, `information_schema`, `pg_toast`, `mysql`, `performance_schema`, `sys`) are filtered out. ADMINs may introspect any datasource in their organization and see every table. Non-ADMINs require an effective permission (direct or group grant; none → `404 DATASOURCE_NOT_FOUND`), and the response is scoped to it (#936): only tables their `allowed_schemas` / `allowed_tables` cover are returned (a table is shown when its `schema.table` name — or a catalog-qualified `catalog.schema.table` ending in it — is listed, when its schema is listed, or when its bare name is listed **and no other schema has a table of that name**; the qualified rules are the query gate's own matcher, and the ambiguous bare case fails closed because the view cannot know which table the database resolves an unqualified name to — qualify the entry. Both lists empty means no restriction), tables and schemas on the grant's `denied_tables` / `denied_schemas` (#939) are hidden whatever the allow-list says (a denied schema is never listed, even when allow-listed), schemas left with no visible table are dropped unless the schema itself is allow-listed, columns on the grant's `denied_columns` are omitted, and a foreign key is omitted when it starts from a denied column, points at a denied column, or references a table the caller cannot see (foreign-key targets are reported by bare name, so one whose name is shared with a hidden table is omitted too). The same scoping applies wherever this user-facing introspection feeds another surface — editor autocomplete, the table preview, the AI analyze-preview and text-to-SQL schema context, and the MCP `get_datasource_schema` / `validate_sql` tools. System paths (async AI analysis at submission, discovery scans, schema drift and promotion snapshots, query snapshots, query-replay compatibility) introspect unfiltered, and the JIT request form keeps its own name-only, unfiltered endpoint (`GET /access-requests/datasources/{id}/schema`). The matcher follows relational qualification; for engine plugins with their own naming (Couchbase `bucket.scope.collection` grants, Elasticsearch index patterns) the view can hide objects the engine's gate allows — admins see everything, and the query gate is unchanged. ```json { @@ -607,10 +607,10 @@ GET /api/v1/datasources/{id}/sample-rows?schema=public&table=users&limit=50 `restricted: true` flags a column the backend masked (via a masking policy or `restricted_columns`); its cell values are the masked output only. `truncated` is `true` when the sample hit the row cap or the result byte cap (#49); `truncated_reason` says which (`"ROW_LIMIT"` | `"BYTE_LIMIT"`, `null` when not truncated). -ADMINs may sample any datasource in their organization; non-ADMINs need a permission row with `can_read` and the target within their `allowed_schemas` / `allowed_tables`. +ADMINs may sample any datasource in their organization; non-ADMINs need a permission row with `can_read` and the target within their `allowed_schemas` / `allowed_tables` and outside their `denied_schemas` / `denied_tables` (#939). **Response 400:** `limit` is out of the `1`–`200` range. `error: VALIDATION_ERROR`. -**Response 404:** Datasource not accessible, or the table is absent from the introspected schema / outside the caller's allow-list. `error: DATASOURCE_NOT_FOUND` or `TABLE_NOT_FOUND`. +**Response 404:** Datasource not accessible, or the table is absent from the introspected schema / outside the caller's allow-list / on their table or schema deny-list. `error: DATASOURCE_NOT_FOUND` or `TABLE_NOT_FOUND`. **Response 422:** Sampling failed (e.g. customer database unreachable). `error: DATASOURCE_CONNECTION_TEST_FAILED`. ### GET /datasources/{id}/permissions — Response 200 @@ -632,6 +632,8 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis "allowed_tables": null, "restricted_columns": ["public.users.ssn"], "denied_columns": ["public.users.password_hash"], + "denied_schemas": null, + "denied_tables": ["public.salary"], "expires_at": null, "created_by": "uuid", "created_at": "2026-05-04T10:15:00Z" @@ -644,6 +646,8 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis `denied_columns` (#935) is a list of `table.column` or `schema.table.column` strings, returned normalised (unquoted, lowercase). A query that references one — in the select list, `WHERE`, `JOIN`, `GROUP BY`, `HAVING`, `ORDER BY`, a subquery, an `UPDATE … SET` target or an `INSERT` column list, or through `*` / `t.*` / a column-list-less `INSERT` on the entry's table — is rejected **before** it is persisted. `POST /queries`, `POST /queries/dry-run` and `GET /datasources/{id}/sample-rows` answer 403 with `error: "FORBIDDEN"` and a localized `detail` naming the denied entries. Break-glass answers `error: "BREAK_GLASS_NOT_PERMITTED"`, and a request-group submit answers `error: "REQUEST_GROUP_PERMISSION_DENIED"`. The table preview is refused whenever its table has a denied column, since it reads every column. Deny beats mask for a column in both lists. Null or empty means nothing is denied. +`denied_schemas` and `denied_tables` (#939) are table/schema deny-lists, returned normalised (unquoted, lowercase), `null` when unset. A denial **always beats** the allow-list and is evaluated after it, so `allowed_schemas: ["crm"]` + `denied_tables: ["crm.salary"]` permits `crm.customer` (and any `crm` table created later) while refusing `crm.salary`. Denials also apply with no allow-list at all. Matching fails closed, because the gate cannot know where the database resolves a name: a `denied_tables` entry matches when either name is a dot-aligned suffix of the other (bare `salary` denies `salary` in every schema; `crm.salary` denies `crm.salary`, `db.crm.salary` and an unqualified `salary`), and a `denied_schemas` entry matches any reference carrying it as a non-final segment **and every unqualified reference** — while any schema is denied, the grantee must schema-qualify table names. A `schema.*` entry in `denied_tables` denies the whole schema. Names are compared segment by segment from the right; an empty segment (SQL Server `db..salary`) matches anything, an Oracle `@dblink` suffix is ignored, and a pattern reference (`*` / `?`, e.g. an Elasticsearch index pattern `sal*`) is denied by any entry. Deny-lists apply to every engine; on engines whose names carry no schema (MongoDB, DynamoDB, Redis) any `denied_schemas` entry refuses every query, so use `denied_tables` there. A JIT approval that replaces the user's expiring direct row carries that row's denials onto the new grant. A query that reaches a denied table is rejected **before** it is persisted: `POST /queries` and `POST /queries/dry-run` answer 403 `FORBIDDEN` with `error.permission.table_denied` ("Query references one or more tables the user is denied on this datasource: …"), break-glass answers `BREAK_GLASS_NOT_PERMITTED`, and a request-group submit answers `REQUEST_GROUP_PERMISSION_DENIED`. A denied table is hidden from `GET /datasources/{id}/schema` (a denied schema disappears entirely) and answers 404 from `GET /datasources/{id}/sample-rows`, exactly like one outside the allow-list. + ### POST /datasources/{id}/permissions — Request Body ```json @@ -658,6 +662,8 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis "allowed_tables": ["users", "orders"], "restricted_columns": ["public.users.ssn", "public.users.email"], "denied_columns": ["public.users.password_hash"], + "denied_schemas": ["audit"], + "denied_tables": ["public.salary"], "expires_at": "2026-12-31T23:59:59Z" } ``` @@ -665,7 +671,15 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis `restricted_columns` is optional. Each entry must be non-blank. `denied_columns` (#935) is optional, at most 200 entries, each `table.column` or `schema.table.column` with no blank part (400 otherwise). It is supported only on the in-process relational engines (PostgreSQL, MySQL, MariaDB, Oracle, -SQL Server, `CUSTOM`). `can_break_glass` (AF-385, optional, +SQL Server, `CUSTOM`). `denied_schemas` (#939) is optional, at most 50 non-blank entries, +each a single schema name with no dots and no `*` / `?` (`analytics.hr` is refused). +`denied_tables` is optional, at most 200 non-blank entries, each `table`, `schema.table` (any depth) +or `schema.*` (the whole schema), with no empty segment and no other wildcard. A violation is +400 `VALIDATION_ERROR` (`validation.denied_schemas.item_invalid` / `validation.denied_tables.item_invalid`, +or the blank / too-many keys); the service re-checks the entry shape and raises +`IllegalDatasourcePermissionException` (422 `ILLEGAL_DATASOURCE_PERMISSION`) for callers that bypass +the web layer. Both are stored normalised with duplicates dropped, and both are +recorded in the `PERMISSION_GRANTED` / `PERMISSION_GROUP_GRANTED` audit metadata when non-empty. `can_break_glass` (AF-385, optional, default `false`) grants the emergency break-glass submission mode on this datasource — time-boxed via `expires_at`. The flag is returned on the permission object alongside `can_read`/`can_write`/`can_ddl`. @@ -685,7 +699,9 @@ default `false`) grants the emergency break-glass submission mode on this dataso Group-based access grants (AF-530). A grant to a **user group** is inherited by every member; a user's **effective** access is the most-permissive union of their direct grant and every unexpired group grant for a group they belong to (flags OR-ed; allow-lists unioned; `restricted_columns` and `denied_columns` -intersected so a column is masked — or denied — only when every contributing grant masks or denies it). Same shape as the per-user list, keyed on +intersected so a column is masked — or denied — only when every contributing grant masks or denies it; +`denied_schemas` / `denied_tables` **unioned** (#939), so a group grant's denial binds every member and no +permissive grant can lift a denial from another). Same shape as the per-user list, keyed on the group instead of a user: ```json @@ -706,6 +722,8 @@ the group instead of a user: "allowed_tables": null, "restricted_columns": ["public.users.ssn"], "denied_columns": [], + "denied_schemas": null, + "denied_tables": null, "expires_at": null, "created_by": "uuid", "created_at": "2026-05-04T10:15:00Z" @@ -730,6 +748,8 @@ Same body as the per-user grant with `group_id` in place of `user_id`: "allowed_tables": ["users", "orders"], "restricted_columns": ["public.users.ssn"], "denied_columns": ["public.users.password_hash"], + "denied_schemas": ["audit"], + "denied_tables": ["public.salary"], "expires_at": "2026-12-31T23:59:59Z" } ``` @@ -1624,7 +1644,7 @@ Identification and audit only — never an authorization input and not a routing **Errors:** - `400 VALIDATION_ERROR` — request body missing `datasource_id` or `sql`. -- `403 FORBIDDEN` — caller has no active permission row for this datasource, the row is missing the capability matching the query type (`can_read` for SELECT, `can_write` for INSERT/UPDATE/DELETE, `can_ddl` for DDL), or the SQL references a table outside the permission's `allowed_schemas` / `allowed_tables` allow-list (walked at the JSqlParser AST level; see [docs/05-backend.md → "Schema / table allow-list enforcement"](05-backend.md#schema--table-allow-list-enforcement)). Admins bypass this check. +- `403 FORBIDDEN` — caller has no active permission row for this datasource, the row is missing the capability matching the query type (`can_read` for SELECT, `can_write` for INSERT/UPDATE/DELETE, `can_ddl` for DDL), the SQL references a table outside the permission's `allowed_schemas` / `allowed_tables` allow-list (walked at the JSqlParser AST level; see [docs/05-backend.md → "Schema / table allow-list enforcement"](05-backend.md#schema--table-allow-list-enforcement)), or a table the permission's `denied_schemas` / `denied_tables` deny-list reaches (#939, `error.permission.table_denied` — a denial beats the allow-list), or a denied column (#935). Admins bypass this check. - `404 DATASOURCE_NOT_FOUND` — datasource does not exist in the caller's organization, or — for non-admin callers — the caller has no permission row for it. - `422 INVALID_SQL` — SQL did not parse, contained multiple statements without a `BEGIN/COMMIT` envelope, or classified as `OTHER`. The `detail` field carries the specific reason. Distinct sub-cases include: - mixed SELECT with INSERT/UPDATE/DELETE inside a transaction → "Transactions cannot mix SELECT with INSERT/UPDATE/DELETE; submit them as separate query requests"; @@ -2882,7 +2902,7 @@ Just-in-time, time-bound access requests. A user requests temporary scoped acces } ``` -**Exactly one** of `datasource_id` / `connector_id` must be set (AF-567). A connector request may carry `allowed_operations` — an optional operation-id allow-list validated against the connector's operation catalog (`null`/empty = all operations) — and must **not** carry `can_ddl`, `pre_approve_queries`, or `allowed_schemas`/`allowed_tables`; a datasource request must not carry `allowed_operations` (all enforced by Bean Validation, mirrored in the frontend form). `can_break_glass` is deliberately not self-requestable. `requested_duration` is an ISO-8601 period (days/hours/minutes/seconds; no months) bounded by `accessflow.access.min-duration` / `max-duration`. At least one of `can_read`/`can_write`/`can_ddl` is required. `pre_approve_queries` (optional, default `false` — #582) opts the resulting grant into **query pre-approval**: while the grant is `APPROVED` and unexpired, a submitted query it covers (capability + table scope) is auto-approved after AI analysis instead of routing to human review — see [docs/05-backend.md → "Grant-covered query auto-approval"](05-backend.md#grant-covered-query-auto-approval-582). The flag is echoed on every access-request response (own list, admin queue item) so the approving reviewer sees exactly what they authorize. **Response 201** returns the created request (`status: "PENDING"`); every access-request response carries `resource_kind` (`DATASOURCE` | `API_CONNECTOR`) plus the matching `datasource_*` / `connector_*` name fields, and the admin queue item nests a `datasource` **or** `connector` `{ id, name }` summary. +**Exactly one** of `datasource_id` / `connector_id` must be set (AF-567). A connector request may carry `allowed_operations` — an optional operation-id allow-list validated against the connector's operation catalog (`null`/empty = all operations) — and must **not** carry `can_ddl`, `pre_approve_queries`, or `allowed_schemas`/`allowed_tables`; a datasource request must not carry `allowed_operations` (all enforced by Bean Validation, mirrored in the frontend form). `can_break_glass` is deliberately not self-requestable, and neither are table/schema deny-lists (#939) — there is no `denied_schemas` / `denied_tables` field. The materialised grant never lifts a denial: denials union across grants, and a replaced expiring direct row's denials carry over onto it. `requested_duration` is an ISO-8601 period (days/hours/minutes/seconds; no months) bounded by `accessflow.access.min-duration` / `max-duration`. At least one of `can_read`/`can_write`/`can_ddl` is required. `pre_approve_queries` (optional, default `false` — #582) opts the resulting grant into **query pre-approval**: while the grant is `APPROVED` and unexpired, a submitted query it covers (capability + table scope) is auto-approved after AI analysis instead of routing to human review — see [docs/05-backend.md → "Grant-covered query auto-approval"](05-backend.md#grant-covered-query-auto-approval-582). The flag is echoed on every access-request response (own list, admin queue item) so the approving reviewer sees exactly what they authorize. **Response 201** returns the created request (`status: "PENDING"`); every access-request response carries `resource_kind` (`DATASOURCE` | `API_CONNECTOR`) plus the matching `datasource_*` / `connector_*` name fields, and the admin queue item nests a `datasource` **or** `connector` `{ id, name }` summary. ### GET /access-requests — Query Parameters @@ -4506,6 +4526,7 @@ follow up with one simulation per user of interest. It is deliberately not an N- { "source_kind": "GROUP", "source_id": "77b2…", "group_id": "0a41…", "group_name": "payments-oncall", "expires_at": "2026-10-01T00:00:00Z" } ], "rejected_tables": [], + "denied_tables": [], "rejected_columns": [], "expires_at": "2026-10-01T00:00:00Z" } @@ -4599,7 +4620,7 @@ that did not apply is reported with `outcome: "SKIP"` rather than omitted. `outc | `DATASOURCE_GATES` | `db_type`, `active`, `ai_analysis_enabled`, `visible_to_user` | always | | `QUOTA` | `quota_type`, `limit`, `current` | `DENY` only; `{}` on `ALLOW` | | `SQL_PARSE` | `query_type`, `referenced_tables`, `transactional`, `has_where_clause`, `has_limit_clause` | whenever the statement parsed; `{}` when it did not | -| `EFFECTIVE_PERMISSION` | `query_admin_short_circuit`, `contributing_grants[]`, `rejected_tables`, `rejected_columns` (#935 — the denied entries the query reaches; a non-empty list denies with `workflow.access_simulation.permission.column_denied`), `expires_at` | always (`expires_at` omitted when the permission is standing or the caller is a `QUERY_ADMIN` holder) | +| `EFFECTIVE_PERMISSION` | `query_admin_short_circuit`, `contributing_grants[]`, `rejected_tables`, `denied_tables` (#939 — the referenced tables a `denied_schemas` / `denied_tables` entry reaches; checked after the allow-list, a non-empty list denies with `workflow.access_simulation.permission.table_denied`), `rejected_columns` (#935 — the denied entries the query reaches; a non-empty list denies with `workflow.access_simulation.permission.column_denied`), `expires_at` | always (`expires_at` omitted when the permission is standing or the caller is a `QUERY_ADMIN` holder) | | `SQL_REVIEW` | `blocking_rule_ids[]`, `blocking_count` | `MATCH` only — a deterministic SQL review rule fired at `BLOCK` (#864); `{}` on `NO_MATCH` | | `ROUTING_POLICIES` | `policies[]` | always (`[]` when the org has none) | | | `matched_policy_id`, `matched_policy_name`, `action`, `effective_min_approvals`, `sql_review_suppressed` | `MATCH` only | @@ -4739,7 +4760,9 @@ schema read (which this endpoint never performs) and would go stale the moment a (`"public"`) or the qualified table (`"public.payments"`). It is `null`, and therefore absent, on a source whose own allow-list does not reach the table: `granted` is computed over the **merged** union, so a source can legitimately contribute the capability while another contributes the coverage. Read the -row's `table_scope` for the verdict and the sources for the provenance. +row's `table_scope` for the verdict and the sources for the provenance. A table any contributing grant +denies through `denied_schemas` / `denied_tables` (#939) is not granted whatever the allow-lists say — +denials union across grants and beat the allow-list — so that user is left out of the list. **`can_break_glass` does not feed `granted`.** Break-glass is a separate submission mode with its own compensating controls (instant admin fanout, a prominent audit row, a mandatory retro-review), not a diff --git a/docs/05-backend.md b/docs/05-backend.md index 44c894300..c268fb37d 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -687,6 +687,7 @@ exceeding it streams returns `413 CUSTOM_DRIVER_TOO_LARGE`. - allow if `T` is `schema.table` and `schema` appears in `allowed_schemas`; - otherwise reject. - Rejection throws `AccessDeniedException` → HTTP 403 (`error: FORBIDDEN`) and emits a `WARN` log with the rejected table list, the user id, and the datasource id. The localised detail uses the `error.permission.table_not_allowed` message bundle key. +- Then, whatever the allow-list said, every referenced table is checked against the permission's `denied_schemas` / `denied_tables` (#939) — see "Table / schema deny-lists" below. A hit is a 403 with `error.permission.table_denied`. The denial runs with or without an allow-list, and always wins. Edge cases: @@ -694,6 +695,74 @@ Edge cases: - **Quoted mixed-case identifiers** (`"Public"."Users"`) are lowercased — case-insensitive matching is v1.0's behaviour across the board. - **Admins** (`SubmissionInput.isAdmin=true`) bypass the entire permission lookup, including the allow-list check. +### Table / schema deny-lists (#939) + +`denied_schemas` and `denied_tables` (`TEXT[]` on both permission tables, V190) let an admin say +"everything in `crm` except `crm.salary`" without enumerating every other table: +`allowed_schemas=[crm]` + `denied_tables=[crm.salary]` admits `crm.customer` and any `crm` table created +later, and refuses `crm.salary`. The denial is evaluated **after** the allow-list and **always beats it**; +it applies equally with no allow-list at all. + +- **Matching.** `core.api.DeniedTables` is the single matcher (`normalize`, `denyingEntry`, `rejected`, + `deniesTable`, `deniesSchema`), over the same normalised names as `AllowedTables` (quotes stripped, + lowercased). Every rule fails closed, because the gate cannot know where the database resolves a name. + A `denied_tables` entry matches when either name is a dot-aligned suffix of the other: bare `salary` + denies `salary` in every schema; `crm.salary` denies `crm.salary`, `db.crm.salary` **and** an + unqualified `salary` (the gate cannot tell where `search_path` sends it). A `denied_schemas` entry + matches any reference carrying it as a non-final segment, and **every unqualified reference** — while + any schema is denied, the grantee must schema-qualify table names. A `schema.*` entry in + `denied_tables` denies the whole schema, the way the UI displays a denied schema. Names are compared + segment by segment from the right, and three reference shapes are hardened, all failing closed: + an **empty segment** (SQL Server `db..salary`, the caller's default schema) matches any segment, so a + matching table entry and any denied schema both deny it; an Oracle **`@dblink` suffix** is ignored + (`hr.salary@loop` is denied by `hr.salary`); and a reference that is a **pattern** rather than a name + (it contains `*` or `?` — an Elasticsearch / OpenSearch index pattern such as `sal*`) is denied by + any deny entry at all, since it may expand to a denied object. +- **Merge.** `DefaultDatasourceUserPermissionLookupService` unions both lists across the direct, group + and JIT contributions — the inverse of every other merged field (booleans OR, allow-lists union with + empty = all, restricted/denied columns intersect). A permissive group grant can never lift a denial + from a direct grant, and a group grant's denial binds every member. `denied_columns` (#935) still + merges by intersection; the asymmetry is intentional and documented, and may be aligned later. +- **Enforcement.** Everywhere the allow-list applies: `DatasourcePermissionVerifier.verify` (submission + and the recurring per-occurrence recheck, 403 `error.permission.table_denied`), + `DefaultBreakGlassService` (`BreakGlassNotPermittedException`), `DefaultQueryDryRunService` (403), + `DefaultAccessSimulationService` (a `DENY` on the permission step with `denied_tables` in the details, + `workflow.access_simulation.permission.table_denied`), `DefaultEffectiveAccessService` (a denied table + is not granted), `DefaultQuerySuggestionService` (a suggestion touching a denied table is not offered), + `DefaultSampleDataService` (a denied target is a 404, like one outside the allow-list), the #936 + schema-view filter (denied tables and whole denied schemas are hidden, so the schema browser, + autocomplete, MCP tools and AI text-to-SQL context never see them), and every `QUERY` member of a + request group (`RequestGroupPermissionException`). `DatasourcePermissionChecker.blockedTables` is the + combined "outside the allow-list or denied" answer for callers that only need reachability. +- **Grant time.** Entries are validated twice — Bean Validation `@Pattern` on the request + (`DeniedTables.SCHEMA_ENTRY_PATTERN` / `TABLE_ENTRY_PATTERN`, 400 with + `validation.denied_schemas.item_invalid` / `validation.denied_tables.item_invalid`) and again in + `DatasourceAdminServiceImpl` (`isValidSchemaEntry` / `isValidTableEntry`, raising + `IllegalDatasourcePermissionException`) for callers that skip the web layer. A `denied_schemas` entry is + one name with no dots and no `*` / `?` (`analytics.hr` is refused); a `denied_tables` entry is `table`, + `schema.table` (any depth) or `schema.*`, with no empty segment and no other wildcard. The service then + normalises both lists and drops duplicates (empty → null); the controller records non-empty lists in + the grant's audit metadata, and attestation snapshots carry both. +- **JIT grants.** A JIT access request cannot ask for a deny-list, but a JIT grant never lifts one: + denials union with every other grant, and when `AccessGrantMaterializer` replaces the requester's + expiring direct row, that row's `denied_columns`, `denied_schemas` and `denied_tables` carry over onto + the new grant. A JIT approval can widen capabilities and expiry, never a denial. +- **Known limits.** + - *Every engine.* Deny-lists apply to relational and plugin engines alike (unlike `denied_columns`). + On engines whose object names carry no schema — MongoDB collections, DynamoDB tables, Redis keys — + every reference is unqualified, so **any `denied_schemas` entry refuses every query**. Use + `denied_tables` there. + - *Reported references only.* A denial can only match what the parser or engine plugin reports as + referenced; the allow-list has the same limit. Known under-reporting: MongoDB aggregation stages + `$lookup` / `$unionWith` / `$graphLookup` read collections that are not reported; Neo4j reports only + labels and relationship types, so `MATCH (n) RETURN n` reports nothing; Redis `KEYS` with a glob + reports no key prefixes. On the relational path, views, function bodies and SQL-text functions are + opaque, as for the allow-list. + - *Removing a grant can widen access.* A denial lives on a grant row. Revoking or expiring that row — + an attestation revoke, JIT or grant expiry, an admin revoke — removes its denial with it, and another + grant the user still holds may then expose the table. Review the remaining grants before revoking one + that carries a denial. + ### Group-based access grants (AF-530) `DatasourceUserPermissionLookupService.findFor(userId, datasourceId)` returns the caller's **effective** @@ -702,7 +771,8 @@ unexpired `datasource_group_permissions` grant for a group they belong to (group `UserGroupMembershipRepository.findGroupIdsForUser`). Booleans OR; `allowed_schemas`/`allowed_tables` merge to their union (any contributor with no allow-list ⇒ all allowed); `restricted_columns` and `denied_columns` (#935, compared normalised) merge to the **intersection** (a column is masked — or -denied — only when every contributing grant masks or denies it); expired grants +denied — only when every contributing grant masks or denies it); `denied_schemas` / `denied_tables` +(#939) merge to their **union**, so no contributor can lift another's denial; expired grants contribute nothing. Because `findFor` is the single choke-point every enforcement path already reads through (proxy dry-run/sample-data, `access` materialiser, AI analyzer, text-to-SQL, workflow submission/lifecycle/break-glass, `requestgroups`), group grants are honoured everywhere without touching @@ -723,7 +793,9 @@ connector enforcement point routes through — including the **API-editor visibi had the datasource gap. **Grouped requests** (`requestgroups`) validate every member at both draft-persist and submit time through the group-aware `DatasourceUserPermissionLookupService.findFor` (query members) and `ApiConnectorPermissionLookupService.findFor` (API members), so a group-only grant is sufficient to -add and run a member. Admins are granted/revoked group access via +add and run a member. A query member is checked for the capability, denied tables and schemas (#939) +and denied columns (#935); it is **not** checked against the table allow-list — a pre-existing gap, +tracked as a follow-up. Admins are granted/revoked group access via `DatasourceAdminService.{grant,list,revoke}GroupPermission` and the `/permissions/groups` endpoints, audited as `PERMISSION_GROUP_GRANTED` / `PERMISSION_GROUP_REVOKED` (connector side: `API_PERMISSION_GROUP_GRANTED` / `API_PERMISSION_GROUP_REVOKED`). @@ -1021,13 +1093,13 @@ simulation must not become a side channel for them. Samples carry the query id, The result is returned via `DatabaseSchemaView` (immutable nested records: `Schema → Table → Column` + `ForeignKey`). The web layer maps to `DatabaseSchemaResponse` for the `GET /api/v1/datasources/{id}/schema` endpoint; the AI module consumes the same view via `SystemPromptRenderer.describeSchema(...)`. -**Scoped to the caller (#936).** For a non-admin, `introspectSchema` resolves the effective permission (`DatasourceUserPermissionLookupService.findFor`; none → `DatasourceNotFoundException`, before any connection is opened) and passes the view through the pure `core.internal.SchemaViewPermissionFilter`: tables outside `allowed_schemas`/`allowed_tables` are dropped. A table is visible when `core.api.AllowedTables.coveringEntry` — the matcher the query gate's `DatasourcePermissionChecker` delegates to — covers its `schema.table`, when an entry ends in `.schema.table` (catalog-qualified BigQuery / SQL Server grants), or when its bare name is listed and no other schema in the view has a table of that name. The last rule fails closed: a bare entry grants whatever the database resolves the unqualified name to, which the view cannot know, so an ambiguous name is shown nowhere until the admin qualifies the entry. Schemas left empty are dropped unless allow-listed, columns matched by `DeniedColumns.deniesColumn` are removed, and foreign keys from or to a denied column, to a table the caller cannot see, or to a bare name shared with a hidden table are removed (the introspector reports `toTable` by bare name, so a schema-qualified deny entry on the referenced side fails closed too). The matching is relational; engine plugins that qualify differently (Couchbase grants on `bucket.scope.collection` while its view names schemas by scope; Elasticsearch index patterns) can see fewer objects than their gate allows — a degradation, never a bypass, since query enforcement is unchanged. Admins get the unfiltered view. `introspectSchemaForSystem` — async AI analysis at submission, discovery, drift, promotion and query snapshots, query-replay compatibility, the JIT request form — is never filtered. Known residual: the submission-time AI analysis prompt is built from the unfiltered view, so its free-text issues shown to the submitter can in principle name a table outside their grant. Every user-path caller (editor, table preview, AI analyze-preview, text-to-SQL, MCP `get_datasource_schema` / `validate_sql`) inherits the scoping: a forbidden table looks absent, which is the point. +**Scoped to the caller (#936).** For a non-admin, `introspectSchema` resolves the effective permission (`DatasourceUserPermissionLookupService.findFor`; none → `DatasourceNotFoundException`, before any connection is opened) and passes the view through the pure `core.internal.SchemaViewPermissionFilter`: tables outside `allowed_schemas`/`allowed_tables` are dropped. A table is visible when `core.api.AllowedTables.coveringEntry` — the matcher the query gate's `DatasourcePermissionChecker` delegates to — covers its `schema.table`, when an entry ends in `.schema.table` (catalog-qualified BigQuery / SQL Server grants), or when its bare name is listed and no other schema in the view has a table of that name. The last rule fails closed: a bare entry grants whatever the database resolves the unqualified name to, which the view cannot know, so an ambiguous name is shown nowhere until the admin qualifies the entry. Tables and schemas matched by `DeniedTables.deniesTable` / `deniesSchema` (#939) are dropped whatever the allow-list says — a denied schema is never listed, even when allow-listed. Schemas left empty are dropped unless allow-listed, columns matched by `DeniedColumns.deniesColumn` are removed, and foreign keys from or to a denied column, to a table the caller cannot see, or to a bare name shared with a hidden table are removed (the introspector reports `toTable` by bare name, so a schema-qualified deny entry on the referenced side fails closed too). The matching is relational; engine plugins that qualify differently (Couchbase grants on `bucket.scope.collection` while its view names schemas by scope; Elasticsearch index patterns) can see fewer objects than their gate allows — a degradation, never a bypass, since query enforcement is unchanged. Admins get the unfiltered view. `introspectSchemaForSystem` — async AI analysis at submission, discovery, drift, promotion and query snapshots, query-replay compatibility, the JIT request form — is never filtered. Known residual: the submission-time AI analysis prompt is built from the unfiltered view, so its free-text issues shown to the submitter can in principle name a table outside their grant. Every user-path caller (editor, table preview, AI analyze-preview, text-to-SQL, MCP `get_datasource_schema` / `validate_sql`) inherits the scoping: a forbidden table looks absent, which is the point. ### Sample data path (AF-443) `proxy.api.SampleDataService` returns a bounded, fully-governed sample of a single table's rows for the schema-explorer UI — an **ad-hoc read that bypasses review but not governance**. It does *not* create a `query_request`; it resolves the caller's directives and runs through the executor exactly like `DefaultQueryLifecycleService.doExecute`: -1. **Authorization + allow-list.** `DefaultSampleDataService` calls `DatasourceAdminService.introspectSchema(...)` (which enforces org + permission-row access) and validates the requested `schema`/`table` against the returned `DatabaseSchemaView`. Non-ADMINs additionally need `can_read` and the target inside their `allowed_schemas`/`allowed_tables`, matched by `core.api.AllowedTables` (`normalize` + `coveringEntry`) — the query gate's matcher (`DatasourcePermissionChecker.rejectedTables`), so a `schema.table` or `allowed_schemas` grant covers the preview exactly as it covers a `SELECT` (#1089). A **bare** `allowed_tables` entry is the one place the two differ: the gate applies it to an *unqualified* reference, which the database resolves, while the preview reads a concrete `schema.table`. The preview admits it only when no other schema in the database has a table of that name — counted over the unfiltered catalog (`introspectSchemaForSystem`, fetched only for this fallback), since the caller's #936-filtered view may already hide the twin — and otherwise fails closed, matching `core.internal.SchemaViewPermissionFilter`. So `allowed_tables=[orders]` with both `public.orders` and `archive.orders` previews neither until the admin qualifies the entry, and a target whose schema reports no name is covered by a bare entry only. The fallback is deliberately looser than the gate in one case: with `orders` only in `archive` and `archive` off the search path, the preview reads `archive.orders` while an unqualified `SELECT * FROM orders` fails to resolve — the same table the schema tree already shows. The view's catalog-suffix rule (`mydb.dbo.orders` showing `dbo.orders`) is not honoured here, so such a table is listed but its preview returns 404. A miss raises `TableNotFoundException` (HTTP 404) — existence is never leaked. +1. **Authorization + allow-list.** `DefaultSampleDataService` calls `DatasourceAdminService.introspectSchema(...)` (which enforces org + permission-row access) and validates the requested `schema`/`table` against the returned `DatabaseSchemaView`. Non-ADMINs additionally need `can_read` and the target inside their `allowed_schemas`/`allowed_tables`, matched by `core.api.AllowedTables` (`normalize` + `coveringEntry`) — the query gate's matcher (`DatasourcePermissionChecker.rejectedTables`), so a `schema.table` or `allowed_schemas` grant covers the preview exactly as it covers a `SELECT` (#1089). A **bare** `allowed_tables` entry is the one place the two differ: the gate applies it to an *unqualified* reference, which the database resolves, while the preview reads a concrete `schema.table`. The preview admits it only when no other schema in the database has a table of that name — counted over the unfiltered catalog (`introspectSchemaForSystem`, fetched only for this fallback), since the caller's #936-filtered view may already hide the twin — and otherwise fails closed, matching `core.internal.SchemaViewPermissionFilter`. So `allowed_tables=[orders]` with both `public.orders` and `archive.orders` previews neither until the admin qualifies the entry, and a target whose schema reports no name is covered by a bare entry only. The fallback is deliberately looser than the gate in one case: with `orders` only in `archive` and `archive` off the search path, the preview reads `archive.orders` while an unqualified `SELECT * FROM orders` fails to resolve — the same table the schema tree already shows. The view's catalog-suffix rule (`mydb.dbo.orders` showing `dbo.orders`) is not honoured here, so such a table is listed but its preview returns 404. A target on the caller's `denied_schemas` / `denied_tables` (#939, `DeniedTables.deniesTable`) is refused before the allow-list is consulted. A miss raises `TableNotFoundException` (HTTP 404) — existence is never leaked. 2. **Directive resolution.** Restricted columns (from the permission), `ColumnMaskDirective`s (`MaskingPolicyResolutionService`), and `RowSecurityDirective`s (`RowSecurityResolutionService`) are resolved for the caller. 3. **Execution.** `QueryExecutor.sampleTable(SampleTableRequest)` enforces the row cap (`maxRowsOverride` — the requested limit, lowered to the caller's effective `row_limit_override` when one applies (#933) and to any row-limit policy on the sampled table (#934) — clamped to the datasource + global `ACCESSFLOW_PROXY_EXECUTION_MAX_ROWS`) and statement timeout, then: - **Relational** datasources: builds `SELECT * FROM ` (via `IdentifierQuoter`, never raw input) and runs the existing JDBC path — `RowSecurityRewriter` injects RLS, `JdbcResultRowMapper` + `ColumnMasker` mask post-fetch, JDBC `setMaxRows` caps without a dialect-specific `LIMIT`. @@ -1039,7 +1111,7 @@ The result is a `SelectExecutionResult` mapped to `SampleRowsResponse` for `GET `proxy.api.QueryDryRunService` returns a **non-committing execution plan + best-effort estimated row impact** for a query — the playground/sandbox a user reaches for before formal submission (`POST /api/v1/queries/dry-run`). Like the sample path it is an **ad-hoc read that bypasses review but not governance**, creates no `query_request`, and never mutates data — every engine plans the statement (relational `EXPLAIN`, Mongo `explain`, …) but never executes it. -1. **Authorization + allow-list.** `DefaultQueryDryRunService` resolves the datasource via `DatasourceAdminService.getForUser`/`getForAdmin` (org + permission-row access; 404 on miss), parses the query through `QueryParser` (`InvalidSqlException` → 422) for the `QueryType` + `referencedTables`, and — for non-ADMINs — verifies the matching capability (`can_read`/`can_write`/`can_ddl`) and that every referenced table is inside the caller's allow-list (`core.api.AllowedTables.coveringEntry`, the query gate's matcher; a miss raises Spring Security `AccessDeniedException` → 403). +1. **Authorization + allow-list.** `DefaultQueryDryRunService` resolves the datasource via `DatasourceAdminService.getForUser`/`getForAdmin` (org + permission-row access; 404 on miss), parses the query through `QueryParser` (`InvalidSqlException` → 422) for the `QueryType` + `referencedTables`, and — for non-ADMINs — verifies the matching capability (`can_read`/`can_write`/`can_ddl`) and that every referenced table is inside the caller's allow-list (`core.api.AllowedTables.coveringEntry`, the query gate's matcher; a miss raises Spring Security `AccessDeniedException` → 403) and outside their `denied_schemas` / `denied_tables` (#939, `core.api.DeniedTables`; 403 `error.permission.table_denied`), then that no denied column is referenced (#935). 2. **Directive resolution.** The caller's `RowSecurityDirective`s (`RowSecurityResolutionService`) are resolved so the plan reflects the **governed** query. Column masks are irrelevant to a plan (no rows are returned) and are omitted. 3. **Planning.** `QueryExecutor.dryRun(QueryExecutionRequest)` applies the `RowSecurityRewriter`, acquires a connection via `RoutingDataSourceResolver` (SELECT dry-runs prefer the read replica; writes plan on the primary — e.g. Oracle writes its scratch `PLAN_TABLE` there), and: - **Relational** datasources: a per-`DbType` `DryRunPlanner` (`proxy/internal/dryrun/`) runs the dialect's non-executing EXPLAIN — PostgreSQL `EXPLAIN (FORMAT JSON)`, MySQL/MariaDB `EXPLAIN FORMAT=JSON`, Oracle `EXPLAIN PLAN FOR` + `PLAN_TABLE` (rows deleted in a `finally`), SQL Server `SET SHOWPLAN_ALL ON` — and maps it to a `QueryPlanNode` tree. `CUSTOM` JDBC has no planner and degrades gracefully. @@ -1615,7 +1687,9 @@ direct rows, the group rows and every relevant membership in one query each, the across its members in memory — and merges each user's own set through `core`'s `mergeContributions`, the same merge `findFor` applies. That matters more than it looks: the merge ORs booleans and **unions** allow-lists, so two grants that each fall short can -together be enough, and any per-grant shortcut would quietly under-report. +together be enough, and any per-grant shortcut would quietly under-report. It also unions the table and +schema deny-lists (#939), so one grant's denial removes the table from every contributor's coverage — +`granted` is read through `DatasourcePermissionChecker.blockedTables` over the merged view, never a part. Two representations carry the interesting cases. An unrestricted grant is `ALL_TABLES` and is never expanded into a table list — enumerating would need a live schema read, which this feature never @@ -2491,7 +2565,7 @@ The `access` module (`com.bablsoft.accessflow.access`) lets users self-request t **Grant materialisation.** On final-stage approval, `approve()` runs `AccessGrantMaterializer` inside the same transaction so approval + grant commit atomically. The materializer computes `expires_at = now + Duration.parse(requested_duration)` and branches on the resource kind: datasource requests call `core.api.DatasourceAdminService.grantPermission(...)`; connector requests call `apigov.api.ApiConnectorAdminService.grantPermission(...)` with `canRead`/`canWrite`, the request's `allowed_operations`, and **never** break-glass or response-field restrictions. The new permission id is stored on the request, and the datasource path also stamps the request id on the permission row (`datasource_user_permissions.access_grant_request_id`, V169 — FK `ON DELETE SET NULL`, backfilled once from `granted_permission_id`; #969), which is what the effective-access report (#859) reads to label a source `JIT_GRANT` instead of correlating on `(requester, datasource)`. -**Pre-existing-permission policy.** If the requester already holds a **direct** permission on the resource (group grants are never considered or touched): a **standing** permission (`expires_at == null`, admin-granted) is never silently deleted — the materializer throws `AccessGrantAlreadyExistsException` (HTTP 409; the datasource path uses `core.api.DatasourceUserPermissionLookupService.findDirectFor`, the connector path `apigov.api.ApiConnectorPermissionLookupService.findDirectFor`). Another **time-boxed** (JIT) permission is replaced so the new grant's capabilities/expiry take effect (extend/widen) — revoke-then-grant on the datasource path, the `(connector_id, user_id)` upsert of `grantPermission` on the connector path. This keeps standing access safe while letting JIT grants stack predictably. (See [docs/07-security.md](07-security.md).) +**Pre-existing-permission policy.** If the requester already holds a **direct** permission on the resource (group grants are never considered or touched): a **standing** permission (`expires_at == null`, admin-granted) is never silently deleted — the materializer throws `AccessGrantAlreadyExistsException` (HTTP 409; the datasource path uses `core.api.DatasourceUserPermissionLookupService.findDirectFor`, the connector path `apigov.api.ApiConnectorPermissionLookupService.findDirectFor`). Any other **time-boxed** direct permission — a JIT grant or an admin-created row with an `expires_at` alike — is replaced so the new grant's capabilities/expiry take effect (extend/widen) — revoke-then-grant on the datasource path, where the replaced row's `denied_columns` / `denied_schemas` / `denied_tables` carry over onto the new grant so a JIT approval never lifts a denial (#939), the `(connector_id, user_id)` upsert of `grantPermission` on the connector path. This keeps standing access safe while letting JIT grants stack predictably. (See [docs/07-security.md](07-security.md).) **Expiry & revoke.** `AccessGrantExpiryJob` (see "Scheduled jobs" above) revokes grants past `expires_at` → `EXPIRED`. An admin may early-revoke an active grant (`POST /admin/access-requests/{id}/revoke`) → `REVOKED`. Both paths revoke the materialised permission — deleting the `datasource_user_permissions` or `api_connector_user_permissions` row by kind (tolerating an already-deleted row) — and publish events consumed by the notifications + realtime modules. Effective-permission resolution needs no special handling: `EffectiveApiConnectorPermissionResolver` already excludes rows past `expires_at`, so a connector JIT grant stops resolving the moment it expires even before the job deletes it. diff --git a/docs/06-frontend.md b/docs/06-frontend.md index 47451dd2c..ea1250d26 100644 --- a/docs/06-frontend.md +++ b/docs/06-frontend.md @@ -382,8 +382,8 @@ immediately. - Connection config form with live test button (`POST /datasources/{id}/test`) - **Schema** tab (AF-443) — a searchable, hierarchical object tree (`components/datasources/SchemaObjectTree.tsx`) over the introspected schema: schemas → tables → columns, with a single filter that matches across **all three levels** (a column-name query surfaces its table and schema). The pure filter logic lives in `src/utils/schemaFilter.ts` (`filterSchema`). Each table row has a **"Preview data"** action that opens `components/datasources/SampleDataDrawer.tsx` → `SampleDataPreview.tsx`, a read-only AntD `Table` of a bounded, **RLS- and masking-aware** sample fetched via `useTableSample` (`GET /datasources/{id}/sample-rows`). Masked columns are badged with a lock icon and only ever render the masked value; a banner notes that row-level security and masking are applied, and the footer shows the row count / cap. The same `SchemaObjectTree` + `SampleDataDrawer` power the editor sidebar (`components/editor/SchemaTree.tsx`), so the cross-hierarchy search and sample preview are available while writing queries too. - **ER diagram** tab (`components/datasources/ErDiagramTab.tsx` → `ErDiagram.tsx`) — renders the introspected schema as a `@xyflow/react` graph, one node per table (showing columns + PK markers) and one edge per foreign key (label `from → to`). Auto-layout via `dagre` (LR rank direction); read-only — `nodesDraggable={false}`. Clicking a node highlights all edges touching it (others fade to opacity 0.18); clicking the canvas background clears the selection. Loading state is a same-size `Skeleton.Node` to avoid CLS; databases without FKs (denormalized warehouses, custom drivers without `getImportedKeys`) render an `EmptyState`. The CSS in `src/styles/globals.css` already honours `prefers-reduced-motion` for all transitions. -- `PermissionMatrix` — table of all users × (can_read, can_write, can_ddl, can_break_glass, row_limit, allowed_schemas, restricted columns count, expires_at). Restricted columns render as `"N columns"` with a hover tooltip listing the fully-qualified names; `"—"` when none. The break-glass column uses the shared `PermCell` check/dash renderer (colour + icon, never colour alone). -- `GrantAccessModal` includes a `can_break_glass` switch (after the read/write/DDL trio, default off, AF-385) and a `restricted_columns` multi-select populated from the datasource's introspected schema (`flattenSchemaToColumns` in `src/utils/schemaColumns.ts`). The break-glass help text explains it is an **additional** emergency capability that bypasses review but still requires the underlying read/write/DDL capability at submission time; the existing "at least one of read/write/DDL" rule is preserved and is **not** satisfied by break-glass alone. The restricted-columns help text explains that values are masked in results and the AI reviewer is informed but does not auto-reject. A `denied_columns` multi-select (#935), fed by the same column options, is shown only for the JSqlParser engines (`supportsDeniedColumns` in `src/utils/deniedColumns.ts`, which reuses `SQL_REVIEW_DB_TYPES`). Its `Form.Item` validator mirrors the backend `@Pattern`/`@Size`: each entry must be `table.column` or `schema.table.column`, at most 200 entries. Both the user and the group permission tables gain a "Denied" column: a red "N columns" tag with the columns in a tooltip, or "—". Permissions are create-only (revoke + re-grant); there is no edit flow. +- `PermissionMatrix` — table of all users × (can_read, can_write, can_ddl, can_break_glass, row_limit, allowed_schemas, restricted columns count, denied columns count, denied tables count, expires_at). Restricted columns render as `"N columns"` with a hover tooltip listing the fully-qualified names; `"—"` when none. The break-glass column uses the shared `PermCell` check/dash renderer (colour + icon, never colour alone). +- `GrantAccessModal` includes a `can_break_glass` switch (after the read/write/DDL trio, default off, AF-385) and a `restricted_columns` multi-select populated from the datasource's introspected schema (`flattenSchemaToColumns` in `src/utils/schemaColumns.ts`). The break-glass help text explains it is an **additional** emergency capability that bypasses review but still requires the underlying read/write/DDL capability at submission time; the existing "at least one of read/write/DDL" rule is preserved and is **not** satisfied by break-glass alone. The restricted-columns help text explains that values are masked in results and the AI reviewer is informed but does not auto-reject. A `denied_columns` multi-select (#935), fed by the same column options, is shown only for the JSqlParser engines (`supportsDeniedColumns` in `src/utils/deniedColumns.ts`, which reuses `SQL_REVIEW_DB_TYPES`). Its `Form.Item` validator mirrors the backend `@Pattern`/`@Size`: each entry must be `table.column` or `schema.table.column`, at most 200 entries. Both the user and the group permission tables gain a "Denied columns" column: a red "N columns" tag with the columns in a tooltip, or "—". Two `mode="tags"` selects, **Denied schemas** and **Denied tables** (#939), follow; their options come from the introspected schema, and table options are schema-qualified (`schema.table`, narrowed to the selected allowed schemas when any are chosen) because a bare name would deny that table in every schema. Free-typed entries are accepted. Validators in `src/utils/deniedTables.ts` mirror the backend `@Size`/`@NotBlank` (at most 50 schemas and 200 tables, no blank entry). Both permission tables render a "Denied tables" column: a red count tag whose tooltip lists the denied schemas as `schema.*` and then the denied tables (`deniedTableEntries`), or "—". Permissions are create-only (revoke + re-grant); there is no edit flow. - **Masking** tab (`components/datasources/MaskingTab.tsx`, AF-381) — a table of dynamic data masking policies (column, strategy, reveal-to summary, enabled) with a create/edit modal. The modal picks a column via an `AutoComplete` from the introspected schema, a strategy `Select` driven by `enumOptions(MASKING_STRATEGIES, maskingStrategyLabel, t)`, a conditional `visible_suffix` field shown only for `PARTIAL`, and reveal-to multi-selects for roles (`enumOptions`), groups, and users. A **live preview** renders the masked output of an editable sample value through `src/utils/maskingPreview.ts` (a pure client-side mirror of the backend `ColumnMasker` strategies; `HASH` shows an illustrative fixed digest since the real SHA-256 is computed server-side). CRUD calls `src/api/maskingPolicies.ts`; validation parity matches the backend DTO (required column ≤ 512 chars, required strategy, `visible_suffix` 1–256). - **Row security** tab (`components/datasources/RowSecurityTab.tsx`, AF-380) — a table of row-level security policies (table, `column operator value` predicate, applies-to summary, enabled) with a create/edit modal. The structured form picks a table and column via `AutoComplete`s from the introspected schema, an operator `Select` (`enumOptions(ROW_SECURITY_OPERATORS, rowSecurityOperatorLabel, t)`), a value-source `Select` (`VARIABLE` | `LITERAL`), and a value field that switches to a `:user.*` variable `AutoComplete` (offering the `:user.id` / `:user.email` / `:user.role` / `:user.groups` built-ins) when the source is `VARIABLE`. Applies-to multi-selects target roles, groups, and users (empty = everyone). CRUD calls `src/api/rowSecurityPolicies.ts`; validation parity matches the backend DTO (required table/column/value ≤ 512 chars, required operator, required value source). - **Row limits** tab (`components/datasources/RowLimitTab.tsx`, #934) — a table of per-table row-limit policies (`schema.table`, max rows, applies-to summary, enabled) with a create/edit modal. The modal picks an optional schema and a table through `AutoComplete`s fed by the introspected schema (the table list narrows to the chosen schema; an empty schema means "any schema"), a `max_rows` `InputNumber` (1–1,000,000) and applies-to multi-selects for roles, groups and users (empty = everyone, admins included). CRUD calls `src/api/rowLimitPolicies.ts`; validation parity matches the backend DTO (required table ≤ 255 chars, optional schema ≤ 255 chars, required `max_rows` 1–1,000,000). diff --git a/docs/07-security.md b/docs/07-security.md index fc95da7a2..86121cb08 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -715,9 +715,12 @@ and every unexpired `datasource_group_permissions` grant for a group they belong capabilities are OR-ed, allow-lists (`allowed_schemas`/`allowed_tables`) unioned, and `restricted_columns` and `denied_columns` intersected (a column is masked — or denied — only when **every** contributing grant masks or denies it; #935), each grant's `expires_at` -honoured independently. `row_limit_override` is the one deliberate inversion: the **smallest** non-null value +honoured independently. Two fields are deliberate inversions. `row_limit_override`: the **smallest** non-null value wins, so a wide group grant can never raise a tight per-user cap, and the proxy clamps it to the datasource -cap and the global ceiling (#933). The union is computed once in `DefaultDatasourceUserPermissionLookupService.findFor`, +cap and the global ceiling (#933). `denied_schemas` / `denied_tables` (#939) are **unioned**: a table stays +denied when **any** contributing grant — direct, group or JIT — denies it, so a permissive group grant can +never lift a denial from a direct grant, and a group grant's denial binds every member. (`denied_columns` +still intersects — an intentional asymmetry, documented here so nobody "fixes" one without the other.) The union is computed once in `DefaultDatasourceUserPermissionLookupService.findFor`, the single choke-point every enforcement path (proxy, JIT/break-glass gates, masking/row-security scoping, `requestgroups` checks) reads through, so groups behave here exactly as they already do for masking-reveal and row-security. Granting a group access lets an admin onboard a whole team without a @@ -746,6 +749,15 @@ Are allowed_schemas / allowed_tables set? a schemas-only allow-list does NOT cover them. Violation → 403 ↓ +Are denied_schemas / denied_tables set? (#939 — checked with or without an allow-list) + YES → match every referenced table against the deny-lists; a denial ALWAYS beats the allow-list. + denied_tables: either name a dot-aligned suffix of the other (`salary` denies every + schema's `salary`; `crm.salary` also denies an unqualified `salary`). + denied_schemas: any reference carrying the schema as a non-final segment, AND every + unqualified reference (the gate cannot know where search_path resolves it). + Reject (403, `error.permission.table_denied`) on any hit. + Violation → 403 + ↓ Are denied_columns set? (#935, relational engines only) YES → resolve every column the parsed statement references (select items, WHERE, JOIN ON/USING, GROUP BY, HAVING, ORDER BY, subqueries, UPDATE SET targets, INSERT column lists, RETURNING) @@ -762,9 +774,10 @@ Are restricted_columns set? **Discovery follows the same rules (#936).** `GET /datasources/{id}/schema` and every surface built on it (editor autocomplete, table preview, AI preview / text-to-SQL context, MCP `get_datasource_schema`) -show a non-admin only the tables their allow-list covers, without their denied columns, and without +show a non-admin only the tables their allow-list covers and their deny-lists do not reach (a denied +schema is never listed; #939), without their denied columns, and without foreign keys that start from or point at a denied column or at a hidden table — through the same -`core.api.AllowedTables` / `DeniedColumns` matchers the gate above uses. Where the view cannot tell +`core.api.AllowedTables` / `DeniedTables` / `DeniedColumns` matchers the gate above uses. Where the view cannot tell what the gate would allow it fails closed: a bare `allowed_tables` entry shows a table only when no other schema has one of the same name. Admins and system-actor introspection are unfiltered; the JIT request form keeps its name-only unfiltered listing, because requesting access to something you cannot yet see is its purpose. @@ -786,8 +799,8 @@ question: lapses between the two reads. 3. **Does the grant carry the capability the query type needs?** A read-only analyst is never shown a DDL suggestion they could not submit. -4. **Is every referenced table inside their allow-list?** `DatasourcePermissionChecker.rejectedTables` - must come back empty. This is safe **only** because the aggregation guarantees +4. **Is every referenced table inside their allow-list, and outside their deny-lists?** + `DatasourcePermissionChecker.blockedTables` (allow-list misses plus #939 denials) must come back empty. This is safe **only** because the aggregation guarantees `referenced_tables` is never empty — that method reports "nothing rejected" for an empty set, so a row whose tables could not be resolved would clear this check for everyone. The aggregation drops such rows rather than storing them; do not relax that guard. @@ -825,14 +838,14 @@ A user can self-request temporary, scoped access — to a datasource or an API c - **A requester can never approve their own request.** Enforced in `DefaultAccessReviewService.prepareDecision()` at the service layer (not just the UI) — `requesterId == reviewerId` raises `AccessDeniedException` (403), exactly as the query-review self-approval block does. - **Eligibility is identical to query review.** The reviewer must be an approver at the request's current stage in the resource's review plan (the datasource's plan, or the connector's `review_plan_id`) *and* — for datasource requests — within the datasource's scoped-reviewer set (`datasource_reviewers`) when one is configured (reviewer scoping is a datasource-only concept). `REVIEWER`/`ADMIN` role is necessary but not sufficient. - **Grants are time-boxed.** On final-stage approval the system writes a `datasource_user_permissions` or `api_connector_user_permissions` row with `expires_at = now + requested_duration` (bounded by `accessflow.access.min-duration` / `max-duration`). `AccessGrantExpiryJob` revokes it on expiry (`EXPIRED`); an admin may early-revoke (`REVOKED`). Once expired/revoked the permission row is gone, so the standard access checks return 403 — and the connector-side effective-permission resolver already excludes rows past `expires_at` even before deletion. -- **Pre-existing-permission policy.** A JIT grant **never silently deletes a standing (admin-granted, non-expiring) direct permission** — approval fails with `ACCESS_GRANT_ALREADY_EXISTS` (409) in that case. An existing *time-boxed* direct permission is revoked and replaced (extend/widen); group grants are never considered or touched. This preserves standing access as the source of truth while letting JIT grants stack predictably. +- **Pre-existing-permission policy.** A JIT grant **never silently deletes a standing (admin-granted, non-expiring) direct permission** — approval fails with `ACCESS_GRANT_ALREADY_EXISTS` (409) in that case. An existing *time-boxed* direct permission — JIT or admin-created with an expiry — is revoked and replaced (extend/widen), and its `denied_columns` / `denied_schemas` / `denied_tables` carry over onto the new grant, so a JIT approval never lifts a denial (#939); group grants are never considered or touched. This preserves standing access as the source of truth while letting JIT grants stack predictably. - **Privilege ceiling on connector requests (AF-567).** A connector access request can only convey `can_read`/`can_write` plus an operation allow-list validated against the connector's catalog — `can_break_glass` and response-field-restriction changes are never self-requestable, and the materialised grant always carries `can_break_glass = false`. ### Break-glass / emergency access (AF-385) A distinct submission mode that **skips pre-approval** for genuine emergencies, with compensating controls and these non-negotiable security invariants: -- **Gated by an explicit `can_break_glass` permission, required for everyone — including admins.** Unlike normal submission (where admins bypass the per-datasource permission check), break-glass is enforced for all callers at the service layer (`DefaultBreakGlassService`): a non-null, non-expired `datasource_user_permissions` row with `can_break_glass=true` **and** the capability for the parsed query type **and** the table allow-list, else `BreakGlassNotPermittedException` (403). Time-boxed via the grant's `expires_at`. +- **Gated by an explicit `can_break_glass` permission, required for everyone — including admins.** Unlike normal submission (where admins bypass the per-datasource permission check), break-glass is enforced for all callers at the service layer (`DefaultBreakGlassService`): a non-null, non-expired `datasource_user_permissions` row with `can_break_glass=true` **and** the capability for the parsed query type **and** the table allow-list **and** the table/schema deny-lists (#939) **and** the denied columns, else `BreakGlassNotPermittedException` (403). Time-boxed via the grant's `expires_at`. - **All proxy guards still apply.** The query runs through the identical execution path — schema/table allow-list, dynamic masking, row-level security, and row caps are enforced exactly as for a reviewed query. Break-glass bypasses *approval*, never the *data-protection* controls. - **Justification is mandatory** and captured on the `break_glass_events` row and in the audit metadata. - **Compensating controls.** Instant fanout to every active org admin (incl. PagerDuty); a prominently distinct `QUERY_BREAK_GLASS_EXECUTED` audit row (not `QUERY_EXECUTED`); and a mandatory retro-review. @@ -846,7 +859,7 @@ A distinct submission mode that **skips pre-approval** for genuine emergencies, - Restricted columns can still be referenced in SQL (WHERE, JOIN, GROUP BY, etc.). The system does not reject the query; it masks the value in the SELECT response and informs the AI reviewer. - Masking happens in `JdbcResultRowMapper` before rows are added to the in-memory result and before they are written to `query_request_results.rows`. The raw value never lands in our database. The sentinel is `"***"`; `null` stays `null`. - The AI analyzer renders `*RESTRICTED*` markers next to flagged columns in the schema context and is instructed to emit `RESTRICTED_COLUMN_ACCESS` issues (severity `LOW`) — the workflow state machine ignores this category for auto-rejection logic. -- For high-confidentiality data where the value must never be retrievable at all, use `denied_columns` (below), an `allowed_tables` denial, or a database-side view that excludes the column. +- For high-confidentiality data where the value must never be retrievable at all, use `denied_columns` (below), a `denied_tables` entry (#939) or an `allowed_tables` omission for the whole table, or a database-side view that excludes the column. ### Denied columns — block, not mask (#935) @@ -910,11 +923,61 @@ refuses the preview), and the access simulator, which reports the refusal as - **Precedence with masking.** Deny is evaluated before execution, so a column that is both denied and restricted is rejected. A column that is only restricted keeps masking exactly as before. - **Who it binds.** Like the table allow-list, `QUERY_ADMIN` holders skip the per-datasource gate at - submission. Break-glass enforces it for everyone. A JIT grant never carries denied columns, and the - intersection merge means a grant that denies nothing lifts the deny for its holder. Entries meet by + submission. Break-glass enforces it for everyone. A JIT request cannot ask for denied columns; a + JIT grant carries only those of the expiring direct row it replaces (#939), and the intersection merge + means a grant that denies nothing lifts the deny for its holder. Entries meet by the column they name, not by spelling: `users.ssn` on one grant and `public.users.ssn` on another still deny `public.users.ssn`. +### Table / schema deny-lists (#939) + +`denied_schemas` and `denied_tables` (`TEXT[]` on both permission tables) are the subtractive +counterpart of the allow-list: `allowed_schemas=[crm]` + `denied_tables=[crm.salary]` grants all of +`crm` — including tables created after the grant — except `crm.salary`. A denial is evaluated after the +allow-list and **always wins**; it also works with no allow-list at all. All gates share one matcher, +`core.api.DeniedTables`. + +- **Fail-closed matching.** The gate reads SQL, not the database's name resolution, so every rule + errs toward refusing. A `denied_tables` entry matches when either name is a dot-aligned suffix of + the other: bare `salary` denies `salary` in every schema, and `crm.salary` denies `crm.salary`, + `db.crm.salary` **and** an unqualified `salary`. A `denied_schemas` entry matches any reference + carrying it as a non-final segment and **every unqualified reference** — while any schema is + denied, the grantee must schema-qualify table names. A `schema.*` entry in `denied_tables` denies the + whole schema. Matching is case-insensitive with identifier quotes stripped and runs segment by segment + from the right: an empty segment (SQL Server `db..salary`) matches anything, an Oracle `@dblink` + suffix is ignored, and a reference that is a pattern (`*` / `?`, e.g. an Elasticsearch index pattern + `sal*`) is denied by any entry at all. +- **Entry validation.** A `denied_schemas` entry is a single name (no dots, no `*` / `?`); a + `denied_tables` entry is `table`, `schema.table` or `schema.*` with no empty segment. Anything else is + refused at grant time (400), and again at the service layer for non-web callers. +- **Union merge.** Denials union across direct, group and JIT grants (the inverse of every other + merged field). A permissive grant cannot dissolve a denial, and a group grant's denial binds every + member. `denied_columns` still merges by intersection — an intentional asymmetry for now. +- **Removing a grant can widen access.** A denial lives on its grant row, so revoking or expiring that + row (an attestation revoke, expiry, an admin revoke) removes the denial too, and another grant the user + still holds may then expose the table. +- **Where it is enforced.** Everywhere the allow-list is: submission (REST and MCP) and the recurring + per-occurrence recheck (403 `error.permission.table_denied`), break-glass (for everyone), dry-run + (403), the access simulator (`denied_tables` detail, `workflow.access_simulation.permission.table_denied`), + the effective-access reverse index (not granted), query suggestions (not offered), the table preview + (404, as if absent), and the filtered schema view — denied tables and schemas are hidden from the + schema browser, autocomplete, MCP tools and AI text-to-SQL context. Request-group `QUERY` members + are checked too; they are still not checked against the allow-list, a pre-existing gap tracked as a + follow-up. +- **Who it binds.** Like the allow-list, `QUERY_ADMIN` holders skip the per-datasource gate at + submission; break-glass enforces it for everyone. A JIT access request cannot add a deny-list, but a + JIT grant never lifts one: denials union across grants, and when a JIT approval replaces the user's + expiring direct row, that row's denials (tables, schemas and columns) carry over onto the new grant. +- **Known limits.** Deny-lists apply to every engine, relational and plugin. On engines whose names + carry no schema (MongoDB collections, DynamoDB tables, Redis keys) every reference is unqualified, so + a `denied_schemas` entry refuses every query — use `denied_tables` there. A denial only sees the + references the parser or engine plugin reports, the same limit as the allow-list: views, function + bodies and SQL-text functions (`query_to_xml`, `dblink`, `OPENQUERY`) are invisible to the AST walk, + MongoDB `$lookup` / `$unionWith` / `$graphLookup` read unreported collections, Neo4j reports only + labels and relationship types (`MATCH (n) RETURN n` reports nothing), and Redis `KEYS` with a glob + reports no prefix. Pair a deny-list with database-side grants on the pool account where the table + must be unreachable even through those. + ### Dynamic data masking policies (AF-381) `masking_policy` rows extend the static masking above with **per-column strategies** and a @@ -1223,7 +1286,7 @@ transformed values; the raw data is never sent over the wire). - `password_encrypted` is **excluded from all API serialization** (`@JsonIgnore`) - Credentials are decrypted only inside the `QueryProxyService` at JDBC pool creation time - The decrypted password is passed directly to HikariCP and not retained in application memory beyond pool initialization -- A dedicated low-privilege service account is recommended on each customer database (SELECT only, or specific table grants matching `allowed_tables`) +- A dedicated low-privilege service account is recommended on each customer database (SELECT only, or specific table grants matching `allowed_tables` and withholding what `denied_tables` / `denied_schemas` cover) ### External secret stores (AF-448) @@ -1509,7 +1572,7 @@ AccessFlow uses defense-in-depth against injection attacks: There is exactly one carve-out, and it never executes anything: **the SQL Server dry-run plan read** (`SqlServerDryRunPlanner`, AF-762). `mssql-jdbc` returns no SHOWPLAN rows at all over the prepared/RPC path, so the plan query is issued as a plain `Statement` language batch. Four properties bound it: the statement text is the caller's own SQL, already JSqlParser-validated and allow-list-checked; nothing is concatenated into it; the planner refuses to run when the row-security rewrite produced binds, so no value is ever interpolated; and `SET SHOWPLAN_ALL ON` means SQL Server plans the statement rather than running it — for DDL and `SET` as much as for DML. All four are verified against a real SQL Server 2022 in `DefaultQueryExecutorMssqlIntegrationTest`, which asserts that an `UPDATE`/`DELETE` dry-run leaves every row untouched and that a `CREATE TABLE` / `DROP TABLE` dry-run changes no schema. -3. **Schema allow-listing at AST level** — If `allowed_schemas` or `allowed_tables` is configured, the parsed SQL AST is walked by `SqlStatementInspector` (a `TablesNamesFinder` subclass) to extract referenced tables. It records tables itself instead of trusting the finder's final set, which drops every name that matches *any* derived-table, LATERAL or `WITH` alias in the statement (`… EXISTS (SELECT 1 FROM (SELECT 1) AS secret)` used to erase the real `secret`): a derived-table alias never hides a table, and a `WITH` name hides an unqualified table only inside the statement that declares it and only after its own body (every name of a `WITH RECURSIVE` list is visible throughout the list), so `WITH secret AS (SELECT * FROM secret) …` still reports `secret`. It also descends into positions the finder skips — window `PARTITION BY`, a function call's aggregate `ORDER BY` / `LIMIT` / `HAVING` / keyword arguments, array subscripts, `TOP`, and `XMLTABLE` / `JSON_TABLE` arguments in `FROM`. Row security (`RowSecurityRewriter`) uses the same walker, so both controls see the same table set. Identifiers are normalised (quotes stripped, ASCII-lowercased) before comparison; the union across `BEGIN; …; COMMIT;` envelopes is enforced as a single set. Violations are rejected (HTTP 403) without touching the database. **Known limit:** functions that take SQL *text* and run it server-side — PostgreSQL `query_to_xml('select … from t', …)`, `dblink(…)`, SQL Server `OPENQUERY(…)` / `OPENROWSET(…)` — are opaque to an AST walk, so the tables they read are not in the set. Block them with the deterministic SQL review `disallowed_function` rule (or revoke `EXECUTE` on them for the pool account); on PostgreSQL the read-only session in (7) stops their write variants on the `SELECT` path. See [docs/05-backend.md → "Schema / table allow-list enforcement"](05-backend.md#schema--table-allow-list-enforcement) for the full match algorithm. +3. **Schema allow-listing at AST level** — If `allowed_schemas` or `allowed_tables` is configured, the parsed SQL AST is walked by `SqlStatementInspector` (a `TablesNamesFinder` subclass) to extract referenced tables. It records tables itself instead of trusting the finder's final set, which drops every name that matches *any* derived-table, LATERAL or `WITH` alias in the statement (`… EXISTS (SELECT 1 FROM (SELECT 1) AS secret)` used to erase the real `secret`): a derived-table alias never hides a table, and a `WITH` name hides an unqualified table only inside the statement that declares it and only after its own body (every name of a `WITH RECURSIVE` list is visible throughout the list), so `WITH secret AS (SELECT * FROM secret) …` still reports `secret`. It also descends into positions the finder skips — window `PARTITION BY`, a function call's aggregate `ORDER BY` / `LIMIT` / `HAVING` / keyword arguments, array subscripts, `TOP`, and `XMLTABLE` / `JSON_TABLE` arguments in `FROM`. Row security (`RowSecurityRewriter`) uses the same walker, so both controls see the same table set. Identifiers are normalised (quotes stripped, ASCII-lowercased) before comparison; the union across `BEGIN; …; COMMIT;` envelopes is enforced as a single set. The same table set is then checked against the grant's `denied_schemas` / `denied_tables` (#939), which beat the allow-list and apply with or without one. Violations are rejected (HTTP 403) without touching the database. **Known limit:** functions that take SQL *text* and run it server-side — PostgreSQL `query_to_xml('select … from t', …)`, `dblink(…)`, SQL Server `OPENQUERY(…)` / `OPENROWSET(…)` — are opaque to an AST walk, so the tables they read are not in the set. Block them with the deterministic SQL review `disallowed_function` rule (or revoke `EXECUTE` on them for the pool account); on PostgreSQL the read-only session in (7) stops their write variants on the `SELECT` path. See [docs/05-backend.md → "Schema / table allow-list enforcement"](05-backend.md#schema--table-allow-list-enforcement) for the full match algorithm. 4. **DDL blocked by default** — `can_ddl=false` (the default) prevents CREATE/ALTER/DROP from being executed even if submitted by an ANALYST or REVIEWER. diff --git a/e2e/helpers/datasources.ts b/e2e/helpers/datasources.ts index ea8e0cbe0..2e4e10af1 100644 --- a/e2e/helpers/datasources.ts +++ b/e2e/helpers/datasources.ts @@ -519,6 +519,10 @@ export async function grantPermissionViaApi( canBreakGlass?: boolean; // #935 — `table.column` / `schema.table.column` entries the grantee may never query. deniedColumns?: string[]; + // #939 — schema/table deny-lists; a denial always beats the allow-list. + allowedSchemas?: string[]; + deniedSchemas?: string[]; + deniedTables?: string[]; } = {}, ): Promise { const res = await request.post( @@ -532,6 +536,9 @@ export async function grantPermissionViaApi( can_ddl: opts.canDdl ?? false, can_break_glass: opts.canBreakGlass ?? false, denied_columns: opts.deniedColumns ?? null, + allowed_schemas: opts.allowedSchemas ?? null, + denied_schemas: opts.deniedSchemas ?? null, + denied_tables: opts.deniedTables ?? null, }, }, ); diff --git a/e2e/tests/datasource-denied-tables.spec.ts b/e2e/tests/datasource-denied-tables.spec.ts new file mode 100644 index 000000000..4c2a8dbda --- /dev/null +++ b/e2e/tests/datasource-denied-tables.spec.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { + acceptInvitationViaApi, + apiBase, + createPostgresDatasource, + deleteDatasource, + findUserByEmailViaApi, + grantPermissionViaApi, + inviteUserViaApi, + loginViaApi, + waitForInviteToken, + type CreatedDatasource, +} from '../helpers/datasources'; +import { login } from '../helpers/login'; + +const ADMIN_EMAIL = 'e2e@accessflow.test'; +const ADMIN_PASSWORD = 'E2ePassword!123'; +const ANALYST_PASSWORD = 'Analyst-Pwd!123'; +const DENIED = 'public.salary'; + +// Table deny-lists (#939): a grant that allows the whole `public` schema but denies one table refuses +// a query on that table with 403 before it is persisted — also when the name is unqualified — while +// any other table in the schema, including one never seen before, is accepted. +test.describe.configure({ timeout: 90_000 }); + +test.describe.serial('datasource denied tables (#939)', () => { + let adminAccessToken = ''; + let analystEmail = ''; + let analystId = ''; + let uiDatasource: CreatedDatasource | null = null; + let apiDatasource: CreatedDatasource | null = null; + + test.beforeAll(async ({ request }) => { + adminAccessToken = await loginViaApi(request, ADMIN_EMAIL, ADMIN_PASSWORD); + analystEmail = `af939-analyst-${randomUUID()}@e2e.local`; + await inviteUserViaApi(request, adminAccessToken, analystEmail, 'AF-939 Analyst', 'ANALYST'); + const token = await waitForInviteToken(request, analystEmail); + await acceptInvitationViaApi(request, token, ANALYST_PASSWORD, 'AF-939 Analyst'); + analystId = (await findUserByEmailViaApi(request, adminAccessToken, analystEmail)).id; + + uiDatasource = await createPostgresDatasource(request, adminAccessToken, { + name: `Postgres E2E AF939 UI ${Date.now()}`, + }); + apiDatasource = await createPostgresDatasource(request, adminAccessToken, { + name: `Postgres E2E AF939 API ${Date.now()}`, + }); + await grantPermissionViaApi(request, adminAccessToken, apiDatasource.id, analystId, { + allowedSchemas: ['public'], + deniedTables: [DENIED], + }); + }); + + test.afterAll(async ({ request }) => { + for (const ds of [uiDatasource, apiDatasource]) { + if (ds) await deleteDatasource(request, adminAccessToken, ds.id); + } + }); + + test('an admin denies a table from the grant modal and the row shows it', async ({ page }) => { + if (!uiDatasource) throw new Error('beforeAll did not create the UI datasource'); + const dsId = uiDatasource.id; + + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`/datasources/${dsId}/settings`); + await page.getByRole('tab', { name: /^Permissions · 0$/ }).click(); + await page.getByRole('button', { name: 'Grant access' }).first().click(); + const grantDialog = page.getByRole('dialog').filter({ hasText: 'Grant datasource access' }); + await expect(grantDialog).toBeVisible(); + + const userCombobox = grantDialog.getByRole('combobox', { name: 'User' }); + await userCombobox.click(); + await userCombobox.fill(analystEmail); + await page.locator('.ant-select-item-option').filter({ hasText: analystEmail }).click(); + + const deniedCombobox = grantDialog.getByRole('combobox', { name: 'Denied tables' }); + await deniedCombobox.click(); + await deniedCombobox.fill(DENIED); + await deniedCombobox.press('Enter'); + + const [grantResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.request().method() === 'POST' && + new RegExp(`/api/v1/datasources/${dsId}/permissions$`).test(r.url()), + { timeout: 15_000 }, + ), + grantDialog.getByRole('button', { name: 'Grant access' }).click(), + ]); + expect(grantResponse.status()).toBe(201); + expect(grantResponse.request().postDataJSON().denied_tables).toEqual([DENIED]); + + const analystRow = page.locator('.ant-table-row').filter({ hasText: analystEmail }); + await expect(analystRow).toHaveCount(1, { timeout: 10_000 }); + await expect(analystRow.getByText('1 entry', { exact: true })).toBeVisible(); + }); + + test('a query on the denied table is refused with 403; other tables in the schema are accepted', async ({ + request, + }) => { + if (!apiDatasource) throw new Error('beforeAll did not create the API datasource'); + const analystToken = await loginViaApi(request, analystEmail, ANALYST_PASSWORD); + const submit = (sql: string) => + request.post(`${apiBase()}/api/v1/queries`, { + headers: { Authorization: `Bearer ${analystToken}` }, + data: { datasource_id: apiDatasource!.id, sql, justification: 'e2e: AF-939' }, + }); + + for (const sql of [ + 'SELECT * FROM public.salary', + 'SELECT * FROM salary', + 'SELECT c.id FROM public.customer c JOIN public.salary s ON s.id = c.id', + ]) { + const res = await submit(sql); + expect(res.status(), sql).toBe(403); + expect((await res.json()).detail, sql).toMatch(/salary/); + } + + for (const sql of ['SELECT id FROM public.customer', 'SELECT id FROM public.brand_new_table']) { + const allowed = await submit(sql); + expect(allowed.status(), sql).toBe(202); + } + }); +}); diff --git a/frontend/src/components/policies/decisionTraceDetails.ts b/frontend/src/components/policies/decisionTraceDetails.ts index db1763088..bf23739ee 100644 --- a/frontend/src/components/policies/decisionTraceDetails.ts +++ b/frontend/src/components/policies/decisionTraceDetails.ts @@ -55,6 +55,7 @@ export const KNOWN_DETAIL_KEYS = [ 'contributing_grants', 'current', 'db_type', + 'denied_tables', 'effective_min_approvals', 'engine_id', 'environment_allows_break_glass', diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 587048c43..9cb37960e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -1198,6 +1198,18 @@ "grant_denied_columns_help": "Jede Abfrage, die auf diese Spalten verweist, wird vor der Ausführung abgelehnt, auch in WHERE, JOIN und über SELECT *. Format: tabelle.spalte oder schema.tabelle.spalte.", "grant_denied_columns_invalid": "Format: tabelle.spalte oder schema.tabelle.spalte", "grant_denied_columns_too_many": "Höchstens 200 gesperrte Spalten angeben", + "grant_denied_schemas_label": "Gesperrte Schemas", + "grant_denied_schemas_placeholder": "Schemas, die der Empfänger nie abfragen darf", + "grant_denied_schemas_help": "Jede Tabelle in diesen Schemas wird abgelehnt, auch innerhalb erlaubter Schemas. Solange ein Schema gesperrt ist, müssen Tabellennamen mit Schema qualifiziert werden.", + "grant_denied_schemas_too_many": "Höchstens 50 gesperrte Schemas angeben", + "grant_denied_schemas_blank": "Gesperrte Schemas dürfen nicht leer sein", + "grant_denied_schemas_invalid": "Einen einzelnen Schemanamen ohne Punkte oder Platzhalter angeben", + "grant_denied_tables_label": "Gesperrte Tabellen", + "grant_denied_tables_placeholder": "Tabellen, die der Empfänger nie abfragen darf", + "grant_denied_tables_help": "Eine Sperre hat immer Vorrang vor der Erlaubnisliste – Sie können ein ganzes Schema erlauben und einzelne Tabellen ausnehmen. Neue Tabellen in einem erlaubten Schema sind automatisch erlaubt, sofern nicht gesperrt. Verwenden Sie schema.tabelle; ein bloßer Name sperrt die Tabelle in jedem Schema.", + "grant_denied_tables_too_many": "Höchstens 200 gesperrte Tabellen angeben", + "grant_denied_tables_blank": "Gesperrte Tabellen dürfen nicht leer sein", + "grant_denied_tables_invalid": "tabelle, schema.tabelle oder schema.* verwenden", "grant_expires_label": "Läuft ab am", "grant_expires_placeholder": "Nie", "grant_expires_help": "Optional. Der Zugriff wird nach diesem Datum automatisch entzogen.", @@ -1224,7 +1236,8 @@ "perm_col_row_limit": "Zeilenlimit", "perm_col_schemas": "Erlaubte Schemata", "perm_col_restricted_columns": "Eingeschränkt", - "perm_col_denied_columns": "Gesperrt", + "perm_col_denied_columns": "Gesperrte Spalten", + "perm_col_denied_tables": "Gesperrte Tabellen", "perm_col_expires": "Läuft ab", "perm_col_actions": "Aktionen", "perm_revoke": "Entziehen", @@ -1235,6 +1248,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} Spalte", "perm_denied_count_other": "{{count}} Spalten", + "perm_denied_tables_count_one": "{{count}} Eintrag", + "perm_denied_tables_count_other": "{{count}} Einträge", "schema_loading": "Schema wird inspiziert…", "schema_error": "Schema konnte nicht geladen werden", "schema_col_table": "Tabelle", @@ -5706,6 +5721,7 @@ "reason_text": "Grund", "referenced_tables": "Referenzierte Tabellen", "rejected_columns": "Erreichte gesperrte Spalten", + "denied_tables": "Erreichte gesperrte Tabellen", "rejected_tables": "Abgelehnte Tabellen", "releasable": "Freigebbar", "require_review": "Prüfung erforderlich", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a103bd754..bfe833a91 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1242,6 +1242,18 @@ "grant_denied_columns_help": "Any query that references these columns is rejected before it runs, including in WHERE, JOIN and through SELECT *. Use table.column or schema.table.column.", "grant_denied_columns_invalid": "Use table.column or schema.table.column", "grant_denied_columns_too_many": "List at most 200 denied columns", + "grant_denied_schemas_label": "Denied schemas", + "grant_denied_schemas_placeholder": "Schemas the grantee must never query", + "grant_denied_schemas_help": "Every table in these schemas is refused, even inside an allowed schema list. While any schema is denied, table names must be schema-qualified.", + "grant_denied_schemas_too_many": "List at most 50 denied schemas", + "grant_denied_schemas_blank": "Denied schema entries must not be blank", + "grant_denied_schemas_invalid": "Use a single schema name, without dots or wildcards", + "grant_denied_tables_label": "Denied tables", + "grant_denied_tables_placeholder": "Tables the grantee must never query", + "grant_denied_tables_help": "A denial always beats the allow-list, so you can allow a whole schema and carve out a few tables. New tables in an allowed schema are allowed automatically unless denied. Use schema.table; a bare name denies that table in every schema.", + "grant_denied_tables_too_many": "List at most 200 denied tables", + "grant_denied_tables_blank": "Denied table entries must not be blank", + "grant_denied_tables_invalid": "Use table, schema.table or schema.*", "grant_expires_label": "Expires at", "grant_expires_placeholder": "Never", "grant_expires_help": "Optional. Access auto-revokes after this date.", @@ -1268,7 +1280,8 @@ "perm_col_row_limit": "Row limit", "perm_col_schemas": "Allowed schemas", "perm_col_restricted_columns": "Restricted", - "perm_col_denied_columns": "Denied", + "perm_col_denied_columns": "Denied columns", + "perm_col_denied_tables": "Denied tables", "perm_col_expires": "Expires", "perm_col_actions": "Actions", "perm_revoke": "Revoke", @@ -1279,6 +1292,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} column", "perm_denied_count_other": "{{count}} columns", + "perm_denied_tables_count_one": "{{count}} entry", + "perm_denied_tables_count_other": "{{count}} entries", "schema_loading": "Introspecting schema…", "schema_error": "Failed to load schema", "schema_col_table": "Table", @@ -5706,6 +5721,7 @@ "reason_text": "Reason", "referenced_tables": "Referenced tables", "rejected_columns": "Denied columns reached", + "denied_tables": "Denied tables reached", "rejected_tables": "Rejected tables", "releasable": "Releasable", "require_review": "Requires review", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 668b75897..ce29e2a23 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -1198,6 +1198,18 @@ "grant_denied_columns_help": "Cualquier consulta que haga referencia a estas columnas se rechaza antes de ejecutarse, también en WHERE, JOIN y mediante SELECT *. Usa tabla.columna o esquema.tabla.columna.", "grant_denied_columns_invalid": "Usa tabla.columna o esquema.tabla.columna", "grant_denied_columns_too_many": "Indica como máximo 200 columnas denegadas", + "grant_denied_schemas_label": "Esquemas denegados", + "grant_denied_schemas_placeholder": "Esquemas que el beneficiario nunca debe consultar", + "grant_denied_schemas_help": "Se rechaza cada tabla de estos esquemas, incluso dentro de un esquema permitido. Mientras haya un esquema denegado, los nombres de tabla deben incluir el esquema.", + "grant_denied_schemas_too_many": "Indica como máximo 50 esquemas denegados", + "grant_denied_schemas_blank": "Los esquemas denegados no pueden estar en blanco", + "grant_denied_schemas_invalid": "Usa un único nombre de esquema, sin puntos ni comodines", + "grant_denied_tables_label": "Tablas denegadas", + "grant_denied_tables_placeholder": "Tablas que el beneficiario nunca debe consultar", + "grant_denied_tables_help": "Una denegación siempre prevalece sobre la lista de permitidos: puedes permitir un esquema completo y excluir algunas tablas. Las tablas nuevas de un esquema permitido quedan permitidas salvo que se denieguen. Usa esquema.tabla; un nombre sin esquema deniega esa tabla en todos los esquemas.", + "grant_denied_tables_too_many": "Indica como máximo 200 tablas denegadas", + "grant_denied_tables_blank": "Las tablas denegadas no pueden estar en blanco", + "grant_denied_tables_invalid": "Usa tabla, esquema.tabla o esquema.*", "grant_expires_label": "Expira el", "grant_expires_placeholder": "Nunca", "grant_expires_help": "Opcional. El acceso se revoca automáticamente tras esta fecha.", @@ -1224,7 +1236,8 @@ "perm_col_row_limit": "Límite de filas", "perm_col_schemas": "Esquemas permitidos", "perm_col_restricted_columns": "Restringidas", - "perm_col_denied_columns": "Denegadas", + "perm_col_denied_columns": "Columnas denegadas", + "perm_col_denied_tables": "Tablas denegadas", "perm_col_expires": "Expira", "perm_col_actions": "Acciones", "perm_revoke": "Revocar", @@ -1235,6 +1248,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} columna", "perm_denied_count_other": "{{count}} columnas", + "perm_denied_tables_count_one": "{{count}} entrada", + "perm_denied_tables_count_other": "{{count}} entradas", "schema_loading": "Inspeccionando esquema…", "schema_error": "No se pudo cargar el esquema", "schema_col_table": "Tabla", @@ -5706,6 +5721,7 @@ "reason_text": "Motivo", "referenced_tables": "Tablas referenciadas", "rejected_columns": "Columnas denegadas alcanzadas", + "denied_tables": "Tablas denegadas alcanzadas", "rejected_tables": "Tablas rechazadas", "releasable": "Publicable", "require_review": "Requiere revisión", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 4e6edff5f..2c891b505 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -1198,6 +1198,18 @@ "grant_denied_columns_help": "Toute requête qui référence ces colonnes est rejetée avant son exécution, y compris dans WHERE, JOIN et via SELECT *. Format : table.colonne ou schema.table.colonne.", "grant_denied_columns_invalid": "Format : table.colonne ou schema.table.colonne", "grant_denied_columns_too_many": "Indiquez au plus 200 colonnes refusées", + "grant_denied_schemas_label": "Schémas refusés", + "grant_denied_schemas_placeholder": "Schémas que le bénéficiaire ne doit jamais interroger", + "grant_denied_schemas_help": "Chaque table de ces schémas est refusée, même dans un schéma autorisé. Tant qu'un schéma est refusé, les noms de table doivent être qualifiés par leur schéma.", + "grant_denied_schemas_too_many": "Indiquez au plus 50 schémas refusés", + "grant_denied_schemas_blank": "Les schémas refusés ne peuvent pas être vides", + "grant_denied_schemas_invalid": "Indiquez un seul nom de schéma, sans point ni caractère générique", + "grant_denied_tables_label": "Tables refusées", + "grant_denied_tables_placeholder": "Tables que le bénéficiaire ne doit jamais interroger", + "grant_denied_tables_help": "Un refus l'emporte toujours sur la liste d'autorisation : vous pouvez autoriser tout un schéma et en exclure quelques tables. Les nouvelles tables d'un schéma autorisé sont autorisées automatiquement sauf refus. Utilisez schema.table ; un nom seul refuse cette table dans tous les schémas.", + "grant_denied_tables_too_many": "Indiquez au plus 200 tables refusées", + "grant_denied_tables_blank": "Les tables refusées ne peuvent pas être vides", + "grant_denied_tables_invalid": "Utilisez table, schema.table ou schema.*", "grant_expires_label": "Expire le", "grant_expires_placeholder": "Jamais", "grant_expires_help": "Optionnel. L'accès est révoqué automatiquement après cette date.", @@ -1224,7 +1236,8 @@ "perm_col_row_limit": "Limite de lignes", "perm_col_schemas": "Schémas autorisés", "perm_col_restricted_columns": "Restreintes", - "perm_col_denied_columns": "Refusées", + "perm_col_denied_columns": "Colonnes refusées", + "perm_col_denied_tables": "Tables refusées", "perm_col_expires": "Expire", "perm_col_actions": "Actions", "perm_revoke": "Révoquer", @@ -1235,6 +1248,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} colonne", "perm_denied_count_other": "{{count}} colonnes", + "perm_denied_tables_count_one": "{{count}} entrée", + "perm_denied_tables_count_other": "{{count}} entrées", "schema_loading": "Inspection du schéma…", "schema_error": "Impossible de charger le schéma", "schema_col_table": "Table", @@ -5706,6 +5721,7 @@ "reason_text": "Motif", "referenced_tables": "Tables référencées", "rejected_columns": "Colonnes refusées atteintes", + "denied_tables": "Tables refusées atteintes", "rejected_tables": "Tables refusées", "releasable": "Publiable", "require_review": "Revue requise", diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index 9d32acae7..9a1286a9e 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -1198,6 +1198,18 @@ "grant_denied_columns_help": "Այս սյունակներին հղում կատարող ցանկացած հարցում մերժվում է մինչև կատարումը, այդ թվում WHERE-ում, JOIN-ում և SELECT *-ի միջոցով։ Օգտագործեք աղյուսակ.սյունակ կամ սխեմա.աղյուսակ.սյունակ։", "grant_denied_columns_invalid": "Օգտագործեք աղյուսակ.սյունակ կամ սխեմա.աղյուսակ.սյունակ", "grant_denied_columns_too_many": "Նշեք առավելագույնը 200 արգելված սյունակ", + "grant_denied_schemas_label": "Արգելված սխեմաներ", + "grant_denied_schemas_placeholder": "Սխեմաներ, որոնց ստացողը երբեք չպետք է հարցում անի", + "grant_denied_schemas_help": "Այս սխեմաների յուրաքանչյուր աղյուսակ մերժվում է, նույնիսկ թույլատրված սխեմայի ներսում։ Քանի դեռ որևէ սխեմա արգելված է, աղյուսակների անունները պետք է նշվեն սխեմայով։", + "grant_denied_schemas_too_many": "Նշեք առավելագույնը 50 արգելված սխեմա", + "grant_denied_schemas_blank": "Արգելված սխեմաների գրառումները չեն կարող լինել դատարկ", + "grant_denied_schemas_invalid": "Նշեք մեկ սխեմայի անուն՝ առանց կետերի կամ նշանների", + "grant_denied_tables_label": "Արգելված աղյուսակներ", + "grant_denied_tables_placeholder": "Աղյուսակներ, որոնց ստացողը երբեք չպետք է հարցում անի", + "grant_denied_tables_help": "Արգելքը միշտ գերակայում է թույլտվությունների ցանկի նկատմամբ․ կարող եք թույլատրել ամբողջ սխեման և բացառել մի քանի աղյուսակ։ Թույլատրված սխեմայի նոր աղյուսակներն ավտոմատ թույլատրված են, եթե արգելված չեն։ Օգտագործեք schema.table․ առանց սխեմայի անունն արգելում է այդ աղյուսակը բոլոր սխեմաներում։", + "grant_denied_tables_too_many": "Նշեք առավելագույնը 200 արգելված աղյուսակ", + "grant_denied_tables_blank": "Արգելված աղյուսակների գրառումները չեն կարող լինել դատարկ", + "grant_denied_tables_invalid": "Օգտագործեք աղյուսակ, սխեմա.աղյուսակ կամ սխեմա.*", "grant_expires_label": "Ավարտվում է", "grant_expires_placeholder": "Երբեք", "grant_expires_help": "Ընտրովի։ Մուտքը ինքնաբերաբար կոչնչացվի այս ամսաթվից հետո։", @@ -1224,7 +1236,8 @@ "perm_col_row_limit": "Տողերի սահմանաչափ", "perm_col_schemas": "Թույլատրված սխեմաներ", "perm_col_restricted_columns": "Սահմանափակ", - "perm_col_denied_columns": "Արգելված", + "perm_col_denied_columns": "Արգելված սյունակներ", + "perm_col_denied_tables": "Արգելված աղյուսակներ", "perm_col_expires": "Ավարտվում է", "perm_col_actions": "Գործողություններ", "perm_revoke": "Չեղարկել", @@ -1235,6 +1248,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} սյունակ", "perm_denied_count_other": "{{count}} սյունակ", + "perm_denied_tables_count_one": "{{count}} գրառում", + "perm_denied_tables_count_other": "{{count}} գրառում", "schema_loading": "Սխեման ուսումնասիրվում է…", "schema_error": "Չհաջողվեց բեռնել սխեման", "schema_col_table": "Աղյուսակ", @@ -5706,6 +5721,7 @@ "reason_text": "Պատճառ", "referenced_tables": "Հղված աղյուսակներ", "rejected_columns": "Հասած արգելված սյունակներ", + "denied_tables": "Հասած արգելված աղյուսակներ", "rejected_tables": "Մերժված աղյուսակներ", "releasable": "Թողարկելի", "require_review": "Պահանջում է վերանայում", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index dfa54fe13..71b06dae5 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -1198,6 +1198,18 @@ "grant_denied_columns_help": "Любой запрос, ссылающийся на эти столбцы, отклоняется до выполнения, в том числе в WHERE, JOIN и через SELECT *. Формат: таблица.столбец или схема.таблица.столбец.", "grant_denied_columns_invalid": "Формат: таблица.столбец или схема.таблица.столбец", "grant_denied_columns_too_many": "Укажите не более 200 запрещённых столбцов", + "grant_denied_schemas_label": "Запрещённые схемы", + "grant_denied_schemas_placeholder": "Схемы, к которым получатель никогда не должен обращаться", + "grant_denied_schemas_help": "Любая таблица в этих схемах отклоняется, даже внутри разрешённой схемы. Пока запрещена хотя бы одна схема, имена таблиц нужно указывать со схемой.", + "grant_denied_schemas_too_many": "Укажите не более 50 запрещённых схем", + "grant_denied_schemas_blank": "Записи запрещённых схем не должны быть пустыми", + "grant_denied_schemas_invalid": "Укажите одно имя схемы без точек и подстановочных знаков", + "grant_denied_tables_label": "Запрещённые таблицы", + "grant_denied_tables_placeholder": "Таблицы, к которым получатель никогда не должен обращаться", + "grant_denied_tables_help": "Запрет всегда важнее списка разрешений: можно разрешить всю схему и исключить несколько таблиц. Новые таблицы в разрешённой схеме разрешены автоматически, если не запрещены. Используйте схема.таблица; имя без схемы запрещает таблицу во всех схемах.", + "grant_denied_tables_too_many": "Укажите не более 200 запрещённых таблиц", + "grant_denied_tables_blank": "Записи запрещённых таблиц не должны быть пустыми", + "grant_denied_tables_invalid": "Используйте таблица, схема.таблица или схема.*", "grant_expires_label": "Истекает", "grant_expires_placeholder": "Никогда", "grant_expires_help": "Необязательно. Доступ автоматически отзывается после этой даты.", @@ -1224,7 +1236,8 @@ "perm_col_row_limit": "Лимит строк", "perm_col_schemas": "Разрешённые схемы", "perm_col_restricted_columns": "Ограничены", - "perm_col_denied_columns": "Запрещено", + "perm_col_denied_columns": "Запрещённые столбцы", + "perm_col_denied_tables": "Запрещённые таблицы", "perm_col_expires": "Истекает", "perm_col_actions": "Действия", "perm_revoke": "Отозвать", @@ -1235,6 +1248,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} столбец", "perm_denied_count_other": "{{count}} столбцов", + "perm_denied_tables_count_one": "{{count}} запись", + "perm_denied_tables_count_other": "{{count}} записей", "schema_loading": "Изучение схемы…", "schema_error": "Не удалось загрузить схему", "schema_col_table": "Таблица", @@ -5706,6 +5721,7 @@ "reason_text": "Причина", "referenced_tables": "Затронутые таблицы", "rejected_columns": "Затронутые запрещённые столбцы", + "denied_tables": "Затронутые запрещённые таблицы", "rejected_tables": "Отклонённые таблицы", "releasable": "Можно выпускать", "require_review": "Требует проверки", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index f0f83e4fb..4058f6e42 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -1198,6 +1198,18 @@ "grant_denied_columns_help": "任何引用这些列的查询都会在执行前被拒绝,包括 WHERE、JOIN 以及 SELECT *。格式为 table.column 或 schema.table.column。", "grant_denied_columns_invalid": "格式为 table.column 或 schema.table.column", "grant_denied_columns_too_many": "最多列出 200 个禁止列", + "grant_denied_schemas_label": "禁止的模式", + "grant_denied_schemas_placeholder": "被授权者绝不能查询的模式", + "grant_denied_schemas_help": "这些模式中的每张表都会被拒绝,即使位于允许的模式中。只要有模式被禁止,表名就必须带上模式限定。", + "grant_denied_schemas_too_many": "最多列出 50 个禁止模式", + "grant_denied_schemas_blank": "禁止模式条目不能为空", + "grant_denied_schemas_invalid": "请使用单个模式名称,不能包含点号或通配符", + "grant_denied_tables_label": "禁止的表", + "grant_denied_tables_placeholder": "被授权者绝不能查询的表", + "grant_denied_tables_help": "禁止始终优先于允许列表:你可以允许整个模式,再排除少数几张表。允许模式中新建的表会自动被允许,除非被禁止。请使用 schema.table;不带模式的名称会在所有模式中禁止该表。", + "grant_denied_tables_too_many": "最多列出 200 个禁止表", + "grant_denied_tables_blank": "禁止表条目不能为空", + "grant_denied_tables_invalid": "请使用 table、schema.table 或 schema.*", "grant_expires_label": "到期时间", "grant_expires_placeholder": "永不", "grant_expires_help": "可选。访问将在此日期后自动撤销。", @@ -1224,7 +1236,8 @@ "perm_col_row_limit": "行数限制", "perm_col_schemas": "允许的模式", "perm_col_restricted_columns": "受限", - "perm_col_denied_columns": "禁止", + "perm_col_denied_columns": "禁止列", + "perm_col_denied_tables": "禁止的表", "perm_col_expires": "到期", "perm_col_actions": "操作", "perm_revoke": "撤销", @@ -1235,6 +1248,8 @@ "perm_no_denied": "—", "perm_denied_count_one": "{{count}} 列", "perm_denied_count_other": "{{count}} 列", + "perm_denied_tables_count_one": "{{count}} 项", + "perm_denied_tables_count_other": "{{count}} 项", "schema_loading": "正在内省模式…", "schema_error": "加载模式失败", "schema_col_table": "表", @@ -5706,6 +5721,7 @@ "reason_text": "原因", "referenced_tables": "引用的表", "rejected_columns": "触及的禁止列", + "denied_tables": "触及的禁止表", "rejected_tables": "被拒绝的表", "releasable": "可发布", "require_review": "需要审查", diff --git a/frontend/src/mocks/data.ts b/frontend/src/mocks/data.ts index 6bac85a35..af9602b15 100644 --- a/frontend/src/mocks/data.ts +++ b/frontend/src/mocks/data.ts @@ -111,6 +111,8 @@ for (const u of USERS) { allowed_tables: null, restricted_columns: null, denied_columns: null, + denied_schemas: null, + denied_tables: null, expires_at: null, created_by: 'u-03', created_at: PERMS_CREATED_AT, @@ -131,6 +133,8 @@ for (const u of USERS) { allowed_tables: null, restricted_columns: null, denied_columns: null, + denied_schemas: null, + denied_tables: null, expires_at: rand() > 0.85 ? '2026-09-30T23:59:59Z' : null, created_by: 'u-03', created_at: PERMS_CREATED_AT, diff --git a/frontend/src/pages/datasources/DatasourceSettingsPage.tsx b/frontend/src/pages/datasources/DatasourceSettingsPage.tsx index 4aeb60a5b..e6b087225 100644 --- a/frontend/src/pages/datasources/DatasourceSettingsPage.tsx +++ b/frontend/src/pages/datasources/DatasourceSettingsPage.tsx @@ -57,6 +57,14 @@ import { isQualifiedColumnRef, supportsDeniedColumns, } from '@/utils/deniedColumns'; +import { + DENIED_SCHEMAS_MAX, + DENIED_TABLES_MAX, + deniedTableEntries, + hasBlankEntry, + isValidDeniedSchema, + isValidDeniedTable, +} from '@/utils/deniedTables'; import { userDisplay } from '@/utils/userDisplay'; import { datasourceKeys, @@ -1096,6 +1104,12 @@ function PermissionMatrix({ dsId, dbType }: { dsId: string; dbType: DbType }) { title: t('datasources.settings.perm_col_denied_columns'), render: (_v, p) => , }, + { + title: t('datasources.settings.perm_col_denied_tables'), + render: (_v, p) => ( + + ), + }, { title: t('datasources.settings.perm_col_expires'), width: 170, @@ -1212,6 +1226,12 @@ function PermissionMatrix({ dsId, dbType }: { dsId: string; dbType: DbType }) { title: t('datasources.settings.perm_col_denied_columns'), render: (_v, p) => , }, + { + title: t('datasources.settings.perm_col_denied_tables'), + render: (_v, p) => ( + + ), + }, { title: t('datasources.settings.perm_col_expires'), width: 170, @@ -1264,6 +1284,27 @@ function DeniedColumnsCell({ columns }: { columns: string[] | null | undefined } ); } +function DeniedTablesCell({ + schemas, + tables, +}: { + schemas: string[] | null | undefined; + tables: string[] | null | undefined; +}) { + const { t } = useTranslation(); + const entries = deniedTableEntries(schemas, tables); + if (entries.length === 0) { + return {t('datasources.settings.perm_no_denied')}; + } + return ( + + + {t('datasources.settings.perm_denied_tables_count', { count: entries.length })} + + + ); +} + type GrantTarget = 'user' | 'group'; interface GrantFormValues { @@ -1279,6 +1320,8 @@ interface GrantFormValues { allowed_tables?: string[]; restricted_columns?: string[]; denied_columns?: string[]; + denied_schemas?: string[]; + denied_tables?: string[]; expires_at?: Dayjs | null; } @@ -1373,6 +1416,38 @@ function GrantAccessModal({ return opts; }, [schemaQuery.data, selectedSchemas]); + // Deny entries are schema-qualified: a bare name would deny that table in every schema. + const deniedTableOptions = useMemo(() => { + const schemas = schemaQuery.data?.schemas ?? []; + const filter = + selectedSchemas && selectedSchemas.length > 0 ? new Set(selectedSchemas) : null; + return schemas + .filter((s) => !filter || filter.has(s.name)) + .flatMap((s) => + s.tables.map((tb) => ({ value: `${s.name}.${tb.name}`, label: `${s.name}.${tb.name}` })), + ); + }, [schemaQuery.data, selectedSchemas]); + + const denyListRule = ( + max: number, + isValid: (entry: string) => boolean, + messages: { tooMany: string; blank: string; invalid: string }, + ) => ({ + validator: (_rule: unknown, value: string[] | undefined) => { + const entries = value ?? []; + if (entries.length > max) { + return Promise.reject(new Error(messages.tooMany)); + } + if (hasBlankEntry(entries)) { + return Promise.reject(new Error(messages.blank)); + } + if (entries.some((entry) => !isValid(entry))) { + return Promise.reject(new Error(messages.invalid)); + } + return Promise.resolve(); + }, + }); + const restrictedColumnOptions = useMemo( () => flattenSchemaToColumns(schemaQuery.data?.schemas ?? []), [schemaQuery.data], @@ -1407,6 +1482,12 @@ function GrantAccessModal({ values.denied_columns && values.denied_columns.length > 0 ? values.denied_columns : null, + denied_schemas: + values.denied_schemas && values.denied_schemas.length > 0 + ? values.denied_schemas + : null, + denied_tables: + values.denied_tables && values.denied_tables.length > 0 ? values.denied_tables : null, expires_at: values.expires_at ? values.expires_at.toISOString() : null, }; if (values.target === 'group') { @@ -1597,6 +1678,50 @@ function GrantAccessModal({ showSearch={{ optionFilterProp: 'label' }} /> + + + { expect(within(row).getByText('2 columns')).toBeInTheDocument(); }); }); + +describe('DatasourceSettingsPage — denied tables (#939)', () => { + beforeEach(() => { + getDatasource.mockReset(); + getDatasource.mockResolvedValue(baseDs); + listPermissions.mockReset(); + listPermissions.mockResolvedValue([]); + listGroupPermissions.mockReset(); + listGroupPermissions.mockResolvedValue([]); + listAllGroups.mockReset(); + listAllGroups.mockResolvedValue([]); + grantPermission.mockReset(); + grantPermission.mockResolvedValue(basePermission({ can_read: true })); + getDatasourceSchema.mockReset(); + getDatasourceSchema.mockResolvedValue({ + schemas: [ + { name: 'crm', tables: [{ name: 'salary', columns: [], foreign_keys: [] }] }, + ], + }); + listUsers.mockReset(); + listUsers.mockResolvedValue({ + content: [analystUser], + page: 0, + size: 100, + total_elements: 1, + total_pages: 1, + }); + }); + + function typeTag(dialog: HTMLElement, label: string, value: string) { + const input = within(dialog).getByLabelText(label); + fireEvent.change(input, { target: { value } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }); + } + + it('sends the denied schemas and tables typed into the grant form', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + typeTag(dialog, 'Denied schemas', 'hr'); + typeTag(dialog, 'Denied tables', 'crm.salary'); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + await waitFor(() => expect(grantPermission).toHaveBeenCalled()); + const input = grantPermission.mock.calls[0]![1] as Record; + expect(input.denied_schemas).toEqual(['hr']); + expect(input.denied_tables).toEqual(['crm.salary']); + }); + + it('sends null when nothing is denied', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + await waitFor(() => expect(grantPermission).toHaveBeenCalled()); + const input = grantPermission.mock.calls[0]![1] as Record; + expect(input.denied_schemas).toBeNull(); + expect(input.denied_tables).toBeNull(); + }); + + it('offers schema-qualified table names for the denied tables field', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await waitFor(() => expect(getDatasourceSchema).toHaveBeenCalled()); + fireEvent.mouseDown(within(dialog).getByLabelText('Denied tables')); + expect((await screen.findAllByText('crm.salary')).length).toBeGreaterThan(0); + }); + + it('shows the field for engine-managed datasources too', async () => { + getDatasource.mockResolvedValue({ ...baseDs, db_type: 'MONGODB' }); + render(wrap()); + const dialog = await openGrantModal(); + + expect(within(dialog).getByText('Denied tables')).toBeInTheDocument(); + expect(within(dialog).getByText('Denied schemas')).toBeInTheDocument(); + }); + + it('shows the denied-table count with schemas as schema.* on the permission row', async () => { + listPermissions.mockResolvedValue([ + basePermission({ can_read: true, denied_schemas: ['hr'], denied_tables: ['crm.salary'] }), + ]); + render(wrap()); + + await waitFor(() => expect(screen.getByRole('tab', { name: /Permissions/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('tab', { name: /Permissions/ })); + + const emailCell = await screen.findByText('analyst@example.com'); + const row = emailCell.closest('tr')!; + const tag = within(row).getByText('2 entries'); + fireEvent.mouseEnter(tag); + expect(await screen.findByText('hr.*, crm.salary')).toBeInTheDocument(); + }); + + it('refuses a denied table that could never match before calling the API', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + typeTag(dialog, 'Denied tables', 'sal*'); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + expect( + await within(dialog).findByText('Use table, schema.table or schema.*'), + ).toBeInTheDocument(); + expect(grantPermission).not.toHaveBeenCalled(); + }); + + it('refuses a dotted denied schema before calling the API', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + typeTag(dialog, 'Denied schemas', 'analytics.hr'); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + expect( + await within(dialog).findByText('Use a single schema name, without dots or wildcards'), + ).toBeInTheDocument(); + expect(grantPermission).not.toHaveBeenCalled(); + }); + + it('refuses more denied schemas than the backend accepts', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + const input = within(dialog).getByLabelText('Denied schemas'); + const entries = Array.from({ length: 51 }, (_v, i) => `s${i}`).join(','); + fireEvent.change(input, { target: { value: `${entries},` } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + expect(await within(dialog).findByText('List at most 50 denied schemas')).toBeInTheDocument(); + expect(grantPermission).not.toHaveBeenCalled(); + }); + + it('shows a dash when a permission row denies no table', async () => { + listPermissions.mockResolvedValue([basePermission({ can_read: true })]); + render(wrap()); + + await waitFor(() => expect(screen.getByRole('tab', { name: /Permissions/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('tab', { name: /Permissions/ })); + + const emailCell = await screen.findByText('analyst@example.com'); + const row = emailCell.closest('tr')!; + expect(screen.getAllByText('Denied tables').length).toBeGreaterThan(0); + expect(screen.getAllByText('Denied columns').length).toBeGreaterThan(0); + expect(within(row).getAllByText('—').length).toBeGreaterThanOrEqual(2); + }); + + it('shows the denied-table count on a group permission row', async () => { + listGroupPermissions.mockResolvedValue([ + { + id: 'gp-1', + datasource_id: 'ds-1', + group_id: 'g-1', + group_name: 'Analysts', + member_count: 3, + can_read: true, + can_write: false, + can_ddl: false, + can_break_glass: false, + row_limit_override: null, + allowed_schemas: null, + allowed_tables: null, + restricted_columns: null, + denied_columns: null, + denied_schemas: null, + denied_tables: ['crm.salary'], + expires_at: null, + created_by: 'admin', + created_at: '2026-05-01T00:00:00Z', + }, + ]); + render(wrap()); + + await waitFor(() => expect(screen.getByRole('tab', { name: /Permissions/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('tab', { name: /Permissions/ })); + + const groupCell = await screen.findByText('Analysts'); + const row = groupCell.closest('tr')!; + expect(within(row).getByText('1 entry')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 38304c832..31bc8ed88 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -841,6 +841,8 @@ export interface CreatePermissionInput { allowed_tables?: string[] | null; restricted_columns?: string[] | null; denied_columns?: string[] | null; + denied_schemas?: string[] | null; + denied_tables?: string[] | null; expires_at?: string | null; } @@ -855,6 +857,8 @@ export interface CreateGroupPermissionInput { allowed_tables?: string[] | null; restricted_columns?: string[] | null; denied_columns?: string[] | null; + denied_schemas?: string[] | null; + denied_tables?: string[] | null; expires_at?: string | null; } @@ -1970,6 +1974,8 @@ export interface DatasourcePermission { allowed_tables: string[] | null; restricted_columns: string[] | null; denied_columns?: string[] | null; + denied_schemas?: string[] | null; + denied_tables?: string[] | null; expires_at: string | null; created_by: string; created_at: string; @@ -1990,6 +1996,8 @@ export interface DatasourceGroupPermission { allowed_tables: string[] | null; restricted_columns: string[] | null; denied_columns?: string[] | null; + denied_schemas?: string[] | null; + denied_tables?: string[] | null; expires_at: string | null; created_by: string; created_at: string; diff --git a/frontend/src/utils/__tests__/deniedTables.test.ts b/frontend/src/utils/__tests__/deniedTables.test.ts new file mode 100644 index 000000000..bc8a727e6 --- /dev/null +++ b/frontend/src/utils/__tests__/deniedTables.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { + DENIED_SCHEMAS_MAX, + DENIED_TABLES_MAX, + deniedTableEntries, + hasBlankEntry, + isValidDeniedSchema, + isValidDeniedTable, +} from '@/utils/deniedTables'; + +describe('deniedTables', () => { + it('mirrors the backend list limits', () => { + expect(DENIED_SCHEMAS_MAX).toBe(50); + expect(DENIED_TABLES_MAX).toBe(200); + }); + + it('flags blank entries', () => { + expect(hasBlankEntry(['crm.salary', ' '])).toBe(true); + expect(hasBlankEntry(['crm.salary'])).toBe(false); + expect(hasBlankEntry([])).toBe(false); + }); + + it('lists denied schemas as schema.* before denied tables', () => { + expect(deniedTableEntries(['hr'], ['crm.salary'])).toEqual(['hr.*', 'crm.salary']); + expect(deniedTableEntries(null, undefined)).toEqual([]); + }); + + it('accepts only denied schemas that are one plain name', () => { + expect(isValidDeniedSchema('hr')).toBe(true); + expect(isValidDeniedSchema('analytics.hr')).toBe(false); + expect(isValidDeniedSchema('h*')).toBe(false); + expect(isValidDeniedSchema(' ')).toBe(false); + }); + + it('accepts table, schema.table and schema.* as denied tables', () => { + expect(isValidDeniedTable('salary')).toBe(true); + expect(isValidDeniedTable('crm.salary')).toBe(true); + expect(isValidDeniedTable('db.crm.salary')).toBe(true); + expect(isValidDeniedTable('crm.*')).toBe(true); + expect(isValidDeniedTable('sal*')).toBe(false); + expect(isValidDeniedTable('crm..salary')).toBe(false); + expect(isValidDeniedTable('*')).toBe(false); + }); +}); diff --git a/frontend/src/utils/deniedTables.ts b/frontend/src/utils/deniedTables.ts new file mode 100644 index 000000000..946409588 --- /dev/null +++ b/frontend/src/utils/deniedTables.ts @@ -0,0 +1,28 @@ +/** Backend `@Size(max = 50)` on `denied_schemas` (#939). */ +export const DENIED_SCHEMAS_MAX = 50; + +/** Backend `@Size(max = 200)` on `denied_tables` (#939). */ +export const DENIED_TABLES_MAX = 200; + +/** Mirrors backend `DeniedTables.SCHEMA_ENTRY_PATTERN`: one name, no dots or wildcards. */ +const SCHEMA_ENTRY = /^[^.*?]*[^.*?\s][^.*?]*$/; + +/** Mirrors backend `DeniedTables.TABLE_ENTRY_PATTERN`: `table`, `schema.table` or `schema.*`. */ +const TABLE_ENTRY = /^[^.*?]*[^.*?\s][^.*?]*(\.[^.*?]*[^.*?\s][^.*?]*)*(\.\*)?$/; + +export const isValidDeniedSchema = (entry: string): boolean => SCHEMA_ENTRY.test(entry); + +export const isValidDeniedTable = (entry: string): boolean => TABLE_ENTRY.test(entry); + +/** Mirrors the backend `@NotBlank` on every deny-list item. */ +export const hasBlankEntry = (entries: string[]): boolean => + entries.some((entry) => entry.trim() === ''); + +/** + * One display list for a grant's table deny-lists: a denied schema reads as `schema.*`, a denied + * table as written. + */ +export const deniedTableEntries = ( + schemas: string[] | null | undefined, + tables: string[] | null | undefined, +): string[] => [...(schemas ?? []).map((schema) => `${schema}.*`), ...(tables ?? [])]; diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index a782e3dbd..d3c935319 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -199,15 +199,16 @@ {"id":"554e1026e829704c","path":"website/docs/configuration/connectors/index.html","url":"https://accessflow.io/docs/configuration/connectors/#cfg-api-connectors","anchor":"cfg-api-connectors","title":"API connectors","section":"Reference","order":3,"tokens":712,"text":"AccessFlow Docs > Reference > Connectors > API connectors (part 4 of 5)\n\nDynamic variables (request signing). Some APIs — common in banking, payments\nand telco — require a value computed per request: an HMAC signature, a nonce, a\ntimestamp, an idempotency key. A requester can't hand-compute those for a governed call,\nbecause the signature covers a body a reviewer approves minutes or hours later and the\ntimestamp would already be stale. On the connector's Variables tab (admin) you\ndeclare named values that AccessFlow computes at execution time and substitutes into\nheaders, the path, query parameters and the body wherever\n{{name}} appears. Kinds cover a constant, a random UUID, a timestamp, epoch\nmilliseconds, random bytes, a hash (SHA-256/MD5), an HMAC signature (HMAC-SHA256/512 with an\nencrypted shared secret), or a re-encoding — output as hex, base64 or URL-safe base64.\nExpressions can reference the in-flight request ({{request.method}},\n{{request.path}}, {{request.query}}, {{request.body}},\n{{request.headers.Authorization}}) and each other; AccessFlow resolves them in\ndependency order and rejects circular references while you're editing, not at run time.\nVariables are evaluated after authentication, so a signature can cover the resolved\nAuthorization header — including a freshly minted OAuth2 token. Instead of a\nplaceholder, a variable can inject itself straight into a fixed header or query parameter.\nEvaluation is template substitution over a fixed function set only: there is no scripting\nengine and no user-supplied code. Shared secrets are encrypted at rest, never returned by\nthe API, and computed values are never stored, snapshotted or logged.\n\nPer-request overrides. An admin can mark a variable overridable, and\ngrant selected teammates the Override variables permission on the connector. Those\nrequesters can then supply their own value for that variable from the API editor's\nVariables tab — pinning a nonce for a replay test, say. Overrides are deny-by-default\nand deliberately narrow: a variable holding a secret can never be made overridable, an\noverride is inserted literally and can never expand into another variable's value, and the\nvalues are saved with the request and shown to the reviewer, so an approval covers exactly\nwhat will be sent."} {"id":"029871f8bea86ab0","path":"website/docs/configuration/connectors/index.html","url":"https://accessflow.io/docs/configuration/connectors/#cfg-api-connectors","anchor":"cfg-api-connectors","title":"API connectors","section":"Reference","order":4,"tokens":355,"text":"AccessFlow Docs > Reference > Connectors > API connectors (part 5 of 5)\n\nUse it. In the API editor (/api-editor) a user picks a\nconnector, searches the operation catalog (or writes a free-form method + path), and composes\nthe call like Postman — query parameters, custom headers (over the connector's read-only\ndefault headers), and a body that can be raw, x-www-form-urlencoded, multipart\nform-data, or a binary file upload. A user can schedule the call for later, sees a debounced\nAI risk preview, and submits. Plain-English text-to-API drafts a call for\nschema-backed connectors. Every call flows through AI risk scoring → routing → human review\n(no self-approval) → guarded execution that injects the connector's auth and a W3C\ntraceparent, caps and field-masks the response, and stores an immutable response\nsnapshot. The full stored response can be downloaded in its original format, and the request\nlist is filterable by submitter, trace id, and span id. Break-glass and scheduled execution\nmirror the query path. Note: gRPC connectors register and review today; gRPC call\nexecution is a follow-up — REST/SOAP/GraphQL execute fully."} {"id":"efab69db751175ea","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-datasources","anchor":"cfg-datasources","title":"Datasources","section":"Reference","order":0,"tokens":250,"text":"AccessFlow Docs > Reference > Datasources\n\nThere is a guide for this. Add your first datasource\nwalks the wizard end to end and then narrows the datasource down with an allow-list, masking and row security. This chapter is the reference behind it.\n\nWhat it is. A governed connection to one of your databases. Every query a\nuser runs against it passes through AccessFlow's review, masking, and row-security guards\ninstead of hitting the database directly — so a datasource is where you decide who\nmay run what against which data. PostgreSQL, MySQL, MariaDB, Oracle, and\nMS SQL Server are built in; MongoDB, Couchbase, Redis, Cassandra / ScyllaDB,\nElasticsearch / OpenSearch, DynamoDB, Neo4j, Snowflake, BigQuery, and Databricks install\nfrom the connector catalog; any other JDBC engine works by uploading\nits driver and choosing Custom."} -{"id":"3b3f8798eb7ce7ae","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":0,"tokens":252,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 1 of 9)\n\nA datasource is a governed connection to one of your databases. Users never receive its credentials — they submit queries to AccessFlow, which reviews them and then executes them over the pooled connection on their behalf. Masking, row-level security, schema allow-lists, and row caps are all configured per datasource.\n\nConfigure it. Create one with the four-step wizard at\n/datasources/new:\n\n/datasources/new — four-step wizard: Database type → Connection details → Connection test → Configuration.\n\n- Database type. Pick a bundled driver tile (PostgreSQL ships built-in; other drivers download on first use and are verified against a pinned SHA-256 checksum). Pick Custom to use a JDBC driver you uploaded under Admin → Custom JDBC drivers."} -{"id":"5a567ae041ce49c2","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":1,"tokens":793,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 2 of 9)\n\n- Connection details. Name the datasource, then enter host, port, database name, service-account username, and password. SSL mode is pre-filled from the engine's own default rather than one global value — PostgreSQL starts at VERIFY_FULL, several NoSQL engines start at DISABLE, and the rest at REQUIRE. Check it rather than assuming it, and prefer VERIFY_FULL in production. For Cassandra and ScyllaDB the wizard also requires a local datacenter (the driver's load-balancing datacenter); this field is unused for every other engine. For Elasticsearch and OpenSearch the wizard offers an Authentication toggle — basic (username + password) or an API key — and the database-name field is optional. For Amazon DynamoDB the connection is cloud credentials, not host/port: the wizard hides host/port and instead asks for the AWS region (the database-name field), the access key ID and secret access key (the username/password fields), and an optional custom endpoint (DynamoDB Local / VPC; blank for AWS). For Neo4j the wizard takes the standard host/port/database/username/password (the SSL mode is encoded in the Bolt scheme) plus an optional Bolt connection URI (advanced) — a full bolt:// / neo4j+s:// URI for Neo4j Aura or clustered routing that, when set, overrides host/port. For Snowflake the wizard asks for the account host (.snowflakecomputing.com; the port field is hidden — always 443), the database, the user, a credential that is either a password or a PKCS#8 private key (PEM) for key-pair authentication, an optional private key passphrase (only for a passphrase-protected key, which is what Snowflake's own openssl instructions produce), and an optional JDBC URL override — a full jdbc:snowflake:// URL carrying warehouse / role / schema parameters. For Google BigQuery the connection is cloud credentials: the wizard hides host/port/username and asks for the GCP project (optionally project.dataset to pin a default dataset) and the service-account key JSON, plus an optional custom endpoint (BigQuery emulator). For Databricks SQL the wizard asks for the workspace host, the required warehouse HTTP path (/sql/1.0/warehouses/ from the warehouse's connection details), an optional Unity Catalog catalog, and a personal access token. The password (and API key / secret access key) are AES-256-GCM encrypted on write, decrypted once into the connection pool, and never returned in any GET response. When an external secrets manager is enabled (see Run & deploy), any credential field also"} -{"id":"ab70ab607bfeefd9","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 9)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."} -{"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 9)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."} -{"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":709,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 9)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:\n\n- What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.\n\n- Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this.\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing, including a temporary just-in-time grant, lifts the deny.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources."} -{"id":"6982bd47758e3828","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":5,"tokens":792,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 9)\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits.\n\nMasking policies. The datasource Masking tab adds per-column\ndynamic data masking on top of the static restricted-columns masking above. Each\npolicy targets a schema.table.column and picks a strategy —\nfull (***), partial (keep the last N characters),\nhash (stable SHA-256), email (j***@domain), or\nformat-preserving — with an optional reveal-to condition. A query\nsubmitter whose role, group, or user id is listed in reveal to sees the unmasked\nvalue; everyone else sees the strategy output. A live preview shows how a sample value will\nrender. Masking is applied at result-read time before results are serialized or stored, so\nunmasked values never persist, and the ids of the policies that applied are recorded in the\nexecution's audit metadata. Reveal is explicit — there is no implicit admin bypass."} -{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":755,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 9)\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters.\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform."} -{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":580,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 9)\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it —\nrow rules are worked out on the stored query text alone. It compares policies against\npolicies — today's rules versus the draft — rather than against what actually\nhappened, because a past result may have come from an emergency, a ticket, or a standing\ngrant the draft has no say over. The results name their own limits: roles and group\nmemberships are read as they stand today, masking is matched on the column name alone\n(so a name two tables share can be over-counted), and where an engine cannot work out\noffline what a row rule would do — Cassandra and ScyllaDB need live key information —\nthose queries are listed as unclassifiable rather than counted as unaffected.\nSimulating is always optional; nothing blocks you from saving.\n\nRow limits. The datasource Row limits tab caps how many rows a\nquery may return when it reads a particular table, so two tables on the same database\ncan have different limits and one team can be held tighter than another on the same\ntable. Each policy names a table (and optionally its schema), a maximum number of rows,\nand the applies to roles / groups / users it covers (empty = everyone, admins\nincluded). A row limit can only ever lower the cap: the datasource's\nMax rows per query and any per-user limit on the access grant still apply, and\nthe smallest number wins. A query that joins several limited tables gets the lowest of\ntheir limits. A policy with a schema also catches queries that name the table without\none or with a database name in front, so neither gets anyone more rows. Results that hit\nthe limit are marked as truncated, the table preview obeys the same limit, and when a\npolicy's limit is the one that applied it is recorded in the query's audit entry."} -{"id":"9b654aff5b19dadf","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":8,"tokens":281,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 9 of 9)\n\nExport policies. Masking and row security govern what a user\nsees; the datasource Export policy tab governs what leaves.\nEach policy sets a mode — allow, watermark, row cap, or\ndeny when classified (optionally scoped to specific classifications) — and an\napplies to roles / groups / users target (empty = every exporter, no implicit\nadmin bypass). When several policies apply, the most restrictive wins. The policies gate\nthe signed CSV/PDF result download on the query detail page and the results attachment\non recurring-run emails: a denied exporter sees a disabled export button with the\nreason, a watermarked download carries the exporter, timestamp, and query id baked into\nthe signed bytes (the modal previews the exact stamp), and every export lands in the\naudit log as RESULT_EXPORTED — with an admin notification whenever a\nclassified result leaves."} +{"id":"3b3f8798eb7ce7ae","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":0,"tokens":252,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 1 of 10)\n\nA datasource is a governed connection to one of your databases. Users never receive its credentials — they submit queries to AccessFlow, which reviews them and then executes them over the pooled connection on their behalf. Masking, row-level security, schema allow-lists, and row caps are all configured per datasource.\n\nConfigure it. Create one with the four-step wizard at\n/datasources/new:\n\n/datasources/new — four-step wizard: Database type → Connection details → Connection test → Configuration.\n\n- Database type. Pick a bundled driver tile (PostgreSQL ships built-in; other drivers download on first use and are verified against a pinned SHA-256 checksum). Pick Custom to use a JDBC driver you uploaded under Admin → Custom JDBC drivers."} +{"id":"5a567ae041ce49c2","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":1,"tokens":793,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 2 of 10)\n\n- Connection details. Name the datasource, then enter host, port, database name, service-account username, and password. SSL mode is pre-filled from the engine's own default rather than one global value — PostgreSQL starts at VERIFY_FULL, several NoSQL engines start at DISABLE, and the rest at REQUIRE. Check it rather than assuming it, and prefer VERIFY_FULL in production. For Cassandra and ScyllaDB the wizard also requires a local datacenter (the driver's load-balancing datacenter); this field is unused for every other engine. For Elasticsearch and OpenSearch the wizard offers an Authentication toggle — basic (username + password) or an API key — and the database-name field is optional. For Amazon DynamoDB the connection is cloud credentials, not host/port: the wizard hides host/port and instead asks for the AWS region (the database-name field), the access key ID and secret access key (the username/password fields), and an optional custom endpoint (DynamoDB Local / VPC; blank for AWS). For Neo4j the wizard takes the standard host/port/database/username/password (the SSL mode is encoded in the Bolt scheme) plus an optional Bolt connection URI (advanced) — a full bolt:// / neo4j+s:// URI for Neo4j Aura or clustered routing that, when set, overrides host/port. For Snowflake the wizard asks for the account host (.snowflakecomputing.com; the port field is hidden — always 443), the database, the user, a credential that is either a password or a PKCS#8 private key (PEM) for key-pair authentication, an optional private key passphrase (only for a passphrase-protected key, which is what Snowflake's own openssl instructions produce), and an optional JDBC URL override — a full jdbc:snowflake:// URL carrying warehouse / role / schema parameters. For Google BigQuery the connection is cloud credentials: the wizard hides host/port/username and asks for the GCP project (optionally project.dataset to pin a default dataset) and the service-account key JSON, plus an optional custom endpoint (BigQuery emulator). For Databricks SQL the wizard asks for the workspace host, the required warehouse HTTP path (/sql/1.0/warehouses/ from the warehouse's connection details), an optional Unity Catalog catalog, and a personal access token. The password (and API key / secret access key) are AES-256-GCM encrypted on write, decrypted once into the connection pool, and never returned in any GET response. When an external secrets manager is enabled (see Run & deploy), any credential field also"} +{"id":"ab70ab607bfeefd9","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 10)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."} +{"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 10)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."} +{"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":714,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 10)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), denied schemas and tables, and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied schemas and tables — everything except. Sometimes it is easier to say what a user may not touch. Allow the schema crm and deny the table crm.salary, and the user can query every table in crm — including tables created later — except crm.salary. A query that touches a denied table is refused before it runs:\n\n- A denial always wins. It is checked after the allowed schemas and tables, and it works on its own too, with no allowed list at all.\n\n- Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.\n\n- Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.\n\n- Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.\n\n- Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the other way round (below).\n\n- Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks."} +{"id":"6982bd47758e3828","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":5,"tokens":788,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 10)\n\n- Just-in-time access keeps denials. A just-in-time access request cannot add a denied schema or table, and approving one never removes a denial: denials from every grant add up, and when the approval replaces the user's own expiring grant, that grant's denials carry over to the new one.\n\n- How to write entries. A denied schema is a single name, such as hr — not analytics.hr. A denied table is table, schema.table, or schema.* for a whole schema. Other wildcards and empty parts are refused when you save the grant.\n\n- Tricky names are refused, not guessed. SQL Server's db..salary (default schema) is treated as matching any schema, an Oracle database link (hr.salary@remote) does not get around a denial, and a name pattern such as the Elasticsearch index pattern sal* is refused whenever the grant denies anything.\n\n- Works on every datasource type. Denied schemas and tables apply to relational, NoSQL and warehouse datasources alike. On datasources whose objects have no schema — MongoDB collections, DynamoDB tables, Redis keys — a denied schema refuses every query, so use denied tables there. A denial can only catch the tables AccessFlow sees in the query: MongoDB $lookup, $unionWith and $graphLookup stages, a Neo4j MATCH (n) with no label, and a Redis KEYS pattern are not caught. The allowed lists share this limit.\n\n- Removing a grant can widen access. A denial belongs to the grant that carries it. Revoke that grant, let it expire, or revoke it in an access review, and its denial goes with it — another grant the user still holds may then let them reach the table. Check the user's other grants first.\n\nDenied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:\n\n- What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.\n\n- Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this."} +{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":773,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 10)\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing lifts the deny. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits."} +{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":747,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 10)\n\nMasking policies. The datasource Masking tab adds per-column\ndynamic data masking on top of the static restricted-columns masking above. Each\npolicy targets a schema.table.column and picks a strategy —\nfull (***), partial (keep the last N characters),\nhash (stable SHA-256), email (j***@domain), or\nformat-preserving — with an optional reveal-to condition. A query\nsubmitter whose role, group, or user id is listed in reveal to sees the unmasked\nvalue; everyone else sees the strategy output. A live preview shows how a sample value will\nrender. Masking is applied at result-read time before results are serialized or stored, so\nunmasked values never persist, and the ids of the policies that applied are recorded in the\nexecution's audit metadata. Reveal is explicit — there is no implicit admin bypass.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters."} +{"id":"9b654aff5b19dadf","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":8,"tokens":551,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 9 of 10)\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform.\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it —\nrow rules are worked out on the stored query text alone. It compares policies against\npolicies — today's rules versus the draft — rather than against what actually\nhappened, because a past result may have come from an emergency, a ticket, or a standing\ngrant the draft has no say over. The results name their own limits: roles and group\nmemberships are read as they stand today, masking is matched on the column name alone\n(so a name two tables share can be over-counted), and where an engine cannot work out\noffline what a row rule would do — Cassandra and ScyllaDB need live key information —\nthose queries are listed as unclassifiable rather than counted as unaffected.\nSimulating is always optional; nothing blocks you from saving."} +{"id":"d179f3a2f88d48a0","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":9,"tokens":569,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 10 of 10)\n\nRow limits. The datasource Row limits tab caps how many rows a\nquery may return when it reads a particular table, so two tables on the same database\ncan have different limits and one team can be held tighter than another on the same\ntable. Each policy names a table (and optionally its schema), a maximum number of rows,\nand the applies to roles / groups / users it covers (empty = everyone, admins\nincluded). A row limit can only ever lower the cap: the datasource's\nMax rows per query and any per-user limit on the access grant still apply, and\nthe smallest number wins. A query that joins several limited tables gets the lowest of\ntheir limits. A policy with a schema also catches queries that name the table without\none or with a database name in front, so neither gets anyone more rows. Results that hit\nthe limit are marked as truncated, the table preview obeys the same limit, and when a\npolicy's limit is the one that applied it is recorded in the query's audit entry.\n\nExport policies. Masking and row security govern what a user\nsees; the datasource Export policy tab governs what leaves.\nEach policy sets a mode — allow, watermark, row cap, or\ndeny when classified (optionally scoped to specific classifications) — and an\napplies to roles / groups / users target (empty = every exporter, no implicit\nadmin bypass). When several policies apply, the most restrictive wins. The policies gate\nthe signed CSV/PDF result download on the query detail page and the results attachment\non recurring-run emails: a denied exporter sees a disabled export button with the\nreason, a watermarked download carries the exporter, timestamp, and query id baked into\nthe signed bytes (the modal previews the exact stamp), and every export lands in the\naudit log as RESULT_EXPORTED — with an admin notification whenever a\nclassified result leaves."} {"id":"f31c837889753d51","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":0,"tokens":791,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 1 of 3)\n\nThe datasource Classification tab tags\ntables and columns with one or more data classifications — PII, PCI,\nPHI, GDPR, FINANCIAL, or SENSITIVE — and\nderives stricter handling automatically. Tagging a column\nauto-applies a masking policy from the classification's default strategy\n(PII / GDPR / FINANCIAL → partial, PCI / PHI → full, SENSITIVE → hash), so you don't\nhand-configure masking for every sensitive field; a table-level tag (no column) is\ninformational. A query that references a tagged table gets an automatic AI risk-score\nbump, and a derivation preview suggests a stricter review posture (AI review,\nhuman approval, minimum approvals) aggregated across the datasource's tags — a suggestion\nyou apply on the datasource's review plan, never auto-changed. Tags are immutable\n(create / delete) and audited; deleting a tag keeps the masking policy it derived. The\nclassifications appear as badges in the schema explorer, and Admin → Data\nclassifications (/admin/data-classifications) lists every tag across all\ndatasources as the evidence base for compliance reporting.\n\nAutomated discovery. Instead of tagging hundreds of tables by hand, the\ndatasource Discovery tab opts a datasource into a scheduled scanner that samples\ncolumn data through the same governed sampling path, detects sensitive values with local\nregex + checksum detectors (emails, credit-card numbers with Luhn, US SSNs, IBANs, phone\nnumbers) and — optionally — your bound AI analyzer, then proposes the\nclassification tags in a review worklist. Confirming a finding applies the tag (deriving\nmasking exactly like a manual tag); dismissing suppresses the proposal permanently. Raw\nsampled values never persist (findings store a redacted sample only), and the AI pass\nonly ever sees column names, types, and redacted samples. Configure the per-datasource\nsample size (10–1000 rows, never more than the datasource's row cap) and cadence (1–720 hours), or hit Scan now for an\nimmediate run; scans and decisions land in the audit log\n(DISCOVERY_SCAN_COMPLETED, DISCOVERY_FINDING_CONFIRMED /\n_DISMISSED). Operator knobs:\nACCESSFLOW_DISCOVERY_SCAN_POLL_INTERVAL (PT15M),\nACCESSFLOW_DISCOVERY_SCAN_TIME_BUDGET (PT10M),\nACCESSFLOW_DISCOVERY_SAMPLE_STATEMENT_TIMEOUT (PT10S),\nACCESSFLOW_DISCOVERY_MAX_TABLES_PER_SCAN (200),\nACCESSFLOW_DISCOVERY_MAX_AI_TABLES_PER_SCAN (25),\nACCESSFLOW_DISCOVERY_MAX_NESTED_DEPTH (5),\nACCESSFLOW_DISCOVERY_MAX_NESTED_LEAVES_PER_ROW (100),\nACCESSFLOW_DISCOVERY_STALE_SCANS_BEFORE_EXPIRY (3),\nACCESSFLOW_DISCOVERY_SCAN_LOCK_AT_MOST_FOR (PT30M)."} {"id":"1b8b2d9602f1d31c","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":1,"tokens":724,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 2 of 3)\n\nOne scan per datasource at a time. A scan claims its datasource for as\nlong as it runs, so the same tables are never sampled twice at once — including when\nyou run AccessFlow on several servers, where a Scan now and a scheduled scan\ncould otherwise start on different ones. Scan now is refused outright while a\nscan is under way, so you always know which run you are looking at; a scheduled scan\nthat finds the datasource busy simply leaves it due and picks it up on the next round.\nACCESSFLOW_DISCOVERY_SCAN_LOCK_AT_MOST_FOR above is only the safety net for\na server that dies mid-scan: it caps how long the claim can outlive the machine holding\nit. Raising ACCESSFLOW_DISCOVERY_SCAN_TIME_BUDGET raises the claim with it,\nkeeping a wide margin over the time a scan is expected to take — so leaving this one\nalone is normally right. To hand a datasource back sooner than the cap after a server\nhas died, delete the Redis key\njob-lock:accessflow:shedlock:discoveryScan:.\n\nProposals that go quiet clean themselves up. When a column is dropped,\nits data is cleared, or you mask it by hand, the scan simply stops proposing it — and\nthe old suggestion would otherwise sit in the worklist forever. Instead, a proposal the\nscanner keeps sampling but no longer finds is marked Stale after a few\nconsecutive scans and drops out of the default Pending view; switch the status\nfilter to Stale to see those proposals and dismiss the batch in one go.\nNothing is thrown away: a stale proposal is still yours to confirm or dismiss, and if\nthe data comes back the next scan returns it to Pending. Only tables a scan\nactually sampled can age this way, so a run cut short by its table cap or time budget\nnever retires proposals it did not look at. Retirements are audited as\nDISCOVERY_FINDING_EXPIRED, up to 100 rows per scan — past that the scan's\nown audit entry carries the full count and flags the trail as truncated. How many\nconsecutive misses it takes is\nACCESSFLOW_DISCOVERY_STALE_SCANS_BEFORE_EXPIRY above.\n\nTune it. Per-datasource fields above set row caps and review behaviour;\nthese environment variables set the engine-level connection and execution ceilings\n(defaults shown):\n\n- Connection pools (all JDBC engines):\nACCESSFLOW_PROXY_CONNECTION_TIMEOUT (30s),\nACCESSFLOW_PROXY_IDLE_TIMEOUT (10m),\nACCESSFLOW_PROXY_MAX_LIFETIME (30m),\nACCESSFLOW_PROXY_LEAK_DETECTION_THRESHOLD (0s = off)."} {"id":"3086bd7be656b9cd","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":2,"tokens":728,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 3 of 3)\n\n- Statement execution (all engines):\nACCESSFLOW_PROXY_EXECUTION_MAX_ROWS (10000),\nACCESSFLOW_PROXY_EXECUTION_STATEMENT_TIMEOUT (30s),\nACCESSFLOW_PROXY_EXECUTION_DEFAULT_FETCH_SIZE (1000),\nACCESSFLOW_PROXY_EXECUTION_INSERT_BATCH_CHUNK_SIZE (1000).\n\n- Heap protection (relational engines):\nACCESSFLOW_PROXY_EXECUTION_MAX_RESULT_BYTES (52428800 —\nper-result byte cap; larger SELECT results are truncated),\nACCESSFLOW_PROXY_EXECUTION_MAX_CONCURRENT (32 —\nglobal in-flight execution budget across all datasources),\nACCESSFLOW_PROXY_EXECUTION_ACQUIRE_TIMEOUT (5s —\nwait before overflow executions are rejected with HTTP 503).\n\n- SELECT result cache:\nACCESSFLOW_PROXY_CACHE_ENABLED (true),\nACCESSFLOW_PROXY_CACHE_DEFAULT_TTL (PT60S),\nACCESSFLOW_PROXY_CACHE_MAX_ENTRY_BYTES (1000000).\n\n- Read-replica health checks:\nACCESSFLOW_PROXY_REPLICA_PROBE_INTERVAL (PT30S),\nACCESSFLOW_PROXY_REPLICA_PROBE_TIMEOUT (PT5S),\nACCESSFLOW_PROXY_REPLICA_COOLDOWN (PT30S).\n\n- MongoDB: ACCESSFLOW_PROXY_MONGO_CONNECT_TIMEOUT\n(PT10S), …_SERVER_SELECTION_TIMEOUT (PT10S),\n…_MAX_POOL_SIZE (10).\n\n- Couchbase:\nACCESSFLOW_PROXY_ENGINES_COUCHBASE_CONNECT_TIMEOUT (PT10S),\n…_WAIT_UNTIL_READY_TIMEOUT (PT10S),\n…_SCAN_CONSISTENCY (request-plus).\n\n- Redis: ACCESSFLOW_PROXY_ENGINES_REDIS_CONNECT_TIMEOUT\n(PT5S), …_SOCKET_TIMEOUT (PT5S),\n…_MAX_POOL_SIZE (10).\n\n- Cassandra / ScyllaDB:\nACCESSFLOW_PROXY_ENGINES_CASSANDRA_CONNECT_TIMEOUT /\n…_SCYLLADB_CONNECT_TIMEOUT (PT10S) and the matching\n…_REQUEST_TIMEOUT (PT10S).\n\n- Elasticsearch / OpenSearch:\nACCESSFLOW_PROXY_ENGINES_ELASTICSEARCH_CONNECT_TIMEOUT /\n…_OPENSEARCH_CONNECT_TIMEOUT (PT10S) and\n…_SOCKET_TIMEOUT (PT30S).\n\n- DynamoDB:\nACCESSFLOW_PROXY_ENGINES_DYNAMODB_CONNECT_TIMEOUT (PT10S),\n…_API_CALL_TIMEOUT (PT30S).\n\n- Neo4j: ACCESSFLOW_PROXY_ENGINES_NEO4J_CONNECT_TIMEOUT\n(PT10S), …_MAX_CONNECTION_POOL_SIZE (100).\n\n- Snowflake: ACCESSFLOW_PROXY_ENGINES_SNOWFLAKE_LOGIN_TIMEOUT\n(PT30S), …_NETWORK_TIMEOUT (PT60S).\n\n- BigQuery: ACCESSFLOW_PROXY_ENGINES_BIGQUERY_CONNECT_TIMEOUT\n(PT10S), …_READ_TIMEOUT (PT30S).\n\n- Databricks: ACCESSFLOW_PROXY_ENGINES_DATABRICKS_CONNECT_TIMEOUT\n(PT10S), …_WAIT_TIMEOUT (PT10S),\n…_POLL_INTERVAL (PT1S),\n…_RESULT_DISPOSITION (auto),\n…_MAX_RESULT_BYTES (52428800)."} @@ -244,8 +245,8 @@ {"id":"6bdb1390c93897ed","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-users","anchor":"cfg-users","title":"Users","section":"Reference","order":0,"tokens":444,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Users\n\nWhat it is. The people who can sign in to AccessFlow and the role each one\ncarries. Create accounts directly, or let SAML / OAuth users be auto-provisioned on first\nsign-in when SSO is enabled (see SAML and OAuth).\n\nConfigure it. Manage everyone from /admin/users:\n\n/admin/users → Invite via email. Enter the recipient's email, pick a role, and AccessFlow emails a one-time signup link.\n\n- Invite via email (default). From /admin/users, click Invite via email, fill in the recipient's email, optional display name, and role, then submit. AccessFlow generates a signup token and emails it; the link expires after ACCESSFLOW_SECURITY_INVITATION_TTL (default 7 days).\n\n- Create with a password. Use the dropdown next to the invite button → Create with password to provision a user directly. Useful when SMTP isn't configured yet, or when you want to seed an account synchronously.\n\n- Edit or deactivate. Click any row to change the role or flip the active toggle. Deactivated users can't sign in but their audit trail is preserved.\n\n- Pending invitations are listed below the user table; resend or revoke them from there.\n\nTune it. ACCESSFLOW_SECURITY_INVITATION_TTL (invite-link\nlifetime, default P7D), ACCESSFLOW_SECURITY_PASSWORD_RESET_TTL\n(reset-link lifetime, default PT1H), and\nACCESSFLOW_SECURITY_PASSWORD_RESET_RESET_BASE_URL (link base, default\nhttp://localhost:5173)."} {"id":"b51b0af37cc37234","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-roles","anchor":"cfg-roles","title":"User roles & RBAC","section":"Reference","order":0,"tokens":120,"text":"AccessFlow Docs > Reference > Users, roles & organizations > User roles & RBAC\n\nWhat it is. Role-based access control. Every user carries one org-wide\nrole that caps what they can do; on top of it, per-datasource permissions decide which\ndatabases they may touch. Pick the lowest-privilege role that still lets someone do their\njob — and a user can never approve their own query, whatever their role."} {"id":"5569e0de6028e95d","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":0,"tokens":787,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 1 of 3)\n\nAccessFlow has five org-wide roles. READONLY submits SELECT queries only. ANALYST adds DML. REVIEWER adds approving other people’s queries. ADMIN adds DDL and every configuration screen. AUDITOR is read-only across the audit log and compliance reports, and cannot submit queries at all.\n\nCustom roles. Beyond the five built-in system roles, an admin can compose\ncustom roles on /admin/roles from a fixed catalog of functional\npermissions (submit SELECT/DML/DDL, review queries, review access requests, manage\ndatasources, view the audit log, and so on) — e.g. a reviewer who may approve queries but\nnot manage users. The five system roles are immutable and behave exactly as the matrix\nbelow; a custom role grants exactly the permissions you tick. Roles that are still\nassigned to users cannot be deleted.\n\nConfigure it. Assign a system or custom role when you create or edit a\nuser on /admin/users; the matrix below is what each built-in role may do.\n\nPlatform admin is separate from the five roles. The\nplatform-admin capability is an orthogonal flag, not a\nfifth role — a platform admin keeps whatever role their home org assigns and is\nadditionally allowed to manage organizations across the cluster\n(/admin/organizations). It grants no extra capability inside any single org;\nthe matrix below still governs every tenant-scoped action.\n\nCapability |\nREADONLY |\nANALYST |\nREVIEWER |\nADMIN |\nAUDITOR |\n\nSubmit SELECT queries | ✓ | ✓ | ✓ | ✓ | — |\n\nSubmit DML (INSERT / UPDATE / DELETE) | — | ✓ | ✓ | ✓ | — |\n\nSubmit DDL (CREATE / ALTER / DROP) | — | — | — | ✓ | — |\n\nView own query history | ✓ | ✓ | ✓ | ✓ | — |\n\nView all queries in the org | — | — | ✓ | ✓ | — |\n\nApprove / reject queries | — | — | ✓ | ✓ | — |\n\nRequest time-bound datasource / API-connection access (JIT) | ✓ | ✓ | ✓ | ✓ | — |\n\nReview / approve access requests | — | — | ✓ | ✓ | — |\n\nReview / approve deployment requests | — | — | ✓ | ✓ | — |\n\nManage datasources | — | — | — | ✓ | — |\n\nManage users | — | — | — | ✓ | — |\n\nManage user groups | — | — | — | ✓ | — |\n\nManage review plans | — | — | — | ✓ | — |\n\nManage deployment pipelines | — | — | — | ✓ | — |\n\nView audit log | — | — | — | ✓ | — |\n\nManage notification channels | — | — | — | ✓ | — |\n\nManage external audit sinks | — | — | — | ✓ | — |"} -{"id":"4c1b4663ec7a67c8","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":1,"tokens":718,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 2 of 3)\n\nConfigure AI | — | — | — | ✓ | — |\n\nConfigure SAML / OAuth | — | — | — | ✓ | — |\n\nView / export compliance reports | — | — | — | ✓ | ✓ |\n\nView the over-provisioned access report | — | — | — | ✓ | ✓ |\n\nView the privileged-access report | — | — | — | ✓ | ✓ |\n\nWho can write to a table (effective-access lookup) | — | — | — | ✓ | ✓ |\n\nRun a decision trace (query, API call or deployment) | — | — | — | ✓ | — |\n\nView behavioural anomalies (UBA) | — | — | — | ✓ | ✓ |\n\nAcknowledge / dismiss anomalies | — | — | — | ✓ | — |\n\nBreak-glass / emergency execution† | ✓ | ✓ | ✓ | ✓ | — |\n\nView break-glass log | — | — | — | ✓ | ✓ |\n\nAcknowledge break-glass events | — | — | — | ✓ | — |\n\n† Break-glass / emergency execution is not granted by role — it is gated by a\nseparate per-user, per-datasource can_break_glass permission that an admin\ngrants explicitly (required for everyone, including admins; time-boxed). A user can\nbreak glass only on a datasource they hold that grant for, and only for query types they\nalready have the capability for.\n\nWhich role for what. Use READONLY for people who only\nneed to look at production data (analysts, on-call engineers reading dashboards).\nUse ANALYST for people who write data through reviewed queries.\nUse REVIEWER for people who approve other users' queries — typically\nsenior engineers or DBAs. Use ADMIN for the platform-team operators who\nconfigure the system itself. Use AUDITOR for a dedicated, read-only\ncompliance reviewer — it sees only the compliance dashboard (/admin/auditor):\npre-built PII/PCI/GDPR access and DDL/DELETE reports with signed PDF/CSV export, and\nnothing else.\n\nDatasource-level permissions. Role is the org-wide ceiling. On top of\nit, every user needs an explicit per-datasource permission grant to\naccess a given database — it controls read / write / DDL per\ndatasource, row caps, allowed schemas / tables, restricted columns (which are masked\nas *** in SELECT results), and denied columns (a query that\nreferences one is refused before it runs). See\ndocs/07-security.md\nfor the full authorization matrix."} -{"id":"052a0364ddf22c61","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":2,"tokens":265,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 3 of 3)\n\nGroup-based access grants. Rather than a row per person, an admin can grant\na user group access to a datasource or an API connector (same\nread / write / DDL / break-glass controls); every member inherits the grant, and adding\nsomeone to the group gives them access without a new grant. When a user has both a direct\ngrant and one or more group grants, their effective access is the most-permissive\nunion — capabilities are OR-ed, allow-lists merge, restricted-column masks and denied columns apply\nonly where every grant restricts or denies them, and each grant's expiry is honoured independently. The\none exception is the row limit override: the smallest one wins, and it can only lower the\ndatasource's cap, never raise it."} +{"id":"4c1b4663ec7a67c8","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":1,"tokens":747,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 2 of 3)\n\nConfigure AI | — | — | — | ✓ | — |\n\nConfigure SAML / OAuth | — | — | — | ✓ | — |\n\nView / export compliance reports | — | — | — | ✓ | ✓ |\n\nView the over-provisioned access report | — | — | — | ✓ | ✓ |\n\nView the privileged-access report | — | — | — | ✓ | ✓ |\n\nWho can write to a table (effective-access lookup) | — | — | — | ✓ | ✓ |\n\nRun a decision trace (query, API call or deployment) | — | — | — | ✓ | — |\n\nView behavioural anomalies (UBA) | — | — | — | ✓ | ✓ |\n\nAcknowledge / dismiss anomalies | — | — | — | ✓ | — |\n\nBreak-glass / emergency execution† | ✓ | ✓ | ✓ | ✓ | — |\n\nView break-glass log | — | — | — | ✓ | ✓ |\n\nAcknowledge break-glass events | — | — | — | ✓ | — |\n\n† Break-glass / emergency execution is not granted by role — it is gated by a\nseparate per-user, per-datasource can_break_glass permission that an admin\ngrants explicitly (required for everyone, including admins; time-boxed). A user can\nbreak glass only on a datasource they hold that grant for, and only for query types they\nalready have the capability for.\n\nWhich role for what. Use READONLY for people who only\nneed to look at production data (analysts, on-call engineers reading dashboards).\nUse ANALYST for people who write data through reviewed queries.\nUse REVIEWER for people who approve other users' queries — typically\nsenior engineers or DBAs. Use ADMIN for the platform-team operators who\nconfigure the system itself. Use AUDITOR for a dedicated, read-only\ncompliance reviewer — it sees only the compliance dashboard (/admin/auditor):\npre-built PII/PCI/GDPR access and DDL/DELETE reports with signed PDF/CSV export, and\nnothing else.\n\nDatasource-level permissions. Role is the org-wide ceiling. On top of\nit, every user needs an explicit per-datasource permission grant to\naccess a given database — it controls read / write / DDL per\ndatasource, row caps, allowed schemas / tables, denied schemas / tables (everything\nexcept these — a denial always beats the allowed list), restricted columns (which are masked\nas *** in SELECT results), and denied columns (a query that\nreferences one is refused before it runs). See\ndocs/07-security.md\nfor the full authorization matrix."} +{"id":"052a0364ddf22c61","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":2,"tokens":365,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 3 of 3)\n\nGroup-based access grants. Rather than a row per person, an admin can grant\na user group access to a datasource or an API connector (same\nread / write / DDL / break-glass controls); every member inherits the grant, and adding\nsomeone to the group gives them access without a new grant. When a user has both a direct\ngrant and one or more group grants, their effective access is the most-permissive\nunion — capabilities are OR-ed, allow-lists merge, restricted-column masks and denied columns apply\nonly where every grant restricts or denies them, and each grant's expiry is honoured independently. Two\nthings work the other way. The row limit override: the smallest one wins, and it can only lower the\ndatasource's cap, never raise it. And denied schemas and tables add up: a table denied by any one\ngrant stays denied, so a wider group grant can never undo a denial, and a group's denial applies to\nevery member. The flip side: revoking or expiring the grant that carries a denial removes it,\nand the user's other grants may then reach the table."} {"id":"cf357e69598c439d","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-access-requests","anchor":"cfg-access-requests","title":"Just-in-time (JIT) access requests","section":"Reference","order":0,"tokens":652,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Just-in-time (JIT) access requests\n\nInstead of an admin pre-granting a\npermission, any user can request temporary, scoped access from\n/access-requests — to a datasource (pick the capabilities they\nneed — read / write / DDL — and an optional schema/table scope) or to an API\nconnection (read / write plus an optional allow-list of specific operations from\nthe connector's schema catalog), with a duration. The request runs\nthrough the same reviewer-eligibility and multi-stage approval engine as query review\n(a requester can never approve their own); API-connection requests route through the\nconnector's assigned review plan. Admins are the backstop approver: an admin\nsees and can approve every pending access request from\n/admin/access-requests — even on resources with no review plan — so a\nrequest is never stuck waiting for an approver who was never configured. On final\napproval AccessFlow writes a time-boxed permission grant (expiring at\nnow + duration) — a datasource permission, or an API-connection permission\nvisible on the connector's Permissions tab alongside admin-granted rows; it's revoked\nautomatically on expiry, and an admin can revoke an\nactive grant early from /admin/access-requests. Tune the revocation cadence\nwith ACCESSFLOW_ACCESS_GRANT_EXPIRY_POLL_INTERVAL (default PT5M)\nand the allowed duration window with ACCESSFLOW_ACCESS_MIN_DURATION /\nACCESSFLOW_ACCESS_MAX_DURATION (defaults PT15M / P30D).\nA requester can additionally tick “Pre-approve queries under this grant” on the\nrequest form (off by default): while such a grant is active, queries it covers —\nmatching capability and schema/table scope — skip human review entirely and are\nauto-approved with the grant and its approver recorded on the query detail and in the\naudit log. The flag is shown as a highlighted tag in the approval queue so the reviewer\nsees exactly what they authorize; auto-reject and escalation routing policies, high-risk\nAI verdicts, and open behavioural anomalies still override the fast-path.\n\n/admin/access-requests — pending JIT access requests; admins approve, reject, or revoke an active grant."} {"id":"3d92254db5c80485","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-break-glass","anchor":"cfg-break-glass","title":"Break-glass / emergency access","section":"Reference","order":0,"tokens":385,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Break-glass / emergency access\n\nFor genuine emergencies — production is\ndown and approvers are unreachable — an admin can grant a user the\ncan_break_glass permission on a datasource (a checkbox on the permission\ngrant, alongside read / write / DDL, time-boxed via the same expires_at).\nWith that grant, an Emergency access button appears on the editor for\nthat datasource: the user supplies a mandatory justification and the query\nexecutes immediately, bypassing review — but still through every proxy\nguard (schema/table allow-list, dynamic masking, row-level security, row caps). The grant\nis required for everyone, including admins. Each break-glass execution fires\ninstant notifications to all admins (including PagerDuty), writes a prominently-tagged\nQUERY_BREAK_GLASS_EXECUTED audit row, and opens a mandatory\nretro-review on the /admin/break-glass log that an admin —\nnever the submitter — must acknowledge after the fact. The executed query keeps\nits normal terminal state; the retro-review is tracked alongside it.\n\n/admin/break-glass — every emergency execution opens a mandatory retro-review here for an admin (never the submitter) to acknowledge."} {"id":"2fc7ae27667de7df","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-groups","anchor":"cfg-groups","title":"User groups","section":"Reference","order":0,"tokens":620,"text":"AccessFlow Docs > Reference > Users, roles & organizations > User groups\n\nWhat it is. Named, organisation-scoped collections of users. Use them to\n(1) bundle reviewers so you can attach a single group — instead of ten individual users —\nto a datasource as eligible reviewers, (2) grant a whole team data or API\naccess (a datasource or API-connector grant on a group is inherited by every\nmember, so you don't add a row per person), and (3) act as the target of IdP group mappings\nso SAML / OAuth2 logins keep membership in sync automatically.\n\nConfigure it. Manage groups from /admin/groups:\n\n- Create a group. Go to /admin/groups → Create\ngroup. Pick a name (e.g. Billing Reviewers) and an optional description.\n\n- Add members. Open the group, click Add member, and pick\nusers from the dropdown. Manually-added members are tagged\nManual and stay put regardless of the IdP sync.\n\n- Use the group. On a datasource's Reviewers tab\n(/datasources//settings), add the group as a reviewer. From\nthat point on, members of the group can see and decide queries against that\ndatasource (in addition to plan-approver rules). On the same page's\nPermissions tab (and an API connector's Permissions tab) you can also\ngrant the group access — switch the grant target from User to\nGroup and every member inherits the read / write / DDL / break-glass grant.\n\n- Optional: IdP-managed memberships. Configure\ngroup_mappings on the SAML or OAuth2 admin pages so an IdP group claim\nauto-maps to the AccessFlow group. On every login, AccessFlow replaces the user's\nIdP-sourced memberships with the mapped set; Manual memberships\nare never touched.\n\nPer-datasource reviewer scoping. Once a datasource has at least one\nassigned reviewer (a user or a group), only those reviewers see its queries. Datasources\nwith none fall back to the review-plan approvers — so adopting groups is purely additive,\nno migration required.\n\n/admin/groups — organisation-scoped user groups; open one to manage members."} @@ -272,7 +273,7 @@ {"id":"c7cdaafc909a9690","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-connect","anchor":"guide-datasource-connect","title":"1. Connect it","section":"Guides","order":0,"tokens":695,"text":"AccessFlow Docs > Guides > Add your first datasource > 1. Connect it (part 1 of 2)\n\nSidebar → Datasources → add one. The wizard is four steps:\nDatabase type, Connection details, Connection\ntest, Configuration.\n\nThe type picker is grouped into Bundled drivers,\nConnectors and Custom drivers, and each tile carries a\nbadge telling you what will happen when you save:\n\n- Bundled — ships inside the image. Only PostgreSQL.\n\n- Downloaded — already fetched and cached on this deployment.\n\n- Will download — fetched on first use, checksum-verified, then\ncached. Expect a slower first save, not a failure.\n\n- Unavailable — the deployment is in offline driver mode or the cache\nis not writable.\n\nYou do not have to visit the connector catalog first. Saving the\ndatasource resolves and verifies whatever it needs. Installing ahead of time from\nConnectors in the admin section just moves the wait earlier. Every\ndownload is checked against a pinned SHA-256 — and re-checked every time it is resolved,\nnot only at install.\n\nStep 1 — pick the engine. The badge on each tile says whether its driver is bundled, cached, or will be downloaded on save.\n\nConnection details adapts to the engine. Most ask for host, port,\ndatabase name, username and password. Some do not:\n\nEngine | What it asks for instead |\n\nBigQuery | A GCP project and a service-account key in JSON. No host, port or username. |\n\nDatabricks | Workspace host, a required warehouse HTTP path, and a personal access token. No username. |\n\nSnowflake | An account host and either a password or a private key for key-pair auth (a passphrase-protected key takes its passphrase in a separate field). No port. |\n\nDynamoDB | An AWS region in place of the database name, plus an access key id and secret. |\n\nElasticsearch / OpenSearch | Either a username and password, or an API key. |\n\nCassandra / ScyllaDB | A local datacenter name, which is required. |\n\nSSL mode is pre-filled from the engine's own default rather than one\nglobal default — PostgreSQL starts at full verification, while several others (ClickHouse\nand a number of the NoSQL engines) start disabled. Check it rather than assuming it.\n\nRun the Connection test. It opens a real connection and runs a trivial\nprobe — the plugin's own equivalent for non-JDBC engines. You can skip it, but a\ndatasource that cannot connect is a datasource nobody can query."} {"id":"03f0ba556d6742f5","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-connect","anchor":"guide-datasource-connect","title":"1. Connect it","section":"Guides","order":1,"tokens":296,"text":"AccessFlow Docs > Guides > Add your first datasource > 1. Connect it (part 2 of 2)\n\nA connection-shape mistake can report a confusing error. Per-engine\nrequirements — a missing warehouse path, a JDBC URL a custom driver needs — are checked\nwhen you save, and the message you get back is currently a generic one about datasource\npermissions rather than the specific field. If a save is rejected and the message does\nnot seem to fit, re-check the engine-specific fields in the table above.\n\nFinally, Configuration: connection pool size, max rows per query, the\nreview plan, whether reads\nand writes each require review, and the AI switches. Turning on AI analysis or text-to-SQL\nrequires choosing an AI configuration in the same step — see\nturn on AI risk analysis.\n\nCredentials are encrypted before storage and are never returned by any endpoint. If you\nkeep secrets elsewhere, the password field also accepts a reference into HashiCorp Vault,\nAWS Secrets Manager or Azure Key Vault instead of a literal value."} {"id":"75941d34a25ca1ef","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-schema","anchor":"guide-datasource-schema","title":"2. Look at what you just connected","section":"Guides","order":0,"tokens":177,"text":"AccessFlow Docs > Guides > Add your first datasource > 2. Look at what you just connected\n\nOpen the datasource's settings. The Schema tab shows the introspected\nobjects as a searchable tree; clicking a table previews sample rows — through the\ngoverned path, so masked columns show masked values, never the raw ones. The ER\ndiagram tab renders the same schema as a graph, which is often the faster way to\nspot the tables you did not know were there.\n\nThis is worth doing before you write any policy. The next three sections all reference\nspecific tables and columns, and the schema tab is where you find their real names."} -{"id":"b62be5b7add30f9e","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-allowlist","anchor":"guide-datasource-allowlist","title":"3. Limit which tables can be queried","section":"Guides","order":0,"tokens":307,"text":"AccessFlow Docs > Guides > Add your first datasource > 3. Limit which tables can be queried\n\nThe table allow-list lives on the grant, not on the datasource. On the\nPermissions tab, a grant carries Allowed schemas and\nAllowed tables, both fed by the introspected schema.\n\nAccessFlow enforces this by walking the parsed query and collecting every table it\nactually touches, then checking each one against the list. It is not a text match on the\nSQL, so aliases, comments and formatting cannot slip past it.\n\nEmpty means unrestricted. A grant with both lists empty can reach every\ntable the database account can. That is the default when you create a grant, so\nnarrowing it is a thing you do, not a thing that happens.\n\nTwo matching details worth knowing. Names are compared case-insensitively with quoting\nstripped, so \"Orders\" and orders are the same entry. But\nqualification is significant: an unqualified orders in the list matches an\nunqualified FROM orders, so list the form your users actually write — or\nallow the schema instead, which covers both."} +{"id":"b62be5b7add30f9e","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-allowlist","anchor":"guide-datasource-allowlist","title":"3. Limit which tables can be queried","section":"Guides","order":0,"tokens":568,"text":"AccessFlow Docs > Guides > Add your first datasource > 3. Limit which tables can be queried\n\nThe table allow-list lives on the grant, not on the datasource. On the\nPermissions tab, a grant carries Allowed schemas and\nAllowed tables, both fed by the introspected schema.\n\nAccessFlow enforces this by walking the parsed query and collecting every table it\nactually touches, then checking each one against the list. It is not a text match on the\nSQL, so aliases, comments and formatting cannot slip past it.\n\nEmpty means unrestricted. A grant with both lists empty can reach every\ntable the database account can. That is the default when you create a grant, so\nnarrowing it is a thing you do, not a thing that happens.\n\nTwo matching details worth knowing. Names are compared case-insensitively with quoting\nstripped, so \"Orders\" and orders are the same entry. But\nqualification is significant: an unqualified orders in the list matches an\nunqualified FROM orders, so list the form your users actually write — or\nallow the schema instead, which covers every schema-qualified name in it (an allowed\nschema never covers an unqualified FROM orders).\n\nWhen it is easier to name the exceptions, use Denied schemas and\nDenied tables on the same grant. Allow the schema crm and\ndeny crm.salary, and the grant reaches every crm table —\nincluding ones created next month — except the salary table. A denial always wins over\nthe allowed lists, and it also hides the table from the schema tree and autocomplete.\nWrite a denied schema as a single name, and a denied table as table,\nschema.table or schema.* for a whole schema.\n\nDenials are deliberately strict. A denied crm.salary also\nrefuses a query that writes plain salary, and while any schema is denied,\nevery table name without a schema is refused — AccessFlow cannot tell which schema the\ndatabase would pick. Tell the people on the grant to write crm.customer, not\ncustomer."} {"id":"fe034feab49b16b2","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-masking","anchor":"guide-datasource-masking","title":"4. Mask the columns that should not be read","section":"Guides","order":0,"tokens":392,"text":"AccessFlow Docs > Guides > Add your first datasource > 4. Mask the columns that should not be read\n\nThe Masking tab defines column-level policies applied to results. Each\npolicy names a column as schema.table.column, picks a strategy, and lists who\nmay see through it.\n\n- Full mask — the value is replaced entirely.\n\n- Partial — keeps a number of trailing characters, for the last four\ndigits of a card or an account.\n\n- Hash — a SHA-256 digest, so values remain comparable without being\nreadable.\n\n- Email-preserving and format-preserving — keep the\nshape so downstream tools and eyeballs still recognise it.\n\nReveal to names the roles, groups or people who see the real value.\nLeave it empty and the column is masked for everyone, admins included — masking is\nopt-in for the reader, not opt-out.\n\nDatasource settings → Masking — one policy per column, with the strategy and who may see through it.\n\nLet AccessFlow find them for you. The Discovery tab\nsamples column data through the governed path and proposes classifications — email\naddresses, card numbers, national ids, bank account numbers, phone numbers — which you\nconfirm or dismiss. Confirming a proposal tags the column and can derive its masking\npolicy for you. Raw sampled values are never stored, and never sent to an AI provider;\nthe optional AI pass sees redacted samples only."} {"id":"982d6456c058e3bc","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-rowsecurity","anchor":"guide-datasource-rowsecurity","title":"5. Limit which rows come back","section":"Guides","order":0,"tokens":617,"text":"AccessFlow Docs > Guides > Add your first datasource > 5. Limit which rows come back\n\nThe Row security tab filters rows rather than columns. A policy names a\ntable and a column, an operator, and a value to compare against — either a literal, or a\nvariable resolved per user such as their region attribute, their role, or their groups.\n\nAccessFlow applies it by rewriting the query: a SELECT gets its table wrapped in a\nfiltered subquery, an UPDATE or DELETE gets the predicate added to its WHERE clause. The\ncomparison value is always bound as a parameter, never concatenated into the SQL.\n\nRow security fails closed, in both directions. If the per-user value\ncannot be resolved — the attribute is missing, the list is empty — the policy matches\nnothing and the user sees zero rows rather than everything. And a query whose shape\ncannot be safely rewritten, such as one using set operations or common table expressions\nover a protected table, is rejected rather than run unfiltered. Expect\nsome legitimate queries to be refused; that is the trade being made.\n\nNon-relational engines each fail closed in their own way. Redis, for instance, cannot\nexpress row security at all, so a policy over it denies rather than degrades; Cassandra\nrefuses policies on non-key columns instead of quietly scanning the whole table.\n\nDatasource settings → Row security — a per-table predicate compared against a per-user value.\n\nUnlike masking, row security applies to everyone by default: leaving\nApplies to empty means the policy is in force for all users. Narrow it\nto specific roles or groups if that is not what you want.\n\nTo limit how many rows come back rather than which, use the\nRow limits tab. A row limit caps the rows a query may return from one\ntable, for everyone or for chosen roles, groups or people, so the orders\ntable can stop at 200 rows while customer stops at 1,000. Limits only ever\ntighten: the datasource's own maximum still applies, a query across several limited\ntables gets the lowest limit, and a query that leaves the schema off the table name, or\nadds the database name, is still caught."} {"id":"7f8bb8096e66cddd","path":"website/docs/guides/datasource/index.html","url":"https://accessflow.io/docs/guides/datasource/#guide-datasource-operate","anchor":"guide-datasource-operate","title":"6. Keep an eye on it","section":"Guides","order":0,"tokens":162,"text":"AccessFlow Docs > Guides > Add your first datasource > 6. Keep an eye on it\n\nDatasource health in the admin section shows connection-pool use, query\nvolume, error counts and execution latency per datasource over the last day. It is the\nfirst place to look when queries start timing out — an exhausted pool looks very\ndifferent from a slow database, and the page distinguishes them.\n\nField-by-field reference for every engine, the full policy options and the tuning env\nvars: Datasources. Engine-specific\nbehaviour and install detail: Connectors."} @@ -331,7 +332,7 @@ {"id":"c934edba3bc5494c","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/","anchor":"","title":"What is the difference between a role and a grant?","section":"Guides","order":0,"tokens":135,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > What is the difference between a role and a grant?\n\nA role decides which parts of AccessFlow a person can use — whether\nthey can submit writes, review other people's queries, or open admin pages. A\ngrant decides which datasource they can query and with what\ncapabilities. Neither implies the other: an analyst with no grants can reach no data,\nand a grant on its own does not let anyone review."} {"id":"51f2d337362ea62b","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-create","anchor":"guide-team-create","title":"1. Create the accounts","section":"Guides","order":0,"tokens":451,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 1. Create the accounts\n\nSidebar → Security & Access → Users. The control at the\ntop right is a split button with two different paths behind it.\n\n- Invite via email — the main half of the button. Opens\nInvite a teammate: Email, an optional Display\nname, and a Role. Send invitation emails\nthem a link they use to set their own password. This is the path you want on a real\ndeployment.\n\n- Create with password — behind the small arrow. Opens Invite\nuser, which asks for an Initial password alongside the same\nfields and creates the account immediately. Despite its Send invite\nbutton, this path sends no mail at all.\n\nInvite via email needs system SMTP first. Without it the action fails\nwith 422 SYSTEM_SMTP_NOT_CONFIGURED_FOR_INVITE before it creates anything.\nSet email up with the notifications\nguide, or use the password path meanwhile.\n\nSent invitations appear in a Pending invitations table below the user\nlist, where you can resend or revoke one. Links expire after seven days by default\n(ACCESSFLOW_SECURITY_INVITATION_TTL), and they are built from\nACCESSFLOW_PUBLIC_BASE_URL — if that is wrong, your invitees get a link\npointing somewhere they cannot reach.\n\nSidebar → Security & Access → Users, with the create-with-password form open.\n\nSearching does not span pages. The search box and the role and provider\nfilters narrow the page you are looking at, twenty users at a time — they are not a\nserver-side search. On a large directory, page to the user rather than expecting the box\nto find them."} {"id":"75929b3b512c7e20","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-roles","anchor":"guide-team-roles","title":"2. Pick the right role","section":"Guides","order":0,"tokens":528,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 2. Pick the right role\n\nFive roles ship with AccessFlow. They are built in: you cannot edit or delete them, and\neach is a superset of the one above it apart from Auditor, which is a different shape\nentirely.\n\nRole | What it can do | Give it to |\n\nRead-only |\nSubmit SELECT queries, and nothing else. |\nPeople who only ever read, and contractors. |\n\nAnalyst |\nRead-only, plus INSERT, UPDATE and DELETE. No schema changes. |\nThe default for engineers and analysts. Most of your users. |\n\nReviewer |\nEverything an analyst can do, plus seeing every query in the organization and\ndeciding them — along with access requests, API calls, deployments, erasure\nrequests and recertification items. |\nTeam leads and data owners. Note it carries no admin or configuration\naccess. |\n\nAuditor |\nCompliance reports, recertification evidence, access-usage reports and the\nbreak-glass log. Cannot submit queries at all, which is why\nsigning in as one lands on the compliance dashboard rather than a dashboard with\nan editor. |\nCompliance and internal audit. |\n\nAdmin |\nEverything, including every permission added in future releases. |\nAs few people as the job allows — see the warning below. |\n\nAdmin is not just \"more access\". It carries the review-override\npermission, which bypasses the approver lists on review plans by design. Any scoping you\nconfigure elsewhere — including on\ndeployment\npipelines — is scoping over non-admins. Grant it deliberately.\n\nIf none of the five fits, build your own: Roles under the admin section\ncreates an organization-scoped role from the permission catalog. It needs a name and at\nleast one permission. Custom role names work as approver rules on review plans just as\nthe built-in ones do."} -{"id":"9dea8c599437b649","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":0,"tokens":559,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource\n\nOpen the datasource → Permissions tab → Grant access.\nGrants go to an individual or to a group, and carry rather more than an on/off switch:\n\n- Can read, Can write, Can run DDL\n— at least one is required.\n\n- Row limit override — a tighter cap than the datasource's default\nfor this person. It can only lower the limit, never raise it. If someone holds\nseveral grants on the same datasource (their own and their groups'), the smallest\noverride wins, so no grant can loosen a tighter cap set by another.\n\n- Allowed schemas and Allowed tables — leave empty\nfor everything, or narrow it. Submitting a query that touches anything outside the\nlist is refused.\n\n- Restricted columns — masked in this person's results.\n\n- Denied columns — stricter than restricted: a query that touches one\nat all, including through SELECT *, is refused before it runs. Relational\ndatasources only. Administrators are not bound by it, and it holds only while every\ngrant this person has on the datasource denies the column.\n\n- Expires at — optional, and the single most useful field on the\nform. Access that removes itself is access nobody has to remember to remove.\n\nWhere someone has both a direct grant and one through a group, the effective result is\nthe most permissive combination of the two, with one exception: the row limit, where\nthe smallest override wins.\n\nBreak-glass is granted here too, and it is not an ordinary capability.\nCan break-glass lets its holder bypass review entirely and execute\nimmediately. It is compensated rather than prevented: every admin is notified, the\naction is prominently audited, and an admin who is not the submitter has to acknowledge\nit afterwards. It does not stand alone — the user still needs the matching read, write\nor DDL capability. Set an expiry on it."} +{"id":"9dea8c599437b649","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":0,"tokens":703,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource\n\nOpen the datasource → Permissions tab → Grant access.\nGrants go to an individual or to a group, and carry rather more than an on/off switch:\n\n- Can read, Can write, Can run DDL\n— at least one is required.\n\n- Row limit override — a tighter cap than the datasource's default\nfor this person. It can only lower the limit, never raise it. If someone holds\nseveral grants on the same datasource (their own and their groups'), the smallest\noverride wins, so no grant can loosen a tighter cap set by another.\n\n- Allowed schemas and Allowed tables — leave empty\nfor everything, or narrow it. Submitting a query that touches anything outside the\nlist is refused.\n\n- Denied schemas and Denied tables — the exceptions:\nallow crm, deny crm.salary, and everything else in\ncrm stays reachable. A denial always beats the allowed lists, and a table\ndenied by any of this person's grants stays denied. While a schema is denied, they must\nwrite table names with their schema. Enter a schema as one name (hr) and a\ntable as table, schema.table or schema.*.\n\n- Restricted columns — masked in this person's results.\n\n- Denied columns — stricter than restricted: a query that touches one\nat all, including through SELECT *, is refused before it runs. Relational\ndatasources only. Administrators are not bound by it, and it holds only while every\ngrant this person has on the datasource denies the column.\n\n- Expires at — optional, and the single most useful field on the\nform. Access that removes itself is access nobody has to remember to remove.\n\nWhere someone has both a direct grant and one through a group, the effective result is\nthe most permissive combination of the two, with two exceptions: the row limit, where\nthe smallest override wins, and denied schemas and tables, which add up across grants.\n\nBreak-glass is granted here too, and it is not an ordinary capability.\nCan break-glass lets its holder bypass review entirely and execute\nimmediately. It is compensated rather than prevented: every admin is notified, the\naction is prominently audited, and an admin who is not the submitter has to acknowledge\nit afterwards. It does not stand alone — the user still needs the matching read, write\nor DDL capability. Set an expiry on it."} {"id":"2a9ddd10a0c5d838","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-groups","anchor":"guide-team-groups","title":"4. Use groups once individual grants stop scaling","section":"Guides","order":0,"tokens":168,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 4. Use groups once individual grants stop scaling\n\nSidebar → User groups. A group is a named set of people that can hold\ndatasource and API-connector grants of its own, so joining the group is what confers\naccess and leaving it is what removes it.\n\nMembership can be manual, or synced from your identity provider — the members table\nshows each person's source as Manual, IdP or SCIM. If you are heading towards\nIdP-managed groups, connect single sign-on first and let\nthe group memberships arrive with the users."} {"id":"3d9f9581d7bc2503","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-jit","anchor":"guide-team-jit","title":"5. Let people ask, instead of granting up front","section":"Guides","order":0,"tokens":324,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 5. Let people ask, instead of granting up front\n\nStanding access is the thing you are trying to avoid. Any signed-in user can open\nRequest access and ask for a scoped, time-boxed grant: a datasource,\nthe capabilities they need, optionally specific schemas and tables, a duration, and a\njustification. Durations run from one hour to seven days.\n\nRequests land in the Access requests queue for anyone who can review\nthem. Approving materialises a real grant that expires by itself; a background job\nrevokes it when the clock runs out. Rejecting requires a comment.\n\nPre-approve queries under this grant is worth understanding before\nsomeone ticks it. It lets queries covered by the grant's capability and table scope skip\nhuman review for as long as the grant is active — useful for a bounded on-call window,\nand a much bigger decision than the checkbox looks. High-risk queries and routing\npolicies still apply.\n\nDuration bounds are configurable with\nACCESSFLOW_ACCESS_MIN_DURATION (15 minutes by default) and\nACCESSFLOW_ACCESS_MAX_DURATION (30 days)."} {"id":"dbbb45cb98e78a45","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-offboarding","anchor":"guide-team-offboarding","title":"6. When someone leaves","section":"Guides","order":0,"tokens":286,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 6. When someone leaves\n\nOn the users page, open the row's menu and choose Deactivate. Three\nthings happen: the account is disabled, every one of its sessions is signed out\nimmediately, and every active just-in-time grant it holds is revoked.\n\nStanding grants are not revoked. Deactivation removes the temporary\ngrants somebody requested, not the permanent rows an admin created on a datasource's\nPermissions tab. Those survive the account being disabled, and would apply again if it\nwere ever reactivated. Remove them explicitly.\n\nYou cannot deactivate yourself — the API refuses it, so an organization can never lock\nout its last admin by accident.\n\nIf your identity provider is the source of truth, this should not be a manual step at\nall: SCIM deprovisioning raises the same event and takes the same actions. See\nSCIM provisioning.\n\nFull reference for roles, the permission matrix, groups and grants:\nUsers & roles."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index a34127bb5..96e387f37 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "b7e0f4f488b8", - "generatedAt": "2026-09-24T12:36:14.636Z", - "sourceCommit": "f37a068d56e37fc9f9710a5e894e3f9cf9e8e0d5", - "chunkCount": 587, - "sha256": "b7e0f4f488b88ca88bf5248c29f68ca94cb317cea961a2722eaa77989f346c38", + "corpusVersion": "65a223d6ac42", + "generatedAt": "2026-09-24T14:33:43.202Z", + "sourceCommit": "cfae20ebca9c845b65aec9466fbd2bf2bd8ef0db", + "chunkCount": 588, + "sha256": "65a223d6ac42b7d3164cd1ebf8d570eef3bbf36037ea318b999fdcec033dd1cc", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -204,8 +204,8 @@ "title": "Datasources", "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", - "chunks": 15, - "sha256": "c04ee5df0d61efc86caa3dcea7b651645bcd474af32edd2ea383e64e1b387cbc" + "chunks": 16, + "sha256": "9639b26e745e192035dfa9d3f05ecf559b11255fb2d4e23c95b25f595db40132" }, { "path": "website/docs/configuration/notifications/index.html", @@ -229,7 +229,7 @@ "url": "https://accessflow.io/docs/configuration/users-roles/", "section": "Reference", "chunks": 15, - "sha256": "6e24fffc3ed9eea66d263a4f1cfe678f47b3fec661d60c1a9e2d8f0def82cb53" + "sha256": "0eb9d8f708ff314a05a5e1662c0bb93f14302571ef4be08c7f6ac1e4cca80ef3" }, { "path": "website/docs/guides/ai-analysis/index.html", @@ -253,7 +253,7 @@ "url": "https://accessflow.io/docs/guides/datasource/", "section": "Guides", "chunks": 9, - "sha256": "4f0c54d54511c1b168ab72f1db19ccc240078d42246117f0f270f62e8afe9d0a" + "sha256": "bbd0f79bcdcb7f68ab6fd0c0516497e72107ad3652b74392d1248718463c4b68" }, { "path": "website/docs/guides/deployment-approval/index.html", @@ -309,7 +309,7 @@ "url": "https://accessflow.io/docs/guides/team/", "section": "Guides", "chunks": 8, - "sha256": "297c57fcf530d09dc9a17d0def19263ca9b41e0dbec94a1809b7c0b12589886a" + "sha256": "ef3539a408cfee958283c77a304fdfd4052a42e19f82f4efb71adbbaa9db2f71" }, { "path": "website/docs/guides/terraform/index.html", @@ -429,7 +429,7 @@ "url": "https://accessflow.io/docs/", "section": "Navigation", "chunks": 9, - "sha256": "95dd1629dd086cab9bf35f8dd5bce167b02cb35159ed22ba6cc88852ea7ce1c8" + "sha256": "2d01ffc289a92b778c0babebe093e292b72d481b9a849da0a12436bd8165bd2d" } ] } diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html index 4757f0374..a4ef5a543 100644 --- a/website/docs/configuration/datasources/index.html +++ b/website/docs/configuration/datasources/index.html @@ -348,8 +348,24 @@

What is a datasource in AccessFlow?

deployment-wide.

- Grant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table. + Grant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), denied schemas and tables, and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.

+

+ Denied schemas and tables — everything except. Sometimes it is easier to say what a user may not touch. Allow the schema crm and deny the table crm.salary, and the user can query every table in crm — including tables created later — except crm.salary. A query that touches a denied table is refused before it runs: +

+
    +
  • A denial always wins. It is checked after the allowed schemas and tables, and it works on its own too, with no allowed list at all.
  • +
  • Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.
  • +
  • Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.
  • +
  • Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.
  • +
  • Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the other way round (below).
  • +
  • Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks.
  • +
  • Just-in-time access keeps denials. A just-in-time access request cannot add a denied schema or table, and approving one never removes a denial: denials from every grant add up, and when the approval replaces the user's own expiring grant, that grant's denials carry over to the new one.
  • +
  • How to write entries. A denied schema is a single name, such as hr — not analytics.hr. A denied table is table, schema.table, or schema.* for a whole schema. Other wildcards and empty parts are refused when you save the grant.
  • +
  • Tricky names are refused, not guessed. SQL Server's db..salary (default schema) is treated as matching any schema, an Oracle database link (hr.salary@remote) does not get around a denial, and a name pattern such as the Elasticsearch index pattern sal* is refused whenever the grant denies anything.
  • +
  • Works on every datasource type. Denied schemas and tables apply to relational, NoSQL and warehouse datasources alike. On datasources whose objects have no schema — MongoDB collections, DynamoDB tables, Redis keys — a denied schema refuses every query, so use denied tables there. A denial can only catch the tables AccessFlow sees in the query: MongoDB $lookup, $unionWith and $graphLookup stages, a Neo4j MATCH (n) with no label, and a Redis KEYS pattern are not caught. The allowed lists share this limit.
  • +
  • Removing a grant can widen access. A denial belongs to the grant that carries it. Revoke that grant, let it expire, or revoke it in an access review, and its denial goes with it — another grant the user still holds may then let them reach the table. Check the user's other grants first.
  • +

Denied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:

@@ -357,11 +373,11 @@

What is a datasource in AccessFlow?

  • What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.
  • Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this.
  • Deny beats mask. A query that uses a column that is both restricted and denied is refused.
  • -
  • Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing, including a temporary just-in-time grant, lifts the deny.
  • +
  • Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing lifts the deny. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.
  • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
  • - Users only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables. + Users only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.

    Schema explorer & ER diagram. Each datasource also carries diff --git a/website/docs/configuration/users-roles/index.html b/website/docs/configuration/users-roles/index.html index 801dc58c1..dc7f595ba 100644 --- a/website/docs/configuration/users-roles/index.html +++ b/website/docs/configuration/users-roles/index.html @@ -574,7 +574,8 @@

    What are the user roles in AccessFlow?

    Datasource-level permissions. Role is the org-wide ceiling. On top of it, every user needs an explicit per-datasource permission grant to access a given database — it controls read / write / DDL per - datasource, row caps, allowed schemas / tables, restricted columns (which are masked + datasource, row caps, allowed schemas / tables, denied schemas / tables (everything + except these — a denial always beats the allowed list), restricted columns (which are masked as *** in SELECT results), and denied columns (a query that references one is refused before it runs). See docs/07-security.md @@ -587,9 +588,12 @@

    What are the user roles in AccessFlow?

    someone to the group gives them access without a new grant. When a user has both a direct grant and one or more group grants, their effective access is the most-permissive union — capabilities are OR-ed, allow-lists merge, restricted-column masks and denied columns apply - only where every grant restricts or denies them, and each grant's expiry is honoured independently. The - one exception is the row limit override: the smallest one wins, and it can only lower the - datasource's cap, never raise it. + only where every grant restricts or denies them, and each grant's expiry is honoured independently. Two + things work the other way. The row limit override: the smallest one wins, and it can only lower the + datasource's cap, never raise it. And denied schemas and tables add up: a table denied by any one + grant stays denied, so a wider group grant can never undo a denial, and a group's denial applies to + every member. The flip side: revoking or expiring the grant that carries a denial removes it, + and the user's other grants may then reach the table.

    Just-in-time (JIT) access requests

    diff --git a/website/docs/guides/datasource/index.html b/website/docs/guides/datasource/index.html index 94f61d298..52c04bfb6 100644 --- a/website/docs/guides/datasource/index.html +++ b/website/docs/guides/datasource/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Guides", "datePublished": "2026-09-01", - "dateModified": "2026-09-23", + "dateModified": "2026-09-24", "url": "https://accessflow.io/docs/guides/datasource/", "mainEntityOfPage": "https://accessflow.io/docs/guides/datasource/", "image": "https://accessflow.io/og-image.png", @@ -280,7 +280,7 @@ Guides

    Add your first datasource.

    -

    Last updated

    +

    Last updated

    @@ -424,8 +424,25 @@

    3. Limit which tables can be queried

    stripped, so "Orders" and orders are the same entry. But qualification is significant: an unqualified orders in the list matches an unqualified FROM orders, so list the form your users actually write — or - allow the schema instead, which covers both. + allow the schema instead, which covers every schema-qualified name in it (an allowed + schema never covers an unqualified FROM orders).

    +

    + When it is easier to name the exceptions, use Denied schemas and + Denied tables on the same grant. Allow the schema crm and + deny crm.salary, and the grant reaches every crm table — + including ones created next month — except the salary table. A denial always wins over + the allowed lists, and it also hides the table from the schema tree and autocomplete. + Write a denied schema as a single name, and a denied table as table, + schema.table or schema.* for a whole schema. +

    +
    + Denials are deliberately strict. A denied crm.salary also + refuses a query that writes plain salary, and while any schema is denied, + every table name without a schema is refused — AccessFlow cannot tell which schema the + database would pick. Tell the people on the grant to write crm.customer, not + customer. +
    diff --git a/website/docs/guides/team/index.html b/website/docs/guides/team/index.html index 7e6bb46ed..51c601db5 100644 --- a/website/docs/guides/team/index.html +++ b/website/docs/guides/team/index.html @@ -428,6 +428,12 @@

    3. Grant access to a datasource

  • Allowed schemas and Allowed tables — leave empty for everything, or narrow it. Submitting a query that touches anything outside the list is refused.
  • +
  • Denied schemas and Denied tables — the exceptions: + allow crm, deny crm.salary, and everything else in + crm stays reachable. A denial always beats the allowed lists, and a table + denied by any of this person's grants stays denied. While a schema is denied, they must + write table names with their schema. Enter a schema as one name (hr) and a + table as table, schema.table or schema.*.
  • Restricted columns — masked in this person's results.
  • Denied columns — stricter than restricted: a query that touches one at all, including through SELECT *, is refused before it runs. Relational @@ -438,8 +444,8 @@

    3. Grant access to a datasource

    Where someone has both a direct grant and one through a group, the effective result is - the most permissive combination of the two, with one exception: the row limit, where - the smallest override wins. + the most permissive combination of the two, with two exceptions: the row limit, where + the smallest override wins, and denied schemas and tables, which add up across grants.

    Break-glass is granted here too, and it is not an ordinary capability. diff --git a/website/sitemap.xml b/website/sitemap.xml index 634e9d648..33677adac 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -230,7 +230,7 @@ https://accessflow.io/docs/guides/datasource/ - 2026-09-23 + 2026-09-24 weekly 0.7