diff --git a/README.md b/README.md index e3f6082d7..504fed2c4 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,13 @@ A glance at the day-to-day flows engineers and approvers actually use. - **Proxy-first execution** — no user ever holds production credentials; the proxy holds them encrypted and opens connections only after approval. Single SQL statements run with autocommit; multi-statement INSERT/UPDATE/DELETE batches wrapped in `BEGIN; … COMMIT;` execute atomically inside one JDBC transaction (mixed SELECT/DML batches are rejected at parse time), with homogeneous INSERT runs collapsed into JDBC `executeBatch()` for bulk-load throughput. Optional **multi-replica read load balancing**: attach any number of replica endpoints to a datasource and SELECT traffic round-robins across the healthy ones — per-node health checks with circuit-breaker failover skip downed replicas, and only full replica-set exhaustion falls back to the primary (with an audit row). Optional **SELECT result caching**: opt a datasource into a Redis-backed result cache (per-datasource TTL) keyed over the security-rewritten query — masking and row-level security still apply — and invalidated on any proxied write to a referenced table. - **Configurable review workflows** — per-datasource review plans, multi-stage sequential approval chains, optional auto-approve for reads, approval timeouts with auto-reject. Reviewers can be scoped **per-datasource** (directly or via groups) so different teams see only the queues that belong to them. Reviewers going away can set an **out-of-office delegation** naming a colleague to cover their review duty for a window — across queries, governed API calls, and grouped requests — with every decision recording both identities. A delegate can never act on the delegator's own requests, delegation never grants a permission they lack, and it does not chain. Plans can also **escalate** a request that nobody has decided on to the reviewers at its current stage plus your admins before the approval timeout auto-rejects it, and **nudge** those same reviewers on a cadence — both optional, both notify-only, so waiting never changes who may approve. -- **Policy-as-code routing** — ordered, attribute-based routing policies decide a query's path after AI analysis and before reviewers see it: **auto-approve**, **auto-reject**, **require N approvals**, or **escalate**. Conditions match on query type, referenced tables (glob), AI risk level / score, requester role or group, time-of-day / day-of-week, WHERE / LIMIT presence, the transactional flag, and the submission **client context** — source IP / CIDR, user-agent, time-since-last-approval, and CI/CD origin (API key or `X-AccessFlow-CI` header) — combined with AND / OR / NOT. Client-context conditions **fail closed** (missing context never auto-approves), so an off-network or stale-approval query escalates to stricter review instead. First match by priority wins; on no match the query falls through to the datasource's review plan. Every automated decision is recorded in the audit log. +- **Policy-as-code routing** — ordered, attribute-based routing policies decide a query's path after AI analysis and before reviewers see it: **auto-approve**, **auto-reject**, **require N approvals**, or **escalate**. Conditions match on query type, referenced tables (glob), AI risk level / score, requester role or group, time-of-day / day-of-week, WHERE / LIMIT presence, **query shape** (joins, set operations, subqueries, CTEs, GROUP BY / HAVING, aggregates, window functions), the transactional flag, and the submission **client context** — source IP / CIDR, user-agent, time-since-last-approval, and CI/CD origin (API key or `X-AccessFlow-CI` header) — combined with AND / OR / NOT. Client-context conditions **fail closed** (missing context never auto-approves), so an off-network or stale-approval query escalates to stricter review instead. First match by priority wins; on no match the query falls through to the datasource's review plan. Every automated decision is recorded in the audit log. - **Policy simulator** — dry-run a draft routing, row-security, or masking policy against your own historical query traffic before you save it. AccessFlow replays the window **twice** — once against your current policies, once with the draft applied — and reports the difference between those two runs: how many past queries would change, which users would be affected, and which queries a row predicate would newly filter, deny, or reject outright. It is strictly read-only — no connection to your database, nothing executed, nothing persisted — and it names its own approximations (memberships are read as they are now; an engine that cannot classify a query shape offline is reported as *unclassifiable*, never as safe) instead of implying a precision the data does not have. - **Deterministic SQL review rules (#860)** — a named rule catalog that judges every SQL query alongside the AI verdict and routing policies, and shows its findings **in the editor as you type**. Fourteen built-in rules derived from the parsed statement alone (missing `WHERE` on `UPDATE` / `DELETE`, an always-true `WHERE`, `SELECT *`, unbounded reads, cross joins, leading-wildcard `LIKE`, `DROP` / `TRUNCATE` / DDL, banned functions, protected tables by glob, DML outside a transaction), each at an admin-set **`OFF` / `WARN` / `BLOCK`** severity configured per **environment** (`DEVELOPMENT` / `TEST` / `STAGING` / `PRODUCTION`, plus an organisation default). **`BLOCK` escalates, it never rejects**: a blocking finding suppresses every auto-approve path and sends the query to a human reviewer; a routing auto-reject still rejects. Evaluated synchronously at submission so findings exist even when AI analysis is off or fails, rendered in each reader's language on the query detail, review queue, break-glass retro-review and request-group detail; break-glass runs record findings but are never gated by them, and non-relational engines report *not applicable*. See [`docs/19-sql-review.md`](https://github.com/bablsoft/accessflow/blob/main/docs/19-sql-review.md). - **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 — and with table/schema **deny-lists** that always beat it ("all of `crm` except `crm.salary`", tables created later included). +- **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) — and with **query-shape deny-lists** on a grant, which refuse joins, unions, subqueries, CTEs, grouping, aggregates or window functions anywhere in a statement before it runs (relational engines, fail-closed). - **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 db7adad21..1a400e155 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 @@ -69,6 +69,7 @@ void materialize(UUID accessRequestId, UUID approvedByUserId) { replaced.map(DatasourceUserPermissionView::deniedColumns).orElse(null), replaced.map(DatasourceUserPermissionView::deniedSchemas).orElse(null), replaced.map(DatasourceUserPermissionView::deniedTables).orElse(null), + replaced.map(DatasourceUserPermissionView::deniedShapes).orElse(null), expiresAt, entity.getId()); var granted = datasourceAdminService.grantPermission(entity.getDatasourceId(), 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 e2777a9bc..16684e151 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 @@ -217,6 +217,8 @@ private String toSnapshotJson(DatasourcePermissionView view) { putStringArray(node, "denied_columns", view.deniedColumns()); putStringArray(node, "denied_schemas", view.deniedSchemas()); putStringArray(node, "denied_tables", view.deniedTables()); + putStringArray(node, "denied_shapes", view.deniedShapes() == null ? null + : view.deniedShapes().stream().map(Enum::name).toList()); 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 4afcfa0ae..dafda393d 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 @@ -17,5 +17,6 @@ public record CreateDatasourceGroupPermissionCommand( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, 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 b553bf043..242e6c9d7 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 @@ -21,6 +21,7 @@ public record CreatePermissionCommand( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, Instant expiresAt, UUID accessGrantRequestId ) {} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminException.java index 5b4745c2b..5478b9a0b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminException.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DatasourceAdminException.java @@ -9,6 +9,7 @@ public sealed class DatasourceAdminException extends RuntimeException DatasourceConnectionTestException, IllegalDatasourcePermissionException, DeniedColumnsNotSupportedException, + DeniedShapesNotSupportedException, MissingAiConfigForDatasourceException, TableNotFoundException { 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 f2bf3a9b0..45c8f71e6 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 @@ -21,6 +21,7 @@ public record DatasourceGroupPermissionView( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, 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 1ec9965c2..93fbafec0 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 @@ -39,6 +39,7 @@ public record DatasourcePermissionContribution( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, Integer rowLimitOverride, Instant expiresAt, UUID accessGrantRequestId) { @@ -50,5 +51,6 @@ public record DatasourcePermissionContribution( deniedColumns = deniedColumns == null ? List.of() : List.copyOf(deniedColumns); deniedSchemas = deniedSchemas == null ? List.of() : List.copyOf(deniedSchemas); deniedTables = deniedTables == null ? List.of() : List.copyOf(deniedTables); + deniedShapes = deniedShapes == null ? List.of() : List.copyOf(deniedShapes); } } 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 f91ec8dcc..6b8a17a5b 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 @@ -21,6 +21,7 @@ public record DatasourcePermissionView( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, 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 3373e0ae5..9f1f3e68e 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,12 +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), 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; + * intersection (a column is masked only when every contributor masks it). Two kinds of field + * 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); the deny-lists — denied + * schemas and tables (#939), columns (#1099) and query shapes (#940) — merge to their + * union, so no contributor can lift another's denial. 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 c70527023..be019dec1 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 @@ -18,6 +18,7 @@ public record DatasourceUserPermissionView( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, Integer rowLimitOverride, Instant expiresAt) { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java new file mode 100644 index 000000000..7b2be045d --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java @@ -0,0 +1,90 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * The one matcher behind a permission's {@code denied_shapes} (#940), shared by every gate that + * refuses a query by its structure — submission, the recurring recheck, break-glass, dry-run, + * request groups and the access simulator — so they can never disagree. + */ +public final class DeniedShapes { + + private DeniedShapes() { + } + + /** Drops nulls and duplicates; the result is in declaration order. */ + public static List normalize(Collection raw) { + if (raw == null || raw.isEmpty()) { + return List.of(); + } + var out = EnumSet.noneOf(QueryShape.class); + for (QueryShape shape : raw) { + if (shape != null) { + out.add(shape); + } + } + return List.copyOf(out); + } + + /** + * Reads stored shape names (the {@code denied_shapes} TEXT[] column) back into shapes. A name this + * version does not know denies every shape: a deny-list never silently loosens. + */ + public static List fromNames(Collection names) { + if (names == null || names.isEmpty()) { + return List.of(); + } + var out = EnumSet.noneOf(QueryShape.class); + for (String name : names) { + try { + out.add(QueryShape.valueOf(name)); + } catch (IllegalArgumentException unknown) { + return List.copyOf(EnumSet.allOf(QueryShape.class)); + } + } + return List.copyOf(out); + } + + /** The shape names to store, in declaration order; {@code null} when nothing is denied. */ + public static String[] toNames(Collection shapes) { + var normalized = normalize(shapes); + return normalized.isEmpty() ? null + : normalized.stream().map(QueryShape::name).toArray(String[]::new); + } + + /** Union of two deny-lists: a denial from either side survives. */ + public static List union(Collection left, Collection right) { + var out = EnumSet.noneOf(QueryShape.class); + out.addAll(normalize(left)); + out.addAll(normalize(right)); + return List.copyOf(out); + } + + /** + * @return the denied shapes the parsed query has, in declaration order; empty when it has none. + * A query whose shape was not analyzed (a non-JSqlParser engine, or an AST the detector + * could not walk) has every denied shape, so a deny-list can never be silently skipped. + * {@code OTHER} statements are checked too — a request-group member may be one. + */ + public static SortedSet rejected(Collection rawDenied, SqlParseResult parsed) { + var denied = normalize(rawDenied); + var out = new TreeSet(); + if (denied.isEmpty() || parsed == null) { + return out; + } + if (!parsed.shapesAnalyzed()) { + out.addAll(denied); + return out; + } + for (QueryShape shape : denied) { + if (parsed.shapes().contains(shape)) { + out.add(shape); + } + } + return out; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapesNotSupportedException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapesNotSupportedException.java new file mode 100644 index 000000000..d1550f07f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapesNotSupportedException.java @@ -0,0 +1,19 @@ +package com.bablsoft.accessflow.core.api; + +/** + * A grant carries {@code denied_shapes} on a datasource whose engine does not produce a SQL AST + * (#940): query-shape detection is relational (JSqlParser) only. + */ +public final class DeniedShapesNotSupportedException extends DatasourceAdminException { + + private final DbType dbType; + + public DeniedShapesNotSupportedException(DbType dbType) { + super("denied_shapes is not supported for db_type " + dbType); + this.dbType = dbType; + } + + public DbType dbType() { + return dbType; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryShape.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryShape.java new file mode 100644 index 000000000..ec4eb078c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/QueryShape.java @@ -0,0 +1,21 @@ +package com.bablsoft.accessflow.core.api; + +/** + * A structural feature of a parsed SQL statement (#940), detected from the JSqlParser AST anywhere in + * the statement — the outer query, subqueries, CTE bodies and every statement of a transactional + * batch. Feeds the {@code query_shape} routing condition and a grant's {@code denied_shapes}. + * + *

{@link #UNION} covers every set operation ({@code UNION}, {@code INTERSECT}, {@code EXCEPT}, + * {@code MINUS}). {@link #AGGREGATE} is the standard aggregate set by name (plus any ordered-set or + * {@code FILTER}ed aggregate); a user-defined aggregate is not detected. + */ +public enum QueryShape { + JOIN, + UNION, + SUBQUERY, + CTE, + GROUP_BY, + HAVING, + AGGREGATE, + WINDOW_FUNCTION +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/SqlParseResult.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/SqlParseResult.java index 78a226cb9..28087a72a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/SqlParseResult.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/SqlParseResult.java @@ -28,11 +28,16 @@ * candidate tables (#935). It is populated only by the JSqlParser path, which then sets * {@code columnsAnalyzed}; engine plugins leave both empty/{@code false}, and a column-level gate * must fail closed over an unanalyzed parse rather than read the empty set as "no columns". + * + *

{@code shapes} lists the structural features of the query (#940), unioned across a + * transactional batch. Like the columns it is populated only by the JSqlParser path, which then sets + * {@code shapesAnalyzed}; an unanalyzed parse must never be read as "no join". */ public record SqlParseResult(QueryType type, boolean transactional, List statements, Set referencedTables, boolean hasWhereClause, boolean hasLimitClause, Set referencedColumns, - boolean columnsAnalyzed) { + boolean columnsAnalyzed, Set shapes, + boolean shapesAnalyzed) { public SqlParseResult { if (statements == null || statements.isEmpty()) { @@ -41,6 +46,15 @@ public record SqlParseResult(QueryType type, boolean transactional, List statements = List.copyOf(statements); referencedTables = referencedTables == null ? Set.of() : Set.copyOf(referencedTables); referencedColumns = referencedColumns == null ? Set.of() : Set.copyOf(referencedColumns); + shapes = shapes == null ? Set.of() : Set.copyOf(shapes); + } + + public SqlParseResult(QueryType type, boolean transactional, List statements, + Set referencedTables, boolean hasWhereClause, + boolean hasLimitClause, Set referencedColumns, + boolean columnsAnalyzed) { + this(type, transactional, statements, referencedTables, hasWhereClause, hasLimitClause, + referencedColumns, columnsAnalyzed, Set.of(), false); } public SqlParseResult(QueryType type, boolean transactional, List statements, 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 d9e58f1bf..bfb9f64b4 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,7 +21,10 @@ 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.DeniedShapes; +import com.bablsoft.accessflow.core.api.DeniedShapesNotSupportedException; import com.bablsoft.accessflow.core.api.DeniedTables; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.DriverCatalogService; import com.bablsoft.accessflow.core.api.QueryEngineCatalog; import com.bablsoft.accessflow.core.api.IllegalDatasourcePermissionException; @@ -659,6 +662,7 @@ public DatasourcePermissionView grantPermission(UUID datasourceId, UUID organiza DeniedTables::isValidSchemaEntry, "denied_schemas"))); entity.setDeniedTables(toArray(deniedTables(command.deniedTables(), DeniedTables::isValidTableEntry, "denied_tables"))); + entity.setDeniedShapes(deniedShapes(datasource, command.deniedShapes())); entity.setExpiresAt(command.expiresAt()); entity.setAccessGrantRequestId(command.accessGrantRequestId()); entity.setCreatedBy(grantedBy); @@ -718,6 +722,7 @@ public DatasourceGroupPermissionView grantGroupPermission( DeniedTables::isValidSchemaEntry, "denied_schemas"))); entity.setDeniedTables(toArray(deniedTables(command.deniedTables(), DeniedTables::isValidTableEntry, "denied_tables"))); + entity.setDeniedShapes(deniedShapes(datasource, command.deniedShapes())); entity.setExpiresAt(command.expiresAt()); entity.setCreatedBy(grantedBy); return toGroupPermissionView(groupPermissionRepository.save(entity)); @@ -1133,6 +1138,18 @@ private List deniedColumns(DatasourceEntity datasource, List raw return denied; } + /** + * Stores a grant's {@code denied_shapes} (#940), refusing them on an engine whose queries are + * not parsed into a SQL AST — there the shape can never be verified. + */ + private String[] deniedShapes(DatasourceEntity datasource, List raw) { + var names = DeniedShapes.toNames(raw); + if (names != null && engineCatalog.isEngineManaged(datasource.getDbType())) { + throw new DeniedShapesNotSupportedException(datasource.getDbType()); + } + return names; + } + private DatasourcePermissionView toPermissionView(DatasourceUserPermissionEntity entity) { UserEntity user = entity.getUser(); return new DatasourcePermissionView( @@ -1152,6 +1169,7 @@ private DatasourcePermissionView toPermissionView(DatasourceUserPermissionEntity toList(entity.getDeniedColumns()), toList(entity.getDeniedSchemas()), toList(entity.getDeniedTables()), + DeniedShapes.fromNames(toList(entity.getDeniedShapes())), entity.getExpiresAt(), entity.getCreatedBy() != null ? entity.getCreatedBy().getId() : null, entity.getCreatedAt()); @@ -1177,6 +1195,7 @@ private DatasourceGroupPermissionView toGroupPermissionView( toList(entity.getDeniedColumns()), toList(entity.getDeniedSchemas()), toList(entity.getDeniedTables()), + DeniedShapes.fromNames(toList(entity.getDeniedShapes())), 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 fd40ee2a7..d2864909b 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,7 +5,9 @@ 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.DeniedShapes; import com.bablsoft.accessflow.core.api.DeniedTables; +import com.bablsoft.accessflow.core.api.QueryShape; 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; @@ -175,7 +177,7 @@ private static boolean isActive(Instant expiresAt, Instant now) { * allow-lists union (null wins = all allowed); restricted-columns intersect (empty wins = * nothing masked); expiry is the latest among contributors (null wins = never expires). The * row limit is the inversion: the smallest non-null override wins (#933). Deny-lists — denied - * schemas, tables (#939) and columns (#1099) — are the other inversion: they union, so no + * schemas, tables (#939), columns (#1099) and query shapes (#940) — are the other inversion: they union, so no * contributing grant can lift another's denial. */ private static DatasourceUserPermissionView merge(UUID userId, UUID datasourceId, @@ -211,6 +213,7 @@ private static DatasourceUserPermissionView merge(UUID userId, UUID datasourceId unionDeniedColumns(parts), unionDenied(parts, DatasourcePermissionContribution::deniedSchemas), unionDenied(parts, DatasourcePermissionContribution::deniedTables), + unionDeniedShapes(parts), minRowLimit(parts), anyNeverExpires ? null : expiresAt); } @@ -230,6 +233,15 @@ private static List unionDenied( return List.copyOf(union); } + /** Like {@link #unionDenied}: a query shape stays denied when any contribution denies it (#940). */ + private static List unionDeniedShapes(List parts) { + List denied = List.of(); + for (var p : parts) { + denied = DeniedShapes.union(denied, p.deniedShapes()); + } + return denied; + } + /** Most restrictive non-null override; {@code null} when no contribution sets one. */ private static Integer minRowLimit(List parts) { Integer min = null; @@ -304,6 +316,7 @@ private static DatasourceUserPermissionView toDirectView(DatasourceUserPermissio DeniedColumns.normalize(toList(entity.getDeniedColumns())), DeniedTables.normalize(toList(entity.getDeniedSchemas())), DeniedTables.normalize(toList(entity.getDeniedTables())), + DeniedShapes.fromNames(toList(entity.getDeniedShapes())), entity.getRowLimitOverride(), entity.getExpiresAt()); } @@ -316,6 +329,7 @@ private static DatasourcePermissionContribution toContribution( toList(e.getAllowedSchemas()), toList(e.getAllowedTables()), toList(e.getRestrictedColumns()), toList(e.getDeniedColumns()), toList(e.getDeniedSchemas()), toList(e.getDeniedTables()), + DeniedShapes.fromNames(toList(e.getDeniedShapes())), e.getRowLimitOverride(), e.getExpiresAt(), e.getAccessGrantRequestId()); } @@ -327,6 +341,7 @@ private static DatasourcePermissionContribution toContribution( e.isCanBreakGlass(), toList(e.getAllowedSchemas()), toList(e.getAllowedTables()), toList(e.getRestrictedColumns()), toList(e.getDeniedColumns()), toList(e.getDeniedSchemas()), toList(e.getDeniedTables()), + DeniedShapes.fromNames(toList(e.getDeniedShapes())), e.getRowLimitOverride(), e.getExpiresAt(), null); } 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 b2f52944c..4c80ac2a0 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 @@ -77,6 +77,10 @@ public class DatasourceGroupPermissionEntity { @Column(name = "denied_tables", columnDefinition = "text[]") private String[] deniedTables; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "denied_shapes", columnDefinition = "text[]") + private String[] deniedShapes; + @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 67e51db4f..d2393fd2c 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 @@ -73,6 +73,10 @@ public class DatasourceUserPermissionEntity { @Column(name = "denied_tables", columnDefinition = "text[]") private String[] deniedTables; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "denied_shapes", columnDefinition = "text[]") + private String[] deniedShapes; + @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 e4c8aed8b..419530efc 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.DeniedShapes; import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.SqlParseResult; import com.bablsoft.accessflow.core.api.QueryDryRunResult; @@ -110,6 +111,13 @@ private void verifyPermission(UUID userId, UUID datasourceId, SqlParseResult par throw new AccessDeniedException(msg("error.permission.column_not_allowed", new Object[]{String.join(", ", deniedColumns)})); } + var deniedShapes = DeniedShapes.rejected(permission.deniedShapes(), parsed); + if (!deniedShapes.isEmpty()) { + log.warn("Dry-run query shape rejection on datasource {} for user {}: shapes {}", + datasourceId, permission.userId(), deniedShapes); + throw new AccessDeniedException(msg("error.permission.shape_denied", + new Object[]{String.join(", ", deniedShapes.stream().map(Enum::name).toList())})); + } } private void verifyAllowedTables(DatasourceUserPermissionView permission, UUID datasourceId, diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java new file mode 100644 index 000000000..199c97dd7 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java @@ -0,0 +1,244 @@ +package com.bablsoft.accessflow.proxy.internal; + +import com.bablsoft.accessflow.core.api.QueryShape; +import net.sf.jsqlparser.expression.AnalyticExpression; +import net.sf.jsqlparser.expression.AnalyticType; +import net.sf.jsqlparser.expression.AnyComparisonExpression; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.JsonAggregateFunction; +import net.sf.jsqlparser.expression.operators.relational.ExistsExpression; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.merge.Merge; +import net.sf.jsqlparser.statement.select.LateralSubSelect; +import net.sf.jsqlparser.statement.select.ParenthesedFromItem; +import net.sf.jsqlparser.statement.select.ParenthesedSelect; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SetOperationList; +import net.sf.jsqlparser.statement.select.WithItem; +import net.sf.jsqlparser.statement.update.Update; +import net.sf.jsqlparser.util.TablesNamesFinder; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.IdentityHashMap; +import java.util.Locale; +import java.util.Set; + +/** + * Detects the {@link QueryShape}s of one statement (#940) by walking its whole AST — select list, + * FROM / JOIN, WHERE, GROUP BY, HAVING, ORDER BY, CTE bodies, INSERT…SELECT, UPDATE…FROM, + * DELETE…USING and every nested subquery — on top of {@link TablesNamesFinder}'s traversal. + * + *

A parenthesised select is a subquery unless it is the statement's own query: the root, a + * set-operation branch of it, a CTE body, or the query an INSERT / CREATE TABLE / CREATE VIEW takes + * its rows from. JSqlParser raises on a few exotic shapes; that propagates, and the caller reports + * the statement as not analyzed so a deny-list fails closed. + */ +final class QueryShapeDetector extends TablesNamesFinder { + + /** + * The built-in aggregates of the in-process engines, matched by unqualified, case-insensitive + * name, plus every {@code regr_*} function. {@code JSON_ARRAYAGG} / {@code JSON_OBJECTAGG} are + * their own AST node and handled separately. + */ + static final Set AGGREGATE_FUNCTIONS = Set.of("count", "count_big", "sum", "avg", "min", + "max", "string_agg", "array_agg", "group_concat", "listagg", "json_agg", "jsonb_agg", + "json_object_agg", "jsonb_object_agg", "json_arrayagg", "json_objectagg", "xmlagg", + "stddev", "stddev_pop", "stddev_samp", "std", "stdev", "stdevp", "variance", "var_pop", + "var_samp", "var", "varp", "corr", "covar_pop", "covar_samp", "bool_and", "bool_or", + "every", "bit_and", "bit_or", "bit_xor", "checksum_agg", "approx_count_distinct", + "any_value", "collect", "median", "mode", "percentile_cont", "percentile_disc"); + + private final Set shapes = EnumSet.noneOf(QueryShape.class); + private final Set covered = Collections.newSetFromMap(new IdentityHashMap<>()); + + private QueryShapeDetector() { + } + + /** + * @throws RuntimeException when JSqlParser cannot traverse the statement shape + */ + static Set detect(Statement statement) { + var detector = new QueryShapeDetector(); + switch (statement) { + case Select select -> detector.ownQuery(select); + case Insert insert -> detector.ownQuery(insert.getSelect()); + case CreateTable create -> detector.ownQuery(create.getSelect()); + case CreateView view -> detector.ownQuery(view.getSelect()); + default -> { /* no top-level query */ } + } + detector.getTables(statement); + return Set.copyOf(detector.shapes); + } + + private void ownQuery(Select select) { + if (select == null) { + return; + } + ownQueries.add(select); + covered.add(select); + switch (select) { + case ParenthesedSelect parenthesed -> ownQuery(parenthesed.getSelect()); + case SetOperationList list -> list.getSelects().forEach(this::ownQuery); + default -> { /* a plain body */ } + } + } + + @Override + public Void visit(WithItem withItem, S context) { + shapes.add(QueryShape.CTE); + ownQuery(withItem.getSelect()); + return super.visit(withItem, context); + } + + @Override + public Void visit(PlainSelect plainSelect, S context) { + flagIfUncovered(plainSelect); + if (notEmpty(plainSelect.getJoins())) { + shapes.add(QueryShape.JOIN); + } + if (plainSelect.getGroupBy() != null) { + shapes.add(QueryShape.GROUP_BY); + } + if (plainSelect.getHaving() != null) { + shapes.add(QueryShape.HAVING); + } + if (notEmpty(plainSelect.getWindowDefinitions())) { + shapes.add(QueryShape.WINDOW_FUNCTION); + } + return super.visit(plainSelect, context); + } + + @Override + public Void visit(SetOperationList list, S context) { + flagIfUncovered(list); + covered.addAll(list.getSelects()); + shapes.add(QueryShape.UNION); + return super.visit(list, context); + } + + @Override + public Void visit(ParenthesedSelect select, S context) { + if (!ownQueries.contains(select)) { + shapes.add(QueryShape.SUBQUERY); + } + covered.add(select); + covered.add(select.getSelect()); + return super.visit(select, context); + } + + @Override + public Void visit(LateralSubSelect select, S context) { + shapes.add(QueryShape.SUBQUERY); + return super.visit(select, context); + } + + @Override + public Void visit(ExistsExpression exists, S context) { + shapes.add(QueryShape.SUBQUERY); + return super.visit(exists, context); + } + + @Override + public Void visit(AnyComparisonExpression any, S context) { + shapes.add(QueryShape.SUBQUERY); + return super.visit(any, context); + } + + /** {@code FROM (a JOIN b ON …)}: the joins live on the parenthesised item, not the select. */ + @Override + public Void visit(ParenthesedFromItem item, S context) { + if (notEmpty(item.getJoins())) { + shapes.add(QueryShape.JOIN); + } + return super.visit(item, context); + } + + /** A MERGE always joins its target to its {@code USING} source. */ + @Override + public Void visit(Merge merge, S context) { + shapes.add(QueryShape.JOIN); + return super.visit(merge, context); + } + + @Override + public Void visit(JsonAggregateFunction aggregate, S context) { + shapes.add(QueryShape.AGGREGATE); + if (aggregate.getAnalyticType() == AnalyticType.OVER + || aggregate.getAnalyticType() == AnalyticType.WITHIN_GROUP_OVER) { + shapes.add(QueryShape.WINDOW_FUNCTION); + } + return super.visit(aggregate, context); + } + + @Override + public Void visit(Update update, S context) { + if (notEmpty(update.getStartJoins()) || update.getFromItem() != null + || notEmpty(update.getJoins())) { + shapes.add(QueryShape.JOIN); + } + return super.visit(update, context); + } + + @Override + public Void visit(Delete delete, S context) { + if (notEmpty(delete.getUsingFromItemList()) || notEmpty(delete.getJoins()) + || (delete.getTables() != null && delete.getTables().size() > 1)) { + shapes.add(QueryShape.JOIN); + } + return super.visit(delete, context); + } + + @Override + public Void visit(Function function, S context) { + if (isAggregate(function.getName())) { + shapes.add(QueryShape.AGGREGATE); + } + return super.visit(function, context); + } + + @Override + public Void visit(AnalyticExpression analytic, S context) { + var type = analytic.getType(); + if (type == null || type == AnalyticType.OVER + || type == AnalyticType.WITHIN_GROUP_OVER) { + shapes.add(QueryShape.WINDOW_FUNCTION); + } + // An ordered-set (WITHIN GROUP) or FILTERed call is an aggregate whatever its name. + if (isAggregate(analytic.getName()) || (type != null + && type != AnalyticType.OVER)) { + shapes.add(QueryShape.AGGREGATE); + } + return super.visit(analytic, context); + } + + static boolean isAggregate(String name) { + if (name == null) { + return false; + } + var bare = SqlParserServiceImpl.normalizeIdentifier(name.substring(name.lastIndexOf('.') + 1)) + .strip().toLowerCase(Locale.ROOT); + return AGGREGATE_FUNCTIONS.contains(bare) || bare.startsWith("regr_"); + } + + private void flagIfUncovered(Select select) { + if (!covered.contains(select)) { + shapes.add(QueryShape.SUBQUERY); + covered.add(select); + } + } + + private static boolean notEmpty(Collection items) { + return items != null && !items.isEmpty(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImpl.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImpl.java index fa226883f..557168aae 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImpl.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImpl.java @@ -1,6 +1,7 @@ package com.bablsoft.accessflow.proxy.internal; import com.bablsoft.accessflow.core.api.ColumnReference; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.InvalidSqlException; import com.bablsoft.accessflow.core.api.SqlParseResult; @@ -19,20 +20,26 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.statement.update.Update; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Service; import java.util.ArrayList; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.Set; @Service @RequiredArgsConstructor class SqlParserServiceImpl implements SqlParserService { + private static final Logger log = LoggerFactory.getLogger(SqlParserServiceImpl.class); + private static final String DDL_PACKAGE_PREFIX = "net.sf.jsqlparser.statement."; private static final List DDL_SUBPACKAGES = @@ -69,8 +76,10 @@ private SqlParseResult parseSingle(String sql) { var statement = statements.get(0); var type = classify(statement); var analysis = analyze(statement, type); + var shapes = detectShapes(statement); return new SqlParseResult(type, false, List.of(sql), analysis.tables(), - hasWhere(statement), hasLimit(statement), analysis.columns(), analysis.columnsAnalyzed()); + hasWhere(statement), hasLimit(statement), analysis.columns(), analysis.columnsAnalyzed(), + shapes.orElse(Set.of()), shapes.isPresent()); } private SqlParseResult parseTransaction(String sql, TransactionMarkerScanner.Boundary boundary) { @@ -114,18 +123,24 @@ private SqlParseResult parseTransaction(String sql, TransactionMarkerScanner.Bou var statementSlices = sliceStatements(statements); var referencedTables = new HashSet(); var referencedColumns = new HashSet(); + var shapes = EnumSet.noneOf(QueryShape.class); boolean anyWhere = false; boolean anyLimit = false; + boolean shapesAnalyzed = true; for (Statement statement : statements) { var analysis = analyze(statement, classify(statement)); referencedTables.addAll(analysis.tables()); referencedColumns.addAll(analysis.columns()); anyWhere = anyWhere || hasWhere(statement); anyLimit = anyLimit || hasLimit(statement); + var statementShapes = detectShapes(statement); + statementShapes.ifPresent(shapes::addAll); + shapesAnalyzed = shapesAnalyzed && statementShapes.isPresent(); } // Every inner statement is INSERT / UPDATE / DELETE here, so each was fully analyzed. return new SqlParseResult(representativeType, true, statementSlices, referencedTables, - anyWhere, anyLimit, referencedColumns, true); + anyWhere, anyLimit, referencedColumns, true, shapesAnalyzed ? shapes : Set.of(), + shapesAnalyzed); } private List parseStatementsOrThrow(String sql) { @@ -188,6 +203,20 @@ private Analysis analyze(Statement statement, QueryType type) { return new Analysis(out, inspection.columns(), type != QueryType.OTHER); } + /** + * The statement's shapes (#940), or empty when the detector could not walk it — never fatal to + * parsing, but the result is then reported as not analyzed so a shape deny-list fails closed. + */ + private static Optional> detectShapes(Statement statement) { + try { + return Optional.of(QueryShapeDetector.detect(statement)); + } catch (RuntimeException ex) { + log.debug("Query shape detection failed for a {} statement: {}", + statement.getClass().getSimpleName(), ex.toString()); + return Optional.empty(); + } + } + private record Analysis(Set tables, Set columns, boolean columnsAnalyzed) { } 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 86165d05d..bfc0b7328 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 @@ -15,6 +15,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.DeniedShapes; import com.bablsoft.accessflow.core.api.DeniedTables; import com.bablsoft.accessflow.core.api.PageRequest; import com.bablsoft.accessflow.core.api.PageResponse; @@ -307,7 +308,7 @@ private SqlParseResult parseQuery(UUID datasourceId, String sql) { /** * A member may not reach a table outside its submitter's allow-list, a table or schema they - * are denied (#939), nor a column they are denied (#935) — break-glass included, as for a + * are denied (#939), a column they are denied (#935), nor a query shape they are denied (#940) — break-glass included, as for a * standalone break-glass query. The allow-list rule is * {@code DatasourcePermissionChecker.rejectedTables}: both lists empty means no restriction, * and a bare entry covers only an unqualified reference. @@ -320,7 +321,8 @@ private void verifyTableAndColumnScope(RequestGroupItemEntity item, var tablesDenied = !DeniedTables.normalize(permission.deniedSchemas()).isEmpty() || !DeniedTables.normalize(permission.deniedTables()).isEmpty(); var columnsDenied = !DeniedColumns.normalize(permission.deniedColumns()).isEmpty(); - if (!restrictsTables && !tablesDenied && !columnsDenied) { + var shapesDenied = !DeniedShapes.normalize(permission.deniedShapes()).isEmpty(); + if (!restrictsTables && !tablesDenied && !columnsDenied && !shapesDenied) { return; } var parsed = parseQuery(item.getDatasourceId(), item.getSqlText()); @@ -348,6 +350,11 @@ private void verifyTableAndColumnScope(RequestGroupItemEntity item, throw new RequestGroupPermissionException( "Denied columns referenced: " + String.join(", ", rejected)); } + var rejectedShapes = DeniedShapes.rejected(permission.deniedShapes(), parsed); + if (!rejectedShapes.isEmpty()) { + throw new RequestGroupPermissionException("Denied query shapes: " + + String.join(", ", rejectedShapes.stream().map(Enum::name).toList())); + } } private void validatePermission(RequestGroupItemEntity item, UUID submitterId, boolean admin, 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 f42fb462d..87dabc4c6 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 @@ -49,12 +49,18 @@ import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -66,6 +72,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.net.URI; +import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -86,6 +93,7 @@ class DatasourceController { private final DatasourceReviewerService datasourceReviewerService; private final SampleDataService sampleDataService; private final SecretResolutionService secretResolutionService; + private final MessageSource messageSource; @GetMapping("/types") @Operation(summary = "List supported database types with driver resolution status") @@ -326,8 +334,8 @@ PermissionListResponse listPermissions(@PathVariable UUID id, Authentication aut @ApiResponse(responseCode = "404", description = "Datasource not found") @ApiResponse(responseCode = "409", description = "Permission already exists for this user") @ApiResponse(responseCode = "422", - description = "Target user is not in the organization, or denied_columns is not " - + "supported by the datasource engine") + description = "Target user is not in the organization, or denied_columns / " + + "denied_shapes is not supported by the datasource engine") ResponseEntity grantPermission( @PathVariable UUID id, @Valid @RequestBody CreatePermissionRequest request, @@ -345,6 +353,7 @@ ResponseEntity grantPermission( request.allowedTables(), request.restrictedColumns(), request.deniedColumns(), request.deniedSchemas(), request.deniedTables(), + request.deniedShapes(), request.expiresAt(), // Admin-created: no originating JIT request (#969). null); @@ -366,6 +375,9 @@ ResponseEntity grantPermission( if (view.deniedTables() != null && !view.deniedTables().isEmpty()) { metadata.put("denied_tables", view.deniedTables()); } + if (view.deniedShapes() != null && !view.deniedShapes().isEmpty()) { + metadata.put("denied_shapes", view.deniedShapes().stream().map(Enum::name).toList()); + } recordAudit(AuditAction.PERMISSION_GRANTED, AuditResourceType.PERMISSION, view.id(), caller, auditContext, metadata); URI location = ServletUriComponentsBuilder.fromCurrentRequest() @@ -413,7 +425,8 @@ GroupPermissionListResponse listGroupPermissions(@PathVariable UUID id, @ApiResponse(responseCode = "404", description = "Datasource or group not found") @ApiResponse(responseCode = "409", description = "Permission already exists for this group") @ApiResponse(responseCode = "422", - description = "denied_columns is not supported by the datasource engine") + description = "denied_columns or denied_shapes is not supported by the datasource " + + "engine") ResponseEntity grantGroupPermission( @PathVariable UUID id, @Valid @RequestBody CreateGroupPermissionRequest request, @@ -431,6 +444,7 @@ ResponseEntity grantGroupPermission( request.allowedTables(), request.restrictedColumns(), request.deniedColumns(), request.deniedSchemas(), request.deniedTables(), + request.deniedShapes(), request.expiresAt()); var view = datasourceAdminService.grantGroupPermission(id, caller.organizationId(), caller.userId(), command); @@ -450,6 +464,9 @@ ResponseEntity grantGroupPermission( if (view.deniedTables() != null && !view.deniedTables().isEmpty()) { metadata.put("denied_tables", view.deniedTables()); } + if (view.deniedShapes() != null && !view.deniedShapes().isEmpty()) { + metadata.put("denied_shapes", view.deniedShapes().stream().map(Enum::name).toList()); + } recordAudit(AuditAction.PERMISSION_GROUP_GRANTED, AuditResourceType.PERMISSION, view.id(), caller, auditContext, metadata); URI location = ServletUriComponentsBuilder.fromCurrentRequest() @@ -579,4 +596,19 @@ private void recordAudit(AuditAction action, AuditResourceType resourceType, UUI resourceId, ex); } } + + /** + * A body that will not deserialize — most often an unknown {@code db_type} or + * {@code denied_shapes} literal — is a client error. Nothing maps the parse failure globally, so + * without this the security module's {@code Exception} catch-all turns it into a 500. + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + ProblemDetail handleUnreadableBody(HttpMessageNotReadableException ex) { + var detail = messageSource.getMessage("error.datasource_body_unreadable", null, + LocaleContextHolder.getLocale()); + var problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail); + problem.setProperty("error", "VALIDATION_ERROR"); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java index c6b99bdf9..7dfbd79ba 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandler.java @@ -19,6 +19,7 @@ import com.bablsoft.accessflow.core.api.SecretResolutionException; import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; import com.bablsoft.accessflow.core.api.DeniedColumnsNotSupportedException; +import com.bablsoft.accessflow.core.api.DeniedShapesNotSupportedException; import com.bablsoft.accessflow.core.api.IllegalDatasourcePermissionException; import com.bablsoft.accessflow.core.api.DataClassificationTagNotFoundException; import com.bablsoft.accessflow.core.api.IllegalDataClassificationTagException; @@ -422,6 +423,16 @@ ProblemDetail handleDeniedColumnsNotSupported(DeniedColumnsNotSupportedException return pd; } + @ExceptionHandler(DeniedShapesNotSupportedException.class) + ProblemDetail handleDeniedShapesNotSupported(DeniedShapesNotSupportedException ex) { + var pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, + msg("error.denied_shapes_not_supported", ex.dbType().name())); + pd.setProperty("error", "DENIED_SHAPES_NOT_SUPPORTED"); + pd.setProperty("dbType", ex.dbType().name()); + pd.setProperty("timestamp", Instant.now().toString()); + return pd; + } + @ExceptionHandler(MaskingPolicyNotFoundException.class) ProblemDetail handleMaskingPolicyNotFound(MaskingPolicyNotFoundException ex) { var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, 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 8f6c47ab9..1db6b7b7c 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,6 +1,7 @@ package com.bablsoft.accessflow.security.internal.web.model; import com.bablsoft.accessflow.core.api.DeniedTables; +import com.bablsoft.accessflow.core.api.QueryShape; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -33,6 +34,8 @@ public record CreateGroupPermissionRequest( List<@NotBlank(message = "{validation.denied_tables.item_blank}") @Pattern(regexp = DeniedTables.TABLE_ENTRY_PATTERN, message = "{validation.denied_tables.item_invalid}") String> deniedTables, + @Size(max = 8, message = "{validation.denied_shapes.too_many}") + List<@NotNull(message = "{validation.denied_shapes.item_blank}") QueryShape> deniedShapes, 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 40c98e50f..a5fa7e22f 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,6 +1,7 @@ package com.bablsoft.accessflow.security.internal.web.model; import com.bablsoft.accessflow.core.api.DeniedTables; +import com.bablsoft.accessflow.core.api.QueryShape; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -33,6 +34,8 @@ public record CreatePermissionRequest( List<@NotBlank(message = "{validation.denied_tables.item_blank}") @Pattern(regexp = DeniedTables.TABLE_ENTRY_PATTERN, message = "{validation.denied_tables.item_invalid}") String> deniedTables, + @Size(max = 8, message = "{validation.denied_shapes.too_many}") + List<@NotNull(message = "{validation.denied_shapes.item_blank}") QueryShape> deniedShapes, 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 f1425fcb9..75f6afd43 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 @@ -1,6 +1,7 @@ package com.bablsoft.accessflow.security.internal.web.model; import com.bablsoft.accessflow.core.api.DatasourceGroupPermissionView; +import com.bablsoft.accessflow.core.api.QueryShape; import java.time.Instant; import java.util.List; @@ -23,6 +24,7 @@ public record GroupPermissionResponse( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, Instant expiresAt, UUID createdBy, Instant createdAt @@ -45,6 +47,7 @@ public static GroupPermissionResponse from(DatasourceGroupPermissionView view) { view.deniedColumns(), view.deniedSchemas(), view.deniedTables(), + view.deniedShapes(), 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 43fb229cd..4b4e2a939 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 @@ -1,6 +1,7 @@ package com.bablsoft.accessflow.security.internal.web.model; import com.bablsoft.accessflow.core.api.DatasourcePermissionView; +import com.bablsoft.accessflow.core.api.QueryShape; import java.time.Instant; import java.util.List; @@ -23,6 +24,7 @@ public record PermissionResponse( List deniedColumns, List deniedSchemas, List deniedTables, + List deniedShapes, Instant expiresAt, UUID createdBy, Instant createdAt @@ -45,6 +47,7 @@ public static PermissionResponse from(DatasourcePermissionView view) { view.deniedColumns(), view.deniedSchemas(), view.deniedTables(), + view.deniedShapes(), view.expiresAt(), view.createdBy(), view.createdAt()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java index 88ae59192..1dea44e52 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionContext.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.api; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; @@ -34,6 +35,10 @@ * the exact affected-row count for UPDATE/DELETE when available, otherwise the EXPLAIN estimate; * {@code scanType} is the plan's root operation (e.g. {@code Seq Scan}). Both are {@code null} * when the estimate is absent, unsupported, or failed — the matching conditions fail closed. + * + *

{@code queryShapes} are the structural features re-derived from the SQL with the WHERE / LIMIT + * signals (#940). {@code shapesAnalyzed} is {@code false} when the SQL could not be parsed or walked + * (typically a non-SQL engine); the {@code query_shape} condition then fails closed. */ public record ConditionContext( QueryType queryType, @@ -52,11 +57,28 @@ public record ConditionContext( Integer minutesSinceLastApproval, boolean anomalyActive, Long estimatedRows, - String scanType) { + String scanType, + Set queryShapes, + boolean shapesAnalyzed) { public ConditionContext { referencedTables = Set.copyOf(referencedTables == null ? Set.of() : referencedTables); requesterGroupIds = Set.copyOf(requesterGroupIds == null ? Set.of() : requesterGroupIds); + queryShapes = Set.copyOf(queryShapes == null ? Set.of() : queryShapes); + } + + /** Backward-compatible constructor without the #940 shape signals (defaults to not analyzed). */ + public ConditionContext(QueryType queryType, Set referencedTables, RiskLevel riskLevel, + int riskScore, String requesterRoleName, Set requesterGroupIds, + LocalDateTime evaluatedAt, boolean hasWhereClause, + boolean hasLimitClause, boolean transactional, + String requesterIpAddress, String requesterUserAgent, + boolean ciCdOrigin, Integer minutesSinceLastApproval, + boolean anomalyActive, Long estimatedRows, String scanType) { + this(queryType, referencedTables, riskLevel, riskScore, requesterRoleName, + requesterGroupIds, evaluatedAt, hasWhereClause, hasLimitClause, transactional, + requesterIpAddress, requesterUserAgent, ciCdOrigin, minutesSinceLastApproval, + anomalyActive, estimatedRows, scanType, Set.of(), false); } /** Backward-compatible constructor without the AF-624 estimate signals (defaults to absent). */ @@ -70,7 +92,7 @@ public ConditionContext(QueryType queryType, Set referencedTables, RiskL this(queryType, referencedTables, riskLevel, riskScore, requesterRoleName, requesterGroupIds, evaluatedAt, hasWhereClause, hasLimitClause, transactional, requesterIpAddress, requesterUserAgent, ciCdOrigin, minutesSinceLastApproval, - anomalyActive, null, null); + anomalyActive, null, null, Set.of(), false); } /** @return {@code true} when an AI risk level / score signal is present. */ diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java index a95e783e6..a273ec809 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/api/ConditionNode.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.api; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; @@ -17,7 +18,7 @@ * *

Combinators ({@link And}, {@link Or}, {@link Not}) compose leaf comparators, one per signal: * query type, referenced tables (glob), AI risk level / score, requester role / group membership, - * time-of-day / day-of-week, presence of WHERE / LIMIT, the transactional flag, and the client + * time-of-day / day-of-week, presence of WHERE / LIMIT, query shape, the transactional flag, and the client * context captured at submission — source IP (CIDR), user-agent (glob), time-since-last-approval, * and CI/CD origin. The client-context leaves fail closed: when the required * signal is absent (no IP / user-agent / prior approval) the leaf evaluates to {@code false}, so a @@ -130,6 +131,17 @@ record HasWhereClause(boolean expected) implements ConditionNode { record HasLimitClause(boolean expected) implements ConditionNode { } + /** + * Matches when the query has any of the {@code anyOf} structural shapes (#940) — a join, set + * operation, subquery, CTE, GROUP BY, HAVING, aggregate or window function anywhere in the + * statement. Fails closed: {@code false} when the shape could not be analyzed. + */ + record QueryShapeIn(Set anyOf) implements ConditionNode { + public QueryShapeIn { + anyOf = Set.copyOf(anyOf == null ? Set.of() : anyOf); + } + } + /** Matches when the transactional ({@code BEGIN…COMMIT}) flag equals {@code expected}. */ record Transactional(boolean expected) implements ConditionNode { } 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 750421622..3df903ec9 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,7 +3,9 @@ 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.DeniedShapes; import com.bablsoft.accessflow.core.api.DeniedTables; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.SqlParseResult; @@ -98,6 +100,20 @@ static Set rejectedColumns(DatasourceUserPermissionView permission, return DeniedColumns.rejected(permission.deniedColumns(), parsed); } + /** + * @return the permission's {@code denied_shapes} the parsed query has (#940), in declaration + * order; empty when it has none. Fails closed over a parse whose shape was not analyzed. + */ + static Set rejectedShapes(DatasourceUserPermissionView permission, + SqlParseResult parsed) { + return DeniedShapes.rejected(permission.deniedShapes(), parsed); + } + + /** The shape names for a message or trace detail, in declaration order. */ + static List shapeNames(Set shapes) { + return shapes.stream().sorted().map(QueryShape::name).toList(); + } + /** * Which allow-list entry covers {@code table} — the qualified table itself, or the schema whose * prefix it carries — or {@code null} when none does. 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 1c57ee4da..3dc2f4df9 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,8 @@ 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 is denied, or any referenced column is denied. + * table falls outside the allow-list or is denied, any referenced column is denied, or + * the query has a denied shape. */ void verify(UUID userId, UUID datasourceId, QueryType queryType, SqlParseResult parsed) { var permission = permissionLookupService.findFor(userId, datasourceId) @@ -55,6 +56,18 @@ void verify(UUID userId, UUID datasourceId, QueryType queryType, SqlParseResult verifyAllowedTables(permission, datasourceId, parsed.referencedTables()); verifyDeniedTables(permission, datasourceId, parsed.referencedTables()); verifyDeniedColumns(permission, datasourceId, parsed); + verifyDeniedShapes(permission, datasourceId, parsed); + } + + private void verifyDeniedShapes(DatasourceUserPermissionView permission, UUID datasourceId, + SqlParseResult parsed) { + var rejected = DatasourcePermissionChecker.rejectedShapes(permission, parsed); + if (!rejected.isEmpty()) { + log.warn("Query shape rejection on datasource {} for user {}: shapes {} denied", + datasourceId, permission.userId(), rejected); + throw new AccessDeniedException(msg("error.permission.shape_denied", + new Object[]{String.join(", ", DatasourcePermissionChecker.shapeNames(rejected))})); + } } private void verifyDeniedTables(DatasourceUserPermissionView permission, UUID datasourceId, 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 84fce217b..f8dec205d 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 @@ -10,6 +10,7 @@ import com.bablsoft.accessflow.core.api.MaskingPolicyResolutionService; import com.bablsoft.accessflow.core.api.Permission; import com.bablsoft.accessflow.core.api.QueryRequestSnapshot; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.QuotaExceededException; @@ -43,6 +44,7 @@ import java.time.Clock; import java.util.ArrayList; +import java.util.Collection; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; @@ -215,6 +217,8 @@ private SqlParseResult parse(DatasourceView datasource, String sql, details.put("transactional", parsed.transactional()); details.put("has_where_clause", parsed.hasWhereClause()); details.put("has_limit_clause", parsed.hasLimitClause()); + details.put("query_shapes", sortedShapeNames(parsed.shapes())); + details.put("shapes_analyzed", parsed.shapesAnalyzed()); if (parsed.type() == QueryType.OTHER) { steps.add(DecisionTraceStep.of(QueryDecisionStepKind.SQL_PARSE, StepOutcome.DENY, "workflow.access_simulation.parse.unsupported_type", details)); @@ -225,6 +229,10 @@ private SqlParseResult parse(DatasourceView datasource, String sql, return parsed; } + private static List sortedShapeNames(Collection shapes) { + return shapes.stream().sorted().map(QueryShape::name).toList(); + } + // ── 4. Effective permission ─────────────────────────────────────────────── private boolean permissionStep(UUID organizationId, AccessSimulationInput input, @@ -247,6 +255,7 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, details.put("rejected_tables", List.of()); details.put("denied_tables", List.of()); details.put("rejected_columns", List.of()); + details.put("denied_shapes", List.of()); details.put("expires_at", null); steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.ALLOW, "workflow.access_simulation.permission.query_admin_bypass", details)); @@ -259,6 +268,7 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, details.put("rejected_tables", List.of()); details.put("denied_tables", List.of()); details.put("rejected_columns", List.of()); + details.put("denied_shapes", List.of()); details.put("expires_at", null); steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.DENY, "workflow.access_simulation.permission.none", details)); @@ -274,6 +284,8 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, details.put("denied_tables", List.copyOf(deniedTables)); var rejectedColumns = DatasourcePermissionChecker.rejectedColumns(permission, parsed); details.put("rejected_columns", List.copyOf(rejectedColumns)); + var deniedShapes = DatasourcePermissionChecker.rejectedShapes(permission, parsed); + details.put("denied_shapes", DatasourcePermissionChecker.shapeNames(deniedShapes)); if (!capable) { steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.DENY, "workflow.access_simulation.permission.capability_missing", details)); @@ -294,6 +306,11 @@ private boolean permissionStep(UUID organizationId, AccessSimulationInput input, "workflow.access_simulation.permission.column_denied", details)); return false; } + if (!deniedShapes.isEmpty()) { + steps.add(DecisionTraceStep.of(QueryDecisionStepKind.EFFECTIVE_PERMISSION, StepOutcome.DENY, + "workflow.access_simulation.permission.shape_denied", details)); + return false; + } // An allow-list check over an empty table set passes vacuously — the enforcement gate has the // same shape. Flagging it stops the trace from reading as a positive verdict it is not. var reasonKey = parsed.referencedTables().isEmpty() 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 47f2acccb..94fba4fb8 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 @@ -185,6 +185,9 @@ private void verifyBreakGlassPermission(UUID userId, UUID datasourceId, QueryTyp if (!DatasourcePermissionChecker.rejectedColumns(permission, parsed).isEmpty()) { throw denied(datasourceId, userId, "denied columns referenced"); } + if (!DatasourcePermissionChecker.rejectedShapes(permission, parsed).isEmpty()) { + throw denied(datasourceId, userId, "denied query shape"); + } } private static BreakGlassNotPermittedException denied(UUID datasourceId, UUID userId, diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java index af72e9a9a..7b5d16544 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactory.java @@ -4,6 +4,7 @@ import com.bablsoft.accessflow.core.api.QueryCorpusRow; import com.bablsoft.accessflow.core.api.QueryEstimateLookupService; import com.bablsoft.accessflow.core.api.QueryRequestLookupService; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryRequestSnapshot; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.core.api.UserGroupService; @@ -63,7 +64,7 @@ public ConditionContext forLiveQuery(QueryRequestSnapshot query, RiskLevel riskL query.datasourceId(), query.id(), clock.instant()), behaviorAnomalyLookupService.hasActiveAnomaly(query.organizationId(), query.submittedByUserId(), query.datasourceId()), - estimate.rows(), estimate.scanType()); + estimate.rows(), estimate.scanType(), parsed.shapes(), parsed.shapesAnalyzed()); } /** @@ -86,7 +87,7 @@ public ConditionContext forHistoricalRow(QueryCorpusRow row, ZoneId zone) { parsed.transactional(), row.submittedIp(), row.submittedUserAgent(), row.ciCdOrigin(), minutesSinceLastApproval(row.organizationId(), row.submittedByUserId(), row.datasourceId(), row.id(), row.createdAt()), - false, estimate.rows(), estimate.scanType()); + false, estimate.rows(), estimate.scanType(), parsed.shapes(), parsed.shapesAnalyzed()); } /** @@ -133,16 +134,18 @@ private ParsedSignals parse(UUID queryId, String sqlText, boolean transactional) try { var parsed = sqlParserService.parse(sqlText); return new ParsedSignals(parsed.referencedTables(), parsed.hasWhereClause(), - parsed.hasLimitClause(), parsed.transactional()); + parsed.hasLimitClause(), parsed.transactional(), parsed.shapes(), + parsed.shapesAnalyzed()); } catch (RuntimeException ex) { log.warn("Routing: failed to re-parse SQL for query {}; table/clause signals unavailable", queryId); - return new ParsedSignals(Set.of(), false, false, transactional); + return new ParsedSignals(Set.of(), false, false, transactional, Set.of(), false); } } - /** The four signals re-derived from the SQL text; unavailable ones fail closed. */ + /** The signals re-derived from the SQL text; unavailable ones fail closed. */ private record ParsedSignals(Set tables, boolean hasWhere, boolean hasLimit, - boolean transactional) { + boolean transactional, Set shapes, + boolean shapesAnalyzed) { } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java index 577888b8d..1d5db9596 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionNodeMixin.java @@ -26,6 +26,7 @@ @JsonSubTypes.Type(value = ConditionNode.DayOfWeekIn.class, name = "day_of_week"), @JsonSubTypes.Type(value = ConditionNode.HasWhereClause.class, name = "has_where"), @JsonSubTypes.Type(value = ConditionNode.HasLimitClause.class, name = "has_limit"), + @JsonSubTypes.Type(value = ConditionNode.QueryShapeIn.class, name = "query_shape"), @JsonSubTypes.Type(value = ConditionNode.Transactional.class, name = "transactional"), @JsonSubTypes.Type(value = ConditionNode.SourceIpMatches.class, name = "source_ip"), @JsonSubTypes.Type(value = ConditionNode.UserAgentMatches.class, name = "user_agent"), diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java index 18bc0880b..339402976 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluator.java @@ -39,6 +39,8 @@ public boolean matches(ConditionNode node, ConditionContext ctx) { c.anyOf().contains(ctx.evaluatedAt().getDayOfWeek()); case ConditionNode.HasWhereClause c -> ctx.hasWhereClause() == c.expected(); case ConditionNode.HasLimitClause c -> ctx.hasLimitClause() == c.expected(); + case ConditionNode.QueryShapeIn c -> ctx.shapesAnalyzed() + && !Collections.disjoint(c.anyOf(), ctx.queryShapes()); case ConditionNode.Transactional c -> ctx.transactional() == c.expected(); case ConditionNode.SourceIpMatches c -> ctx.requesterIpAddress() != null diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidator.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidator.java index 9c7777637..10f938e56 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidator.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidator.java @@ -31,6 +31,10 @@ public void validate(ConditionNode node) { case ConditionNode.Or or -> or.children().forEach(this::validate); case ConditionNode.Not not -> validate(not.child()); case ConditionNode.SourceIpMatches sourceIp -> validateCidrs(sourceIp); + case ConditionNode.QueryShapeIn shape when shape.anyOf().isEmpty() -> + throw new IllegalRoutingPolicyException(messageSource.getMessage( + "error.routing_policy_query_shape_empty", null, + LocaleContextHolder.getLocale())); default -> { /* no additional validation for other leaves */ } } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponse.java b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponse.java index b2d860ac5..c41468144 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponse.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.internal.web; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.core.api.SimulationCaveat; @@ -66,7 +67,8 @@ record EvaluatedContext(com.bablsoft.accessflow.core.api.QueryType queryType, boolean hasLimitClause, boolean transactional, String requesterIpAddress, String requesterUserAgent, boolean ciCdOrigin, Integer minutesSinceLastApproval, - boolean anomalyActive, Long estimatedRows, String scanType) { + boolean anomalyActive, Long estimatedRows, String scanType, + List queryShapes, boolean shapesAnalyzed) { static EvaluatedContext from(ConditionContext context) { if (context == null) { @@ -79,7 +81,8 @@ static EvaluatedContext from(ConditionContext context) { context.hasWhereClause(), context.hasLimitClause(), context.transactional(), context.requesterIpAddress(), context.requesterUserAgent(), context.ciCdOrigin(), context.minutesSinceLastApproval(), - context.anomalyActive(), context.estimatedRows(), context.scanType()); + context.anomalyActive(), context.estimatedRows(), context.scanType(), + context.queryShapes().stream().sorted().toList(), context.shapesAnalyzed()); } } } diff --git a/backend/src/main/resources/db/migration/V191__add_denied_shapes_to_permissions.sql b/backend/src/main/resources/db/migration/V191__add_denied_shapes_to_permissions.sql new file mode 100644 index 000000000..7a491b64e --- /dev/null +++ b/backend/src/main/resources/db/migration/V191__add_denied_shapes_to_permissions.sql @@ -0,0 +1,5 @@ +-- Query-shape deny-lists (#940): a grant may refuse queries by structure — JOIN, UNION, SUBQUERY, +-- CTE, GROUP_BY, HAVING, AGGREGATE, WINDOW_FUNCTION (the QueryShape enum names). Denials merge as a +-- union across a user's grants. Nullable: NULL/empty denies nothing. +ALTER TABLE datasource_user_permissions ADD COLUMN denied_shapes TEXT[]; +ALTER TABLE datasource_group_permissions ADD COLUMN denied_shapes TEXT[]; diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index d19ff6a16..799f0c02d 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -54,6 +54,8 @@ 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.denied_shapes.item_blank=Denied query shape entries must not be blank +validation.denied_shapes.too_many=At most 8 denied query shapes 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 @@ -111,6 +113,7 @@ 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.permission.shape_denied=Query has one or more shapes 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 @@ -139,6 +142,7 @@ error.datasource_group_permission_already_exists=Permission already exists for t error.datasource_connection_test_failed=Connection test failed error.illegal_datasource_permission=This datasource permission operation is not allowed error.denied_columns_not_supported=Denied columns are not supported for {0} datasources; column-level blocking is available for relational engines only +error.denied_shapes_not_supported=Denied query shapes are not supported for {0} datasources; query-shape blocking is available for relational engines only error.review_plan_not_found=Review plan not found error.review_plan_in_use=This review plan is attached to one or more datasources and cannot be deleted error.review_plan_name_already_exists=A review plan with that name already exists. Pick a different name. @@ -522,6 +526,7 @@ error.routing_policy_approvals_required=The number of required approvals must be error.routing_policy_condition_required=A routing condition is required error.routing_policy_condition_invalid=The routing condition is malformed error.routing_policy_cidr_invalid=Not a valid CIDR block: {0} +error.routing_policy_query_shape_empty=A query shape condition must name at least one shape error.routing_policy_priority_conflict=Another routing policy already uses this priority error.routing_policy_reorder_mismatch=The reorder request must list every routing policy in the organization exactly once validation.routing_policy_name.required=Routing policy name is required @@ -1295,6 +1300,7 @@ workflow.access_simulation.permission.capability_missing=The effective permissio 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.shape_denied=The effective permission denies a shape the query has 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 @@ -1312,6 +1318,7 @@ workflow.access_simulation.break_glass.eligible=The user could bypass every stag workflow.access_simulation.break_glass.not_eligible=The user holds no break-glass grant on this datasource error.effective_access_table_invalid=Table must contain at least one letter, digit or underscore error.access_simulation_body_unreadable=Request body could not be read; check the enum values +error.datasource_body_unreadable=Request body could not be read; check the enum values (for example db_type or denied_shapes) # ── API-call decision trace (AF-967) ── apigov.decision.routing.matched=Routing policy "{0}" matched with action {1} diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties index 0ac80ce6c..b928a04d1 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -49,6 +49,8 @@ validation.denied_schemas.too_many=Es dürfen höchstens 50 gesperrte Schemas an 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.denied_shapes.item_blank=Gesperrte Abfrageformen dürfen nicht leer sein +validation.denied_shapes.too_many=Es dürfen höchstens 8 gesperrte Abfrageformen 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 @@ -101,6 +103,7 @@ 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.permission.shape_denied=Die Abfrage hat eine oder mehrere Formen, 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 @@ -122,6 +125,7 @@ error.datasource_group_permission_already_exists=Für diese Gruppe existiert ber error.datasource_connection_test_failed=Der Verbindungstest ist fehlgeschlagen error.illegal_datasource_permission=Diese Datenquellen-Berechtigungs-Operation ist nicht zulässig error.denied_columns_not_supported=Gesperrte Spalten werden für {0}-Datenquellen nicht unterstützt; die Sperre auf Spaltenebene gibt es nur für relationale Engines +error.denied_shapes_not_supported=Gesperrte Abfrageformen werden für {0}-Datenquellen nicht unterstützt; die Sperre nach Abfrageform gibt es nur für relationale Engines error.review_plan_not_found=Prüfplan nicht gefunden error.review_plan_in_use=Dieser Prüfplan ist mit einer oder mehreren Datenquellen verknüpft und kann nicht gelöscht werden error.illegal_review_plan=Diese Prüfplan-Konfiguration ist nicht zulässig @@ -522,6 +526,7 @@ error.routing_policy_approvals_required=Die Anzahl der erforderlichen Genehmigun error.routing_policy_condition_required=Eine Routing-Bedingung ist erforderlich error.routing_policy_condition_invalid=Die Routing-Bedingung ist fehlerhaft error.routing_policy_cidr_invalid=Kein gültiger CIDR-Block: {0} +error.routing_policy_query_shape_empty=Eine Abfrageform-Bedingung muss mindestens eine Form angeben error.routing_policy_priority_conflict=Eine andere Routing-Richtlinie verwendet diese Priorität bereits error.routing_policy_reorder_mismatch=Die Neuanordnung muss jede Routing-Richtlinie der Organisation genau einmal enthalten validation.routing_policy_name.required=Name der Routing-Richtlinie ist erforderlich @@ -1274,6 +1279,7 @@ workflow.access_simulation.permission.capability_missing=Die effektive Berechtig 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.shape_denied=Die effektive Berechtigung sperrt eine Form, die die Abfrage hat 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 @@ -1291,6 +1297,7 @@ workflow.access_simulation.break_glass.eligible=Der Benutzer könnte alle obigen workflow.access_simulation.break_glass.not_eligible=Der Benutzer hat keine Break-Glass-Berechtigung für diese Datenquelle error.effective_access_table_invalid=Die Tabelle muss mindestens einen Buchstaben, eine Ziffer oder einen Unterstrich enthalten error.access_simulation_body_unreadable=Der Anfragetext konnte nicht gelesen werden; prüfen Sie die Enum-Werte +error.datasource_body_unreadable=Der Anfragetext konnte nicht gelesen werden; prüfen Sie die Enum-Werte (zum Beispiel db_type oder denied_shapes) # ── API-Aufruf-Entscheidungsverlauf (AF-967) ── apigov.decision.routing.matched=Routing-Richtlinie "{0}" traf zu, Aktion {1} diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties index f6a688353..58b1e2ff0 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -49,6 +49,8 @@ validation.denied_schemas.too_many=Se pueden indicar como máximo 50 esquemas de 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.denied_shapes.item_blank=Las formas de consulta denegadas no pueden estar vacías +validation.denied_shapes.too_many=Se pueden indicar como máximo 8 formas de consulta 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 @@ -101,6 +103,7 @@ 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.permission.shape_denied=La consulta tiene una o más formas 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 @@ -122,6 +125,7 @@ error.datasource_group_permission_already_exists=Ya existe un permiso para este error.datasource_connection_test_failed=La prueba de conexión ha fallado error.illegal_datasource_permission=Esta operación de permiso de fuente de datos no está permitida error.denied_columns_not_supported=Las columnas denegadas no se admiten en fuentes de datos {0}; el bloqueo por columna solo está disponible para motores relacionales +error.denied_shapes_not_supported=Las formas de consulta denegadas no se admiten en fuentes de datos {0}; el bloqueo por forma de consulta solo está disponible para motores relacionales error.review_plan_not_found=Plan de revisión no encontrado error.review_plan_in_use=Este plan de revisión está asociado a una o varias fuentes de datos y no puede eliminarse error.illegal_review_plan=Esta configuración del plan de revisión no está permitida @@ -522,6 +526,7 @@ error.routing_policy_approvals_required=El número de aprobaciones requeridas de error.routing_policy_condition_required=Se requiere una condición de enrutamiento error.routing_policy_condition_invalid=La condición de enrutamiento no es válida error.routing_policy_cidr_invalid=Bloque CIDR no válido: {0} +error.routing_policy_query_shape_empty=Una condición de forma de consulta debe indicar al menos una forma error.routing_policy_priority_conflict=Otra política de enrutamiento ya usa esta prioridad error.routing_policy_reorder_mismatch=La reordenación debe incluir todas las políticas de enrutamiento de la organización exactamente una vez validation.routing_policy_name.required=El nombre de la política de enrutamiento es obligatorio @@ -1274,6 +1279,7 @@ workflow.access_simulation.permission.capability_missing=El permiso efectivo no 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.shape_denied=El permiso efectivo deniega una forma que tiene la consulta 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 @@ -1291,6 +1297,7 @@ workflow.access_simulation.break_glass.eligible=El usuario podría omitir todas workflow.access_simulation.break_glass.not_eligible=El usuario no tiene concesión de acceso de emergencia en esta fuente de datos error.effective_access_table_invalid=La tabla debe contener al menos una letra, un dígito o un guion bajo error.access_simulation_body_unreadable=No se pudo leer el cuerpo de la solicitud; compruebe los valores de enumeración +error.datasource_body_unreadable=No se pudo leer el cuerpo de la solicitud; compruebe los valores de enumeración (por ejemplo db_type o denied_shapes) # ── Traza de decisión de llamadas API (AF-967) ── apigov.decision.routing.matched=La política de enrutamiento "{0}" coincidió con la acción {1} diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties index 9d5259a6c..25881f4a1 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -49,6 +49,8 @@ validation.denied_schemas.too_many=Au plus 50 schémas refusés peuvent être in 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.denied_shapes.item_blank=Les formes de requête refusées ne doivent pas être vides +validation.denied_shapes.too_many=Au plus 8 formes de requête 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 @@ -101,6 +103,7 @@ 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.permission.shape_denied=La requête présente une ou plusieurs formes 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). @@ -124,6 +127,7 @@ error.datasource_group_permission_already_exists=Une autorisation existe déjà error.datasource_connection_test_failed=Le test de connexion a échoué error.illegal_datasource_permission=Cette opération d'autorisation de source de données n'est pas autorisée error.denied_columns_not_supported=Les colonnes refusées ne sont pas prises en charge pour les sources de données {0} ; le blocage par colonne est réservé aux moteurs relationnels +error.denied_shapes_not_supported=Les formes de requête refusées ne sont pas prises en charge pour les sources de données {0} ; le blocage par forme de requête est réservé aux moteurs relationnels error.review_plan_not_found=Plan d'examen introuvable error.review_plan_in_use=Ce plan d'examen est associé à une ou plusieurs sources de données et ne peut pas être supprimé error.illegal_review_plan=Cette configuration de plan d'examen n'est pas autorisée @@ -528,6 +532,7 @@ error.routing_policy_approvals_required=Le nombre d'approbations requises doit error.routing_policy_condition_required=Une condition de routage est requise error.routing_policy_condition_invalid=La condition de routage est mal formée error.routing_policy_cidr_invalid=Bloc CIDR non valide : {0} +error.routing_policy_query_shape_empty=Une condition de forme de requête doit indiquer au moins une forme error.routing_policy_priority_conflict=Une autre politique de routage utilise déjà cette priorité error.routing_policy_reorder_mismatch=La réorganisation doit inclure chaque politique de routage de l'organisation exactement une fois validation.routing_policy_name.required=Le nom de la politique de routage est requis @@ -1280,6 +1285,7 @@ workflow.access_simulation.permission.capability_missing=La permission effective 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.shape_denied=La permission effective refuse une forme présente dans la requête 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 @@ -1297,6 +1303,7 @@ workflow.access_simulation.break_glass.eligible=L’utilisateur pourrait contour workflow.access_simulation.break_glass.not_eligible=L’utilisateur ne détient aucun octroi d’accès d’urgence sur cette source de données error.effective_access_table_invalid=La table doit contenir au moins une lettre, un chiffre ou un tiret bas error.access_simulation_body_unreadable=Le corps de la requête n’a pas pu être lu ; vérifiez les valeurs d’énumération +error.datasource_body_unreadable=Le corps de la requête n’a pas pu être lu ; vérifiez les valeurs d’énumération (par exemple db_type ou denied_shapes) # ── Trace de décision des appels API (AF-967) ── apigov.decision.routing.matched=La politique de routage « {0} » a correspondu avec l''action {1} diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties index 8892a3e4f..fc51435bf 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -49,6 +49,8 @@ validation.denied_schemas.too_many=Կարելի է նշել առավելագու validation.denied_tables.item_blank=Արգելված աղյուսակների գրառումները չեն կարող լինել դատարկ validation.denied_tables.item_invalid=Արգելված աղյուսակները պետք է լինեն աղյուսակ, սխեմա.աղյուսակ կամ սխեմա.* ձևաչափով validation.denied_tables.too_many=Կարելի է նշել առավելագույնը 200 արգելված աղյուսակ +validation.denied_shapes.item_blank=Արգելված հարցման ձևերը չեն կարող դատարկ լինել +validation.denied_shapes.too_many=Կարելի է նշել առավելագույնը 8 արգելված հարցման ձև validation.schemas.max=Սխեմաների ցանկը պետք է լինի առավելագույնը 4 000 նիշ validation.review_plan_name.required=Վերանայման պլանի անունը պարտադիր է validation.review_plan_name.max=Վերանայման պլանի անունը պետք է լինի 1-ից 255 նիշ միջակայքում @@ -101,6 +103,7 @@ error.forbidden=Մուտքն արգելված է error.permission.table_not_allowed=Հարցումը հղում է կատարում մեկ կամ մի քանի աղյուսակների, որոնք օգտատիրոջ թույլատրված ցանկում չեն՝ {0} error.permission.column_not_allowed=Հարցումը հղում է կատարում մեկ կամ մի քանի սյունակների, որոնք այս տվյալների աղբյուրում արգելված են օգտատիրոջ համար՝ {0} error.permission.table_denied=Հարցումը հղում է կատարում մեկ կամ մի քանի աղյուսակների, որոնք այս տվյալների աղբյուրում արգելված են օգտատիրոջ համար՝ {0} +error.permission.shape_denied=Հարցումն ունի մեկ կամ մի քանի ձև, որոնք այս տվյալների աղբյուրում արգելված են օգտատիրոջ համար՝ {0} error.dry_run.unsupported={0} շարժիչի համար փորձնական գործարկումը չի աջակցվում error.dry_run.mssql_row_security_unsupported=Փորձնական գործարկումը հասանելի չէ այս հարցման համար, քանի որ կիրառվում է տողերի անվտանգության քաղաքականություն. SQL Server-ը կարող է կատարման պլան վերադարձնել միայն առանց կապված պարամետրերի հրահանգի համար error.dry_run.transactional_unsupported=Փորձնական գործարկումը հասանելի չէ գործարքային փաթեթային հարցումների համար @@ -122,6 +125,7 @@ error.datasource_group_permission_already_exists=Այս խմբի համար ար error.datasource_connection_test_failed=Միացման թեստը ձախողվեց error.illegal_datasource_permission=Այս տվյալների աղբյուրի թույլտվության գործողությունը թույլատրված չէ error.denied_columns_not_supported=Արգելված սյունակները չեն աջակցվում {0} տվյալների աղբյուրների համար. սյունակային արգելափակումը հասանելի է միայն ռելյացիոն շարժիչների համար +error.denied_shapes_not_supported=Արգելված հարցման ձևերը չեն աջակցվում {0} տվյալների աղբյուրների համար. ըստ հարցման ձևի արգելափակումը հասանելի է միայն ռելյացիոն շարժիչների համար error.review_plan_not_found=Վերանայման պլանը չի գտնվել error.review_plan_in_use=Այս վերանայման պլանը կցված է մեկ կամ ավելի տվյալների աղբյուրների, և այն չի կարող ջնջվել error.illegal_review_plan=Այս վերանայման պլանի կարգավորումը թույլատրված չէ @@ -522,6 +526,7 @@ error.routing_policy_approvals_required=Այս գործողության համա error.routing_policy_condition_required=Երթուղավորման պայմանը պարտադիր է error.routing_policy_condition_invalid=Երթուղավորման պայմանը սխալ ձևաչափով է error.routing_policy_cidr_invalid=Անվավեր CIDR բլոկ՝ {0} +error.routing_policy_query_shape_empty=Հարցման ձևի պայմանը պետք է նշի առնվազն մեկ ձև error.routing_policy_priority_conflict=Մեկ այլ երթուղավորման քաղաքականություն արդեն օգտագործում է այս առաջնահերթությունը error.routing_policy_reorder_mismatch=Վերադասավորումը պետք է ներառի կազմակերպության յուրաքանչյուր երթուղավորման քաղաքականություն ճիշտ մեկ անգամ validation.routing_policy_name.required=Երթուղավորման քաղաքականության անունը պարտադիր է @@ -1274,6 +1279,7 @@ 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.shape_denied=Գործող թույլտվությունը արգելում է հարցման ձևերից մեկը workflow.access_simulation.permission.query_admin_bypass=Օգտատերը ունի QUERY_ADMIN, ինչը լիովին շրջանցում է տվյալների աղբյուրի թույլտվության ստուգումը workflow.access_simulation.reviewers.assigned=Այս տվյալների աղբյուրին նշանակված են վերանայողներ, որոնք կարող են գործել հարցման վրա workflow.access_simulation.reviewers.none=Ներկայացնողից բացի ոչ մի վերանայող չէր կարող գործել հարցման վրա @@ -1291,6 +1297,7 @@ workflow.access_simulation.break_glass.eligible=Օգտատերը կարող էր workflow.access_simulation.break_glass.not_eligible=Օգտատերը չունի արտակարգ մուտքի թույլտվություն այս տվյալների աղբյուրի վրա error.effective_access_table_invalid=Աղյուսակը պետք է պարունակի առնվազն մեկ տառ, թվանշան կամ ընդգծում error.access_simulation_body_unreadable=Հարցման մարմինը հնարավոր չէր կարդալ. ստուգեք թվարկման արժեքները +error.datasource_body_unreadable=Հարցման մարմինը հնարավոր չէր կարդալ. ստուգեք թվարկման արժեքները (օրինակ՝ db_type կամ denied_shapes) # ── API կանչի որոշման հետագիծ (AF-967) ── apigov.decision.routing.matched=«{0}» երթուղղման քաղաքականությունը համընկավ {1} գործողությամբ diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties index 2580e8314..13d462ce9 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -49,6 +49,8 @@ validation.denied_schemas.too_many=Можно указать не более 50 validation.denied_tables.item_blank=Записи запрещённых таблиц не должны быть пустыми validation.denied_tables.item_invalid=Запрещённые таблицы указываются как таблица, схема.таблица или схема.* validation.denied_tables.too_many=Можно указать не более 200 запрещённых таблиц +validation.denied_shapes.item_blank=Запрещённые формы запроса не могут быть пустыми +validation.denied_shapes.too_many=Можно указать не более 8 запрещённых форм запроса validation.schemas.max=Список схем не должен превышать 4 000 символов validation.review_plan_name.required=Укажите название плана проверки validation.review_plan_name.max=Название плана проверки должно содержать от 1 до 255 символов @@ -101,6 +103,7 @@ error.forbidden=Доступ запрещён error.permission.table_not_allowed=Запрос обращается к одной или нескольким таблицам, отсутствующим в списке разрешённых пользователя: {0} error.permission.column_not_allowed=Запрос обращается к одному или нескольким столбцам, запрещённым пользователю в этом источнике данных: {0} error.permission.table_denied=Запрос обращается к одной или нескольким таблицам, запрещённым пользователю в этом источнике данных: {0} +error.permission.shape_denied=Запрос имеет одну или несколько форм, запрещённых пользователю в этом источнике данных: {0} error.dry_run.unsupported=Пробный запуск не поддерживается для движка {0} error.dry_run.mssql_row_security_unsupported=Пробный запуск недоступен для этого запроса, так как применяется политика безопасности на уровне строк: SQL Server может вернуть план выполнения только для инструкции без связанных параметров error.dry_run.transactional_unsupported=Пробный запуск недоступен для транзакционных пакетных конвертов @@ -122,6 +125,7 @@ error.datasource_group_permission_already_exists=Для этой группы у error.datasource_connection_test_failed=Проверка подключения не удалась error.illegal_datasource_permission=Эта операция с разрешением источника данных недопустима error.denied_columns_not_supported=Запрещённые столбцы не поддерживаются для источников данных {0}; блокировка на уровне столбцов доступна только для реляционных движков +error.denied_shapes_not_supported=Запрещённые формы запроса не поддерживаются для источников данных {0}; блокировка по форме запроса доступна только для реляционных движков error.review_plan_not_found=План проверки не найден error.review_plan_in_use=Этот план проверки прикреплён к одному или нескольким источникам данных и не может быть удалён error.illegal_review_plan=Эта конфигурация плана проверки недопустима @@ -522,6 +526,7 @@ error.routing_policy_approvals_required=Для этого действия ко error.routing_policy_condition_required=Требуется условие маршрутизации error.routing_policy_condition_invalid=Условие маршрутизации некорректно error.routing_policy_cidr_invalid=Недопустимый блок CIDR: {0} +error.routing_policy_query_shape_empty=Условие формы запроса должно указывать хотя бы одну форму error.routing_policy_priority_conflict=Другая политика маршрутизации уже использует этот приоритет error.routing_policy_reorder_mismatch=Переупорядочивание должно включать каждую политику маршрутизации организации ровно один раз validation.routing_policy_name.required=Имя политики маршрутизации обязательно @@ -1274,6 +1279,7 @@ 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.shape_denied=Действующее разрешение запрещает форму, которую имеет запрос workflow.access_simulation.permission.query_admin_bypass=У пользователя есть QUERY_ADMIN, который полностью обходит проверку разрешений по источнику данных workflow.access_simulation.reviewers.assigned=К этому источнику данных назначены проверяющие, которые могли бы обработать запрос workflow.access_simulation.reviewers.none=Ни один проверяющий, кроме автора запроса, не смог бы его обработать @@ -1291,6 +1297,7 @@ workflow.access_simulation.break_glass.eligible=Пользователь мог workflow.access_simulation.break_glass.not_eligible=У пользователя нет права аварийного доступа на этом источнике данных error.effective_access_table_invalid=Таблица должна содержать хотя бы одну букву, цифру или подчёркивание error.access_simulation_body_unreadable=Не удалось прочитать тело запроса; проверьте значения перечислений +error.datasource_body_unreadable=Не удалось прочитать тело запроса; проверьте значения перечислений (например, db_type или denied_shapes) # ── Трассировка решения по API-вызову (AF-967) ── apigov.decision.routing.matched=Сработала политика маршрутизации «{0}» с действием {1} diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 8b603c75e..f5fe78f60 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -49,6 +49,8 @@ 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.denied_shapes.item_blank=禁止的查询结构不能为空 +validation.denied_shapes.too_many=最多可列出 8 种禁止的查询结构 validation.schemas.max=模式列表最多 4,000 个字符 validation.review_plan_name.required=请输入审核计划名称 validation.review_plan_name.max=审核计划名称必须在 1 到 255 个字符之间 @@ -101,6 +103,7 @@ error.forbidden=访问被拒绝 error.permission.table_not_allowed=查询引用了一个或多个不在用户允许列表中的表:{0} error.permission.column_not_allowed=查询引用了一个或多个在此数据源上对该用户禁止的列:{0} error.permission.table_denied=查询引用了一个或多个在此数据源上对该用户禁止的表:{0} +error.permission.shape_denied=查询包含一种或多种在此数据源上对该用户禁止的结构:{0} error.dry_run.unsupported={0} 引擎不支持试运行 error.dry_run.mssql_row_security_unsupported=该查询适用行级安全策略,因此无法试运行:SQL Server 仅能为不含绑定参数的语句返回执行计划 error.dry_run.transactional_unsupported=事务批处理语句不支持试运行 @@ -122,6 +125,7 @@ error.datasource_group_permission_already_exists=该组在此数据源上已有 error.datasource_connection_test_failed=连接测试失败 error.illegal_datasource_permission=不允许执行该数据源权限操作 error.denied_columns_not_supported={0} 数据源不支持禁止列;列级拦截仅适用于关系型引擎 +error.denied_shapes_not_supported={0} 数据源不支持禁止查询结构;按查询结构拦截仅适用于关系型引擎 error.review_plan_not_found=未找到审核计划 error.review_plan_in_use=该审核计划已附加到一个或多个数据源,无法删除 error.illegal_review_plan=不允许该审核计划配置 @@ -522,6 +526,7 @@ error.routing_policy_approvals_required=对于此操作,所需审批数必须 error.routing_policy_condition_required=路由条件为必填项 error.routing_policy_condition_invalid=路由条件格式不正确 error.routing_policy_cidr_invalid=无效的 CIDR 网段:{0} +error.routing_policy_query_shape_empty=查询结构条件必须至少指定一种结构 error.routing_policy_priority_conflict=另一个路由策略已使用此优先级 error.routing_policy_reorder_mismatch=重新排序必须恰好包含组织中的每个路由策略一次 validation.routing_policy_name.required=路由策略名称为必填项 @@ -1274,6 +1279,7 @@ 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.shape_denied=有效权限禁止了查询所含的某种结构 workflow.access_simulation.permission.query_admin_bypass=该用户持有 QUERY_ADMIN,会完全跳过按数据源的权限校验 workflow.access_simulation.reviewers.assigned=此数据源已指派审核人,他们可以处理该请求 workflow.access_simulation.reviewers.none=除提交者之外没有审核人可以处理该请求 @@ -1291,6 +1297,7 @@ workflow.access_simulation.break_glass.eligible=该用户可通过紧急访问 workflow.access_simulation.break_glass.not_eligible=该用户在此数据源上没有紧急访问授权 error.effective_access_table_invalid=表名必须至少包含一个字母、数字或下划线 error.access_simulation_body_unreadable=无法读取请求体;请检查枚举值 +error.datasource_body_unreadable=无法读取请求体;请检查枚举值(例如 db_type 或 denied_shapes) # ── API 调用决策轨迹(AF-967)── apigov.decision.routing.matched=路由策略“{0}”匹配,动作为 {1} 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 4690a2452..27bad6809 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 @@ -70,6 +70,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, List.of(), List.of(), + List.of(), Instant.now().plusSeconds(3600), approverId, Instant.now()); } @@ -103,7 +104,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, List.of(), List.of(), null, null /* no expiry = standing */); + true, false, false, false, null, null, null, null, List.of(), List.of(), List.of(), null, null /* no expiry = standing */); when(permissionLookupService.findDirectFor(requesterId, datasourceId)) .thenReturn(Optional.of(standing)); @@ -117,7 +118,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, List.of(), List.of(), null, Instant.now().plusSeconds(60)); + true, false, false, false, null, null, null, null, List.of(), 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()); @@ -134,7 +135,8 @@ 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, + List.of("crm.customer.ssn"), List.of("hr"), List.of("crm.salary"), + List.of(com.bablsoft.accessflow.core.api.QueryShape.JOIN), null, Instant.now().plusSeconds(60)); when(permissionLookupService.findDirectFor(requesterId, datasourceId)) .thenReturn(Optional.of(existing)); @@ -149,6 +151,8 @@ void materialiseCarriesTheReplacedRowsDenialsOntoTheNewGrant() { assertThat(captor.getValue().deniedColumns()).containsExactly("crm.customer.ssn"); assertThat(captor.getValue().deniedSchemas()).containsExactly("hr"); assertThat(captor.getValue().deniedTables()).containsExactly("crm.salary"); + assertThat(captor.getValue().deniedShapes()) + .containsExactly(com.bablsoft.accessflow.core.api.QueryShape.JOIN); } @Test @@ -167,6 +171,7 @@ void materialiseWithoutAnExistingRowCarriesNoDenials() { assertThat(captor.getValue().deniedSchemas()).isNull(); assertThat(captor.getValue().deniedTables()).isNull(); assertThat(captor.getValue().deniedColumns()).isNull(); + assertThat(captor.getValue().deniedShapes()).isNull(); } // --- AF-567: connector-targeted requests ---------------------------------------------------- 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 107b65077..4aa4ac2a6 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 @@ -110,6 +110,7 @@ private void givenDatasourceGrant(List allowedTables, Instant grantedAt) 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, List.of(), 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 2148deaed..1ef29e622 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, List.of(), List.of(), null, expiresAt, null); + List.of(), null, List.of(), 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, List.of(), List.of(), null, expiresAt, null); + List.of(), List.of(), null, List.of(), 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 c39eccd66..3a7fa7b5e 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, List.of(), List.of(), null, null); + List.of(), List.of(), List.of("public.users.ssn"), null, List.of(), 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 826e49f60..619ef1b24 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, List.of(), List.of(), null, null); + true, false, false, false, List.of(), List.of(), List.of("public.orders.created_at"), null, List.of(), 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 681aa5c99..22fbde2d5 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, null, null)); + false, null, List.of("public"), null, null, null, null, null, List.of(), 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 d94cc6730..e3d149031 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, List.of(), List.of(), null, UUID.randomUUID(), Instant.now()); + List.of("public"), List.of(), List.of(), null, List.of(), List.of(), List.of(), null, UUID.randomUUID(), Instant.now()); } @Test @@ -114,7 +114,9 @@ void openSnapshotsTheGrantsTableAndSchemaDenials() { .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()))); + List.of("crm.salary"), + List.of(com.bablsoft.accessflow.core.api.QueryShape.GROUP_BY), null, + UUID.randomUUID(), Instant.now()))); when(itemRepository.existsByCampaignIdAndPermissionId(any(), any())).thenReturn(false); service.openCampaign(campaignId); @@ -126,6 +128,7 @@ void openSnapshotsTheGrantsTableAndSchemaDenials() { 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); + assertThat(snapshot.get("denied_shapes").get(0).asString()).isEqualTo("GROUP_BY"); } @Test @@ -136,7 +139,7 @@ void openSnapshotsEmptyDenialArraysForAGrantWithoutDenials() { .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); + null, null, null, null, null, null); when(datasourceAdminService.listPermissions(datasourceId, orgId)).thenReturn(List.of(view)); when(itemRepository.existsByCampaignIdAndPermissionId(any(), any())).thenReturn(false); @@ -148,6 +151,7 @@ void openSnapshotsEmptyDenialArraysForAGrantWithoutDenials() { assertThat(snapshot.get("denied_schemas").isArray()).isTrue(); assertThat(snapshot.get("denied_schemas")).isEmpty(); assertThat(snapshot.get("denied_tables")).isEmpty(); + assertThat(snapshot.get("denied_shapes")).isEmpty(); } /** diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedShapesTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedShapesTest.java new file mode 100644 index 000000000..4b751565d --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedShapesTest.java @@ -0,0 +1,97 @@ +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 DeniedShapesTest { + + private static SqlParseResult parsed(QueryType type, Set shapes, boolean analyzed) { + return new SqlParseResult(type, false, List.of("sql"), Set.of(), false, false, Set.of(), + true, shapes, analyzed); + } + + @Test + void normalizeDropsNullsAndDuplicatesInDeclarationOrder() { + var input = new ArrayList(); + input.add(QueryShape.WINDOW_FUNCTION); + input.add(null); + input.add(QueryShape.JOIN); + input.add(QueryShape.JOIN); + + assertThat(DeniedShapes.normalize(input)) + .containsExactly(QueryShape.JOIN, QueryShape.WINDOW_FUNCTION); + assertThat(DeniedShapes.normalize(null)).isEmpty(); + assertThat(DeniedShapes.normalize(List.of())).isEmpty(); + } + + @Test + void unionKeepsADenialFromEitherSide() { + assertThat(DeniedShapes.union(List.of(QueryShape.HAVING), List.of(QueryShape.JOIN, QueryShape.HAVING))) + .containsExactly(QueryShape.JOIN, QueryShape.HAVING); + assertThat(DeniedShapes.union(null, null)).isEmpty(); + } + + @Test + void namesRoundTripThroughStorage() { + var names = DeniedShapes.toNames(List.of(QueryShape.CTE, QueryShape.JOIN, QueryShape.CTE)); + + assertThat(names).containsExactly("JOIN", "CTE"); + assertThat(DeniedShapes.fromNames(List.of(names))).containsExactly(QueryShape.JOIN, QueryShape.CTE); + assertThat(DeniedShapes.toNames(List.of())).isNull(); + assertThat(DeniedShapes.toNames(null)).isNull(); + assertThat(DeniedShapes.fromNames(null)).isEmpty(); + // A name this version does not know denies every shape rather than none. + assertThat(DeniedShapes.fromNames(List.of("JOIN", "NOPE"))).containsExactly(QueryShape.values()); + } + + @Test + void rejectedReturnsTheDeniedShapesTheQueryHas() { + var query = parsed(QueryType.SELECT, Set.of(QueryShape.JOIN, QueryShape.AGGREGATE), true); + + assertThat(DeniedShapes.rejected(List.of(QueryShape.AGGREGATE, QueryShape.UNION, QueryShape.JOIN), query)) + .containsExactly(QueryShape.JOIN, QueryShape.AGGREGATE); + assertThat(DeniedShapes.rejected(List.of(QueryShape.UNION), query)).isEmpty(); + } + + @Test + void rejectedFailsClosedOnAnUnanalyzedParse() { + var query = parsed(QueryType.SELECT, Set.of(), false); + + assertThat(DeniedShapes.rejected(List.of(QueryShape.UNION, QueryShape.JOIN), query)) + .containsExactly(QueryShape.JOIN, QueryShape.UNION); + } + + @Test + void rejectedIgnoresAnEmptyDenyListAndANullParse() { + assertThat(DeniedShapes.rejected(List.of(), parsed(QueryType.SELECT, Set.of(), false))).isEmpty(); + assertThat(DeniedShapes.rejected(List.of(QueryShape.JOIN), null)).isEmpty(); + } + + @Test + void otherStatementsAreCheckedTooBecauseARequestGroupMemberMayBeOne() { + assertThat(DeniedShapes.rejected(List.of(QueryShape.JOIN), parsed(QueryType.OTHER, Set.of(QueryShape.JOIN), true))) + .containsExactly(QueryShape.JOIN); + assertThat(DeniedShapes.rejected(List.of(QueryShape.JOIN), parsed(QueryType.OTHER, Set.of(), false))) + .containsExactly(QueryShape.JOIN); + assertThat(DeniedShapes.rejected(List.of(QueryShape.JOIN), parsed(QueryType.OTHER, Set.of(), true))) + .isEmpty(); + } + + @Test + void theLegacyParseResultConstructorsReportShapesAsNotAnalyzed() { + var eightArg = new SqlParseResult(QueryType.SELECT, false, List.of("sql"), Set.of(), false, + false, Set.of(), true); + var sixArg = new SqlParseResult(QueryType.SELECT, false, List.of("sql"), Set.of(), true, true); + + assertThat(eightArg.shapesAnalyzed()).isFalse(); + assertThat(eightArg.shapes()).isEmpty(); + assertThat(sixArg.shapesAnalyzed()).isFalse(); + assertThat(new SqlParseResult(QueryType.SELECT, false, List.of("sql"), Set.of(), false, + false, Set.of(), true, null, true).shapes()).isEmpty(); + } +} 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 ae4c5b59e..db0c4ca80 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 @@ -9,9 +9,11 @@ import com.bablsoft.accessflow.core.api.DatasourcePermissionAlreadyExistsException; import com.bablsoft.accessflow.core.api.DatasourcePermissionNotFoundException; import com.bablsoft.accessflow.core.api.DbType; +import com.bablsoft.accessflow.core.api.DeniedShapesNotSupportedException; import com.bablsoft.accessflow.core.api.DriverResolutionException; import com.bablsoft.accessflow.core.api.IllegalDatasourcePermissionException; import com.bablsoft.accessflow.core.api.MissingAiConfigForDatasourceException; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.ReplicaEndpointInput; import com.bablsoft.accessflow.core.api.SslMode; import com.bablsoft.accessflow.core.api.UpdateDatasourceCommand; @@ -1075,7 +1077,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, List.of(), null, null); assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(IllegalDatasourcePermissionException.class); } @@ -1087,7 +1089,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, List.of(), null, null); assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(IllegalDatasourcePermissionException.class); } @@ -1106,7 +1108,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, List.of(), null, null); assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(DatasourcePermissionAlreadyExistsException.class); } @@ -1133,7 +1135,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, null, null); + List.of("public"), List.of("orders"), List.of("public.orders.ssn"), null, null, null, List.of(), null, null); var view = service.grantPermission(datasourceId, orgId, adminId, command); // An admin-created row has no originating JIT request (#969). @@ -1173,6 +1175,7 @@ void grantPermissionStampsTheOriginatingAccessRequest() { service.grantPermission(datasourceId, orgId, adminId, new CreatePermissionCommand(userId, true, false, false, false, null, null, null, null, null, null, null, + List.of(), Instant.now().plusSeconds(3600), accessGrantRequestId)); assertThat(saved.getValue().getAccessGrantRequestId()).isEqualTo(accessGrantRequestId); @@ -1238,7 +1241,7 @@ void grantPermissionPersistsNormalizedDeniedSchemasAndTables() { 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)); + List.of("CRM.Salary", "`crm`.`salary`", "bonus"), List.of(), null, null)); assertThat(saved.getValue().getDeniedSchemas()).containsExactly("hr"); assertThat(saved.getValue().getDeniedTables()).containsExactly("crm.salary", "bonus"); @@ -1252,12 +1255,12 @@ void grantPermissionRefusesADeniedEntryThatCouldNeverMatch() { 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))) + null, null, List.of("analytics.hr"), null, List.of(), 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))) + null, null, null, List.of("sal*"), List.of(), null, null))) .isInstanceOf(IllegalDatasourcePermissionException.class) .hasMessageContaining("denied_tables"); verify(permissionRepository, never()).save(any()); @@ -1271,7 +1274,7 @@ void grantPermissionStoresNoDeniedSchemasOrTablesWhenEmptyOrBlank() { 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)); + null, null, List.of(), List.of(" "), List.of(), null, null)); assertThat(saved.getValue().getDeniedSchemas()).isNull(); assertThat(saved.getValue().getDeniedTables()).isNull(); @@ -1279,6 +1282,49 @@ void grantPermissionStoresNoDeniedSchemasOrTablesWhenEmptyOrBlank() { assertThat(view.deniedTables()).isNullOrEmpty(); } + @Test + void grantPermissionPersistsDeniedShapesInDeclarationOrder() { + 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, + shapesCommand(List.of(QueryShape.SUBQUERY, QueryShape.JOIN, QueryShape.JOIN))); + + assertThat(saved.getValue().getDeniedShapes()).containsExactly("JOIN", "SUBQUERY"); + assertThat(view.deniedShapes()).containsExactly(QueryShape.JOIN, QueryShape.SUBQUERY); + } + + @Test + void grantPermissionStoresNoDeniedShapesWhenEmpty() { + stubGrantableUser(DbType.MONGODB); + var saved = ArgumentCaptor.forClass(DatasourceUserPermissionEntity.class); + when(permissionRepository.save(saved.capture())).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.grantPermission(datasourceId, orgId, adminId, shapesCommand(List.of())); + + assertThat(saved.getValue().getDeniedShapes()).isNull(); + assertThat(view.deniedShapes()).isEmpty(); + } + + @Test + void grantPermissionRejectsDeniedShapesOnAnEngineManagedDatasource() { + stubGrantableUser(DbType.MONGODB); + when(engineCatalog.isEngineManaged(DbType.MONGODB)).thenReturn(true); + + assertThatThrownBy(() -> service.grantPermission(datasourceId, orgId, adminId, + shapesCommand(List.of(QueryShape.JOIN)))) + .isInstanceOf(DeniedShapesNotSupportedException.class) + .satisfies(ex -> assertThat(((DeniedShapesNotSupportedException) ex).dbType()) + .isEqualTo(DbType.MONGODB)); + verify(permissionRepository, never()).save(any()); + } + + private CreatePermissionCommand shapesCommand(List deniedShapes) { + return new CreatePermissionCommand(userId, true, false, false, false, null, null, null, + null, null, null, null, deniedShapes, null, null); + } + private void stubGrantableUser(DbType dbType) { var entity = buildDatasource(datasourceId, orgId, "Prod"); entity.setDbType(dbType); @@ -1295,7 +1341,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, null); + null, deniedColumns, null, null, List.of(), null, null); } @Test @@ -1362,7 +1408,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, null, null); + groupId, true, true, false, false, null, List.of("public"), null, null, null, null, null, List.of(), null); var view = service.grantGroupPermission(datasourceId, orgId, adminId, command); assertThat(view.groupId()).isEqualTo(groupId); @@ -1394,7 +1440,7 @@ void grantGroupPermissionPersistsNormalizedDeniedSchemasAndTables() { 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)); + List.of("Audit"), List.of("CRM.Salary", " "), List.of(), null)); assertThat(saved.getValue().getDeniedSchemas()).containsExactly("audit"); assertThat(saved.getValue().getDeniedTables()).containsExactly("crm.salary"); @@ -1402,6 +1448,36 @@ void grantGroupPermissionPersistsNormalizedDeniedSchemasAndTables() { assertThat(view.deniedTables()).containsExactly("crm.salary"); } + @Test + void grantGroupPermissionPersistsDeniedShapesAndRefusesThemOnAnEngineManagedDatasource() { + 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 command = new com.bablsoft.accessflow.core.api.CreateDatasourceGroupPermissionCommand( + groupId, true, false, false, false, null, null, null, null, null, null, null, + List.of(QueryShape.HAVING, QueryShape.GROUP_BY), null); + + var view = service.grantGroupPermission(datasourceId, orgId, adminId, command); + + assertThat(saved.getValue().getDeniedShapes()).containsExactly("GROUP_BY", "HAVING"); + assertThat(view.deniedShapes()).containsExactly(QueryShape.GROUP_BY, QueryShape.HAVING); + + entity.setDbType(DbType.REDIS); + when(engineCatalog.isEngineManaged(DbType.REDIS)).thenReturn(true); + assertThatThrownBy(() -> service.grantGroupPermission(datasourceId, orgId, adminId, command)) + .isInstanceOf(DeniedShapesNotSupportedException.class); + } + @Test void grantGroupPermissionStoresNoDeniedSchemasOrTablesWhenAbsent() { var groupId = UUID.randomUUID(); @@ -1421,7 +1497,7 @@ void grantGroupPermissionStoresNoDeniedSchemasOrTablesWhenAbsent() { 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)); + List.of(), null, List.of(), null)); assertThat(saved.getValue().getDeniedSchemas()).isNull(); assertThat(saved.getValue().getDeniedTables()).isNull(); @@ -1440,7 +1516,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, null, null); + groupId, true, false, false, false, null, null, null, null, null, null, null, List.of(), null); assertThatThrownBy(() -> service.grantGroupPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(com.bablsoft.accessflow.core.api.DatasourceGroupPermissionAlreadyExistsException.class); } @@ -1454,7 +1530,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, null, null); + groupId, true, false, false, false, null, null, null, null, null, null, null, List.of(), null); assertThatThrownBy(() -> service.grantGroupPermission(datasourceId, orgId, adminId, command)) .isInstanceOf(com.bablsoft.accessflow.core.api.UserGroupNotFoundException.class); } @@ -1641,7 +1717,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, List.of(), List.of(), null, + userId, datasourceId, true, false, false, false, null, tables, null, null, List.of(), 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 8c7aa37fb..4954d9034 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 @@ -2,6 +2,7 @@ import com.bablsoft.accessflow.core.api.DatasourcePermissionSourceKind; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; +import com.bablsoft.accessflow.core.api.QueryShape; 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; @@ -313,6 +314,45 @@ void findForUnionsDenialsAcrossGrantsNormalisedAndDeduplicated() { assertThat(view.deniedTables()).containsExactly("crm.salary", "crm.bonus"); } + @Test + void findForUnionsDeniedShapesAcrossGrantsSoAGroupGrantCannotLiftOne() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var groupId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setCanRead(true); + direct.setDeniedShapes(new String[] {"JOIN", "CTE"}); + var group = newGroupPermission(groupId, datasourceId); + group.setCanRead(true); + group.setDeniedShapes(new String[] {"UNION", "JOIN"}); + 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(); + var contributions = service.findContributions(userId, datasourceId); + + assertThat(view.deniedShapes()).containsExactly(QueryShape.JOIN, QueryShape.UNION, QueryShape.CTE); + assertThat(contributions.get(0).deniedShapes()).containsExactly(QueryShape.JOIN, QueryShape.CTE); + assertThat(contributions.get(1).deniedShapes()).containsExactly(QueryShape.JOIN, QueryShape.UNION); + } + + @Test + void findDirectForReadsTheStoredDeniedShapes() { + var userId = UUID.randomUUID(); + var datasourceId = UUID.randomUUID(); + var direct = newPermission(UUID.randomUUID(), userId, datasourceId); + direct.setDeniedShapes(new String[] {"WINDOW_FUNCTION", "GROUP_BY"}); + when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId)) + .thenReturn(Optional.of(direct)); + + var view = service.findDirectFor(userId, datasourceId).orElseThrow(); + + assertThat(view.deniedShapes()).containsExactly(QueryShape.GROUP_BY, QueryShape.WINDOW_FUNCTION); + } + @Test void findForDeniesNothingWhenNoGrantDeniesATable() { var userId = UUID.randomUUID(); @@ -326,6 +366,7 @@ void findForDeniesNothingWhenNoGrantDeniesATable() { assertThat(view.deniedSchemas()).isEmpty(); assertThat(view.deniedTables()).isEmpty(); + assertThat(view.deniedShapes()).isEmpty(); } @Test 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 5b0d51d0e..3225105f4 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, List.of(), List.of(), null, + UUID.randomUUID(), true, false, false, false, schemas, tables, null, denied, List.of(), List.of(), List.of(), null, null); } @@ -167,7 +167,7 @@ private static DatasourceUserPermissionView denying(List allowedSchemas, List deniedTables) { return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), true, false, false, false, allowedSchemas, null, null, null, - deniedSchemas, deniedTables, null, null); + deniedSchemas, deniedTables, List.of(), null, null); } @Test 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 3e7834d0e..a98d49e8a 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, List.of(), List.of(), null, null); + false, false, false, schemas, tables, List.of(), null, List.of(), 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"), List.of(), List.of(), null, null))); + List.of(), List.of(), List.of(), List.of("users.ssn"), List.of(), List.of(), List.of(), null, null))); when(messageSource.getMessage(eq("error.permission.column_not_allowed"), any(), any())) .thenReturn("column denied"); @@ -199,7 +199,7 @@ void nonAdminReferencingDeniedTableIsDeniedBeforePlanning() { .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))); + List.of("crm.salary"), List.of(), null, null))); when(messageSource.getMessage(eq("error.permission.table_denied"), any(), any())) .thenReturn("table denied"); @@ -210,6 +210,27 @@ void nonAdminReferencingDeniedTableIsDeniedBeforePlanning() { verify(queryExecutor, never()).dryRun(any()); } + @Test + void nonAdminWithADeniedQueryShapeIsDeniedBeforePlanning() { + when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); + when(queryParser.parse(anyString(), any())).thenReturn(new SqlParseResult(QueryType.SELECT, + false, List.of("sql"), Set.of("public.orders"), false, false, Set.of(), true, + Set.of(com.bablsoft.accessflow.core.api.QueryShape.AGGREGATE), true)); + 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(), List.of(), + List.of(com.bablsoft.accessflow.core.api.QueryShape.AGGREGATE), null, null))); + when(messageSource.getMessage(eq("error.permission.shape_denied"), any(), any())) + .thenReturn("shape denied"); + + assertThatThrownBy(() -> service.dryRun(datasourceId, "SELECT count(*) FROM public.orders", + userId, orgId, false)) + .isInstanceOf(AccessDeniedException.class) + .hasMessage("shape denied"); + verify(queryExecutor, never()).dryRun(any()); + } + @Test void nonAdminReferencingATableInADeniedSchemaIsDeniedWithoutAnAllowList() { when(datasourceAdminService.getForUser(datasourceId, orgId, userId)).thenReturn(view()); @@ -218,7 +239,7 @@ void nonAdminReferencingATableInADeniedSchemaIsDeniedWithoutAnAllowList() { 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, + List.of(), List.of(), List.of(), null, List.of("hr"), List.of(), List.of(), null, null))); assertThatThrownBy(() -> service.dryRun(datasourceId, "SELECT * FROM hr.payroll", userId, 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 60db69a7f..2549c01b0 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"), List.of(), List.of(), null, null))); + List.of("users.ssn"), List.of(), 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"), List.of(), List.of(), null, null))); + List.of("public.orders.card"), List.of(), List.of(), List.of(), null, null))); assertThat(service.sample(datasourceId, organizationId, userId, false, "public", "users", 10)).isSameAs(result); @@ -286,6 +286,7 @@ 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, List.of(), List.of(), + List.of(), 5, null))); service.sample(datasourceId, organizationId, userId, false, "public", "users", 50); @@ -300,6 +301,7 @@ 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, List.of(), List.of(), + List.of(), 500, null))); service.sample(datasourceId, organizationId, userId, false, "public", "users", 50); @@ -314,6 +316,7 @@ 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, List.of(), List.of(), + List.of(), 20, null))); when(rowLimitPolicyResolutionService.resolve(organizationId, datasourceId, userId, java.util.Set.of("public.users"))) @@ -462,7 +465,7 @@ 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, List.of(), List.of(), null, null); + false, false, false, allowedSchemas, allowedTables, restrictedColumns, null, List.of(), List.of(), List.of(), null, null); } private DatasourceUserPermissionView denying(List allowedSchemas, @@ -470,6 +473,6 @@ private DatasourceUserPermissionView denying(List allowedSchemas, List deniedTables) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, false, false, allowedSchemas, List.of(), List.of(), null, deniedSchemas, - deniedTables, null, null); + deniedTables, List.of(), null, null); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetectorTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetectorTest.java new file mode 100644 index 000000000..aba99de30 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetectorTest.java @@ -0,0 +1,187 @@ +package com.bablsoft.accessflow.proxy.internal; + +import com.bablsoft.accessflow.core.api.QueryShape; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static com.bablsoft.accessflow.core.api.QueryShape.AGGREGATE; +import static com.bablsoft.accessflow.core.api.QueryShape.CTE; +import static com.bablsoft.accessflow.core.api.QueryShape.GROUP_BY; +import static com.bablsoft.accessflow.core.api.QueryShape.HAVING; +import static com.bablsoft.accessflow.core.api.QueryShape.JOIN; +import static com.bablsoft.accessflow.core.api.QueryShape.SUBQUERY; +import static com.bablsoft.accessflow.core.api.QueryShape.UNION; +import static com.bablsoft.accessflow.core.api.QueryShape.WINDOW_FUNCTION; +import static org.assertj.core.api.Assertions.assertThat; + +class QueryShapeDetectorTest { + + private static Set shapes(String sql) throws JSQLParserException { + return QueryShapeDetector.detect(CCJSqlParserUtil.parse(sql)); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT id, name FROM users WHERE id = 1 ORDER BY name", + "SELECT * FROM users LIMIT 10", + "INSERT INTO users (id) VALUES (1)", + "UPDATE users SET name = 'x' WHERE id = 1", + "DELETE FROM users WHERE id = 1", + "SELECT upper(name), coalesce(email, '') FROM users", + "(SELECT id FROM users)" + }) + void aSimpleQueryHasNoShape(String sql) throws JSQLParserException { + assertThat(shapes(sql)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * FROM a JOIN b ON a.id = b.a_id", + "SELECT * FROM a LEFT OUTER JOIN b ON a.id = b.a_id", + "SELECT * FROM a, b WHERE a.id = b.a_id", + "SELECT * FROM a CROSS JOIN b", + "SELECT * FROM (a JOIN b ON a.id = b.id)", + "SELECT * FROM (t1 LEFT JOIN t2 USING (id))", + "UPDATE a SET x = b.x FROM b WHERE a.id = b.id", + "DELETE FROM a USING b WHERE a.id = b.id" + }) + void detectsJoins(String sql) throws JSQLParserException { + assertThat(shapes(sql)).containsExactly(JOIN); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT id FROM a UNION SELECT id FROM b", + "SELECT id FROM a UNION ALL SELECT id FROM b", + "SELECT id FROM a INTERSECT SELECT id FROM b", + "SELECT id FROM a EXCEPT SELECT id FROM b", + "(SELECT id FROM a) UNION (SELECT id FROM b)" + }) + void detectsSetOperationsWithoutCountingBranchesAsSubqueries(String sql) throws JSQLParserException { + assertThat(shapes(sql)).containsExactly(UNION); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)", + "SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)", + "SELECT (SELECT 1) AS one FROM users", + "SELECT * FROM (SELECT id FROM users) t", + "SELECT * FROM users WHERE id = ANY (SELECT user_id FROM orders)", + "UPDATE users SET name = 'x' WHERE id IN (SELECT user_id FROM orders)", + "DELETE FROM users WHERE id IN (SELECT user_id FROM orders)", + "SELECT * FROM t WHERE a = ANY(ARRAY(SELECT id FROM u WHERE u.x = t.x))", + "SELECT array(SELECT id FROM u)" + }) + void detectsSubqueries(String sql) throws JSQLParserException { + assertThat(shapes(sql)).containsExactly(SUBQUERY); + } + + @Test + void detectsLateralSubqueryAsJoinAndSubquery() throws JSQLParserException { + assertThat(shapes("SELECT * FROM users u, LATERAL (SELECT 1 FROM orders o WHERE o.user_id = u.id) x")) + .contains(SUBQUERY, JOIN); + } + + @Test + void aCteBodyIsNotASubquery() throws JSQLParserException { + assertThat(shapes("WITH t AS (SELECT id FROM users) SELECT * FROM t")).containsExactly(CTE); + } + + @Test + void detectsCtesOnDataModifyingStatements() throws JSQLParserException { + assertThat(shapes("WITH t AS (SELECT id FROM users) DELETE FROM orders WHERE user_id IN (SELECT id FROM t)")) + .containsExactlyInAnyOrder(CTE, SUBQUERY); + } + + @Test + void theRowSourceOfAnInsertIsNotASubquery() throws JSQLParserException { + assertThat(shapes("INSERT INTO archive SELECT * FROM users")).isEmpty(); + assertThat(shapes("INSERT INTO archive SELECT u.* FROM users u JOIN orders o ON o.user_id = u.id")) + .containsExactly(JOIN); + } + + @Test + void detectsGroupByHavingAndAggregates() throws JSQLParserException { + assertThat(shapes("SELECT status, count(*) FROM orders GROUP BY status HAVING count(*) > 1")) + .containsExactlyInAnyOrder(GROUP_BY, HAVING, AGGREGATE); + assertThat(shapes("SELECT status FROM orders GROUP BY status")).containsExactly(GROUP_BY); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT COUNT(*) FROM users", + "SELECT sum(amount) FROM orders", + "SELECT AVG(amount), MIN(amount), MAX(amount) FROM orders", + "SELECT string_agg(name, ',') FROM users", + "SELECT pg_catalog.count(*) FROM users", + "SELECT count(*) FILTER (WHERE active) FROM users", + "SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) FROM orders", + "SELECT json_arrayagg(ssn) FROM users", + "SELECT JSON_OBJECTAGG(KEY k VALUE v) FROM t", + "SELECT stdev(x) FROM t", + "SELECT xmlagg(x) FROM t", + "SELECT corr(a, b) FROM t", + "SELECT regr_slope(a, b) FROM t", + "SELECT approx_count_distinct(a) FROM t" + }) + void detectsAggregates(String sql) throws JSQLParserException { + assertThat(shapes(sql)).containsExactly(AGGREGATE); + } + + @Test + void aUserDefinedFunctionIsNotAnAggregate() throws JSQLParserException { + assertThat(shapes("SELECT my_rollup(amount) FROM orders")).isEmpty(); + } + + @Test + void detectsWindowFunctions() throws JSQLParserException { + assertThat(shapes("SELECT row_number() OVER (ORDER BY id) FROM users")) + .containsExactly(WINDOW_FUNCTION); + assertThat(shapes("SELECT count(*) OVER (PARTITION BY status) FROM orders")) + .containsExactlyInAnyOrder(WINDOW_FUNCTION, AGGREGATE); + } + + @Test + void detectsShapesInsideSubqueries() throws JSQLParserException { + assertThat(shapes("SELECT * FROM users WHERE id IN (SELECT o.user_id FROM orders o JOIN items i ON i.order_id = o.id GROUP BY o.user_id)")) + .containsExactlyInAnyOrder(SUBQUERY, JOIN, GROUP_BY); + } + + @Test + void aMergeJoinsItsTargetToItsSource() throws JSQLParserException { + assertThat(shapes("MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.v = s.v")) + .contains(JOIN); + } + + @Test + void aSetOperationInsideAFunctionArgumentIsASubquery() throws JSQLParserException { + assertThat(shapes("SELECT array(SELECT id FROM a UNION SELECT id FROM b)")) + .contains(SUBQUERY, UNION); + } + + @Test + void aWindowedJsonAggregateIsAlsoAWindowFunction() throws JSQLParserException { + assertThat(shapes("SELECT json_arrayagg(x) OVER (PARTITION BY y) FROM t")) + .contains(AGGREGATE, WINDOW_FUNCTION); + } + + @Test + void theQueryOfACreateTableAsSelectIsNotASubquery() throws JSQLParserException { + assertThat(shapes("CREATE TABLE t AS SELECT a.id FROM a JOIN b ON a.id = b.id")) + .containsExactly(JOIN); + } + + @Test + void isAggregateHandlesQuotesQualifiersAndNull() { + assertThat(QueryShapeDetector.isAggregate("\"COUNT\"")).isTrue(); + assertThat(QueryShapeDetector.isAggregate("dbo.SUM")).isTrue(); + assertThat(QueryShapeDetector.isAggregate("lower")).isFalse(); + assertThat(QueryShapeDetector.isAggregate(null)).isFalse(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImplTest.java b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImplTest.java index eb6d92a5e..4da137621 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImplTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/SqlParserServiceImplTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import com.bablsoft.accessflow.core.api.ColumnReference; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.InvalidSqlException; import com.bablsoft.accessflow.core.api.SqlParseResult; @@ -143,6 +144,58 @@ void transactionalBatchOrsWhereClauseAcrossStatements() { assertThat(result.hasWhereClause()).isTrue(); } + @Test + void reportsQueryShapesOnASingleStatement() { + var result = service.parse("SELECT u.id, count(*) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id"); + + assertThat(result.shapesAnalyzed()).isTrue(); + assertThat(result.shapes()).containsExactlyInAnyOrder(QueryShape.JOIN, QueryShape.AGGREGATE, + QueryShape.GROUP_BY); + } + + @Test + void aSimpleQueryIsAnalyzedWithNoShapes() { + var result = service.parse("SELECT id FROM users WHERE id = 1"); + + assertThat(result.shapesAnalyzed()).isTrue(); + assertThat(result.shapes()).isEmpty(); + } + + @Test + void transactionalBatchUnionsShapesAcrossStatements() { + var result = service.parse("BEGIN; UPDATE users SET active = false WHERE id IN (SELECT user_id FROM bans);" + + " DELETE FROM orders USING users WHERE orders.user_id = users.id; COMMIT;"); + + assertThat(result.transactional()).isTrue(); + assertThat(result.shapesAnalyzed()).isTrue(); + assertThat(result.shapes()).containsExactlyInAnyOrder(QueryShape.SUBQUERY, QueryShape.JOIN); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE TABLE t (id INT)", + "CREATE INDEX idx_users_name ON users (name)", + "ALTER TABLE users ADD COLUMN age INT", + "DROP TABLE users", + "TRUNCATE TABLE users" + }) + void commonDdlIsShapeAnalyzed(String sql) { + var result = service.parse(sql); + + assertThat(result.shapesAnalyzed()).isTrue(); + assertThat(result.shapes()).isEmpty(); + } + + @Test + void aStatementTheDetectorCannotWalkIsReportedAsNotShapeAnalyzed() { + // JSqlParser's finder raises on CREATE SCHEMA; parsing still succeeds, the shape is unknown. + var result = service.parse("CREATE SCHEMA reporting"); + + assertThat(result.type()).isEqualTo(QueryType.DDL); + assertThat(result.shapesAnalyzed()).isFalse(); + assertThat(result.shapes()).isEmpty(); + } + @Test void parsesSelectWithCte() { SqlParseResult result = 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 24b42d1a9..f18178876 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, List.of(), List.of(), null, null); + false, bg, List.of(), List.of(), List.of(), null, List.of(), List.of(), List.of(), null, null); } private ApiConnectorPermissionLookupView apiPerm(boolean read, boolean write, boolean bg) { @@ -285,6 +285,30 @@ void breakGlassSubmitAlsoRejectsADeniedTable() { verify(stateService, org.mockito.Mockito.never()).apply(any(), any()); } + @Test + void submitRejectsAMemberWithADeniedQueryShape() { + 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(new DatasourceUserPermissionView(UUID.randomUUID(), userId, + datasourceId, true, false, false, false, List.of(), List.of(), List.of(), + List.of(), List.of(), List.of(), + List.of(com.bablsoft.accessflow.core.api.QueryShape.SUBQUERY), null, null))); + 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(), true, + java.util.Set.of(com.bablsoft.accessflow.core.api.QueryShape.SUBQUERY), true)); + + assertThatThrownBy(() -> service.submit(new SubmitRequestGroupCommand(group.getId(), orgId, + userId, false, false, null, "1.2.3.4", "ua"))) + .isInstanceOf(RequestGroupPermissionException.class) + .hasMessageContaining("SUBQUERY"); + 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); @@ -297,7 +321,7 @@ private RequestGroupItemEntity deniedTableItem() { 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); + List.of("crm.salary"), List.of(), null, null); } private void stubDeniedTableParse() { @@ -319,7 +343,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"), List.of(), List.of(), null, null); + false, breakGlass, List.of(), List.of(), List.of(), List.of("customer.ssn"), List.of(), List.of(), List.of(), null, null); } private void stubDeniedColumnParse() { @@ -434,7 +458,7 @@ private RequestGroupItemEntity allowListItem() { private DatasourceUserPermissionView allowListPerm(List schemas, List tables, boolean breakGlass) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false, - false, breakGlass, schemas, tables, List.of(), List.of(), List.of(), List.of(), null, null); + false, breakGlass, schemas, tables, List.of(), List.of(), List.of(), List.of(), List.of(), null, null); } @Test 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 e483c94ec..8ecee73e8 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, List.of(), List.of(), null, null); + false, breakGlass, List.of(), List.of(), List.of(), null, List.of(), 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 7917cf097..7d60404be 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, List.of(), List.of(), 100, null))); + List.of("public.users.ssn"), null, List.of(), 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, List.of(), List.of(), 100, null))); + List.of("public.users.ssn"), null, List.of(), 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 2a3610f81..245f1e70d 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, List.of(), List.of(), null, null))); + List.of(), List.of(), List.of(), null, List.of(), 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 9359f9327..0343cf896 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 @@ -820,6 +820,65 @@ void grantGroupPermissionRejectsBlankDeniedTableAndOversizeDeniedSchemas() { assertThat(oversize).hasStatus(400); } + @Test + void grantPermissionRoundTripsDeniedShapesInDeclarationOrder() { + 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_shapes":["SUBQUERY","JOIN","JOIN"]} + """.formatted(analyst.getId())) + .exchange(); + + assertThat(result).hasStatus(201); + assertThat(result).bodyJson().extractingPath("$.denied_shapes").asArray() + .containsExactly("JOIN", "SUBQUERY"); + var listed = mvc.get().uri("/api/v1/datasources/" + ds.getId() + "/permissions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(listed).bodyJson().extractingPath("$.content[0].denied_shapes").asArray() + .containsExactly("JOIN", "SUBQUERY"); + } + + @Test + void grantPermissionRejectsUnknownNullOrTooManyDeniedShapes() { + var ds = saveDatasource(primaryOrg, "DS"); + + for (var shapes : List.of("[\"CROSS_APPLY\"]", "[null]", + "[\"JOIN\",\"JOIN\",\"JOIN\",\"JOIN\",\"JOIN\",\"JOIN\",\"JOIN\",\"JOIN\",\"JOIN\"]")) { + 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_shapes\":%s}" + .formatted(analyst.getId(), shapes)) + .exchange(); + + assertThat(result).hasStatus(400); + } + assertThat(permissionRepository.existsByUser_IdAndDatasource_Id(analyst.getId(), + ds.getId())).isFalse(); + } + + @Test + void grantGroupPermissionRoundTripsDeniedShapes() { + 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_shapes":["WINDOW_FUNCTION","CTE"]} + """.formatted(group.getId())) + .exchange(); + + assertThat(result).hasStatus(201); + assertThat(result).bodyJson().extractingPath("$.denied_shapes").asArray() + .containsExactly("CTE", "WINDOW_FUNCTION"); + } + private static String jsonNames(String prefix, int count) { var names = new java.util.ArrayList(); for (int i = 0; i < count; i++) { diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandlerTest.java index c8d6ca563..156aa50e5 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/GlobalExceptionHandlerTest.java @@ -291,6 +291,18 @@ void deniedColumnsNotSupportedReturns422() { .containsEntry("dbType", "MONGODB"); } + @Test + void deniedShapesNotSupportedReturns422() { + var pd = handler.handleDeniedShapesNotSupported( + new com.bablsoft.accessflow.core.api.DeniedShapesNotSupportedException( + com.bablsoft.accessflow.core.api.DbType.REDIS)); + + assertThat(pd.getStatus()).isEqualTo(422); + assertThat(pd.getProperties()) + .containsEntry("error", "DENIED_SHAPES_NOT_SUPPORTED") + .containsEntry("dbType", "REDIS"); + } + @Test void invalidSqlReturns422() { var pd = handler.handleInvalidSql(new InvalidSqlException("nope")); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java index ef60391f9..6b32b3085 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/api/ConditionNodeTest.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.api; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; import org.junit.jupiter.api.Test; @@ -120,6 +121,14 @@ void booleanLeavesExposeExpected() { assertThat(new ConditionNode.Transactional(true).expected()).isTrue(); } + @Test + void queryShapeInNullBecomesEmptyImmutableSet() { + var node = new ConditionNode.QueryShapeIn(null); + assertThat(node.anyOf()).isEmpty(); + assertThatThrownBy(() -> node.anyOf().add(QueryShape.JOIN)) + .isInstanceOf(UnsupportedOperationException.class); + } + @Test void sourceIpMatchesNullBecomesEmptyImmutableList() { assertThat(new ConditionNode.SourceIpMatches(null).cidrs()).isEmpty(); 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 6b3273d46..995fbc4ed 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 @@ -1,7 +1,9 @@ package com.bablsoft.accessflow.workflow.internal; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; +import com.bablsoft.accessflow.core.api.SqlParseResult; import org.junit.jupiter.api.Test; import java.util.List; @@ -93,13 +95,26 @@ 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, List.of(), List.of(), null, null); + List.of(), List.of(), List.of(), null, List.of(), 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, List.of(), List.of(), null, null); + allowedSchemas, allowedTables, List.of(), null, List.of(), List.of(), List.of(), null, null); + } + + @Test + void rejectedShapesReadsTheMergedDenyListAndShapeNamesAreOrdered() { + var permission = new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), true, false, false, false, List.of(), List.of(), List.of(), null, + List.of(), List.of(), List.of(QueryShape.SUBQUERY, QueryShape.JOIN), null, null); + var parsed = new SqlParseResult(QueryType.SELECT, false, List.of("sql"), Set.of(), false, + false, Set.of(), true, Set.of(QueryShape.SUBQUERY, QueryShape.JOIN, QueryShape.CTE), true); + + var rejected = DatasourcePermissionChecker.rejectedShapes(permission, parsed); + + assertThat(DatasourcePermissionChecker.shapeNames(rejected)).containsExactly("JOIN", "SUBQUERY"); } @Test @@ -144,7 +159,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"), List.of(), List.of(), null, null); + List.of("users.ssn"), List.of(), 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( @@ -218,6 +233,6 @@ private DatasourceUserPermissionView denying(List allowedSchemas, List deniedTables) { return new DatasourceUserPermissionView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), true, true, true, false, allowedSchemas, allowedTables, - List.of(), null, deniedSchemas, deniedTables, null, null); + List.of(), null, deniedSchemas, deniedTables, List.of(), 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 26109f2a8..19308877e 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 @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.core.api.ColumnReference; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService; import com.bablsoft.accessflow.core.api.DatasourceUserPermissionView; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.SqlParseResult; import org.junit.jupiter.api.BeforeEach; @@ -52,7 +53,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, List.of(), List.of(), null, expiresAt); + canRead, canWrite, false, false, null, allowedTables, null, null, List.of(), List.of(), List.of(), null, expiresAt); } @Test @@ -257,17 +258,71 @@ void verifyReportsTheAllowListBeforeTheDenyList() { .hasMessage("TABLE_NOT_ALLOWED_MARKER"); } + @Test + void verifyRejectsAQueryWithADeniedShape() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingShapes(List.of(QueryShape.JOIN, QueryShape.CTE)))); + when(messageSource.getMessage(eq("error.permission.shape_denied"), any(), any(Locale.class))) + .thenAnswer(inv -> "SHAPE_DENIED " + ((Object[]) inv.getArgument(1))[0]); + + assertThatThrownBy(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + shaped(Set.of(QueryShape.CTE, QueryShape.JOIN, QueryShape.AGGREGATE), true))) + .isInstanceOf(AccessDeniedException.class) + .hasMessage("SHAPE_DENIED JOIN, CTE"); + } + + @Test + void verifyLetsAQueryWithoutADeniedShapeThrough() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingShapes(List.of(QueryShape.JOIN)))); + + assertThatCode(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + shaped(Set.of(QueryShape.AGGREGATE), true))) + .doesNotThrowAnyException(); + } + + @Test + void verifyFailsClosedWhenTheShapeWasNotAnalyzed() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingShapes(List.of(QueryShape.JOIN)))); + + assertThatThrownBy(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + shaped(Set.of(), false))) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void anUnanalyzedShapeIsIrrelevantWithoutADenyList() { + when(permissionLookupService.findFor(userId, datasourceId)) + .thenReturn(Optional.of(denyingShapes(List.of()))); + + assertThatCode(() -> verifier.verify(userId, datasourceId, QueryType.SELECT, + shaped(Set.of(), false))) + .doesNotThrowAnyException(); + } + + private DatasourceUserPermissionView denyingShapes(List deniedShapes) { + return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, + true, false, false, false, null, null, null, null, List.of(), List.of(), + deniedShapes, null, null); + } + + private static SqlParseResult shaped(Set shapes, boolean analyzed) { + return new SqlParseResult(QueryType.SELECT, false, List.of("sql"), Set.of("public.users"), + false, false, Set.of(), true, shapes, analyzed); + } + 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); + deniedTables, List.of(), null, null); } private DatasourceUserPermissionView denying(List deniedColumns) { return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, - true, false, false, false, null, null, null, deniedColumns, List.of(), List.of(), null, null); + true, false, false, false, null, null, null, deniedColumns, List.of(), 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 f85314ca6..0afae9775 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 @@ -14,6 +14,7 @@ import com.bablsoft.accessflow.core.api.MaskingStrategy; import com.bablsoft.accessflow.core.api.Permission; import com.bablsoft.accessflow.core.api.QueryStatus; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.QuotaExceededException; import com.bablsoft.accessflow.core.api.QuotaService; @@ -376,7 +377,7 @@ 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))); + List.of(), List.of(), List.of(), List.of("public.payments"), List.of(), null, null))); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -389,12 +390,32 @@ void aDeniedTableStopsTheRequestAndNamesIt() { .containsEntry("denied_tables", List.of("public.payments")); } + @Test + void aDeniedQueryShapeStopsTheRequestAndNamesIt() { + when(queryParser.parse(any(), any())).thenReturn( + new SqlParseResult(QueryType.SELECT, false, List.of("SELECT 1"), Set.of("public.payments"), + false, false, Set.of(), true, Set.of(QueryShape.JOIN, QueryShape.UNION), true)); + 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(), List.of(), List.of(), List.of(QueryShape.UNION, QueryShape.CTE), + 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.shape_denied"); + assertThat(permission.details()).containsEntry("denied_shapes", List.of("UNION")); + } + @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))); + List.of(), List.of(), List.of("public"), List.of(), List.of(), null, null))); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -410,7 +431,7 @@ 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))); + List.of(), List.of(), List.of(), List.of("public.salary"), List.of(), null, null))); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -428,7 +449,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"), List.of(), List.of(), null, null))); + List.of("public.payments.card"), List.of(), List.of(), List.of(), null, null))); var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); @@ -440,6 +461,19 @@ void aDeniedColumnStopsTheRequestAndNamesIt() { .containsEntry("rejected_columns", List.of("public.payments.card")); } + @Test + void theParseStepEchoesTheQueryShapesInDeclarationOrder() { + when(queryParser.parse(any(), any())).thenReturn( + new SqlParseResult(QueryType.SELECT, false, List.of("SELECT 1"), Set.of(), false, + false, Set.of(), true, Set.of(QueryShape.UNION, QueryShape.JOIN), true)); + + var result = service.simulate(organizationId, input(AiOutcome.COMPLETED, RiskLevel.LOW, 5)); + + assertThat(step(result.steps(), QueryDecisionStepKind.SQL_PARSE).details()) + .containsEntry("query_shapes", List.of("JOIN", "UNION")) + .containsEntry("shapes_analyzed", true); + } + @Test void anAllowListPassOverNoTablesIsFlaggedAsVacuous() { // rejectedTables() returns empty for an empty table set, in the simulator exactly as in the @@ -753,13 +787,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, List.of(), List.of(), null, null, null); + true, false, false, false, List.of("public"), List.of(), List.of(), null, List.of(), 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, List.of(), List.of(), null, null); + canWrite, canDdl, false, allowedSchemas, List.of(), List.of(), null, List.of(), 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 71463ea84..633649da3 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, List.of(), List.of(), null, expiresAt); + true, false, false, true, List.of(), List.of(), List.of(), null, List.of(), 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 6fd7f9e6c..ea5c0fe05 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 @@ -9,6 +9,7 @@ import com.bablsoft.accessflow.core.api.InvalidSqlException; import com.bablsoft.accessflow.core.api.QueryRequestPersistenceService; import com.bablsoft.accessflow.core.api.QueryRequestStateService; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.QuotaService; @@ -226,7 +227,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"), List.of(), List.of(), null, null))); + List.of(), List.of(), List.of(), List.of("customer.ssn"), List.of(), List.of(), List.of(), null, null))); assertThatThrownBy(() -> service.breakGlassExecute( input("SELECT ssn FROM customer", false))) @@ -245,7 +246,7 @@ void deniesWhenDeniedTableReferencedEvenInsideAnAllowedSchema() { .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))); + List.of("crm.salary"), List.of(), null, null))); assertThatThrownBy(() -> service.breakGlassExecute( input("SELECT * FROM crm.salary", false))) @@ -264,6 +265,7 @@ void deniesWhenTableInADeniedSchemaReferenced() { .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(), + List.of(), null, null))); assertThatThrownBy(() -> service.breakGlassExecute( @@ -272,6 +274,25 @@ void deniesWhenTableInADeniedSchemaReferenced() { verify(queryRequestPersistenceService, never()).submit(any()); } + @Test + void deniesWhenTheQueryHasADeniedShape() { + stubDatasourceForUser(true); + when(queryParser.parse(eq("SELECT * FROM a JOIN b ON a.id = b.id"), any())) + .thenReturn(new SqlParseResult(QueryType.SELECT, false, + List.of("SELECT * FROM a JOIN b ON a.id = b.id"), Set.of("a", "b"), false, + false, Set.of(), true, Set.of(QueryShape.JOIN), 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(), List.of(), + List.of(QueryShape.JOIN), null, null))); + + assertThatThrownBy(() -> service.breakGlassExecute( + input("SELECT * FROM a JOIN b ON a.id = b.id", false))) + .isInstanceOf(BreakGlassNotPermittedException.class); + verify(queryRequestPersistenceService, never()).submit(any()); + } + @Test void rejectsQueryTypeOther() { stubDatasourceForUser(true); @@ -363,7 +384,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, List.of(), List.of(), null, expiresAt))); + allowedSchemas, allowedTables, List.of(), null, List.of(), 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 cc1472a96..2031d7c6c 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 @@ -414,14 +414,14 @@ 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, List.of(), List.of(), null, expiresAt, null); + breakGlass, schemas, tables, List.of(), null, List.of(), 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, + List.of(), List.of(), List.of(), null, deniedSchemas, deniedTables, List.of(), null, null, null); } @@ -430,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, List.of(), List.of(), null, expiresAt, accessGrantRequestId); + List.of("public"), List.of(), List.of(), null, List.of(), 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, List.of(), List.of(), null, null, null); + List.of(), List.of(), List.of(), null, List.of(), List.of(), List.of(), null, null, null); } private DatasourcePermissionContribution group(UUID userId, boolean canRead, boolean canWrite, @@ -444,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, List.of(), List.of(), null, null, null); + canWrite, false, false, schemas, tables, List.of(), null, List.of(), List.of(), List.of(), null, null, null); } private AccessGrantView grant(UUID grantId, UUID requesterId) { @@ -498,6 +498,7 @@ private DatasourceUserPermissionView merge(List(schemas), unrestrictedTables ? List.of() : new ArrayList<>(tables), List.of(), null, new ArrayList<>(deniedSchemas), new ArrayList<>(deniedTables), + List.of(), 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 eb18c651c..8ffa5b9a9 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, List.of(), List.of(), null, null); + List.of(), List.of(), List.of("public.users.ssn", "public.users.email"), null, List.of(), 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, List.of(), List.of(), rowLimitOverride, null); + List.of(), List.of(), List.of(), null, List.of(), 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 c1e1a7c21..bc27f8bf7 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, List.of(), List.of(), null, expiresAt))); + allowedSchemas, allowedTables, List.of(), null, List.of(), 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 a76d1eb56..a8916bbf8 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 @@ -164,7 +164,7 @@ void suggestionsReferencingADeniedTableAreDroppedEvenWithoutAnAllowList() { 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))); + null, List.of("hr"), List.of("crm.salary"), List.of(), null, null))); var railed = service.findForViewer(DATASOURCE, ORG, USER, false, 10); @@ -291,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, List.of(), List.of(), null, expiresAt); + ddl, false, schemas, tables, List.of(), null, List.of(), List.of(), List.of(), null, expiresAt); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedShapesEnforcementIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedShapesEnforcementIntegrationTest.java new file mode 100644 index 000000000..771adb082 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/DeniedShapesEnforcementIntegrationTest.java @@ -0,0 +1,218 @@ +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.QueryShape; +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; + +/** + * #940 acceptance: a grant denying {@code JOIN} refuses a joined query at submission with 403 before + * anything is persisted — also inside a transactional batch and through a subquery — while a simple + * query passes, a grant with no denied shapes is unaffected, and a permissive group grant can never + * lift the denial. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class DeniedShapesEnforcementIntegrationTest { + + @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; + private DatasourceUserPermissionEntity permission; + + @BeforeEach + void setUp() { + organization = new OrganizationEntity(); + organization.setId(UUID.randomUUID()); + organization.setName("Denied shapes"); + organization.setSlug("denied-shapes-" + 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); + + permission = new DatasourceUserPermissionEntity(); + permission.setId(UUID.randomUUID()); + permission.setDatasource(datasource); + permission.setUser(analyst); + permission.setCanRead(true); + permission.setCanWrite(true); + permission.setCreatedBy(analyst); + permission.setDeniedShapes(new String[] {"JOIN"}); + permission = 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 c.id FROM customer c JOIN orders o ON o.customer_id = c.id", + "SELECT * FROM customer c, orders o WHERE o.customer_id = c.id", + "SELECT id FROM customer WHERE id IN (SELECT o.customer_id FROM orders o JOIN items i ON i.order_id = o.id)", + "BEGIN; UPDATE customer SET active = false WHERE id = 1; DELETE FROM orders USING customer WHERE orders.customer_id = customer.id; COMMIT;"}) + void aQueryWithADeniedShapeIsRefusedBeforeAnythingIsPersisted(String sql) { + assertThatThrownBy(() -> submit(sql)) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("JOIN"); + + assertThat(persistedQueries()).isZero(); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT id, name FROM customer WHERE id = 1 ORDER BY name", + "SELECT count(*) FROM customer", + "SELECT id FROM customer WHERE id IN (SELECT customer_id FROM orders)"}) + void aQueryWithoutTheDeniedShapePassesTheGate(String sql) { + var parsed = queryParser.parse(sql, DbType.POSTGRESQL); + + assertThat(parsed.shapesAnalyzed()).isTrue(); + assertThatCode(() -> permissionVerifier.verify(analyst.getId(), datasource.getId(), + parsed.type(), parsed)).doesNotThrowAnyException(); + } + + @Test + void aGrantWithNoDeniedShapesLeavesAJoinedQueryAlone() { + permission.setDeniedShapes(null); + permissionRepository.save(permission); + var parsed = queryParser.parse("SELECT c.id FROM customer c JOIN orders o ON o.customer_id = c.id", + DbType.POSTGRESQL); + + assertThat(parsed.shapes()).contains(QueryShape.JOIN); + 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.setDeniedShapes(new String[] {"UNION"}); + groupPermissionRepository.save(groupPermission); + + assertThatThrownBy(() -> submit("SELECT c.id FROM customer c JOIN orders o ON o.customer_id = c.id")) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("JOIN"); + // The group's own denial joins the union too. + assertThatThrownBy(() -> submit("SELECT id FROM customer UNION SELECT id FROM orders")) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("UNION"); + 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 bc77ee9a4..3c03e0c04 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, List.of(), List.of(), null, null); + true, false, false, false, schemas, tables, List.of(), null, List.of(), 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, List.of(), List.of(), null, null, null))); + List.of(), null, List.of(), List.of(), List.of(), null, null, null))); when(permissionLookupService.mergeContributions(any())) .thenReturn(Optional.of(permission)); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java index 64beadce7..b9fc2cfe8 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/ConditionContextFactoryTest.java @@ -6,6 +6,7 @@ import com.bablsoft.accessflow.core.api.QueryEstimateLookupService; import com.bablsoft.accessflow.core.api.QueryRequestLookupService; import com.bablsoft.accessflow.core.api.QueryStatus; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.core.api.SqlParseResult; @@ -204,5 +205,19 @@ void anUnparseableHistoricalQueryDegradesToEmptyTableSignals() { assertThat(context.referencedTables()).isEmpty(); assertThat(context.hasWhereClause()).isFalse(); assertThat(context.hasLimitClause()).isFalse(); + assertThat(context.queryShapes()).isEmpty(); + assertThat(context.shapesAnalyzed()).isFalse(); + } + + @Test + void historicalRowCarriesTheParsedQueryShapes() { + when(sqlParserService.parse(any())).thenReturn(new SqlParseResult(QueryType.SELECT, false, + List.of("SELECT 1"), Set.of("public.orders"), true, false, Set.of(), true, + Set.of(QueryShape.JOIN, QueryShape.AGGREGATE), true)); + + var context = factory.forHistoricalRow(row(RiskLevel.LOW, 10), ZoneId.of("UTC")); + + assertThat(context.queryShapes()).containsExactlyInAnyOrder(QueryShape.JOIN, QueryShape.AGGREGATE); + assertThat(context.shapesAnalyzed()).isTrue(); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java index 96a60a597..12515eafa 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionCodecTest.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.internal.routing; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.workflow.api.ComparisonOperator; @@ -45,6 +46,7 @@ void roundTripsEveryLeafAndCombinator() { new ConditionNode.DayOfWeekIn(Set.of(DayOfWeek.MONDAY, DayOfWeek.FRIDAY)), new ConditionNode.HasLimitClause(false), new ConditionNode.Transactional(true), + new ConditionNode.QueryShapeIn(Set.of(QueryShape.JOIN, QueryShape.WINDOW_FUNCTION)), new ConditionNode.SourceIpMatches(List.of("203.0.113.0/24", "2001:db8::/32")), new ConditionNode.UserAgentMatches(List.of("*curl*", "*GitHubActions*")), new ConditionNode.TimeSinceLastApproval(ComparisonOperator.GT, 1440), @@ -68,6 +70,15 @@ void estimatedRowsAndScanTypeUseSnakeCaseDiscriminators() { .contains("\"type\":\"scan_type\""); } + @Test + void queryShapeUsesItsDiscriminatorAndUpperCaseShapeNames() { + var json = codec.encode(new ConditionNode.QueryShapeIn(Set.of(QueryShape.GROUP_BY))); + + assertThat(json).contains("\"type\":\"query_shape\"").contains("\"any_of\":[\"GROUP_BY\"]"); + assertThat(codec.decode("{\"type\":\"query_shape\",\"any_of\":[\"JOIN\",\"CTE\"]}")) + .isEqualTo(new ConditionNode.QueryShapeIn(Set.of(QueryShape.JOIN, QueryShape.CTE))); + } + @Test void wireShapeForClientContextLeaves() { assertThat(codec.encode(new ConditionNode.SourceIpMatches(List.of("10.0.0.0/8")))) diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java index b5930af93..8abd7b199 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionEvaluatorTest.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.internal.routing; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.workflow.api.ComparisonOperator; @@ -151,6 +152,37 @@ void hasLimitClause() { assertThat(evaluator.matches(new ConditionNode.HasLimitClause(true), context())).isFalse(); } + private ConditionContext shapedContext(Set shapes, boolean analyzed) { + return new ConditionContext(QueryType.SELECT, Set.of("public.orders"), RiskLevel.LOW, 10, + "ANALYST", Set.of(groupId), LocalDateTime.of(2026, 6, 3, 14, 30), + false, false, false, null, null, false, null, false, null, null, shapes, analyzed); + } + + @Test + void queryShapeMatchesAJoinedQueryAndLeavesASimpleOneAlone() { + var node = new ConditionNode.QueryShapeIn(Set.of(QueryShape.JOIN)); + + assertThat(evaluator.matches(node, shapedContext(Set.of(QueryShape.JOIN, QueryShape.AGGREGATE), true))) + .isTrue(); + assertThat(evaluator.matches(node, shapedContext(Set.of(), true))).isFalse(); + assertThat(evaluator.matches(node, shapedContext(Set.of(QueryShape.UNION), true))).isFalse(); + } + + @Test + void queryShapeMatchesAnyListedShape() { + var node = new ConditionNode.QueryShapeIn(Set.of(QueryShape.CTE, QueryShape.SUBQUERY)); + + assertThat(evaluator.matches(node, shapedContext(Set.of(QueryShape.SUBQUERY), true))).isTrue(); + } + + @Test + void queryShapeFailsClosedWhenTheShapeWasNotAnalyzed() { + var node = new ConditionNode.QueryShapeIn(Set.of(QueryShape.JOIN)); + + assertThat(evaluator.matches(node, shapedContext(Set.of(QueryShape.JOIN), false))).isFalse(); + assertThat(evaluator.matches(node, context())).isFalse(); + } + @Test void transactionalFlag() { assertThat(evaluator.matches(new ConditionNode.Transactional(false), context())).isTrue(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidatorTest.java index 01d891a02..02fbba9eb 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidatorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/routing/RoutingConditionValidatorTest.java @@ -1,5 +1,6 @@ package com.bablsoft.accessflow.workflow.internal.routing; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.workflow.api.ConditionNode; import com.bablsoft.accessflow.workflow.api.IllegalRoutingPolicyException; import org.junit.jupiter.api.BeforeEach; @@ -22,6 +23,8 @@ void setUp() { var messages = new StaticMessageSource(); messages.addMessage("error.routing_policy_cidr_invalid", Locale.getDefault(), "Not a valid CIDR block: {0}"); + messages.addMessage("error.routing_policy_query_shape_empty", Locale.getDefault(), + "A query shape condition must name at least one shape"); validator = new RoutingConditionValidator(messages); } @@ -38,6 +41,19 @@ void rejectsInvalidSourceIpCidr() { validator.validate(new ConditionNode.SourceIpMatches(List.of("10.0.0.0/8", "nope")))); } + @Test + void rejectsAQueryShapeConditionWithNoShapes() { + assertThatExceptionOfType(IllegalRoutingPolicyException.class) + .isThrownBy(() -> validator.validate(new ConditionNode.Not(new ConditionNode.QueryShapeIn(Set.of())))) + .withMessage("A query shape condition must name at least one shape"); + } + + @Test + void acceptsAQueryShapeConditionWithShapes() { + assertThatCode(() -> validator.validate(new ConditionNode.QueryShapeIn(Set.of(QueryShape.JOIN)))) + .doesNotThrowAnyException(); + } + @Test void walksCombinatorsRecursively() { var nested = new ConditionNode.And(List.of( diff --git a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponseTest.java b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponseTest.java index c8ae4ccc4..a599ae205 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponseTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/workflow/internal/web/AccessSimulationResponseTest.java @@ -1,6 +1,7 @@ package com.bablsoft.accessflow.workflow.internal.web; import com.bablsoft.accessflow.core.api.QueryStatus; +import com.bablsoft.accessflow.core.api.QueryShape; import com.bablsoft.accessflow.core.api.QueryType; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.core.api.SimulationCaveat; @@ -56,7 +57,8 @@ void echoesEveryRoutingSignalTheEvaluationSaw() { var evaluatedAt = LocalDateTime.parse("2026-09-10T11:04:00"); var context = new ConditionContext(QueryType.UPDATE, Set.of("public.payments"), RiskLevel.HIGH, 82, "ANALYST", Set.of(groupId), evaluatedAt, true, false, false, - null, null, false, 47, false, null, null); + null, null, false, 47, false, null, null, + Set.of(QueryShape.SUBQUERY, QueryShape.JOIN), true); var response = AccessSimulationResponse.from( new AccessSimulationResult(List.of(), QueryStatus.PENDING_REVIEW, context, @@ -74,5 +76,7 @@ void echoesEveryRoutingSignalTheEvaluationSaw() { // A hypothetical request carries no client context; the caveat says so, the field stays null. assertThat(echoed.requesterIpAddress()).isNull(); assertThat(echoed.estimatedRows()).isNull(); + assertThat(echoed.queryShapes()).containsExactly(QueryShape.JOIN, QueryShape.SUBQUERY); + assertThat(echoed.shapesAnalyzed()).isTrue(); } } diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 76e2b9e8c..48bf1fbe6 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -313,6 +313,7 @@ Grants a specific user access to a specific datasource with granular controls. | `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. | +| `denied_shapes` | TEXT[] nullable (#940, Flyway V191) — query shapes the grantee may not use, stored as `QueryShape` names in declaration order: `JOIN`, `UNION` (every set operation — `UNION` / `INTERSECT` / `EXCEPT` / `MINUS`), `SUBQUERY`, `CTE`, `GROUP_BY`, `HAVING`, `AGGREGATE` (the standard aggregate set by name, plus any ordered-set `WITHIN GROUP` or `FILTER`ed call — user-defined aggregates are not detected) and `WINDOW_FUNCTION`. The shape is detected from the JSqlParser AST anywhere in the statement — subqueries, CTE bodies, `INSERT … SELECT` and every statement of a `BEGIN … COMMIT` batch — and a query with a denied shape is rejected with 403 before it is persisted. **Fails closed**: a statement whose shape could not be analysed has every denied shape. Relational engines only: a non-empty list is refused at grant time (422 `DENIED_SHAPES_NOT_SUPPORTED`) for an engine-managed datasource. 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` | @@ -330,8 +331,8 @@ grant they belong to — resolved in `DefaultDatasourceUserPermissionLookupServi allow-lists unioned; `restricted_columns` intersected so a column is masked only when every contributing grant masks 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 the deny-lists — `denied_schemas` / `denied_tables` (#939) and -`denied_columns` (#1099) — merge to their **union**, so a permissive grant can never lift another +raise a tight per-user cap (#933), and the deny-lists — `denied_schemas` / `denied_tables` (#939), +`denied_columns` (#1099) and `denied_shapes` (#940) — merge to their **union**, so a permissive grant can never lift another grant's denial and a group grant's denial binds every member. 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 @@ -345,7 +346,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` / `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) | +| `allowed_schemas` / `allowed_tables` / `restricted_columns` / `denied_columns` / `denied_schemas` / `denied_tables` / `denied_shapes` | TEXT[] nullable — same semantics as the per-user table (`denied_columns` added by V186, #935; `denied_schemas` / `denied_tables` by V190, #939; `denied_shapes` by V191, #940 — deny-lists merge 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 | @@ -819,6 +820,7 @@ The condition is a polymorphic, `"type"`-discriminated tree (snake_case, no exte | `day_of_week` | `any_of: [DayOfWeek]` | the submission day is in the set | | `has_where` | `expected: bool` | presence of a WHERE clause equals `expected` | | `has_limit` | `expected: bool` | presence of a LIMIT clause equals `expected` | +| `query_shape` (#940) | `any_of: [QueryShape]` | the query has **any** of the listed shapes — `JOIN`, `UNION` (any set operation), `SUBQUERY`, `CTE`, `GROUP_BY`, `HAVING`, `AGGREGATE`, `WINDOW_FUNCTION` — anywhere in the statement, unioned across a `BEGIN…COMMIT` batch (the same detection as a grant's `denied_shapes`). Re-derived from the SQL text at routing time like `has_where`, so nothing is persisted on the query. `any_of` must be non-empty (422). **Fails closed**: false when the shape could not be analysed — routing re-parses the stored SQL with JSqlParser for every engine, so this is a statement JSqlParser cannot parse (any MongoDB / Redis command, a warehouse-specific construct) or one its walker cannot traverse | | `transactional` | `expected: bool` | the `BEGIN…COMMIT` transactional flag equals `expected` | | `source_ip` (AF-446) | `cidrs: [string]` | the submission source IP falls within any CIDR (IPv4 or IPv6). CIDR syntax is validated on create / update (422 on a malformed block). **Fails closed**: false when no source IP was captured | | `user_agent` (AF-446) | `patterns: [string]` | the submission user-agent matches any glob (`*` wildcard, case-insensitive). **Fails closed**: false when no user-agent was captured | diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 7fb205a03..43e8309de 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -634,6 +634,7 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis "denied_columns": ["public.users.password_hash"], "denied_schemas": null, "denied_tables": ["public.salary"], + "denied_shapes": ["JOIN", "SUBQUERY"], "expires_at": null, "created_by": "uuid", "created_at": "2026-05-04T10:15:00Z" @@ -648,6 +649,8 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis `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. +`denied_shapes` (#940) is a query-shape deny-list — `QueryShape` names returned in declaration order, `null` when unset: `JOIN`, `UNION` (every set operation — `UNION`, `INTERSECT`, `EXCEPT`, `MINUS`), `SUBQUERY`, `CTE`, `GROUP_BY`, `HAVING`, `AGGREGATE` and `WINDOW_FUNCTION`. The shape is read from the JSqlParser AST **anywhere** in the statement — a join inside a subquery or a CTE body counts, and a `BEGIN … COMMIT` batch carries the union of its statements' shapes. `AGGREGATE` is the built-in aggregate set of the in-process engines, matched by name (`COUNT`, `COUNT_BIG`, `SUM`, `AVG`, `MIN`, `MAX`, `STRING_AGG`, `ARRAY_AGG`, `GROUP_CONCAT`, `LISTAGG`, `XMLAGG`, `COLLECT`, `JSON_AGG` / `JSONB_AGG` / `JSON_OBJECT_AGG`, `JSON_ARRAYAGG` / `JSON_OBJECTAGG`, the `STDDEV*` / `STD` / `STDEV*` / `VAR*` family, `CORR`, `COVAR_*`, `REGR_*`, `BOOL_AND` / `BOOL_OR` / `EVERY`, `BIT_*`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`, `ANY_VALUE`, `MEDIAN`, `MODE`, `PERCENTILE_*`), plus any ordered-set (`WITHIN GROUP`) or `FILTER`ed call; a user-defined aggregate is not detected, and the list is best-effort — an engine's rarer built-in can be missing. A parenthesised select that is the statement's own query — a set-operation branch, a CTE body, the rows of `INSERT … SELECT` / `CREATE TABLE … AS SELECT` — is not a `SUBQUERY`; any other select is, including one passed as a function argument (`ARRAY(SELECT …)`). A parenthesised join (`FROM (a JOIN b ON …)`) and every `MERGE` count as `JOIN`, and `OTHER`-type statements (a request-group member may be one) are checked like the rest. The check **fails closed**: a statement whose shape could not be analysed has every denied shape. A query with a denied shape is rejected **before** it is persisted: `POST /queries` and `POST /queries/dry-run` answer 403 `FORBIDDEN` with `error.permission.shape_denied` ("Query has one or more shapes the user is denied on this datasource: …"), break-glass answers `BREAK_GLASS_NOT_PERMITTED`, and a request-group submit answers `REQUEST_GROUP_PERMISSION_DENIED`. No `denied_shapes` means behaviour is unchanged. To escalate a shape rather than refuse it, use the `query_shape` routing condition instead. + ### POST /datasources/{id}/permissions — Request Body ```json @@ -664,6 +667,7 @@ ADMINs may sample any datasource in their organization; non-ADMINs need a permis "denied_columns": ["public.users.password_hash"], "denied_schemas": ["audit"], "denied_tables": ["public.salary"], + "denied_shapes": ["JOIN"], "expires_at": "2026-12-31T23:59:59Z" } ``` @@ -679,7 +683,12 @@ or `schema.*` (the whole schema), with no empty segment and no other wildcard. A 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, +recorded in the `PERMISSION_GRANTED` / `PERMISSION_GROUP_GRANTED` audit metadata when non-empty. +`denied_shapes` (#940) is optional, at most 8 non-null `QueryShape` names; an unknown name is 400 +`VALIDATION_ERROR` (`error.datasource_body_unreadable` — every `/datasources` endpoint answers an +unreadable body, such as an unknown `db_type`, with this 400 rather than a 500), too many is 400 (`validation.denied_shapes.too_many`). +It is stored in declaration order with duplicates dropped, recorded in the grant audit metadata when +non-empty, and supported only on the in-process relational engines. `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`. @@ -688,6 +697,7 @@ default `false`) grants the emergency break-glass submission mode on this dataso **Response 409:** A permission row already exists for `(user_id, datasource_id)`. `error: DATASOURCE_PERMISSION_ALREADY_EXISTS`. **Response 422:** Target user does not exist or does not belong to the caller's organization. `error: ILLEGAL_DATASOURCE_PERMISSION`. **Response 422:** `denied_columns` is non-empty on an engine-managed datasource (every engine plugin, warehouses included). `error: DENIED_COLUMNS_NOT_SUPPORTED`, with `dbType`. +**Response 422:** `denied_shapes` is non-empty on an engine-managed datasource. `error: DENIED_SHAPES_NOT_SUPPORTED`, with `dbType`. ### DELETE /datasources/{id}/permissions/{permId} @@ -699,8 +709,8 @@ 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` intersected so a -column is masked only when every contributing grant masks it; `denied_schemas` / `denied_tables` (#939) -and `denied_columns` (#1099) **unioned**, so a group grant's denial binds every member and no permissive +column is masked only when every contributing grant masks it; `denied_schemas` / `denied_tables` (#939), +`denied_columns` (#1099) and `denied_shapes` (#940) **unioned**, 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: @@ -724,6 +734,7 @@ the group instead of a user: "denied_columns": [], "denied_schemas": null, "denied_tables": null, + "denied_shapes": null, "expires_at": null, "created_by": "uuid", "created_at": "2026-05-04T10:15:00Z" @@ -750,6 +761,7 @@ Same body as the per-user grant with `group_id` in place of `user_id`: "denied_columns": ["public.users.password_hash"], "denied_schemas": ["audit"], "denied_tables": ["public.salary"], + "denied_shapes": ["JOIN"], "expires_at": "2026-12-31T23:59:59Z" } ``` @@ -758,6 +770,7 @@ Same body as the per-user grant with `group_id` in place of `user_id`: **Response 404:** Datasource or group does not exist in the caller's organization. `error: DATASOURCE_NOT_FOUND` / `USER_GROUP_NOT_FOUND`. **Response 409:** A permission row already exists for `(group_id, datasource_id)`. `error: DATASOURCE_GROUP_PERMISSION_ALREADY_EXISTS`. **Response 422:** `denied_columns` on an engine-managed datasource. `error: DENIED_COLUMNS_NOT_SUPPORTED`. +**Response 422:** `denied_shapes` on an engine-managed datasource. `error: DENIED_SHAPES_NOT_SUPPORTED`. ### DELETE /datasources/{id}/permissions/groups/{permId} @@ -1644,7 +1657,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), 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. +- `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), or a query shape on the permission's `denied_shapes` (#940, `error.permission.shape_denied` — fails closed when the shape could not be analysed). 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"; @@ -2902,7 +2915,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, 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. +**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 deny-lists (#939, #940) — there is no `denied_schemas` / `denied_tables` / `denied_shapes` 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 @@ -3988,7 +4001,7 @@ All endpoints require `role=ADMIN` and operate within the caller's organization. } ``` -`name`, `condition`, and `action` are **required**. `datasource_id` is optional (null = org-wide). `priority` must be unique within the organization. `required_approvals` is required (and only meaningful) for `action: REQUIRE_APPROVALS` (absolute minimum approvers) and `action: ESCALATE` (delta added to the review-plan minimum, default 1); it must be null for `AUTO_APPROVE` / `AUTO_REJECT`. The `condition` is the typed `"type"`-discriminated tree documented in the data model — including the AF-446 client-context operands `source_ip` (CIDR allow-list; deny via `not`), `user_agent`, `time_since_last_approval`, and `cicd_origin`, which **fail closed** when their signal is absent. A malformed CIDR in a `source_ip` leaf is rejected with **422** `ROUTING_POLICY_INVALID`. +`name`, `condition`, and `action` are **required**. `datasource_id` is optional (null = org-wide). `priority` must be unique within the organization. `required_approvals` is required (and only meaningful) for `action: REQUIRE_APPROVALS` (absolute minimum approvers) and `action: ESCALATE` (delta added to the review-plan minimum, default 1); it must be null for `AUTO_APPROVE` / `AUTO_REJECT`. The `condition` is the typed `"type"`-discriminated tree documented in the data model — including the AF-446 client-context operands `source_ip` (CIDR allow-list; deny via `not`), `user_agent`, `time_since_last_approval`, and `cicd_origin`, which **fail closed** when their signal is absent. A malformed CIDR in a `source_ip` leaf is rejected with **422** `ROUTING_POLICY_INVALID`. The `query_shape` operand (#940) — `{"type": "query_shape", "any_of": ["JOIN", "SUBQUERY"]}` — matches a query that has any listed shape (`JOIN`, `UNION`, `SUBQUERY`, `CTE`, `GROUP_BY`, `HAVING`, `AGGREGATE`, `WINDOW_FUNCTION`) anywhere in the statement; an empty `any_of` is rejected with **422** `ROUTING_POLICY_INVALID`, and the leaf fails closed when the SQL cannot be parsed for its shape. Routing re-parses the stored SQL text with JSqlParser whatever the engine, so on a plugin datasource the leaf matches only when that text happens to be standard SQL JSqlParser can read (often true for a warehouse, never for a MongoDB or Redis command). Pair it with `ESCALATE` or `REQUIRE_APPROVALS` to send, say, every joined query to a second reviewer, or with `AUTO_REJECT` to refuse it outright; a grant's `denied_shapes` refuses at submission instead. **Response 201:** Full routing-policy object (see the list shape below). `Location` header points to `/api/v1/admin/routing-policies/{id}`. **Response 400:** Bean Validation failure on the request body. `error: VALIDATION_ERROR`. @@ -4513,7 +4526,7 @@ follow up with one simulation per user of interest. It is deliberately not an N- "step": "SQL_PARSE", "outcome": "ALLOW", "reason": "Statement parsed as UPDATE", - "details": { "query_type": "UPDATE", "referenced_tables": ["payments"], "transactional": false, "has_where_clause": true, "has_limit_clause": false } + "details": { "query_type": "UPDATE", "referenced_tables": ["payments"], "transactional": false, "has_where_clause": true, "has_limit_clause": false, "query_shapes": [], "shapes_analyzed": true } }, { "step": "EFFECTIVE_PERMISSION", @@ -4592,7 +4605,9 @@ follow up with one simulation per user of interest. It is deliberately not an N- "minutes_since_last_approval": 47, "anomaly_active": false, "estimated_rows": null, - "scan_type": null + "scan_type": null, + "query_shapes": [], + "shapes_analyzed": true }, "caveats": ["CLIENT_CONTEXT_ABSENT", "COST_ESTIMATE_ABSENT"] } @@ -4619,8 +4634,8 @@ 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`, `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_PARSE` | `query_type`, `referenced_tables`, `transactional`, `has_where_clause`, `has_limit_clause`, `query_shapes` (#940 — the statement's shapes in declaration order), `shapes_analyzed` (`false` for every engine plugin — warehouses included, since only the in-process JSqlParser path reads shapes — and for a statement the walker cannot traverse) | whenever the statement parsed; `{}` when it did not | +| `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`), `denied_shapes` (#940 — the grant's denied shapes the query has, in declaration order; checked last, a non-empty list denies with `workflow.access_simulation.permission.shape_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 | diff --git a/docs/05-backend.md b/docs/05-backend.md index f9ea5afcb..8335aec4e 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -763,6 +763,57 @@ it applies equally with no allow-list at all. grant the user still holds may then expose the table. Review the remaining grants before revoking one that carries a denial. +### Query-shape deny-lists (#940) + +`denied_shapes` (`TEXT[]` on both permission tables, V191) refuses a query by its **structure** rather +than its type or tables — "this analyst may read these tables, but never with a join". The values are +the `core.api.QueryShape` names: `JOIN`, `UNION` (every set operation), `SUBQUERY`, `CTE`, `GROUP_BY`, +`HAVING`, `AGGREGATE`, `WINDOW_FUNCTION`. + +- **Detection.** `proxy.internal.QueryShapeDetector` (a `TablesNamesFinder` subclass) walks the whole + JSqlParser AST — select list, FROM / JOIN, WHERE, GROUP BY, HAVING, ORDER BY, CTE bodies, + `INSERT … SELECT`, `UPDATE … FROM`, `DELETE … USING` and every nested subquery — and + `SqlParserServiceImpl` puts the result on `SqlParseResult.shapes`, unioned across a `BEGIN … COMMIT` + batch the way `hasWhereClause` is OR-ed. `JOIN` covers explicit and comma joins and the multi-table + `UPDATE` / `DELETE` forms. A parenthesised select that is the statement's own query — the root, a + set-operation branch, a CTE body, the rows of `INSERT` / `CREATE TABLE … AS` / `CREATE VIEW … AS` — + is not a `SUBQUERY`; `EXISTS`, `IN (SELECT …)`, `ANY` / `ALL`, scalar and derived subqueries and + `LATERAL` are. `WINDOW_FUNCTION` is any `OVER` clause or named `WINDOW`. `AGGREGATE` is a fixed + standard set matched by unqualified, case-insensitive name (`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, + `STRING_AGG`, `ARRAY_AGG`, `GROUP_CONCAT`, `LISTAGG`, `XMLAGG`, `COLLECT`, `JSON[B]_AGG`, the + `STDDEV*` / `STD` / `STDEV*` / `VAR*` family, `CORR`, `COVAR_*`, `REGR_*`, `BOOL_AND` / `BOOL_OR` / + `EVERY`, `BIT_*`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`, `ANY_VALUE`, `MEDIAN`, `MODE`, + `PERCENTILE_*`) plus any `WITHIN GROUP` or `FILTER`ed call; `JSON_ARRAYAGG` / `JSON_OBJECTAGG` are + their own AST node (`JsonAggregateFunction`) and always count. A user-defined aggregate is **not** + detected and the name list is best-effort — say so when an admin relies on it. A select the walk + reaches that is not a statement's own query, the body of a parenthesised select or a set-operation + branch is a `SUBQUERY` — that is how `ARRAY(SELECT …)` / `CURSOR(SELECT …)` arguments are caught. + `FROM (a JOIN b …)` (a `ParenthesedFromItem` carrying the joins) and every `MERGE` are `JOIN`. + `DeniedShapes.rejected` checks `OTHER` statements too, since a request-group member may be one; a + stored name the enum no longer has makes `fromNames` deny every shape. +- **Third state.** `SqlParseResult.shapesAnalyzed` is `true` only on the JSqlParser path when the + walk succeeded. Engine plugins build the result through the pre-#940 constructors and report + `false` (the plugins' pinned JARs stay binary-compatible — no re-pin), and a statement the detector + cannot walk (JSqlParser raises on e.g. `CREATE SCHEMA`) reports `false` too; parsing itself never + fails on it. `core.api.DeniedShapes.rejected` **fails closed**: an unanalysed parse has every denied + shape. +- **Where it is enforced.** The same matcher backs every gate that checks tables: submission and the + recurring recheck (`DatasourcePermissionVerifier`, 403 `error.permission.shape_denied`), break-glass + (`DefaultBreakGlassService`), dry-run (`DefaultQueryDryRunService`), request-group members + (`DefaultRequestGroupService.verifyTableAndColumnScope`) and the access simulator (a `DENY` on the + permission step with `denied_shapes` in the details). It runs after the table and column checks, so + a query that also reaches a denied table reports the table. +- **Grant time.** Relational engines only: `DatasourceAdminServiceImpl` refuses a non-empty list on an + engine-managed datasource with `DeniedShapesNotSupportedException` (422 `DENIED_SHAPES_NOT_SUPPORTED`), + like `denied_columns`. Values are stored as names in declaration order, duplicates dropped; the web + layer caps the list at 8 and rejects an unknown name (400). +- **Merge.** Union across a user's grants (`DeniedShapes.union`), and a JIT approval that replaces an + expiring direct row carries the row's `denied_shapes` over (`AccessGrantMaterializer`). Attestation + snapshots include it. + +The softer sibling is the `query_shape` routing condition (see "Policy-as-code routing engine"), +which escalates or rejects by shape through a policy instead of refusing at the grant. + ### Group-based access grants (AF-530) `DatasourceUserPermissionLookupService.findFor(userId, datasourceId)` returns the caller's **effective** @@ -771,8 +822,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` merge to the **intersection** (a column is masked only when every contributing grant masks it); the deny-lists — -`denied_schemas` / `denied_tables` (#939) and `denied_columns` (#935/#1099, compared normalised) — merge -to their **union**, so no contributor can lift another's denial; expired grants +`denied_schemas` / `denied_tables` (#939), `denied_columns` (#935/#1099, compared normalised) and +`denied_shapes` (#940) — 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 @@ -1112,7 +1163,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) and outside their `denied_schemas` / `denied_tables` (#939, `core.api.DeniedTables`; 403 `error.permission.table_denied`), then that no denied column is referenced (#935). +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) and the query has no shape on `denied_shapes` (#940, 403 `error.permission.shape_denied`). 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. @@ -1765,7 +1816,7 @@ recommendation, nothing consumes the report, and nothing revokes on its strength Routing policies are ordered, attribute-based rules that decide how a submitted query is routed **before** the default review-plan logic runs. The engine is owned by the `workflow` module and evaluated inside the same `QueryReviewStateMachine` listener, **after** AI analysis (or the skip event) and **before** reviewer fan-out: -1. `RoutingPolicyEngine` loads the org's enabled policies (org-wide + this datasource) in ascending `priority` and evaluates each `condition` against the query context (query type, referenced tables, AI risk level / score, requester role + group memberships, time-of-day / day-of-week, WHERE / LIMIT presence, transactional flag, the pre-flight cost estimate — estimated/affected rows and root scan type, read live from `query_estimates` and fail-closed when absent (AF-624) — and the client context captured at submission — source IP / CIDR, user-agent, time-since-last-approval, CI/CD origin) via `RoutingConditionEvaluator`. +1. `RoutingPolicyEngine` loads the org's enabled policies (org-wide + this datasource) in ascending `priority` and evaluates each `condition` against the query context (query type, referenced tables, AI risk level / score, requester role + group memberships, time-of-day / day-of-week, WHERE / LIMIT presence, query shape (#940 — joins, set operations, subqueries, CTEs, GROUP BY, HAVING, aggregates, window functions, fail-closed when the SQL could not be walked), transactional flag, the pre-flight cost estimate — estimated/affected rows and root scan type, read live from `query_estimates` and fail-closed when absent (AF-624) — and the client context captured at submission — source IP / CIDR, user-agent, time-since-last-approval, CI/CD origin) via `RoutingConditionEvaluator`. 2. **First match wins.** The first enabled policy whose condition matches decides the action; evaluation stops there. On **no match** the grant-covered auto-approval fast-path (#582, see the [JIT section](#grant-covered-query-auto-approval-582)) is consulted next, and only then does the query fall through to the datasource's review plan exactly as before — so **any** matching policy (AUTO_REJECT, REQUIRE_APPROVALS, ESCALATE — including anomaly-driven ones) always wins over the grant fast-path. 3. The outcome (matched policy id, action, resolved `effective_min_approvals`, reason) is persisted as a single `routing_decision` row (`RoutingDecisionService`), and surfaced on `GET /queries/{id}` as `matched_policy`. @@ -2566,7 +2617,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`). 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).) +**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` / `denied_shapes` 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 ea1250d26..56b26f770 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, 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. +- `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, denied shapes 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 "—". A **Denied query shapes** multi-select (#940) follows, offering the eight `QueryShape` values labelled through `queryShapeLabel` (`enums.query_shape.*`); like denied columns it is shown only for the JSqlParser engines (`supportsDeniedShapes` in `src/utils/deniedShapes.ts`), and its `Form.Item` rule mirrors the backend `@Size(max = 8)`. An empty selection is sent as `null`. Both permission tables render a "Denied shapes" column: a red "N shapes" tag whose tooltip lists the labelled shapes, 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). @@ -1467,6 +1467,10 @@ conditions, each optionally negated (NOT); there is no raw-JSON editor. API acce [frontend/src/api/routingPolicies.ts](../frontend/src/api/routingPolicies.ts); the form↔wire mapping helper is [frontend/src/pages/admin/routingPolicyForm.ts](../frontend/src/pages/admin/routingPolicyForm.ts); types (`RoutingPolicy`, `RoutingCondition`, `RoutingAction`, …) live in `src/types/api.ts`. +The builder offers a **Query shape** operand (#940, `query_shape`) as a required multi-select of the +eight `QueryShape` values (`QUERY_SHAPES` / `queryShapeLabel` in `src/utils/enumLabels.ts`); the row +summary lists the labelled shapes, and the decision trace labels `query_shapes` / `denied_shapes` +details the same way. The builder covers the `estimated_rows` (comparison operator + row count) and `scan_type` (glob tags, e.g. `Seq*`, `COLLSCAN`) pre-flight-estimate operands (AF-624) alongside the original leaf set. `QueryDetailPage` shows a **matched-policy** alert when `GET /queries/{id}` diff --git a/docs/07-security.md b/docs/07-security.md index 72f5e7e27..4b41992ac 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -716,8 +716,8 @@ capabilities are OR-ed, allow-lists (`allowed_schemas`/`allowed_tables`) unioned intersected (a column is masked only when **every** contributing grant masks it), each grant's `expires_at` 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 deny-lists — `denied_schemas` / `denied_tables` (#939) and -`denied_columns` (#935, #1099) — are **unioned**: a table or column stays denied when **any** contributing +cap and the global ceiling (#933). The deny-lists — `denied_schemas` / `denied_tables` (#939), +`denied_columns` (#935, #1099) and `denied_shapes` (#940) — are **unioned**: a table, column or query shape 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. The merge is computed once in `DefaultDatasourceUserPermissionLookupService.findFor`, the single choke-point every enforcement path (proxy, JIT/break-glass gates, masking/row-security scoping, @@ -766,6 +766,13 @@ Are denied_columns set? (#935, relational engines only) names counts as a reference; reject (403, `error.permission.column_not_allowed`) on any hit. Violation → 403 ↓ +Are denied_shapes set? (#940, relational engines only) + YES → read the statement's shapes from the JSqlParser AST (JOIN, UNION / set operation, SUBQUERY, + CTE, GROUP_BY, HAVING, AGGREGATE, WINDOW_FUNCTION — anywhere in the statement, unioned + across a BEGIN…COMMIT batch); reject (403, `error.permission.shape_denied`) on any hit. + A statement whose shape could not be analysed has every denied shape (fail closed). + Violation → 403 + ↓ Are restricted_columns set? YES → AI analyzer is told which columns are sensitive (informational — never auto-rejects) SELECT result rows have those values replaced with "***" before persistence @@ -839,7 +846,7 @@ 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 — 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. +- **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` / `denied_shapes` 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) @@ -980,6 +987,36 @@ allow-list and **always wins**; it also works with no allow-list at all. All gat reports no prefix. Pair a deny-list with database-side grants on the pool account where the table must be unreachable even through those. +### Query-shape deny-lists (#940) + +`denied_shapes` (`TEXT[]` on both permission tables) restricts the **grammar** a grantee may use, not +just the statement type and the tables: an analyst can be limited to single-table +`SELECT … WHERE … ORDER BY` by denying `JOIN`, `UNION`, `SUBQUERY`, `CTE`, `GROUP_BY`, `HAVING`, +`AGGREGATE` and `WINDOW_FUNCTION`. Shapes do not restrict the statement type — a grant with `can_write` +still admits a single-table `UPDATE … WHERE` — so pair them with the read/write/DDL flags. + +- **Detection.** One walker, `proxy.internal.QueryShapeDetector`, reads the JSqlParser AST of every + statement — subqueries, CTE bodies and `INSERT … SELECT` included — and a `BEGIN … COMMIT` batch + carries the union of its statements' shapes. All gates share one matcher, `core.api.DeniedShapes`. +- **Fail closed.** A parse whose shape was not analysed — any engine plugin, or a statement the walker + cannot traverse — counts as having every denied shape, so a deny-list is never silently skipped. A + non-empty list is refused at grant time for an engine-managed datasource (422 + `DENIED_SHAPES_NOT_SUPPORTED`), so in practice it only ever binds relational datasources. +- **Aggregate scope.** `AGGREGATE` is the engines' built-in aggregate set matched by name (a + best-effort list), `JSON_ARRAYAGG` / `JSON_OBJECTAGG`, and any `WITHIN GROUP` / `FILTER`ed call. A + user-defined aggregate, a built-in missing from the list, or an aggregate wrapped in a view or + function is not detected — pair the deny-list with database-side privileges where that matters. +- **Where it is enforced.** Submission (REST and MCP) and the recurring per-occurrence recheck (403 + `error.permission.shape_denied`), break-glass (for everyone), dry-run (403), request-group `QUERY` + members, and the access simulator (`denied_shapes` detail, + `workflow.access_simulation.permission.shape_denied`). `QUERY_ADMIN` holders skip it at submission, + like the rest of the per-datasource gate. +- **Merge.** Denials union across direct, group and JIT grants; a JIT approval that replaces an + expiring direct row carries its `denied_shapes` over, and a JIT request cannot ask for them. +- **Softer alternative.** The `query_shape` routing condition escalates, requires extra approvals for, + or auto-rejects a shape through a routing policy instead of refusing it at the grant. It fails closed + the other way — an unanalysed shape does not match — so prefer the grant when the rule must hold. + ### Dynamic data masking policies (AF-381) `masking_policy` rows extend the static masking above with **per-column strategies** and a diff --git a/e2e/helpers/datasources.ts b/e2e/helpers/datasources.ts index 2e4e10af1..a2bf8f934 100644 --- a/e2e/helpers/datasources.ts +++ b/e2e/helpers/datasources.ts @@ -523,6 +523,8 @@ export async function grantPermissionViaApi( allowedSchemas?: string[]; deniedSchemas?: string[]; deniedTables?: string[]; + // #940 — QueryShape names the grantee may never use (JOIN, UNION, SUBQUERY, …). + deniedShapes?: string[]; } = {}, ): Promise { const res = await request.post( @@ -539,6 +541,7 @@ export async function grantPermissionViaApi( allowed_schemas: opts.allowedSchemas ?? null, denied_schemas: opts.deniedSchemas ?? null, denied_tables: opts.deniedTables ?? null, + denied_shapes: opts.deniedShapes ?? null, }, }, ); diff --git a/e2e/tests/admin-routing-policies.spec.ts b/e2e/tests/admin-routing-policies.spec.ts index b2306bd4b..ce7847f2b 100644 --- a/e2e/tests/admin-routing-policies.spec.ts +++ b/e2e/tests/admin-routing-policies.spec.ts @@ -18,6 +18,7 @@ const UNIQUE_SUFFIX = `af379-${Date.now()}`; const BUILDER_POLICY_NAME = `Builder policy ${UNIQUE_SUFFIX}`; const AUTO_REJECT_POLICY_NAME = `Auto-reject deletes ${UNIQUE_SUFFIX}`; const CICD_REJECT_POLICY_NAME = `Block CI/CD ${UNIQUE_SUFFIX}`; +const JOIN_REJECT_POLICY_NAME = `Block joins ${UNIQUE_SUFFIX}`; const ROUTED_DS_NAME = `Routed DS ${UNIQUE_SUFFIX}`; const DEFAULT_API_BASE = 'http://localhost:8080'; @@ -160,4 +161,48 @@ test.describe.serial('/admin/routing-policies — routing engine', () => { const ciQuery = (await ciRes.json()) as { id: string }; await waitForQueryStatus(request, adminAccessToken, ciQuery.id, 'REJECTED', 20_000); }); + + // #940 — a query_shape condition matches a joined query and leaves a single-table one alone. + test('matches the query_shape condition on a joined query only', async ({ request }) => { + const policy = await createRoutingPolicyViaApi(request, adminAccessToken, { + name: JOIN_REJECT_POLICY_NAME, + // Scoped to this spec's datasource with a run-unique priority: an org-wide AUTO_REJECT on + // every JOIN would reject the joins other specs run concurrently. + datasource_id: datasourceId as string, + priority: 100_000 + Math.floor(Math.random() * 800_000), + enabled: true, + action: 'AUTO_REJECT', + reason: 'joins are blocked', + condition: { type: 'query_shape', any_of: ['JOIN'] }, + }); + createdPolicyIds.push(policy.id); + + const joined = await submitQueryViaApi( + request, + adminAccessToken, + datasourceId as string, + 'SELECT a.id FROM accounts a JOIN orders o ON o.account_id = a.id', + 'e2e: query-shape routing', + ); + await waitForQueryStatus(request, adminAccessToken, joined.id, 'REJECTED', 20_000); + + const simple = await submitQueryViaApi( + request, + adminAccessToken, + datasourceId as string, + 'SELECT id FROM accounts WHERE id = 1', + 'e2e: query-shape routing', + ); + await expect + .poll( + async () => { + const res = await request.get(`${apiBase()}/api/v1/queries/${simple.id}`, { + headers: { Authorization: `Bearer ${adminAccessToken}` }, + }); + return ((await res.json()) as { status: string }).status; + }, + { timeout: 20_000 }, + ) + .not.toMatch(/^(PENDING_AI|REJECTED)$/); + }); }); diff --git a/e2e/tests/datasource-denied-shapes.spec.ts b/e2e/tests/datasource-denied-shapes.spec.ts new file mode 100644 index 000000000..cbf00d7ee --- /dev/null +++ b/e2e/tests/datasource-denied-shapes.spec.ts @@ -0,0 +1,116 @@ +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'; + +// Query-shape deny-lists (#940): a grant that denies JOIN refuses a joined query with 403 before it +// is persisted — also when the join hides in a subquery — while a single-table query is accepted. +test.describe.configure({ timeout: 90_000 }); + +test.describe.serial('datasource denied query shapes (#940)', () => { + 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 = `af940-analyst-${randomUUID()}@e2e.local`; + await inviteUserViaApi(request, adminAccessToken, analystEmail, 'AF-940 Analyst', 'ANALYST'); + const token = await waitForInviteToken(request, analystEmail); + await acceptInvitationViaApi(request, token, ANALYST_PASSWORD, 'AF-940 Analyst'); + analystId = (await findUserByEmailViaApi(request, adminAccessToken, analystEmail)).id; + + uiDatasource = await createPostgresDatasource(request, adminAccessToken, { + name: `Postgres E2E AF940 UI ${Date.now()}`, + }); + apiDatasource = await createPostgresDatasource(request, adminAccessToken, { + name: `Postgres E2E AF940 API ${Date.now()}`, + }); + await grantPermissionViaApi(request, adminAccessToken, apiDatasource.id, analystId, { + deniedShapes: ['JOIN'], + }); + }); + + test.afterAll(async ({ request }) => { + for (const ds of [uiDatasource, apiDatasource]) { + if (ds) await deleteDatasource(request, adminAccessToken, ds.id); + } + }); + + test('an admin denies a query shape 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(); + + await grantDialog.getByRole('combobox', { name: 'Denied query shapes' }).click(); + await page.locator('.ant-select-item-option').filter({ hasText: /^Join$/ }).click(); + + 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_shapes).toEqual(['JOIN']); + + const analystRow = page.locator('.ant-table-row').filter({ hasText: analystEmail }); + await expect(analystRow).toHaveCount(1, { timeout: 10_000 }); + await expect(analystRow.getByText('1 shape', { exact: true })).toBeVisible(); + }); + + test('a joined query is refused with 403; a single-table query is 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-940' }, + }); + + for (const sql of [ + 'SELECT c.id FROM public.customer c JOIN public.orders o ON o.customer_id = c.id', + 'SELECT id FROM public.customer WHERE id IN (SELECT o.id FROM public.orders o, public.items i WHERE i.order_id = o.id)', + ]) { + const res = await submit(sql); + expect(res.status(), sql).toBe(403); + expect((await res.json()).detail, sql).toMatch(/JOIN/); + } + + const allowed = await submit('SELECT id, name FROM public.customer WHERE id = 1 ORDER BY name'); + expect(allowed.status()).toBe(202); + }); +}); diff --git a/frontend/src/components/policies/decisionTraceDetails.test.ts b/frontend/src/components/policies/decisionTraceDetails.test.ts index f1c82f4b2..84378f4fa 100644 --- a/frontend/src/components/policies/decisionTraceDetails.test.ts +++ b/frontend/src/components/policies/decisionTraceDetails.test.ts @@ -39,6 +39,16 @@ describe('formatStepDetails', () => { expect(rows[3]?.label).toBe('Extra thing'); }); + it('labels query shapes and denied shapes, and passes an unknown shape through', () => { + const rows = formatStepDetails( + { query_shapes: ['JOIN', 'GROUP_BY'], shapes_analyzed: true, denied_shapes: ['JOIN', 'MYSTERY'] }, + t, + ); + expect(rows[0]?.value).toEqual(['Join', 'GROUP BY']); + expect(rows[1]?.value).toBe('Yes'); + expect(rows[2]?.value).toEqual(['Join', 'MYSTERY']); + }); + it('renders the EFFECTIVE_PERMISSION contributing grants, one line each', () => { const rows = formatStepDetails( { diff --git a/frontend/src/components/policies/decisionTraceDetails.ts b/frontend/src/components/policies/decisionTraceDetails.ts index bf23739ee..2dc1294cd 100644 --- a/frontend/src/components/policies/decisionTraceDetails.ts +++ b/frontend/src/components/policies/decisionTraceDetails.ts @@ -1,6 +1,7 @@ import type { TFunction } from 'i18next'; import type { MaskingStrategy, + QueryShape, QueryStatus, QueryType, RiskLevel, @@ -10,10 +11,12 @@ import type { import { fmtDate } from '@/utils/dateFormat'; import { MASKING_STRATEGIES, + QUERY_SHAPES, QUERY_TYPES, RISK_LEVELS, ROUTING_ACTIONS, maskingStrategyLabel, + queryShapeLabel, queryStatusLabel, queryTypeLabel, riskLevelLabel, @@ -55,6 +58,7 @@ export const KNOWN_DETAIL_KEYS = [ 'contributing_grants', 'current', 'db_type', + 'denied_shapes', 'denied_tables', 'effective_min_approvals', 'engine_id', @@ -84,6 +88,7 @@ export const KNOWN_DETAIL_KEYS = [ 'protocol', 'provider', 'query_admin_short_circuit', + 'query_shapes', 'query_type', 'quota_type', 'reason_text', @@ -108,6 +113,7 @@ export const KNOWN_DETAIL_KEYS = [ 'scan_type', 'scheduled_for', 'scope', + 'shapes_analyzed', 'sql_review_suppressed', 'status', 'submitter_excluded', @@ -221,6 +227,9 @@ function maskLine(mask: Record, t: TFunction): string | null { function listValue(key: string, items: unknown[], t: TFunction): string[] { if (items.length === 0) return [t('decisionTrace.none')]; return items.map((item) => { + if ((key === 'query_shapes' || key === 'denied_shapes') && includes(QUERY_SHAPES, item)) { + return queryShapeLabel(t, item); + } if (!isRecord(item)) return scalar(item, t); switch (key) { case 'contributing_grants': diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 9cb37960e..4282e3447 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -1210,6 +1210,10 @@ "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_denied_shapes_label": "Gesperrte Abfrageformen", + "grant_denied_shapes_placeholder": "Abfragestrukturen, die der Berechtigte nie verwenden darf", + "grant_denied_shapes_help": "Jede Abfrage mit einer dieser Strukturen – an beliebiger Stelle, auch in einer Unterabfrage oder einem BEGIN…COMMIT-Block – wird abgelehnt, bevor sie ausgeführt wird. Aggregate sind die Standardfunktionen (COUNT, SUM, AVG, MIN, MAX und ähnliche); benutzerdefinierte Aggregate werden nicht erkannt.", + "grant_denied_shapes_too_many": "Höchstens 8 gesperrte Abfrageformen angeben", "grant_expires_label": "Läuft ab am", "grant_expires_placeholder": "Nie", "grant_expires_help": "Optional. Der Zugriff wird nach diesem Datum automatisch entzogen.", @@ -1238,6 +1242,7 @@ "perm_col_restricted_columns": "Eingeschränkt", "perm_col_denied_columns": "Gesperrte Spalten", "perm_col_denied_tables": "Gesperrte Tabellen", + "perm_col_denied_shapes": "Gesperrte Formen", "perm_col_expires": "Läuft ab", "perm_col_actions": "Aktionen", "perm_revoke": "Entziehen", @@ -1250,6 +1255,8 @@ "perm_denied_count_other": "{{count}} Spalten", "perm_denied_tables_count_one": "{{count}} Eintrag", "perm_denied_tables_count_other": "{{count}} Einträge", + "perm_denied_shapes_count_one": "{{count}} Form", + "perm_denied_shapes_count_other": "{{count}} Formen", "schema_loading": "Schema wird inspiziert…", "schema_error": "Schema konnte nicht geladen werden", "schema_col_table": "Tabelle", @@ -2438,6 +2445,7 @@ "not_prefix": "NICHT", "is_prefix": "IST", "bool_present_label": "Vorhanden", + "query_shapes_label": "Abfrageformen", "group_count": "{{count}} Gruppe(n)", "condition_advanced": "Erweiterte Bedingung", "condition_advanced_warning": "Diese Richtlinie verwendet eine verschachtelte Bedingung, die der geführte Editor nicht bearbeiten kann; Speichern ersetzt sie.", @@ -3255,6 +3263,7 @@ "day_of_week": "Wochentag", "has_where": "Hat WHERE-Klausel", "has_limit": "Hat LIMIT-Klausel", + "query_shape": "Abfrageform", "transactional": "Transaktional", "source_ip": "Quell-IP / CIDR", "user_agent": "User-Agent", @@ -3263,6 +3272,16 @@ "estimated_rows": "Geschätzte Zeilen", "scan_type": "Scan-Typ" }, + "query_shape": { + "JOIN": "Join", + "UNION": "Mengenoperation (UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "Unterabfrage", + "CTE": "Common Table Expression (WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "Aggregatfunktion", + "WINDOW_FUNCTION": "Fensterfunktion" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "Anbieter", "query_admin_short_circuit": "QUERY_ADMIN-Kurzschluss", "query_type": "Abfragetyp", + "query_shapes": "Abfrageformen", "quota_type": "Kontingent", "reason_text": "Grund", "referenced_tables": "Referenzierte Tabellen", "rejected_columns": "Erreichte gesperrte Spalten", + "denied_shapes": "Erreichte verbotene Abfrageformen", "denied_tables": "Erreichte gesperrte Tabellen", "rejected_tables": "Abgelehnte Tabellen", "releasable": "Freigebbar", @@ -5734,6 +5755,7 @@ "row_security_outcome": "Ergebnis der Zeilensicherheit", "scheduled_for": "Geplant für", "scope": "Bereich", + "shapes_analyzed": "Form analysiert", "sql_review_suppressed": "Durch SQL-Prüfung unterdrückt", "status": "Status", "submitter_excluded": "Einreicher ausgeschlossen", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index bfe833a91..a344ddc38 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1254,6 +1254,10 @@ "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_denied_shapes_label": "Denied query shapes", + "grant_denied_shapes_placeholder": "Query structures the grantee must never use", + "grant_denied_shapes_help": "Any query with one of these structures — anywhere, including inside a subquery or a BEGIN…COMMIT batch — is rejected before it runs. Aggregates are the standard functions (COUNT, SUM, AVG, MIN, MAX and similar); user-defined aggregates are not detected.", + "grant_denied_shapes_too_many": "List at most 8 denied query shapes", "grant_expires_label": "Expires at", "grant_expires_placeholder": "Never", "grant_expires_help": "Optional. Access auto-revokes after this date.", @@ -1282,6 +1286,7 @@ "perm_col_restricted_columns": "Restricted", "perm_col_denied_columns": "Denied columns", "perm_col_denied_tables": "Denied tables", + "perm_col_denied_shapes": "Denied shapes", "perm_col_expires": "Expires", "perm_col_actions": "Actions", "perm_revoke": "Revoke", @@ -1294,6 +1299,8 @@ "perm_denied_count_other": "{{count}} columns", "perm_denied_tables_count_one": "{{count}} entry", "perm_denied_tables_count_other": "{{count}} entries", + "perm_denied_shapes_count_one": "{{count}} shape", + "perm_denied_shapes_count_other": "{{count}} shapes", "schema_loading": "Introspecting schema…", "schema_error": "Failed to load schema", "schema_col_table": "Table", @@ -2438,6 +2445,7 @@ "not_prefix": "NOT", "is_prefix": "IS", "bool_present_label": "Present", + "query_shapes_label": "Query shapes", "group_count": "{{count}} group(s)", "condition_advanced": "Advanced condition", "condition_advanced_warning": "This policy uses a nested condition the guided builder can't edit; saving here will replace it.", @@ -3285,6 +3293,7 @@ "day_of_week": "Day of week", "has_where": "Has WHERE clause", "has_limit": "Has LIMIT clause", + "query_shape": "Query shape", "transactional": "Transactional", "source_ip": "Source IP / CIDR", "user_agent": "User-agent", @@ -3293,6 +3302,16 @@ "estimated_rows": "Estimated rows", "scan_type": "Scan type" }, + "query_shape": { + "JOIN": "Join", + "UNION": "Set operation (UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "Subquery", + "CTE": "Common table expression (WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "Aggregate function", + "WINDOW_FUNCTION": "Window function" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "Provider", "query_admin_short_circuit": "QUERY_ADMIN short-circuit", "query_type": "Query type", + "query_shapes": "Query shapes", "quota_type": "Quota", "reason_text": "Reason", "referenced_tables": "Referenced tables", "rejected_columns": "Denied columns reached", + "denied_shapes": "Denied query shapes reached", "denied_tables": "Denied tables reached", "rejected_tables": "Rejected tables", "releasable": "Releasable", @@ -5734,6 +5755,7 @@ "row_security_outcome": "Row-security outcome", "scheduled_for": "Scheduled for", "scope": "Scope", + "shapes_analyzed": "Shapes analyzed", "sql_review_suppressed": "Suppressed by SQL review", "status": "Status", "submitter_excluded": "Submitter excluded", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index ce29e2a23..2f6b4d179 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -1210,6 +1210,10 @@ "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_denied_shapes_label": "Formas de consulta denegadas", + "grant_denied_shapes_placeholder": "Estructuras de consulta que el beneficiario nunca debe usar", + "grant_denied_shapes_help": "Cualquier consulta con una de estas estructuras —en cualquier lugar, incluso dentro de una subconsulta o un lote BEGIN…COMMIT— se rechaza antes de ejecutarse. Los agregados son las funciones estándar (COUNT, SUM, AVG, MIN, MAX y similares); los agregados definidos por el usuario no se detectan.", + "grant_denied_shapes_too_many": "Indique como máximo 8 formas de consulta denegadas", "grant_expires_label": "Expira el", "grant_expires_placeholder": "Nunca", "grant_expires_help": "Opcional. El acceso se revoca automáticamente tras esta fecha.", @@ -1238,6 +1242,7 @@ "perm_col_restricted_columns": "Restringidas", "perm_col_denied_columns": "Columnas denegadas", "perm_col_denied_tables": "Tablas denegadas", + "perm_col_denied_shapes": "Formas denegadas", "perm_col_expires": "Expira", "perm_col_actions": "Acciones", "perm_revoke": "Revocar", @@ -1250,6 +1255,8 @@ "perm_denied_count_other": "{{count}} columnas", "perm_denied_tables_count_one": "{{count}} entrada", "perm_denied_tables_count_other": "{{count}} entradas", + "perm_denied_shapes_count_one": "{{count}} forma", + "perm_denied_shapes_count_other": "{{count}} formas", "schema_loading": "Inspeccionando esquema…", "schema_error": "No se pudo cargar el esquema", "schema_col_table": "Tabla", @@ -2438,6 +2445,7 @@ "not_prefix": "NO", "is_prefix": "ES", "bool_present_label": "Presente", + "query_shapes_label": "Formas de consulta", "group_count": "{{count}} grupo(s)", "condition_advanced": "Condición avanzada", "condition_advanced_warning": "Esta política usa una condición anidada que el editor guiado no puede editar; guardar aquí la reemplazará.", @@ -3255,6 +3263,7 @@ "day_of_week": "Día de la semana", "has_where": "Tiene cláusula WHERE", "has_limit": "Tiene cláusula LIMIT", + "query_shape": "Forma de la consulta", "transactional": "Transaccional", "source_ip": "IP de origen / CIDR", "user_agent": "Agente de usuario", @@ -3263,6 +3272,16 @@ "estimated_rows": "Filas estimadas", "scan_type": "Tipo de escaneo" }, + "query_shape": { + "JOIN": "Join", + "UNION": "Operación de conjuntos (UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "Subconsulta", + "CTE": "Expresión de tabla común (WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "Función de agregado", + "WINDOW_FUNCTION": "Función de ventana" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "Proveedor", "query_admin_short_circuit": "Atajo QUERY_ADMIN", "query_type": "Tipo de consulta", + "query_shapes": "Formas de consulta", "quota_type": "Cuota", "reason_text": "Motivo", "referenced_tables": "Tablas referenciadas", "rejected_columns": "Columnas denegadas alcanzadas", + "denied_shapes": "Formas de consulta denegadas alcanzadas", "denied_tables": "Tablas denegadas alcanzadas", "rejected_tables": "Tablas rechazadas", "releasable": "Publicable", @@ -5734,6 +5755,7 @@ "row_security_outcome": "Resultado de seguridad por filas", "scheduled_for": "Programado para", "scope": "Alcance", + "shapes_analyzed": "Forma analizada", "sql_review_suppressed": "Suprimido por la revisión SQL", "status": "Estado", "submitter_excluded": "Solicitante excluido", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 2c891b505..d68360682 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -1210,6 +1210,10 @@ "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_denied_shapes_label": "Formes de requête refusées", + "grant_denied_shapes_placeholder": "Structures de requête que le bénéficiaire ne doit jamais utiliser", + "grant_denied_shapes_help": "Toute requête présentant l’une de ces structures — n’importe où, y compris dans une sous-requête ou un lot BEGIN…COMMIT — est refusée avant son exécution. Les agrégats sont les fonctions standard (COUNT, SUM, AVG, MIN, MAX et similaires) ; les agrégats définis par l’utilisateur ne sont pas détectés.", + "grant_denied_shapes_too_many": "Indiquez au plus 8 formes de requête refusées", "grant_expires_label": "Expire le", "grant_expires_placeholder": "Jamais", "grant_expires_help": "Optionnel. L'accès est révoqué automatiquement après cette date.", @@ -1238,6 +1242,7 @@ "perm_col_restricted_columns": "Restreintes", "perm_col_denied_columns": "Colonnes refusées", "perm_col_denied_tables": "Tables refusées", + "perm_col_denied_shapes": "Formes refusées", "perm_col_expires": "Expire", "perm_col_actions": "Actions", "perm_revoke": "Révoquer", @@ -1250,6 +1255,8 @@ "perm_denied_count_other": "{{count}} colonnes", "perm_denied_tables_count_one": "{{count}} entrée", "perm_denied_tables_count_other": "{{count}} entrées", + "perm_denied_shapes_count_one": "{{count}} forme", + "perm_denied_shapes_count_other": "{{count}} formes", "schema_loading": "Inspection du schéma…", "schema_error": "Impossible de charger le schéma", "schema_col_table": "Table", @@ -2438,6 +2445,7 @@ "not_prefix": "NON", "is_prefix": "EST", "bool_present_label": "Présent", + "query_shapes_label": "Formes de requête", "group_count": "{{count}} groupe(s)", "condition_advanced": "Condition avancée", "condition_advanced_warning": "Cette politique utilise une condition imbriquée que l'éditeur guidé ne peut pas modifier ; l'enregistrement ici la remplacera.", @@ -3255,6 +3263,7 @@ "day_of_week": "Jour de la semaine", "has_where": "A une clause WHERE", "has_limit": "A une clause LIMIT", + "query_shape": "Forme de la requête", "transactional": "Transactionnel", "source_ip": "IP source / CIDR", "user_agent": "Agent utilisateur", @@ -3263,6 +3272,16 @@ "estimated_rows": "Lignes estimées", "scan_type": "Type de scan" }, + "query_shape": { + "JOIN": "Jointure", + "UNION": "Opération ensembliste (UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "Sous-requête", + "CTE": "Expression de table commune (WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "Fonction d’agrégation", + "WINDOW_FUNCTION": "Fonction de fenêtrage" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "Fournisseur", "query_admin_short_circuit": "Court-circuit QUERY_ADMIN", "query_type": "Type de requête", + "query_shapes": "Formes de requête", "quota_type": "Quota", "reason_text": "Motif", "referenced_tables": "Tables référencées", "rejected_columns": "Colonnes refusées atteintes", + "denied_shapes": "Formes de requête refusées atteintes", "denied_tables": "Tables refusées atteintes", "rejected_tables": "Tables refusées", "releasable": "Publiable", @@ -5734,6 +5755,7 @@ "row_security_outcome": "Résultat de sécurité par ligne", "scheduled_for": "Planifié pour", "scope": "Portée", + "shapes_analyzed": "Forme analysée", "sql_review_suppressed": "Neutralisé par la revue SQL", "status": "Statut", "submitter_excluded": "Demandeur exclu", diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index 9a1286a9e..582d46bfc 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -1210,6 +1210,10 @@ "grant_denied_tables_too_many": "Նշեք առավելագույնը 200 արգելված աղյուսակ", "grant_denied_tables_blank": "Արգելված աղյուսակների գրառումները չեն կարող լինել դատարկ", "grant_denied_tables_invalid": "Օգտագործեք աղյուսակ, սխեմա.աղյուսակ կամ սխեմա.*", + "grant_denied_shapes_label": "Արգելված հարցման ձևեր", + "grant_denied_shapes_placeholder": "Հարցման կառուցվածքներ, որոնք ստացողը երբեք չպետք է օգտագործի", + "grant_denied_shapes_help": "Այս կառուցվածքներից որևէ մեկն ունեցող ցանկացած հարցում՝ ցանկացած տեղում, ներառյալ ենթահարցումում կամ BEGIN…COMMIT փաթեթում, մերժվում է նախքան կատարումը։ Ագրեգատները ստանդարտ ֆունկցիաներն են (COUNT, SUM, AVG, MIN, MAX և նմանատիպ)․ օգտատիրոջ սահմանած ագրեգատները չեն հայտնաբերվում։", + "grant_denied_shapes_too_many": "Նշեք առավելագույնը 8 արգելված հարցման ձև", "grant_expires_label": "Ավարտվում է", "grant_expires_placeholder": "Երբեք", "grant_expires_help": "Ընտրովի։ Մուտքը ինքնաբերաբար կոչնչացվի այս ամսաթվից հետո։", @@ -1238,6 +1242,7 @@ "perm_col_restricted_columns": "Սահմանափակ", "perm_col_denied_columns": "Արգելված սյունակներ", "perm_col_denied_tables": "Արգելված աղյուսակներ", + "perm_col_denied_shapes": "Արգելված ձևեր", "perm_col_expires": "Ավարտվում է", "perm_col_actions": "Գործողություններ", "perm_revoke": "Չեղարկել", @@ -1250,6 +1255,8 @@ "perm_denied_count_other": "{{count}} սյունակ", "perm_denied_tables_count_one": "{{count}} գրառում", "perm_denied_tables_count_other": "{{count}} գրառում", + "perm_denied_shapes_count_one": "{{count}} ձև", + "perm_denied_shapes_count_other": "{{count}} ձև", "schema_loading": "Սխեման ուսումնասիրվում է…", "schema_error": "Չհաջողվեց բեռնել սխեման", "schema_col_table": "Աղյուսակ", @@ -2438,6 +2445,7 @@ "not_prefix": "ՈՉ", "is_prefix": "Է", "bool_present_label": "Առկա", + "query_shapes_label": "Հարցման ձևեր", "group_count": "{{count}} խումբ", "condition_advanced": "Բարդ պայման", "condition_advanced_warning": "Այս քաղաքականությունն օգտագործում է ներդրված պայման, որը կառավարվող խմբագրիչը չի կարող խմբագրել. այստեղ պահպանելը այն կփոխարինի.", @@ -3255,6 +3263,7 @@ "day_of_week": "Շաբաթվա օր", "has_where": "Ունի WHERE դրույթ", "has_limit": "Ունի LIMIT դրույթ", + "query_shape": "Հարցման ձև", "transactional": "Տրանզակցիոն", "source_ip": "Աղբյուրի IP / CIDR", "user_agent": "User-Agent", @@ -3263,6 +3272,16 @@ "estimated_rows": "Գնահատված տողեր", "scan_type": "Սկանավորման տեսակ" }, + "query_shape": { + "JOIN": "Միացում (JOIN)", + "UNION": "Բազմությունների գործողություն (UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "Ենթահարցում", + "CTE": "Ընդհանուր աղյուսակային արտահայտություն (WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "Ագրեգատային ֆունկցիա", + "WINDOW_FUNCTION": "Պատուհանային ֆունկցիա" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "Մատակարար", "query_admin_short_circuit": "QUERY_ADMIN կարճ ճանապարհ", "query_type": "Հարցման տեսակ", + "query_shapes": "Հարցման ձևեր", "quota_type": "Քվոտա", "reason_text": "Պատճառ", "referenced_tables": "Հղված աղյուսակներ", "rejected_columns": "Հասած արգելված սյունակներ", + "denied_shapes": "Հասանելի արգելված հարցման ձևեր", "denied_tables": "Հասած արգելված աղյուսակներ", "rejected_tables": "Մերժված աղյուսակներ", "releasable": "Թողարկելի", @@ -5734,6 +5755,7 @@ "row_security_outcome": "Տողային անվտանգության արդյունք", "scheduled_for": "Պլանավորված է", "scope": "Շրջանակ", + "shapes_analyzed": "Ձևը վերլուծված է", "sql_review_suppressed": "Կասեցված է SQL ստուգմամբ", "status": "Կարգավիճակ", "submitter_excluded": "Ներկայացնողը բացառված է", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 71b06dae5..fec35742c 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -1210,6 +1210,10 @@ "grant_denied_tables_too_many": "Укажите не более 200 запрещённых таблиц", "grant_denied_tables_blank": "Записи запрещённых таблиц не должны быть пустыми", "grant_denied_tables_invalid": "Используйте таблица, схема.таблица или схема.*", + "grant_denied_shapes_label": "Запрещённые формы запроса", + "grant_denied_shapes_placeholder": "Структуры запросов, которые получателю нельзя использовать", + "grant_denied_shapes_help": "Любой запрос с одной из этих структур — в любом месте, включая подзапрос или пакет BEGIN…COMMIT, — отклоняется до выполнения. Агрегаты — это стандартные функции (COUNT, SUM, AVG, MIN, MAX и подобные); пользовательские агрегаты не распознаются.", + "grant_denied_shapes_too_many": "Укажите не более 8 запрещённых форм запроса", "grant_expires_label": "Истекает", "grant_expires_placeholder": "Никогда", "grant_expires_help": "Необязательно. Доступ автоматически отзывается после этой даты.", @@ -1238,6 +1242,7 @@ "perm_col_restricted_columns": "Ограничены", "perm_col_denied_columns": "Запрещённые столбцы", "perm_col_denied_tables": "Запрещённые таблицы", + "perm_col_denied_shapes": "Запрещённые формы", "perm_col_expires": "Истекает", "perm_col_actions": "Действия", "perm_revoke": "Отозвать", @@ -1250,6 +1255,8 @@ "perm_denied_count_other": "{{count}} столбцов", "perm_denied_tables_count_one": "{{count}} запись", "perm_denied_tables_count_other": "{{count}} записей", + "perm_denied_shapes_count_one": "{{count}} форма", + "perm_denied_shapes_count_other": "{{count}} форм", "schema_loading": "Изучение схемы…", "schema_error": "Не удалось загрузить схему", "schema_col_table": "Таблица", @@ -2438,6 +2445,7 @@ "not_prefix": "НЕ", "is_prefix": "ЕСТЬ", "bool_present_label": "Присутствует", + "query_shapes_label": "Формы запроса", "group_count": "групп: {{count}}", "condition_advanced": "Расширенное условие", "condition_advanced_warning": "Эта политика использует вложенное условие, которое нельзя изменить в визуальном редакторе; сохранение заменит его.", @@ -3255,6 +3263,7 @@ "day_of_week": "День недели", "has_where": "Есть условие WHERE", "has_limit": "Есть условие LIMIT", + "query_shape": "Форма запроса", "transactional": "Транзакционный", "source_ip": "IP-адрес источника / CIDR", "user_agent": "User-Agent", @@ -3263,6 +3272,16 @@ "estimated_rows": "Оценка строк", "scan_type": "Тип сканирования" }, + "query_shape": { + "JOIN": "Соединение (JOIN)", + "UNION": "Операция над множествами (UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "Подзапрос", + "CTE": "Обобщённое табличное выражение (WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "Агрегатная функция", + "WINDOW_FUNCTION": "Оконная функция" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "Провайдер", "query_admin_short_circuit": "Обход через QUERY_ADMIN", "query_type": "Тип запроса", + "query_shapes": "Формы запроса", "quota_type": "Квота", "reason_text": "Причина", "referenced_tables": "Затронутые таблицы", "rejected_columns": "Затронутые запрещённые столбцы", + "denied_shapes": "Задействованные запрещённые формы запроса", "denied_tables": "Затронутые запрещённые таблицы", "rejected_tables": "Отклонённые таблицы", "releasable": "Можно выпускать", @@ -5734,6 +5755,7 @@ "row_security_outcome": "Итог построчной безопасности", "scheduled_for": "Запланировано на", "scope": "Область", + "shapes_analyzed": "Форма проанализирована", "sql_review_suppressed": "Подавлено проверкой SQL", "status": "Статус", "submitter_excluded": "Автор исключён", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 4058f6e42..11dfabd35 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -1210,6 +1210,10 @@ "grant_denied_tables_too_many": "最多列出 200 个禁止表", "grant_denied_tables_blank": "禁止表条目不能为空", "grant_denied_tables_invalid": "请使用 table、schema.table 或 schema.*", + "grant_denied_shapes_label": "禁止的查询结构", + "grant_denied_shapes_placeholder": "被授权者绝不能使用的查询结构", + "grant_denied_shapes_help": "任何包含这些结构之一的查询(无论位于何处,包括子查询或 BEGIN…COMMIT 批次内)都会在执行前被拒绝。聚合指标准函数(COUNT、SUM、AVG、MIN、MAX 等);不会识别用户自定义聚合。", + "grant_denied_shapes_too_many": "最多列出 8 种禁止的查询结构", "grant_expires_label": "到期时间", "grant_expires_placeholder": "永不", "grant_expires_help": "可选。访问将在此日期后自动撤销。", @@ -1238,6 +1242,7 @@ "perm_col_restricted_columns": "受限", "perm_col_denied_columns": "禁止列", "perm_col_denied_tables": "禁止的表", + "perm_col_denied_shapes": "禁止的结构", "perm_col_expires": "到期", "perm_col_actions": "操作", "perm_revoke": "撤销", @@ -1250,6 +1255,8 @@ "perm_denied_count_other": "{{count}} 列", "perm_denied_tables_count_one": "{{count}} 项", "perm_denied_tables_count_other": "{{count}} 项", + "perm_denied_shapes_count_one": "{{count}} 种结构", + "perm_denied_shapes_count_other": "{{count}} 种结构", "schema_loading": "正在内省模式…", "schema_error": "加载模式失败", "schema_col_table": "表", @@ -2438,6 +2445,7 @@ "not_prefix": "非", "is_prefix": "是", "bool_present_label": "存在", + "query_shapes_label": "查询结构", "group_count": "{{count}} 个组", "condition_advanced": "高级条件", "condition_advanced_warning": "此策略使用了引导式编辑器无法编辑的嵌套条件;在此保存将替换它。", @@ -3255,6 +3263,7 @@ "day_of_week": "星期", "has_where": "含 WHERE 子句", "has_limit": "含 LIMIT 子句", + "query_shape": "查询结构", "transactional": "事务性", "source_ip": "来源 IP / CIDR", "user_agent": "用户代理", @@ -3263,6 +3272,16 @@ "estimated_rows": "预估行数", "scan_type": "扫描类型" }, + "query_shape": { + "JOIN": "连接(JOIN)", + "UNION": "集合运算(UNION / INTERSECT / EXCEPT)", + "SUBQUERY": "子查询", + "CTE": "公用表表达式(WITH)", + "GROUP_BY": "GROUP BY", + "HAVING": "HAVING", + "AGGREGATE": "聚合函数", + "WINDOW_FUNCTION": "窗口函数" + }, "row_security_operator": { "EQUALS": "=", "NOT_EQUALS": "≠", @@ -5717,10 +5736,12 @@ "provider": "提供方", "query_admin_short_circuit": "QUERY_ADMIN 短路", "query_type": "查询类型", + "query_shapes": "查询结构", "quota_type": "配额", "reason_text": "原因", "referenced_tables": "引用的表", "rejected_columns": "触及的禁止列", + "denied_shapes": "命中的禁止查询结构", "denied_tables": "触及的禁止表", "rejected_tables": "被拒绝的表", "releasable": "可发布", @@ -5734,6 +5755,7 @@ "row_security_outcome": "行级安全结果", "scheduled_for": "计划时间", "scope": "范围", + "shapes_analyzed": "已分析结构", "sql_review_suppressed": "被 SQL 审查抑制", "status": "状态", "submitter_excluded": "已排除提交人", diff --git a/frontend/src/mocks/data.ts b/frontend/src/mocks/data.ts index af9602b15..eca6ba4d8 100644 --- a/frontend/src/mocks/data.ts +++ b/frontend/src/mocks/data.ts @@ -113,6 +113,7 @@ for (const u of USERS) { denied_columns: null, denied_schemas: null, denied_tables: null, + denied_shapes: null, expires_at: null, created_by: 'u-03', created_at: PERMS_CREATED_AT, @@ -135,6 +136,7 @@ for (const u of USERS) { denied_columns: null, denied_schemas: null, denied_tables: null, + denied_shapes: 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/admin/RoutingPoliciesPage.tsx b/frontend/src/pages/admin/RoutingPoliciesPage.tsx index 59215d185..72607cd0f 100644 --- a/frontend/src/pages/admin/RoutingPoliciesPage.tsx +++ b/frontend/src/pages/admin/RoutingPoliciesPage.tsx @@ -51,6 +51,7 @@ import { routingPolicyErrorMessage } from '@/utils/apiErrors'; import { COMPARISON_OPERATORS, CONDITION_OPERANDS, + QUERY_SHAPES, QUERY_TYPES, RISK_LEVELS, ROUTING_ACTIONS, @@ -58,6 +59,7 @@ import { comparisonOperatorLabel, conditionOperandLabel, enumOptions, + queryShapeLabel, queryTypeLabel, riskLevelLabel, routingActionLabel, @@ -836,6 +838,16 @@ function ConditionValueEditor({ name, operand, groups, roleOptions }: ConditionV + + ); case 'has_where': case 'has_limit': case 'transactional': diff --git a/frontend/src/pages/admin/routingPolicyForm.test.ts b/frontend/src/pages/admin/routingPolicyForm.test.ts index ca64ad442..e1edd144b 100644 --- a/frontend/src/pages/admin/routingPolicyForm.test.ts +++ b/frontend/src/pages/admin/routingPolicyForm.test.ts @@ -35,6 +35,25 @@ describe('routingPolicyForm', () => { tsla_minutes: 1440, }); expect(defaultRow('cicd_origin')).toMatchObject({ bool_value: true }); + expect(defaultRow('query_shape')).toMatchObject({ shapes: ['JOIN'] }); + }); + + it('maps a query_shape row to its any_of leaf and summarises it with shape labels', () => { + const rows: RoutingConditionRow[] = [ + { operand: 'query_shape', negate: false, shapes: ['JOIN', 'SUBQUERY'] }, + ]; + const condition = rowsToCondition('ALL', rows); + expect(condition).toEqual({ + type: 'and', + children: [{ type: 'query_shape', any_of: ['JOIN', 'SUBQUERY'] }], + }); + expect(conditionSummary(t, condition)).toBe( + 'enums.condition_operand.query_shape: enums.query_shape.JOIN, enums.query_shape.SUBQUERY', + ); + expect(rowsToCondition('ALL', [{ operand: 'query_shape', negate: false }])).toEqual({ + type: 'and', + children: [{ type: 'query_shape', any_of: [] }], + }); }); it('rowsToCondition wraps leaves in AND/OR and applies negation', () => { @@ -64,6 +83,7 @@ describe('routingPolicyForm', () => { { operand: 'day_of_week', negate: false, weekdays: ['MONDAY'] }, { operand: 'has_where', negate: false, bool_value: false }, { operand: 'has_limit', negate: true, bool_value: false }, + { operand: 'query_shape', negate: true, shapes: ['UNION', 'WINDOW_FUNCTION'] }, { operand: 'transactional', negate: false, bool_value: true }, { operand: 'source_ip', negate: false, cidrs: ['10.0.0.0/8', '2001:db8::/32'] }, { operand: 'user_agent', negate: true, ua_patterns: ['*curl*'] }, diff --git a/frontend/src/pages/admin/routingPolicyForm.ts b/frontend/src/pages/admin/routingPolicyForm.ts index 568eba058..0894cd70a 100644 --- a/frontend/src/pages/admin/routingPolicyForm.ts +++ b/frontend/src/pages/admin/routingPolicyForm.ts @@ -1,6 +1,7 @@ import type { TFunction } from 'i18next'; import type { ComparisonOperator, + QueryShape, QueryType, RiskLevel, RoutingAction, @@ -11,6 +12,7 @@ import type { import { comparisonOperatorLabel, conditionOperandLabel, + queryShapeLabel, queryTypeLabel, riskLevelLabel, roleLabel, @@ -40,6 +42,7 @@ export interface RoutingConditionRow { time_start_min?: number; time_end_min?: number; bool_value?: boolean; + shapes?: QueryShape[]; cidrs?: string[]; ua_patterns?: string[]; tsla_operator?: ComparisonOperator; @@ -86,6 +89,8 @@ export function defaultRow(operand: RoutingConditionOperand): RoutingConditionRo case 'has_limit': case 'transactional': return { ...base, bool_value: false }; + case 'query_shape': + return { ...base, shapes: ['JOIN'] }; case 'source_ip': return { ...base, cidrs: [] }; case 'user_agent': @@ -159,6 +164,8 @@ function rowToLeaf(row: RoutingConditionRow): RoutingCondition { return { type: 'has_where', expected: row.bool_value ?? false }; case 'has_limit': return { type: 'has_limit', expected: row.bool_value ?? false }; + case 'query_shape': + return { type: 'query_shape', any_of: row.shapes ?? [] }; case 'transactional': return { type: 'transactional', expected: row.bool_value ?? false }; case 'source_ip': @@ -227,6 +234,8 @@ function leafToRow(node: RoutingCondition, negate: boolean): RoutingConditionRow return { operand: 'has_where', negate, bool_value: node.expected }; case 'has_limit': return { operand: 'has_limit', negate, bool_value: node.expected }; + case 'query_shape': + return { operand: 'query_shape', negate, shapes: node.any_of }; case 'transactional': return { operand: 'transactional', negate, bool_value: node.expected }; case 'source_ip': @@ -357,6 +366,9 @@ function rowSummary(t: TFunction, row: RoutingConditionRow): string { case 'transactional': value = row.bool_value ? t('common.yes') : t('common.no'); break; + case 'query_shape': + value = (row.shapes ?? []).map((v) => queryShapeLabel(t, v)).join(', '); + break; case 'source_ip': value = (row.cidrs ?? []).join(', '); break; diff --git a/frontend/src/pages/datasources/DatasourceSettingsPage.tsx b/frontend/src/pages/datasources/DatasourceSettingsPage.tsx index e6b087225..4cc647411 100644 --- a/frontend/src/pages/datasources/DatasourceSettingsPage.tsx +++ b/frontend/src/pages/datasources/DatasourceSettingsPage.tsx @@ -40,7 +40,13 @@ import { SampleDataDrawer } from '@/components/datasources/SampleDataDrawer'; import { fmtDate, fmtNum, timeAgo } from '@/utils/dateFormat'; import { formatDurationCompact, remainingTtlMs } from '@/utils/accessTtl'; import { apiErrorMessage, datasourceGrantErrorMessage } from '@/utils/apiErrors'; -import { aiProviderLabel, dbTypeLabel } from '@/utils/enumLabels'; +import { + QUERY_SHAPES, + aiProviderLabel, + dbTypeLabel, + enumOptions, + queryShapeLabel, +} from '@/utils/enumLabels'; import { datasourceEnvironmentOptions, toEnvironmentFormValue, @@ -65,6 +71,11 @@ import { isValidDeniedSchema, isValidDeniedTable, } from '@/utils/deniedTables'; +import { + DENIED_SHAPES_MAX, + deniedShapesPayload, + supportsDeniedShapes, +} from '@/utils/deniedShapes'; import { userDisplay } from '@/utils/userDisplay'; import { datasourceKeys, @@ -115,6 +126,7 @@ import type { DbType, DatasourceGroupPermission, DatasourcePermission, + QueryShape, UpdateDatasourceInput, User, } from '@/types/api'; @@ -1110,6 +1122,10 @@ function PermissionMatrix({ dsId, dbType }: { dsId: string; dbType: DbType }) { ), }, + { + title: t('datasources.settings.perm_col_denied_shapes'), + render: (_v, p) => , + }, { title: t('datasources.settings.perm_col_expires'), width: 170, @@ -1232,6 +1248,10 @@ function PermissionMatrix({ dsId, dbType }: { dsId: string; dbType: DbType }) { ), }, + { + title: t('datasources.settings.perm_col_denied_shapes'), + render: (_v, p) => , + }, { title: t('datasources.settings.perm_col_expires'), width: 170, @@ -1305,6 +1325,21 @@ function DeniedTablesCell({ ); } +function DeniedShapesCell({ shapes }: { shapes: QueryShape[] | null | undefined }) { + const { t } = useTranslation(); + const entries = shapes ?? []; + if (entries.length === 0) { + return {t('datasources.settings.perm_no_denied')}; + } + return ( + queryShapeLabel(t, shape)).join(', ')}> + + {t('datasources.settings.perm_denied_shapes_count', { count: entries.length })} + + + ); +} + type GrantTarget = 'user' | 'group'; interface GrantFormValues { @@ -1322,6 +1357,7 @@ interface GrantFormValues { denied_columns?: string[]; denied_schemas?: string[]; denied_tables?: string[]; + denied_shapes?: QueryShape[]; expires_at?: Dayjs | null; } @@ -1488,6 +1524,7 @@ function GrantAccessModal({ : null, denied_tables: values.denied_tables && values.denied_tables.length > 0 ? values.denied_tables : null, + denied_shapes: deniedShapesPayload(values.denied_shapes), expires_at: values.expires_at ? values.expires_at.toISOString() : null, }; if (values.target === 'group') { @@ -1772,6 +1809,27 @@ function GrantAccessModal({ /> )} + {supportsDeniedShapes(dbType) && ( + +