From ef197619924ca6c15ad2b7045ee0354429fe4341 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Fri, 25 Sep 2026 10:59:40 +0400 Subject: [PATCH 1/3] feat(AF-940): query_shape routing condition over parsed query shapes Detect joins, set operations, subqueries, CTEs, GROUP BY, HAVING, aggregates and window functions anywhere in a statement's AST, unioned across a transactional batch. SqlParseResult gains shapes + shapesAnalyzed; plugin engines keep the old constructors and report not-analyzed, so the new query_shape routing leaf fails closed on them. --- .../accessflow/core/api/DeniedShapes.java | 64 ++++++ .../accessflow/core/api/QueryShape.java | 21 ++ .../accessflow/core/api/SqlParseResult.java | 16 +- .../proxy/internal/QueryShapeDetector.java | 192 ++++++++++++++++++ .../proxy/internal/SqlParserServiceImpl.java | 33 ++- .../workflow/api/ConditionContext.java | 26 ++- .../workflow/api/ConditionNode.java | 14 +- .../DefaultAccessSimulationService.java | 8 + .../routing/ConditionContextFactory.java | 15 +- .../internal/routing/ConditionNodeMixin.java | 1 + .../routing/RoutingConditionEvaluator.java | 2 + .../routing/RoutingConditionValidator.java | 4 + .../web/AccessSimulationResponse.java | 7 +- .../main/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_de.properties | 1 + .../resources/i18n/messages_es.properties | 1 + .../resources/i18n/messages_fr.properties | 1 + .../resources/i18n/messages_hy.properties | 1 + .../resources/i18n/messages_ru.properties | 1 + .../resources/i18n/messages_zh_CN.properties | 1 + .../internal/QueryShapeDetectorTest.java | 158 ++++++++++++++ .../internal/SqlParserServiceImplTest.java | 53 +++++ .../workflow/api/ConditionNodeTest.java | 9 + .../DefaultAccessSimulationServiceTest.java | 14 ++ .../routing/ConditionContextFactoryTest.java | 15 ++ .../routing/RoutingConditionCodecTest.java | 11 + .../RoutingConditionEvaluatorTest.java | 32 +++ .../RoutingConditionValidatorTest.java | 16 ++ .../web/AccessSimulationResponseTest.java | 6 +- .../policies/decisionTraceDetails.test.ts | 10 + .../policies/decisionTraceDetails.ts | 9 + frontend/src/locales/de.json | 15 ++ frontend/src/locales/en.json | 15 ++ frontend/src/locales/es.json | 15 ++ frontend/src/locales/fr.json | 15 ++ frontend/src/locales/hy.json | 15 ++ frontend/src/locales/ru.json | 15 ++ frontend/src/locales/zh-CN.json | 15 ++ .../src/pages/admin/RoutingPoliciesPage.tsx | 12 ++ .../src/pages/admin/routingPolicyForm.test.ts | 20 ++ frontend/src/pages/admin/routingPolicyForm.ts | 12 ++ frontend/src/types/api.ts | 12 ++ frontend/src/utils/enumLabels.ts | 16 ++ 43 files changed, 905 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/QueryShape.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetectorTest.java 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..21760725c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java @@ -0,0 +1,64 @@ +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); + } + + /** 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} is never checked, since no permission grants it. + */ + public static SortedSet rejected(Collection rawDenied, SqlParseResult parsed) { + var denied = normalize(rawDenied); + var out = new TreeSet(); + if (denied.isEmpty() || parsed == null || parsed.type() == QueryType.OTHER) { + 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/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/proxy/internal/QueryShapeDetector.java b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java new file mode 100644 index 000000000..78bb19a59 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java @@ -0,0 +1,192 @@ +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.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.select.LateralSubSelect; +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 standard aggregates, matched by unqualified, case-insensitive name. */ + 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", "stddev", "stddev_pop", "stddev_samp", "variance", + "var_pop", "var_samp", "bool_and", "bool_or", "every", "bit_and", "bit_or", "bit_xor", + "any_value", "median", "mode", "percentile_cont", "percentile_disc"); + + private final Set shapes = EnumSet.noneOf(QueryShape.class); + private final Set ); + case 'query_shape': + return ( + + + + )} { expect(within(row).getByText('1 entry')).toBeInTheDocument(); }); }); + +describe('DatasourceSettingsPage — denied query shapes (#940)', () => { + beforeEach(() => { + getDatasource.mockReset(); + getDatasource.mockResolvedValue(baseDs); + listPermissions.mockReset(); + listPermissions.mockResolvedValue([]); + listGroupPermissions.mockReset(); + listGroupPermissions.mockResolvedValue([]); + listAllGroups.mockReset(); + listAllGroups.mockResolvedValue([]); + grantPermission.mockReset(); + grantPermission.mockResolvedValue(basePermission({ can_read: true })); + getDatasourceSchema.mockReset(); + getDatasourceSchema.mockResolvedValue({ schemas: [] }); + listUsers.mockReset(); + listUsers.mockResolvedValue({ + content: [analystUser], + page: 0, + size: 100, + total_elements: 1, + total_pages: 1, + }); + }); + + it('sends the denied shapes picked in the grant form', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + fireEvent.mouseDown(within(dialog).getByLabelText('Denied query shapes')); + fireEvent.click(await screen.findByText('Subquery')); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + await waitFor(() => expect(grantPermission).toHaveBeenCalled()); + const input = grantPermission.mock.calls[0]![1] as Record; + expect(input.denied_shapes).toEqual(['SUBQUERY']); + }); + + it('sends null when no shape is denied', async () => { + render(wrap()); + const dialog = await openGrantModal(); + + await selectAnalyst(dialog); + fireEvent.click(within(dialog).getByRole('button', { name: /Grant access/ })); + + await waitFor(() => expect(grantPermission).toHaveBeenCalled()); + const input = grantPermission.mock.calls[0]![1] as Record; + expect(input.denied_shapes).toBeNull(); + }); + + it('hides the field for engine-managed datasources', async () => { + getDatasource.mockResolvedValue({ ...baseDs, db_type: 'MONGODB' }); + render(wrap()); + const dialog = await openGrantModal(); + + expect(within(dialog).getByText('Denied tables')).toBeInTheDocument(); + expect(within(dialog).queryByText('Denied query shapes')).not.toBeInTheDocument(); + }); + + it('shows the denied-shape count with labelled shapes on the permission row', async () => { + listPermissions.mockResolvedValue([ + basePermission({ can_read: true, denied_shapes: ['JOIN', 'GROUP_BY'] }), + ]); + render(wrap()); + + await waitFor(() => expect(screen.getByRole('tab', { name: /Permissions/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('tab', { name: /Permissions/ })); + + const emailCell = await screen.findByText('analyst@example.com'); + const row = emailCell.closest('tr')!; + const tag = within(row).getByText('2 shapes'); + fireEvent.mouseEnter(tag); + expect(await screen.findByText('Join, GROUP BY')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c3e5879b0..5abc9dcfa 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -854,6 +854,7 @@ export interface CreatePermissionInput { denied_columns?: string[] | null; denied_schemas?: string[] | null; denied_tables?: string[] | null; + denied_shapes?: QueryShape[] | null; expires_at?: string | null; } @@ -870,6 +871,7 @@ export interface CreateGroupPermissionInput { denied_columns?: string[] | null; denied_schemas?: string[] | null; denied_tables?: string[] | null; + denied_shapes?: QueryShape[] | null; expires_at?: string | null; } @@ -1988,6 +1990,7 @@ export interface DatasourcePermission { denied_columns?: string[] | null; denied_schemas?: string[] | null; denied_tables?: string[] | null; + denied_shapes?: QueryShape[] | null; expires_at: string | null; created_by: string; created_at: string; @@ -2010,6 +2013,7 @@ export interface DatasourceGroupPermission { denied_columns?: string[] | null; denied_schemas?: string[] | null; denied_tables?: string[] | null; + denied_shapes?: QueryShape[] | null; expires_at: string | null; created_by: string; created_at: string; diff --git a/frontend/src/utils/__tests__/deniedShapes.test.ts b/frontend/src/utils/__tests__/deniedShapes.test.ts new file mode 100644 index 000000000..104e5e5a9 --- /dev/null +++ b/frontend/src/utils/__tests__/deniedShapes.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { DENIED_SHAPES_MAX, deniedShapesPayload, supportsDeniedShapes } from '../deniedShapes'; + +describe('deniedShapes', () => { + it('mirrors the backend size cap', () => { + expect(DENIED_SHAPES_MAX).toBe(8); + }); + + it('is supported on the in-process relational engines only', () => { + expect(supportsDeniedShapes('POSTGRESQL')).toBe(true); + expect(supportsDeniedShapes('MYSQL')).toBe(true); + expect(supportsDeniedShapes('MONGODB')).toBe(false); + expect(supportsDeniedShapes('REDIS')).toBe(false); + }); + + it('drops an empty selection from the payload', () => { + expect(deniedShapesPayload(undefined)).toBeNull(); + expect(deniedShapesPayload([])).toBeNull(); + expect(deniedShapesPayload(['JOIN', 'CTE'])).toEqual(['JOIN', 'CTE']); + }); +}); diff --git a/frontend/src/utils/apiErrors.ts b/frontend/src/utils/apiErrors.ts index 91dce0bbe..88adb56f5 100644 --- a/frontend/src/utils/apiErrors.ts +++ b/frontend/src/utils/apiErrors.ts @@ -252,6 +252,7 @@ export function datasourceGrantErrorMessage(err: unknown): string { } // The server's localized detail names the engine (#935); the title is a bare "422". if (code === 'DENIED_COLUMNS_NOT_SUPPORTED' && body?.detail) return body.detail; + if (code === 'DENIED_SHAPES_NOT_SUPPORTED' && body?.detail) return body.detail; if (body?.title) return body.title; if (body?.detail) return body.detail; if (ax.message) return ax.message; diff --git a/frontend/src/utils/deniedShapes.ts b/frontend/src/utils/deniedShapes.ts new file mode 100644 index 000000000..47f83c708 --- /dev/null +++ b/frontend/src/utils/deniedShapes.ts @@ -0,0 +1,17 @@ +import type { DbType, QueryShape } from '@/types/api'; +import { SQL_REVIEW_DB_TYPES } from '@/utils/sqlReview'; + +/** Backend `@Size(max = 8)` on `denied_shapes` (#940). */ +export const DENIED_SHAPES_MAX = 8; + +/** + * Query-shape blocking reads the JSqlParser AST, so it covers the same in-process relational + * engines as the SQL review catalog. The backend refuses the field with 422 + * `DENIED_SHAPES_NOT_SUPPORTED` for any other engine. + */ +export const supportsDeniedShapes = (dbType: DbType): boolean => + SQL_REVIEW_DB_TYPES.includes(dbType); + +/** Omit an empty selection from a grant payload, as the other deny-lists do. */ +export const deniedShapesPayload = (shapes: QueryShape[] | undefined): QueryShape[] | null => + shapes && shapes.length > 0 ? shapes : null; diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index 36ae15ad0..fe33d0919 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -199,16 +199,17 @@ {"id":"554e1026e829704c","path":"website/docs/configuration/connectors/index.html","url":"https://accessflow.io/docs/configuration/connectors/#cfg-api-connectors","anchor":"cfg-api-connectors","title":"API connectors","section":"Reference","order":3,"tokens":712,"text":"AccessFlow Docs > Reference > Connectors > API connectors (part 4 of 5)\n\nDynamic variables (request signing). Some APIs — common in banking, payments\nand telco — require a value computed per request: an HMAC signature, a nonce, a\ntimestamp, an idempotency key. A requester can't hand-compute those for a governed call,\nbecause the signature covers a body a reviewer approves minutes or hours later and the\ntimestamp would already be stale. On the connector's Variables tab (admin) you\ndeclare named values that AccessFlow computes at execution time and substitutes into\nheaders, the path, query parameters and the body wherever\n{{name}} appears. Kinds cover a constant, a random UUID, a timestamp, epoch\nmilliseconds, random bytes, a hash (SHA-256/MD5), an HMAC signature (HMAC-SHA256/512 with an\nencrypted shared secret), or a re-encoding — output as hex, base64 or URL-safe base64.\nExpressions can reference the in-flight request ({{request.method}},\n{{request.path}}, {{request.query}}, {{request.body}},\n{{request.headers.Authorization}}) and each other; AccessFlow resolves them in\ndependency order and rejects circular references while you're editing, not at run time.\nVariables are evaluated after authentication, so a signature can cover the resolved\nAuthorization header — including a freshly minted OAuth2 token. Instead of a\nplaceholder, a variable can inject itself straight into a fixed header or query parameter.\nEvaluation is template substitution over a fixed function set only: there is no scripting\nengine and no user-supplied code. Shared secrets are encrypted at rest, never returned by\nthe API, and computed values are never stored, snapshotted or logged.\n\nPer-request overrides. An admin can mark a variable overridable, and\ngrant selected teammates the Override variables permission on the connector. Those\nrequesters can then supply their own value for that variable from the API editor's\nVariables tab — pinning a nonce for a replay test, say. Overrides are deny-by-default\nand deliberately narrow: a variable holding a secret can never be made overridable, an\noverride is inserted literally and can never expand into another variable's value, and the\nvalues are saved with the request and shown to the reviewer, so an approval covers exactly\nwhat will be sent."} {"id":"029871f8bea86ab0","path":"website/docs/configuration/connectors/index.html","url":"https://accessflow.io/docs/configuration/connectors/#cfg-api-connectors","anchor":"cfg-api-connectors","title":"API connectors","section":"Reference","order":4,"tokens":355,"text":"AccessFlow Docs > Reference > Connectors > API connectors (part 5 of 5)\n\nUse it. In the API editor (/api-editor) a user picks a\nconnector, searches the operation catalog (or writes a free-form method + path), and composes\nthe call like Postman — query parameters, custom headers (over the connector's read-only\ndefault headers), and a body that can be raw, x-www-form-urlencoded, multipart\nform-data, or a binary file upload. A user can schedule the call for later, sees a debounced\nAI risk preview, and submits. Plain-English text-to-API drafts a call for\nschema-backed connectors. Every call flows through AI risk scoring → routing → human review\n(no self-approval) → guarded execution that injects the connector's auth and a W3C\ntraceparent, caps and field-masks the response, and stores an immutable response\nsnapshot. The full stored response can be downloaded in its original format, and the request\nlist is filterable by submitter, trace id, and span id. Break-glass and scheduled execution\nmirror the query path. Note: gRPC connectors register and review today; gRPC call\nexecution is a follow-up — REST/SOAP/GraphQL execute fully."} {"id":"efab69db751175ea","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-datasources","anchor":"cfg-datasources","title":"Datasources","section":"Reference","order":0,"tokens":250,"text":"AccessFlow Docs > Reference > Datasources\n\nThere is a guide for this. Add your first datasource\nwalks the wizard end to end and then narrows the datasource down with an allow-list, masking and row security. This chapter is the reference behind it.\n\nWhat it is. A governed connection to one of your databases. Every query a\nuser runs against it passes through AccessFlow's review, masking, and row-security guards\ninstead of hitting the database directly — so a datasource is where you decide who\nmay run what against which data. PostgreSQL, MySQL, MariaDB, Oracle, and\nMS SQL Server are built in; MongoDB, Couchbase, Redis, Cassandra / ScyllaDB,\nElasticsearch / OpenSearch, DynamoDB, Neo4j, Snowflake, BigQuery, and Databricks install\nfrom the connector catalog; any other JDBC engine works by uploading\nits driver and choosing Custom."} -{"id":"3b3f8798eb7ce7ae","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":0,"tokens":252,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 1 of 10)\n\nA datasource is a governed connection to one of your databases. Users never receive its credentials — they submit queries to AccessFlow, which reviews them and then executes them over the pooled connection on their behalf. Masking, row-level security, schema allow-lists, and row caps are all configured per datasource.\n\nConfigure it. Create one with the four-step wizard at\n/datasources/new:\n\n/datasources/new — four-step wizard: Database type → Connection details → Connection test → Configuration.\n\n- Database type. Pick a bundled driver tile (PostgreSQL ships built-in; other drivers download on first use and are verified against a pinned SHA-256 checksum). Pick Custom to use a JDBC driver you uploaded under Admin → Custom JDBC drivers."} -{"id":"5a567ae041ce49c2","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":1,"tokens":793,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 2 of 10)\n\n- Connection details. Name the datasource, then enter host, port, database name, service-account username, and password. SSL mode is pre-filled from the engine's own default rather than one global value — PostgreSQL starts at VERIFY_FULL, several NoSQL engines start at DISABLE, and the rest at REQUIRE. Check it rather than assuming it, and prefer VERIFY_FULL in production. For Cassandra and ScyllaDB the wizard also requires a local datacenter (the driver's load-balancing datacenter); this field is unused for every other engine. For Elasticsearch and OpenSearch the wizard offers an Authentication toggle — basic (username + password) or an API key — and the database-name field is optional. For Amazon DynamoDB the connection is cloud credentials, not host/port: the wizard hides host/port and instead asks for the AWS region (the database-name field), the access key ID and secret access key (the username/password fields), and an optional custom endpoint (DynamoDB Local / VPC; blank for AWS). For Neo4j the wizard takes the standard host/port/database/username/password (the SSL mode is encoded in the Bolt scheme) plus an optional Bolt connection URI (advanced) — a full bolt:// / neo4j+s:// URI for Neo4j Aura or clustered routing that, when set, overrides host/port. For Snowflake the wizard asks for the account host (.snowflakecomputing.com; the port field is hidden — always 443), the database, the user, a credential that is either a password or a PKCS#8 private key (PEM) for key-pair authentication, an optional private key passphrase (only for a passphrase-protected key, which is what Snowflake's own openssl instructions produce), and an optional JDBC URL override — a full jdbc:snowflake:// URL carrying warehouse / role / schema parameters. For Google BigQuery the connection is cloud credentials: the wizard hides host/port/username and asks for the GCP project (optionally project.dataset to pin a default dataset) and the service-account key JSON, plus an optional custom endpoint (BigQuery emulator). For Databricks SQL the wizard asks for the workspace host, the required warehouse HTTP path (/sql/1.0/warehouses/ from the warehouse's connection details), an optional Unity Catalog catalog, and a personal access token. The password (and API key / secret access key) are AES-256-GCM encrypted on write, decrypted once into the connection pool, and never returned in any GET response. When an external secrets manager is enabled (see Run & deploy), any credential field also"} -{"id":"ab70ab607bfeefd9","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 10)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."} -{"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 10)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."} -{"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":711,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 10)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), denied schemas and tables, and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied schemas and tables — everything except. Sometimes it is easier to say what a user may not touch. Allow the schema crm and deny the table crm.salary, and the user can query every table in crm — including tables created later — except crm.salary. A query that touches a denied table is refused before it runs:\n\n- A denial always wins. It is checked after the allowed schemas and tables, and it works on its own too, with no allowed list at all.\n\n- Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.\n\n- Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.\n\n- Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.\n\n- Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the same way (below).\n\n- Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks."} -{"id":"6982bd47758e3828","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":5,"tokens":788,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 10)\n\n- Just-in-time access keeps denials. A just-in-time access request cannot add a denied schema or table, and approving one never removes a denial: denials from every grant add up, and when the approval replaces the user's own expiring grant, that grant's denials carry over to the new one.\n\n- How to write entries. A denied schema is a single name, such as hr — not analytics.hr. A denied table is table, schema.table, or schema.* for a whole schema. Other wildcards and empty parts are refused when you save the grant.\n\n- Tricky names are refused, not guessed. SQL Server's db..salary (default schema) is treated as matching any schema, an Oracle database link (hr.salary@remote) does not get around a denial, and a name pattern such as the Elasticsearch index pattern sal* is refused whenever the grant denies anything.\n\n- Works on every datasource type. Denied schemas and tables apply to relational, NoSQL and warehouse datasources alike. On datasources whose objects have no schema — MongoDB collections, DynamoDB tables, Redis keys — a denied schema refuses every query, so use denied tables there. A denial can only catch the tables AccessFlow sees in the query: MongoDB $lookup, $unionWith and $graphLookup stages, a Neo4j MATCH (n) with no label, and a Redis KEYS pattern are not caught. The allowed lists share this limit.\n\n- Removing a grant can widen access. A denial belongs to the grant that carries it. Revoke that grant, let it expire, or revoke it in an access review, and its denial goes with it — another grant the user still holds may then let them reach the table. Check the user's other grants first.\n\nDenied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:\n\n- What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.\n\n- Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this."} -{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":786,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 10)\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column denied by any one of them stays denied. A wider grant that denies nothing cannot undo it, and a group's denied columns apply to every member. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits."} -{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":747,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 10)\n\nMasking policies. The datasource Masking tab adds per-column\ndynamic data masking on top of the static restricted-columns masking above. Each\npolicy targets a schema.table.column and picks a strategy —\nfull (***), partial (keep the last N characters),\nhash (stable SHA-256), email (j***@domain), or\nformat-preserving — with an optional reveal-to condition. A query\nsubmitter whose role, group, or user id is listed in reveal to sees the unmasked\nvalue; everyone else sees the strategy output. A live preview shows how a sample value will\nrender. Masking is applied at result-read time before results are serialized or stored, so\nunmasked values never persist, and the ids of the policies that applied are recorded in the\nexecution's audit metadata. Reveal is explicit — there is no implicit admin bypass.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters."} -{"id":"9b654aff5b19dadf","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":8,"tokens":551,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 9 of 10)\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform.\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it —\nrow rules are worked out on the stored query text alone. It compares policies against\npolicies — today's rules versus the draft — rather than against what actually\nhappened, because a past result may have come from an emergency, a ticket, or a standing\ngrant the draft has no say over. The results name their own limits: roles and group\nmemberships are read as they stand today, masking is matched on the column name alone\n(so a name two tables share can be over-counted), and where an engine cannot work out\noffline what a row rule would do — Cassandra and ScyllaDB need live key information —\nthose queries are listed as unclassifiable rather than counted as unaffected.\nSimulating is always optional; nothing blocks you from saving."} -{"id":"d179f3a2f88d48a0","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":9,"tokens":569,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 10 of 10)\n\nRow limits. The datasource Row limits tab caps how many rows a\nquery may return when it reads a particular table, so two tables on the same database\ncan have different limits and one team can be held tighter than another on the same\ntable. Each policy names a table (and optionally its schema), a maximum number of rows,\nand the applies to roles / groups / users it covers (empty = everyone, admins\nincluded). A row limit can only ever lower the cap: the datasource's\nMax rows per query and any per-user limit on the access grant still apply, and\nthe smallest number wins. A query that joins several limited tables gets the lowest of\ntheir limits. A policy with a schema also catches queries that name the table without\none or with a database name in front, so neither gets anyone more rows. Results that hit\nthe limit are marked as truncated, the table preview obeys the same limit, and when a\npolicy's limit is the one that applied it is recorded in the query's audit entry.\n\nExport policies. Masking and row security govern what a user\nsees; the datasource Export policy tab governs what leaves.\nEach policy sets a mode — allow, watermark, row cap, or\ndeny when classified (optionally scoped to specific classifications) — and an\napplies to roles / groups / users target (empty = every exporter, no implicit\nadmin bypass). When several policies apply, the most restrictive wins. The policies gate\nthe signed CSV/PDF result download on the query detail page and the results attachment\non recurring-run emails: a denied exporter sees a disabled export button with the\nreason, a watermarked download carries the exporter, timestamp, and query id baked into\nthe signed bytes (the modal previews the exact stamp), and every export lands in the\naudit log as RESULT_EXPORTED — with an admin notification whenever a\nclassified result leaves."} +{"id":"3b3f8798eb7ce7ae","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":0,"tokens":252,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 1 of 11)\n\nA datasource is a governed connection to one of your databases. Users never receive its credentials — they submit queries to AccessFlow, which reviews them and then executes them over the pooled connection on their behalf. Masking, row-level security, schema allow-lists, and row caps are all configured per datasource.\n\nConfigure it. Create one with the four-step wizard at\n/datasources/new:\n\n/datasources/new — four-step wizard: Database type → Connection details → Connection test → Configuration.\n\n- Database type. Pick a bundled driver tile (PostgreSQL ships built-in; other drivers download on first use and are verified against a pinned SHA-256 checksum). Pick Custom to use a JDBC driver you uploaded under Admin → Custom JDBC drivers."} +{"id":"5a567ae041ce49c2","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":1,"tokens":793,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 2 of 11)\n\n- Connection details. Name the datasource, then enter host, port, database name, service-account username, and password. SSL mode is pre-filled from the engine's own default rather than one global value — PostgreSQL starts at VERIFY_FULL, several NoSQL engines start at DISABLE, and the rest at REQUIRE. Check it rather than assuming it, and prefer VERIFY_FULL in production. For Cassandra and ScyllaDB the wizard also requires a local datacenter (the driver's load-balancing datacenter); this field is unused for every other engine. For Elasticsearch and OpenSearch the wizard offers an Authentication toggle — basic (username + password) or an API key — and the database-name field is optional. For Amazon DynamoDB the connection is cloud credentials, not host/port: the wizard hides host/port and instead asks for the AWS region (the database-name field), the access key ID and secret access key (the username/password fields), and an optional custom endpoint (DynamoDB Local / VPC; blank for AWS). For Neo4j the wizard takes the standard host/port/database/username/password (the SSL mode is encoded in the Bolt scheme) plus an optional Bolt connection URI (advanced) — a full bolt:// / neo4j+s:// URI for Neo4j Aura or clustered routing that, when set, overrides host/port. For Snowflake the wizard asks for the account host (.snowflakecomputing.com; the port field is hidden — always 443), the database, the user, a credential that is either a password or a PKCS#8 private key (PEM) for key-pair authentication, an optional private key passphrase (only for a passphrase-protected key, which is what Snowflake's own openssl instructions produce), and an optional JDBC URL override — a full jdbc:snowflake:// URL carrying warehouse / role / schema parameters. For Google BigQuery the connection is cloud credentials: the wizard hides host/port/username and asks for the GCP project (optionally project.dataset to pin a default dataset) and the service-account key JSON, plus an optional custom endpoint (BigQuery emulator). For Databricks SQL the wizard asks for the workspace host, the required warehouse HTTP path (/sql/1.0/warehouses/ from the warehouse's connection details), an optional Unity Catalog catalog, and a personal access token. The password (and API key / secret access key) are AES-256-GCM encrypted on write, decrypted once into the connection pool, and never returned in any GET response. When an external secrets manager is enabled (see Run & deploy), any credential field also"} +{"id":"ab70ab607bfeefd9","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 11)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."} +{"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 11)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."} +{"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":718,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 11)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), denied schemas and tables, denied columns, and denied query shapes. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied schemas and tables — everything except. Sometimes it is easier to say what a user may not touch. Allow the schema crm and deny the table crm.salary, and the user can query every table in crm — including tables created later — except crm.salary. A query that touches a denied table is refused before it runs:\n\n- A denial always wins. It is checked after the allowed schemas and tables, and it works on its own too, with no allowed list at all.\n\n- Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.\n\n- Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.\n\n- Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.\n\n- Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the same way (below).\n\n- Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks."} +{"id":"6982bd47758e3828","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":5,"tokens":788,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 11)\n\n- Just-in-time access keeps denials. A just-in-time access request cannot add a denied schema or table, and approving one never removes a denial: denials from every grant add up, and when the approval replaces the user's own expiring grant, that grant's denials carry over to the new one.\n\n- How to write entries. A denied schema is a single name, such as hr — not analytics.hr. A denied table is table, schema.table, or schema.* for a whole schema. Other wildcards and empty parts are refused when you save the grant.\n\n- Tricky names are refused, not guessed. SQL Server's db..salary (default schema) is treated as matching any schema, an Oracle database link (hr.salary@remote) does not get around a denial, and a name pattern such as the Elasticsearch index pattern sal* is refused whenever the grant denies anything.\n\n- Works on every datasource type. Denied schemas and tables apply to relational, NoSQL and warehouse datasources alike. On datasources whose objects have no schema — MongoDB collections, DynamoDB tables, Redis keys — a denied schema refuses every query, so use denied tables there. A denial can only catch the tables AccessFlow sees in the query: MongoDB $lookup, $unionWith and $graphLookup stages, a Neo4j MATCH (n) with no label, and a Redis KEYS pattern are not caught. The allowed lists share this limit.\n\n- Removing a grant can widen access. A denial belongs to the grant that carries it. Revoke that grant, let it expire, or revoke it in an access review, and its denial goes with it — another grant the user still holds may then let them reach the table. Check the user's other grants first.\n\nDenied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:\n\n- What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.\n\n- Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this."} +{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":721,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 11)\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column denied by any one of them stays denied. A wider grant that denies nothing cannot undo it, and a group's denied columns apply to every member. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\nDenied query shapes — limit how a query is written. Some grants should allow a table but not every way of querying it — for example, simple lookups but no joins or totals. Pick the query shapes to refuse under Denied query shapes: joins, set operations (UNION, INTERSECT, EXCEPT), subqueries, WITH clauses, GROUP BY, HAVING, aggregate functions and window functions. Denying all of them leaves plain SELECT … FROM … WHERE … ORDER BY. A query that uses a denied shape is refused before it runs:\n\n- Anywhere in the query. A join inside a subquery or a WITH clause counts, and in a BEGIN; …; COMMIT; batch every statement is checked.\n\n- Aggregate functions. The standard ones — COUNT, SUM, AVG, MIN, MAX and similar. A custom aggregate function defined in your database is not recognised, so don't rely on this setting to block one.\n\n- When in doubt, refuse. If AccessFlow cannot work out a query's shape, it treats the query as using every shape the grant denies.\n\n- Several grants add up. A shape denied by any of a user's grants stays denied, and a just-in-time grant that replaces a user's own expiring grant keeps its denied shapes.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\n- Rather review than refuse? Use the Query shape condition in a routing policy instead, for example to send every joined query to a second reviewer."} +{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":554,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 11)\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits."} +{"id":"9b654aff5b19dadf","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":8,"tokens":747,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 9 of 11)\n\nMasking policies. The datasource Masking tab adds per-column\ndynamic data masking on top of the static restricted-columns masking above. Each\npolicy targets a schema.table.column and picks a strategy —\nfull (***), partial (keep the last N characters),\nhash (stable SHA-256), email (j***@domain), or\nformat-preserving — with an optional reveal-to condition. A query\nsubmitter whose role, group, or user id is listed in reveal to sees the unmasked\nvalue; everyone else sees the strategy output. A live preview shows how a sample value will\nrender. Masking is applied at result-read time before results are serialized or stored, so\nunmasked values never persist, and the ids of the policies that applied are recorded in the\nexecution's audit metadata. Reveal is explicit — there is no implicit admin bypass.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters."} +{"id":"d179f3a2f88d48a0","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":9,"tokens":551,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 10 of 11)\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform.\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it —\nrow rules are worked out on the stored query text alone. It compares policies against\npolicies — today's rules versus the draft — rather than against what actually\nhappened, because a past result may have come from an emergency, a ticket, or a standing\ngrant the draft has no say over. The results name their own limits: roles and group\nmemberships are read as they stand today, masking is matched on the column name alone\n(so a name two tables share can be over-counted), and where an engine cannot work out\noffline what a row rule would do — Cassandra and ScyllaDB need live key information —\nthose queries are listed as unclassifiable rather than counted as unaffected.\nSimulating is always optional; nothing blocks you from saving."} +{"id":"1107d71956e97891","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":10,"tokens":569,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 11 of 11)\n\nRow limits. The datasource Row limits tab caps how many rows a\nquery may return when it reads a particular table, so two tables on the same database\ncan have different limits and one team can be held tighter than another on the same\ntable. Each policy names a table (and optionally its schema), a maximum number of rows,\nand the applies to roles / groups / users it covers (empty = everyone, admins\nincluded). A row limit can only ever lower the cap: the datasource's\nMax rows per query and any per-user limit on the access grant still apply, and\nthe smallest number wins. A query that joins several limited tables gets the lowest of\ntheir limits. A policy with a schema also catches queries that name the table without\none or with a database name in front, so neither gets anyone more rows. Results that hit\nthe limit are marked as truncated, the table preview obeys the same limit, and when a\npolicy's limit is the one that applied it is recorded in the query's audit entry.\n\nExport policies. Masking and row security govern what a user\nsees; the datasource Export policy tab governs what leaves.\nEach policy sets a mode — allow, watermark, row cap, or\ndeny when classified (optionally scoped to specific classifications) — and an\napplies to roles / groups / users target (empty = every exporter, no implicit\nadmin bypass). When several policies apply, the most restrictive wins. The policies gate\nthe signed CSV/PDF result download on the query detail page and the results attachment\non recurring-run emails: a denied exporter sees a disabled export button with the\nreason, a watermarked download carries the exporter, timestamp, and query id baked into\nthe signed bytes (the modal previews the exact stamp), and every export lands in the\naudit log as RESULT_EXPORTED — with an admin notification whenever a\nclassified result leaves."} {"id":"f31c837889753d51","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":0,"tokens":791,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 1 of 3)\n\nThe datasource Classification tab tags\ntables and columns with one or more data classifications — PII, PCI,\nPHI, GDPR, FINANCIAL, or SENSITIVE — and\nderives stricter handling automatically. Tagging a column\nauto-applies a masking policy from the classification's default strategy\n(PII / GDPR / FINANCIAL → partial, PCI / PHI → full, SENSITIVE → hash), so you don't\nhand-configure masking for every sensitive field; a table-level tag (no column) is\ninformational. A query that references a tagged table gets an automatic AI risk-score\nbump, and a derivation preview suggests a stricter review posture (AI review,\nhuman approval, minimum approvals) aggregated across the datasource's tags — a suggestion\nyou apply on the datasource's review plan, never auto-changed. Tags are immutable\n(create / delete) and audited; deleting a tag keeps the masking policy it derived. The\nclassifications appear as badges in the schema explorer, and Admin → Data\nclassifications (/admin/data-classifications) lists every tag across all\ndatasources as the evidence base for compliance reporting.\n\nAutomated discovery. Instead of tagging hundreds of tables by hand, the\ndatasource Discovery tab opts a datasource into a scheduled scanner that samples\ncolumn data through the same governed sampling path, detects sensitive values with local\nregex + checksum detectors (emails, credit-card numbers with Luhn, US SSNs, IBANs, phone\nnumbers) and — optionally — your bound AI analyzer, then proposes the\nclassification tags in a review worklist. Confirming a finding applies the tag (deriving\nmasking exactly like a manual tag); dismissing suppresses the proposal permanently. Raw\nsampled values never persist (findings store a redacted sample only), and the AI pass\nonly ever sees column names, types, and redacted samples. Configure the per-datasource\nsample size (10–1000 rows, never more than the datasource's row cap) and cadence (1–720 hours), or hit Scan now for an\nimmediate run; scans and decisions land in the audit log\n(DISCOVERY_SCAN_COMPLETED, DISCOVERY_FINDING_CONFIRMED /\n_DISMISSED). Operator knobs:\nACCESSFLOW_DISCOVERY_SCAN_POLL_INTERVAL (PT15M),\nACCESSFLOW_DISCOVERY_SCAN_TIME_BUDGET (PT10M),\nACCESSFLOW_DISCOVERY_SAMPLE_STATEMENT_TIMEOUT (PT10S),\nACCESSFLOW_DISCOVERY_MAX_TABLES_PER_SCAN (200),\nACCESSFLOW_DISCOVERY_MAX_AI_TABLES_PER_SCAN (25),\nACCESSFLOW_DISCOVERY_MAX_NESTED_DEPTH (5),\nACCESSFLOW_DISCOVERY_MAX_NESTED_LEAVES_PER_ROW (100),\nACCESSFLOW_DISCOVERY_STALE_SCANS_BEFORE_EXPIRY (3),\nACCESSFLOW_DISCOVERY_SCAN_LOCK_AT_MOST_FOR (PT30M)."} {"id":"1b8b2d9602f1d31c","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":1,"tokens":724,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 2 of 3)\n\nOne scan per datasource at a time. A scan claims its datasource for as\nlong as it runs, so the same tables are never sampled twice at once — including when\nyou run AccessFlow on several servers, where a Scan now and a scheduled scan\ncould otherwise start on different ones. Scan now is refused outright while a\nscan is under way, so you always know which run you are looking at; a scheduled scan\nthat finds the datasource busy simply leaves it due and picks it up on the next round.\nACCESSFLOW_DISCOVERY_SCAN_LOCK_AT_MOST_FOR above is only the safety net for\na server that dies mid-scan: it caps how long the claim can outlive the machine holding\nit. Raising ACCESSFLOW_DISCOVERY_SCAN_TIME_BUDGET raises the claim with it,\nkeeping a wide margin over the time a scan is expected to take — so leaving this one\nalone is normally right. To hand a datasource back sooner than the cap after a server\nhas died, delete the Redis key\njob-lock:accessflow:shedlock:discoveryScan:.\n\nProposals that go quiet clean themselves up. When a column is dropped,\nits data is cleared, or you mask it by hand, the scan simply stops proposing it — and\nthe old suggestion would otherwise sit in the worklist forever. Instead, a proposal the\nscanner keeps sampling but no longer finds is marked Stale after a few\nconsecutive scans and drops out of the default Pending view; switch the status\nfilter to Stale to see those proposals and dismiss the batch in one go.\nNothing is thrown away: a stale proposal is still yours to confirm or dismiss, and if\nthe data comes back the next scan returns it to Pending. Only tables a scan\nactually sampled can age this way, so a run cut short by its table cap or time budget\nnever retires proposals it did not look at. Retirements are audited as\nDISCOVERY_FINDING_EXPIRED, up to 100 rows per scan — past that the scan's\nown audit entry carries the full count and flags the trail as truncated. How many\nconsecutive misses it takes is\nACCESSFLOW_DISCOVERY_STALE_SCANS_BEFORE_EXPIRY above.\n\nTune it. Per-datasource fields above set row caps and review behaviour;\nthese environment variables set the engine-level connection and execution ceilings\n(defaults shown):\n\n- Connection pools (all JDBC engines):\nACCESSFLOW_PROXY_CONNECTION_TIMEOUT (30s),\nACCESSFLOW_PROXY_IDLE_TIMEOUT (10m),\nACCESSFLOW_PROXY_MAX_LIFETIME (30m),\nACCESSFLOW_PROXY_LEAK_DETECTION_THRESHOLD (0s = off)."} {"id":"3086bd7be656b9cd","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-data-classifications","anchor":"cfg-data-classifications","title":"Data classification","section":"Reference","order":2,"tokens":728,"text":"AccessFlow Docs > Reference > Datasources > Data classification (part 3 of 3)\n\n- Statement execution (all engines):\nACCESSFLOW_PROXY_EXECUTION_MAX_ROWS (10000),\nACCESSFLOW_PROXY_EXECUTION_STATEMENT_TIMEOUT (30s),\nACCESSFLOW_PROXY_EXECUTION_DEFAULT_FETCH_SIZE (1000),\nACCESSFLOW_PROXY_EXECUTION_INSERT_BATCH_CHUNK_SIZE (1000).\n\n- Heap protection (relational engines):\nACCESSFLOW_PROXY_EXECUTION_MAX_RESULT_BYTES (52428800 —\nper-result byte cap; larger SELECT results are truncated),\nACCESSFLOW_PROXY_EXECUTION_MAX_CONCURRENT (32 —\nglobal in-flight execution budget across all datasources),\nACCESSFLOW_PROXY_EXECUTION_ACQUIRE_TIMEOUT (5s —\nwait before overflow executions are rejected with HTTP 503).\n\n- SELECT result cache:\nACCESSFLOW_PROXY_CACHE_ENABLED (true),\nACCESSFLOW_PROXY_CACHE_DEFAULT_TTL (PT60S),\nACCESSFLOW_PROXY_CACHE_MAX_ENTRY_BYTES (1000000).\n\n- Read-replica health checks:\nACCESSFLOW_PROXY_REPLICA_PROBE_INTERVAL (PT30S),\nACCESSFLOW_PROXY_REPLICA_PROBE_TIMEOUT (PT5S),\nACCESSFLOW_PROXY_REPLICA_COOLDOWN (PT30S).\n\n- MongoDB: ACCESSFLOW_PROXY_MONGO_CONNECT_TIMEOUT\n(PT10S), …_SERVER_SELECTION_TIMEOUT (PT10S),\n…_MAX_POOL_SIZE (10).\n\n- Couchbase:\nACCESSFLOW_PROXY_ENGINES_COUCHBASE_CONNECT_TIMEOUT (PT10S),\n…_WAIT_UNTIL_READY_TIMEOUT (PT10S),\n…_SCAN_CONSISTENCY (request-plus).\n\n- Redis: ACCESSFLOW_PROXY_ENGINES_REDIS_CONNECT_TIMEOUT\n(PT5S), …_SOCKET_TIMEOUT (PT5S),\n…_MAX_POOL_SIZE (10).\n\n- Cassandra / ScyllaDB:\nACCESSFLOW_PROXY_ENGINES_CASSANDRA_CONNECT_TIMEOUT /\n…_SCYLLADB_CONNECT_TIMEOUT (PT10S) and the matching\n…_REQUEST_TIMEOUT (PT10S).\n\n- Elasticsearch / OpenSearch:\nACCESSFLOW_PROXY_ENGINES_ELASTICSEARCH_CONNECT_TIMEOUT /\n…_OPENSEARCH_CONNECT_TIMEOUT (PT10S) and\n…_SOCKET_TIMEOUT (PT30S).\n\n- DynamoDB:\nACCESSFLOW_PROXY_ENGINES_DYNAMODB_CONNECT_TIMEOUT (PT10S),\n…_API_CALL_TIMEOUT (PT30S).\n\n- Neo4j: ACCESSFLOW_PROXY_ENGINES_NEO4J_CONNECT_TIMEOUT\n(PT10S), …_MAX_CONNECTION_POOL_SIZE (100).\n\n- Snowflake: ACCESSFLOW_PROXY_ENGINES_SNOWFLAKE_LOGIN_TIMEOUT\n(PT30S), …_NETWORK_TIMEOUT (PT60S).\n\n- BigQuery: ACCESSFLOW_PROXY_ENGINES_BIGQUERY_CONNECT_TIMEOUT\n(PT10S), …_READ_TIMEOUT (PT30S).\n\n- Databricks: ACCESSFLOW_PROXY_ENGINES_DATABRICKS_CONNECT_TIMEOUT\n(PT10S), …_WAIT_TIMEOUT (PT10S),\n…_POLL_INTERVAL (PT1S),\n…_RESULT_DISPOSITION (auto),\n…_MAX_RESULT_BYTES (52428800)."} @@ -224,8 +225,9 @@ {"id":"ac46e854b95af0fb","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-review-plan-settings","anchor":"cfg-review-plan-settings","title":"What can a review plan control?","section":"Reference","order":0,"tokens":772,"text":"AccessFlow Docs > Reference > Review workflows > What can a review plan control?\n\nA review plan is six settings plus an approver chain. Together they decide whether a\nquery needs AI scoring, how many humans must sign off, which of them, and what happens\nif nobody does. Every setting below is per-plan, and a plan attaches to a datasource.\n\nSetting |\nWhat it does |\nWhen disabled / unset |\n\nRequire AI review |\nEvery query is scored for risk before it queues for humans. |\nThe query skips PENDING_AI and goes straight to review. |\n\nRequire human approval |\nAt least one reviewer must sign off before execution. |\nThe query is approved without a human — AI-only gating. |\n\nAuto-approve LOW-risk reads |\nSELECTs below the AI risk threshold skip human approval. |\nReads queue for review like any other statement. |\n\nMinimum approvals |\nHow many distinct reviewers must approve before the query advances. |\nDefaults to one approval per stage. |\n\nApproval timeout (hours) |\nHow long an idle PENDING_REVIEW query waits before auto-rejection. |\nThe query waits indefinitely for a decision. |\n\nApprover chain |\nOrdered stages, each naming a role or a specific user as approver. |\nNo chain means no staged escalation — one flat approval step. |\n\nStages advance sequentially and a single Reject at any stage terminates the\nquery. A user can never approve their own query, whatever their role.\n\nReviewer decisions. Reviewers can Approve, Reject, or\nRequest changes. Reject and Request changes both require a\nnon-empty comment — the server enforces this (HTTP 400 VALIDATION_ERROR),\nand the UI disables the confirm button until the textarea is populated. The comment is\npersisted on the decision row, rendered on the rejected stage of the timeline on\n/queries/, and surfaced to the submitter as a \"Changes requested\"\nalert whenever the latest decision is REQUESTED_CHANGES and the query is\nstill PENDING_REVIEW. Approve still treats the comment as\noptional.\n\nQuery status transitions. A query moves through these states:\n\nPENDING_AI → PENDING_REVIEW → APPROVED → EXECUTED\n↘ REJECTED (manual reviewer rejection)\n↘ TIMED_OUT (approval-timeout auto-reject)\nPENDING_REVIEW → CANCELLED (submitter only)\nAPPROVED → FAILED (execution error)\n\nA single REJECTED decision at any stage terminates the query. If a query\nsits in PENDING_REVIEW past the plan's approval timeout, AccessFlow\nauto-rejects it (it scans on a cadence set by\nACCESSFLOW_WORKFLOW_TIMEOUT_POLL_INTERVAL, default PT5M).\nAuto-approve reads lets SELECTs skip human approval entirely;\nRequire AI review still scores the read but won't block on a human."} {"id":"da27d4223286793a","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-review-escalation","anchor":"cfg-review-escalation","title":"Escalation & reminders","section":"Reference","order":0,"tokens":556,"text":"AccessFlow Docs > Reference > Review workflows > Escalation & reminders\n\nWhat it is. Between a request arriving and the approval timeout\nauto-rejecting it, nothing used to happen — a stalled chain was silent until the\nsubmitter found out by being rejected. Two optional settings on a review plan add the\nwarning shots.\n\nWhere. Admin → Review plans, alongside\nApproval timeout: Escalate after (hours) and\nNudge every (hours). Both are blank by default, and blank means off — an\nexisting plan behaves exactly as it did before you set them.\n\n-\nEscalate after — when nobody has decided within this window, the\nrequest is raised to the reviewers it is waiting on and your organization's admins,\nonce. It appears with an escalation notice on the request page and goes out over the\nplan's notification channels; PagerDuty pages for it on channels that enable the\nREVIEW_STALLED trigger. It must be shorter than the approval timeout\n— a longer window could never fire, so AccessFlow rejects it rather than\nletting you save a setting that quietly does nothing.\n\n-\nNudge every — re-notifies the reviewers who still have not\ndecided, on this cadence. It reaches only the people already on the hook, never\nadmins, and never pages: a reminder is not an incident.\n\nWhat it does not do. Escalation is notify-only. It\nnever changes who is allowed to approve, and it never approves, rejects, or times out\nanything — waiting must not become a way around the approvers you configured.\nThe request stays exactly where it was, with the same people able to act on it.\n\nGrouped requests have no plan of their own, so a bundle uses the\nshortest escalation window among its members' plans. A member whose plan has\nescalation switched off simply does not contribute one. For bundles the escalation is\nrecorded on the request group and nothing more — no notification and no queue badge\nyet, since grouped requests have no notification path at all."} {"id":"a2145564ed8ff33c","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-review-delegation","anchor":"cfg-review-delegation","title":"Out-of-office delegation","section":"Reference","order":0,"tokens":614,"text":"AccessFlow Docs > Reference > Review workflows > Out-of-office delegation\n\nWhat it is. An approval chain moves at the speed of its slowest human.\nWhen a named reviewer goes on holiday, a request sits in PENDING_REVIEW\nuntil the approval timeout auto-rejects it. Delegation lets a reviewer hand their review\nduty to a named colleague for a set window instead.\n\nWhere. Any reviewer sets this themselves on Profile settings\n→ Out-of-office delegation. No admin involvement, and no permission is\nrequired to delegate — a delegation from someone with no review rights simply\nconfers nothing.\n\nHow to set one up. Choose a colleague, optionally narrow the delegation\nto a single datasource, pick a start and end time, and save. During the window the\ndelegate becomes an eligible approver everywhere you were — query review, governed\nAPI requests, and grouped requests — and the requests show up in their review queue\nwith a Delegated tag. Revoking is immediate and takes effect on the next\ndecision; the record itself is kept as evidence for anything already approved under it.\n\nWhat it cannot do. These limits are enforced by the server, not the\ninterface:\n\n-\nA delegation never grants a permission. The delegate still needs\nreview rights of their own; delegation only widens which requests they may act\non.\n\n-\nThe delegate can never act on a request the delegator submitted\n— the no-self-approval rule follows the borrowed identity, not just the person\nclicking.\n\n-\nDelegation does not chain. If A delegates to B and B delegates to C, C\ngains nothing from A.\n\n-\nOne human still gets one vote. Covering for two absent approvers at\nonce does not let someone satisfy a two-approval requirement alone.\n\n-\nIt stops the moment either party is deactivated, including via SCIM\ndeprovisioning.\n\nAudit. Every decision made under a delegation records both people\n— who clicked approve, and whose authority they used — so the trail never\nimplies the absent reviewer acted. Admins can see every delegation in the organization\nunder Admin, which is what makes an “on behalf of” entry\ninterpretable months later."} -{"id":"24fe190f5a746622","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":0,"tokens":676,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 1 of 2)\n\nWhat it is. Policy-as-code that decides a query's path automatically,\nafter AI analysis and before reviewers see it. Use it to auto-approve\nroutine reads, hard-block dangerous patterns, or escalate sensitive ones — instead of\nsending everything through the same review plan. Policies run in ascending priority and the\nfirst enabled one whose condition matches wins; anything unmatched falls\nthrough to the datasource's review plan exactly as before.\n\nConfigure it. Manage them at /admin/routing-policies (the\nRouting policies entry in the Security nav group):\n\n- Open /admin/routing-policies (the Routing policies entry in the Security nav group, next to Review plans) and click Add policy.\n\n- Name the policy and optionally scope it to one datasource — leave the datasource blank for an org-wide rule. Set its priority (unique per organisation; lower runs first) and the enabled toggle.\n\n- Build the condition with the guided builder: pick match ALL (AND) or match ANY (OR), then add leaf conditions — each can be negated (NOT). Operands include query type, referenced tables (glob, e.g. payroll.*), AI risk level, AI risk score (with a comparison operator), requester role, requester group, time-of-day window, day-of-week, presence of a WHERE clause, presence of a LIMIT clause, the transactional (BEGIN…COMMIT) flag, and the pre-flight cost estimate — estimated rows (comparison against the engine's own EXPLAIN estimate, or the exact affected-row count for UPDATE/DELETE) and scan type (glob match on the plan's root operation, e.g. Seq*) — so a policy can route a 10-million-row sequential-scan DELETE differently from a 10-row indexed one.\n\n- Choose the action. Auto-approve (skip human review), Auto-reject (block the query), Require approvals (force human review with an absolute minimum number of approvers), or Escalate (force human review, adding a delta on top of the review plan's minimum). The approver count applies only to the last two actions.\n\n- Reorder policies any time with the per-row up/down controls — the order is the evaluation order."} -{"id":"ef446fedd00559b9","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":1,"tokens":748,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 2 of 2)\n\nHow it routes. Time-of-day and day-of-week conditions are evaluated in the\nserver's local timezone (overnight windows wrap around midnight). On datasources with\nAI analysis disabled, risk-based conditions never match (there's no AI signal); routing does not\nrun when AI analysis fails — the query goes to a human instead. The cost-estimate conditions\nlikewise never match while no estimate exists (engine without a plan concept, or the estimate\nfailed) — they fail closed rather than auto-approving blind. Every automated decision is\nrecorded in the audit log (QUERY_APPROVED /\nQUERY_REJECTED with source: \"ROUTING_POLICY\"), and the query detail page\nshows which policy matched. Routing policies are managed via the ADMIN-only\n/api/v1/admin/routing-policies CRUD and /reorder endpoints.\n\n/admin/routing-policies — ordered, attribute-based auto-decision rules; first match by priority wins, unmatched falls through to the review plan.\n\nSimulate it before you save it. A routing rule is easy to write and hard\nto predict — the same condition that blocks one dangerous DELETE can quietly block a\nnightly job nobody remembered. Every policy form has a Simulate button that\ndry-runs the draft against your own past queries and shows the blast radius first.\nPick a date range (up to 90 days) and AccessFlow replays that traffic twice\n— once against the policies you have today, once with the draft added or replacing the one\nyou are editing — then reports the difference between those two runs: how many past\nqueries would take a different path, which of them, and which people would feel it. The\nsame button sits on the masking\nand row security forms, so you can preview those before saving too.\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it.\nIt compares policies against policies — today's rules versus the draft — rather\nthan against what actually happened, because a past decision may have come from an\nemergency, a ticket, or a standing grant the draft has no say over. Some things simply\ncannot be reconstructed from months-old traffic, so the results say so: memberships and\nroles are read as they stand today, and where an engine cannot work out offline what a\nrow rule would do, those queries are listed as unclassifiable — never counted as\nunaffected. Simulating is always optional; nothing blocks you from saving."} +{"id":"24fe190f5a746622","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":0,"tokens":741,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 1 of 3)\n\nWhat it is. Policy-as-code that decides a query's path automatically,\nafter AI analysis and before reviewers see it. Use it to auto-approve\nroutine reads, hard-block dangerous patterns, or escalate sensitive ones — instead of\nsending everything through the same review plan. Policies run in ascending priority and the\nfirst enabled one whose condition matches wins; anything unmatched falls\nthrough to the datasource's review plan exactly as before.\n\nConfigure it. Manage them at /admin/routing-policies (the\nRouting policies entry in the Security nav group):\n\n- Open /admin/routing-policies (the Routing policies entry in the Security nav group, next to Review plans) and click Add policy.\n\n- Name the policy and optionally scope it to one datasource — leave the datasource blank for an org-wide rule. Set its priority (unique per organisation; lower runs first) and the enabled toggle.\n\n- Build the condition with the guided builder: pick match ALL (AND) or match ANY (OR), then add leaf conditions — each can be negated (NOT). Operands include query type, referenced tables (glob, e.g. payroll.*), AI risk level, AI risk score (with a comparison operator), requester role, requester group, time-of-day window, day-of-week, presence of a WHERE clause, presence of a LIMIT clause, query shape (the query contains a join, a set operation such as UNION, a subquery, a WITH clause, GROUP BY, HAVING, an aggregate function or a window function — anywhere, including inside subqueries), the transactional (BEGIN…COMMIT) flag, and the pre-flight cost estimate — estimated rows (comparison against the engine's own EXPLAIN estimate, or the exact affected-row count for UPDATE/DELETE) and scan type (glob match on the plan's root operation, e.g. Seq*) — so a policy can route a 10-million-row sequential-scan DELETE differently from a 10-row indexed one.\n\n- Choose the action. Auto-approve (skip human review), Auto-reject (block the query), Require approvals (force human review with an absolute minimum number of approvers), or Escalate (force human review, adding a delta on top of the review plan's minimum). The approver count applies only to the last two actions.\n\n- Reorder policies any time with the per-row up/down controls — the order is the evaluation order."} +{"id":"ef446fedd00559b9","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":1,"tokens":600,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 2 of 3)\n\nHow it routes. Time-of-day and day-of-week conditions are evaluated in the\nserver's local timezone (overnight windows wrap around midnight). On datasources with\nAI analysis disabled, risk-based conditions never match (there's no AI signal); routing does not\nrun when AI analysis fails — the query goes to a human instead. The cost-estimate conditions\nlikewise never match while no estimate exists (engine without a plan concept, or the estimate\nfailed) — they fail closed rather than auto-approving blind. A query-shape condition never\nmatches a query AccessFlow cannot read as SQL, such as a MongoDB or Redis command. To refuse a\nshape for one person or group outright, set Denied query shapes on their\ndatasource grant instead. Every automated decision is\nrecorded in the audit log (QUERY_APPROVED /\nQUERY_REJECTED with source: \"ROUTING_POLICY\"), and the query detail page\nshows which policy matched. Routing policies are managed via the ADMIN-only\n/api/v1/admin/routing-policies CRUD and /reorder endpoints.\n\n/admin/routing-policies — ordered, attribute-based auto-decision rules; first match by priority wins, unmatched falls through to the review plan.\n\nSimulate it before you save it. A routing rule is easy to write and hard\nto predict — the same condition that blocks one dangerous DELETE can quietly block a\nnightly job nobody remembered. Every policy form has a Simulate button that\ndry-runs the draft against your own past queries and shows the blast radius first.\nPick a date range (up to 90 days) and AccessFlow replays that traffic twice\n— once against the policies you have today, once with the draft added or replacing the one\nyou are editing — then reports the difference between those two runs: how many past\nqueries would take a different path, which of them, and which people would feel it. The\nsame button sits on the masking\nand row security forms, so you can preview those before saving too."} +{"id":"3279d01a87ac8c08","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":2,"tokens":243,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 3 of 3)\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it.\nIt compares policies against policies — today's rules versus the draft — rather\nthan against what actually happened, because a past decision may have come from an\nemergency, a ticket, or a standing grant the draft has no say over. Some things simply\ncannot be reconstructed from months-old traffic, so the results say so: memberships and\nroles are read as they stand today, and where an engine cannot work out offline what a\nrow rule would do, those queries are listed as unclassifiable — never counted as\nunaffected. Simulating is always optional; nothing blocks you from saving."} {"id":"16c52a767cb82ddb","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-sql-review","anchor":"cfg-sql-review","title":"SQL review rules","section":"Reference","order":0,"tokens":768,"text":"AccessFlow Docs > Reference > Review workflows > SQL review rules (part 1 of 2)\n\nWhat it is. A catalog of named, deterministic checks that every SQL query\nis measured against — the third opinion next to the\nAI analysis and\nrouting policies. Where the AI gives a judgement and a\nrouting policy gives a single verdict, a rule gives a named, repeatable finding:\n\"this DELETE has no WHERE clause\", \"this touches a protected\ntable\", \"this pattern starts with a wildcard\". The same query always produces the same\nfindings, they cost nothing to compute, and they keep working with AI analysis switched off.\nAuthors see them in the editor as they type, before anything is submitted.\n\nFourteen built-in rules — missing WHERE on\nUPDATE or DELETE, an always-true WHERE such as\n1 = 1 that would otherwise defeat those two, SELECT *, a\nSELECT with no row limit, ORDER BY without a limit, cross joins,\nLIKE '%…', DROP, TRUNCATE, any DDL, a call to a banned\nfunction (pg_sleep, sleep, benchmark,\nload_file out of the box), any statement touching a protected\ntable you name by pattern (payroll.*, *.audit_log), and a\ndata change submitted outside a BEGIN … COMMIT transaction. Every rule is\nderived from the parsed statement alone — AccessFlow never connects to your database to\nevaluate one.\n\nThree severities, per rule. Each rule runs at one of\nOff (not checked), Warn (the finding is recorded and shown to the author\nand the reviewers, and changes nothing about the approval path), or Block (the\nfinding is recorded and the query can no longer be approved automatically —\na person must look at it). Out of the box the dangerous ones block — missing\nWHERE, always-true WHERE, DROP,\nTRUNCATE, banned functions, protected tables — and the rest warn: the\nperformance hints, any other DDL, and a data change outside a transaction.\n\nBlock means a person must look. It never rejects. A blocking finding\nswitches off every path that would have approved the query without a human —\na routing policy's auto-approve, a standing grant's pre-approval, a review plan that\nauto-approves reads or needs no human sign-off — and sends the query to the reviewers\ninstead. It does not refuse the query, and it never overrides a routing policy that\nrejects: nothing in this feature adds a new way for a query to be turned down\nwithout someone deciding it. The author can still submit; the Submit button's tooltip says\nhow many blocking findings will require human approval. Every time a block changed the\noutcome, the audit log\nrecords which rules did it."} {"id":"dd7676e238d930a9","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-sql-review","anchor":"cfg-sql-review","title":"SQL review rules","section":"Reference","order":1,"tokens":735,"text":"AccessFlow Docs > Reference > Review workflows > SQL review rules (part 2 of 2)\n\nConfigure it. Rulesets live at /admin/sql-review (the\nSQL review entry in the Security nav group, next to Routing\npolicies; needs the Manage SQL review rules permission, which the built-in\nAdmin role holds):\n\n- Give every datasource an environment. On the datasource's create wizard or settings page, set Environment to Development, Test, Staging or Production — or leave it unset. See Datasources.\n\n- Open /admin/sql-review and click Add ruleset. Name it, and bind it to one environment — or pick Organization default, the ruleset that applies to every datasource with no environment set (and to any environment that has no ruleset of its own). One ruleset per environment, one organization default.\n\n- Set the severities. The rules table lists every built-in rule with its default severity underneath; change only the ones you want to. For Protected table add the table patterns; for Disallowed function add or replace the banned names. Rules left at their default are not stored, so a ruleset stays a short list of what you changed.\n\n- Enable it. A ruleset can be switched off with the Enabled toggle — note that a disabled ruleset bound to an environment evaluates no rules and does not fall back to the organization default, so switching production off never quietly re-enables the default there.\n\nHow it decides. A query is checked at submission, before the AI is\nasked, so its findings exist even on datasources where AI analysis is disabled, and\neven when the AI provider is down — the cases where a written-down rule is the only signal\nthere is. The datasource's environment picks the ruleset; no environment means the\norganization default; no default means no rules. Findings are shown to reviewers in their\nown language on the query's detail page, as a block count on the review queue, on a\nrequest group's member list, and on the retro-review of an emergency\nbreak-glass run — which is\nrecorded but never held up by a rule, because emergency access stays an emergency path.\nRules apply to the SQL engines (PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom\nJDBC drivers); on MongoDB, Couchbase, Redis, Cassandra, ScyllaDB, Elasticsearch, OpenSearch,\nNeo4j, DynamoDB and the cloud warehouses the check reports not applicable and never blocks — an engine without rule\nsupport must never make its queries harder to approve than they are today."} {"id":"f84696c47980c176","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-attestation","anchor":"cfg-attestation","title":"Access recertification campaigns","section":"Reference","order":0,"tokens":518,"text":"AccessFlow Docs > Reference > Review workflows > Access recertification campaigns (part 1 of 3)\n\nWhat it is. Recurring attestation campaigns that make someone periodically\nre-confirm who still needs standing datasource access — the review control SOC 2 and\nISO 27001 auditors ask for. An admin schedules an org- or datasource-scoped campaign;\nwhen it opens it snapshots the current standing grants into one item per\ngrant and notifies the eligible reviewers (multi-channel, plus an\nattestation.campaign_opened WebSocket event). Reviewers work a\ncertify / revoke worklist that reuses the review-queue patterns\n(self-review blocked, bulk-certify); a revoke routes through the normal\npermission-revoke path, so access is actually removed.\n\nConfigure it. Manage campaigns from /admin/attestation and\ncertify items from the reviewer worklist at /reviews/attestations. Two\nclustered-safe jobs run the lifecycle: one opens SCHEDULED campaigns at their\nscheduled_open_at, the other closes OPEN campaigns at their\ndue_at and applies each campaign's pending-default (KEEP or\nREVOKE) to anything a reviewer never got to. A completed campaign exports as a\nCSV evidence file (who reviewed what, decisions, timestamps), and every\ntransition — ATTESTATION_CAMPAIGN_OPENED/CLOSED,\nATTESTATION_ITEM_CERTIFIED/REVOKED — lands in the tamper-evident\naudit log.\n\nTune it. ACCESSFLOW_ATTESTATION_OPEN_POLL_INTERVAL and\nACCESSFLOW_ATTESTATION_CLOSE_POLL_INTERVAL (open / close scan cadence, both\ndefault PT5M) and ACCESSFLOW_ATTESTATION_MAX_EVIDENCE_ROWS (row\ncap before an evidence CSV is marked truncated, default 50000).\n\n/admin/attestation — admins schedule recurring access-recertification campaigns; reviewers certify or revoke each snapshotted grant."} @@ -245,8 +247,8 @@ {"id":"6bdb1390c93897ed","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-users","anchor":"cfg-users","title":"Users","section":"Reference","order":0,"tokens":444,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Users\n\nWhat it is. The people who can sign in to AccessFlow and the role each one\ncarries. Create accounts directly, or let SAML / OAuth users be auto-provisioned on first\nsign-in when SSO is enabled (see SAML and OAuth).\n\nConfigure it. Manage everyone from /admin/users:\n\n/admin/users → Invite via email. Enter the recipient's email, pick a role, and AccessFlow emails a one-time signup link.\n\n- Invite via email (default). From /admin/users, click Invite via email, fill in the recipient's email, optional display name, and role, then submit. AccessFlow generates a signup token and emails it; the link expires after ACCESSFLOW_SECURITY_INVITATION_TTL (default 7 days).\n\n- Create with a password. Use the dropdown next to the invite button → Create with password to provision a user directly. Useful when SMTP isn't configured yet, or when you want to seed an account synchronously.\n\n- Edit or deactivate. Click any row to change the role or flip the active toggle. Deactivated users can't sign in but their audit trail is preserved.\n\n- Pending invitations are listed below the user table; resend or revoke them from there.\n\nTune it. ACCESSFLOW_SECURITY_INVITATION_TTL (invite-link\nlifetime, default P7D), ACCESSFLOW_SECURITY_PASSWORD_RESET_TTL\n(reset-link lifetime, default PT1H), and\nACCESSFLOW_SECURITY_PASSWORD_RESET_RESET_BASE_URL (link base, default\nhttp://localhost:5173)."} {"id":"b51b0af37cc37234","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-roles","anchor":"cfg-roles","title":"User roles & RBAC","section":"Reference","order":0,"tokens":120,"text":"AccessFlow Docs > Reference > Users, roles & organizations > User roles & RBAC\n\nWhat it is. Role-based access control. Every user carries one org-wide\nrole that caps what they can do; on top of it, per-datasource permissions decide which\ndatabases they may touch. Pick the lowest-privilege role that still lets someone do their\njob — and a user can never approve their own query, whatever their role."} {"id":"5569e0de6028e95d","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":0,"tokens":787,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 1 of 3)\n\nAccessFlow has five org-wide roles. READONLY submits SELECT queries only. ANALYST adds DML. REVIEWER adds approving other people’s queries. ADMIN adds DDL and every configuration screen. AUDITOR is read-only across the audit log and compliance reports, and cannot submit queries at all.\n\nCustom roles. Beyond the five built-in system roles, an admin can compose\ncustom roles on /admin/roles from a fixed catalog of functional\npermissions (submit SELECT/DML/DDL, review queries, review access requests, manage\ndatasources, view the audit log, and so on) — e.g. a reviewer who may approve queries but\nnot manage users. The five system roles are immutable and behave exactly as the matrix\nbelow; a custom role grants exactly the permissions you tick. Roles that are still\nassigned to users cannot be deleted.\n\nConfigure it. Assign a system or custom role when you create or edit a\nuser on /admin/users; the matrix below is what each built-in role may do.\n\nPlatform admin is separate from the five roles. The\nplatform-admin capability is an orthogonal flag, not a\nfifth role — a platform admin keeps whatever role their home org assigns and is\nadditionally allowed to manage organizations across the cluster\n(/admin/organizations). It grants no extra capability inside any single org;\nthe matrix below still governs every tenant-scoped action.\n\nCapability |\nREADONLY |\nANALYST |\nREVIEWER |\nADMIN |\nAUDITOR |\n\nSubmit SELECT queries | ✓ | ✓ | ✓ | ✓ | — |\n\nSubmit DML (INSERT / UPDATE / DELETE) | — | ✓ | ✓ | ✓ | — |\n\nSubmit DDL (CREATE / ALTER / DROP) | — | — | — | ✓ | — |\n\nView own query history | ✓ | ✓ | ✓ | ✓ | — |\n\nView all queries in the org | — | — | ✓ | ✓ | — |\n\nApprove / reject queries | — | — | ✓ | ✓ | — |\n\nRequest time-bound datasource / API-connection access (JIT) | ✓ | ✓ | ✓ | ✓ | — |\n\nReview / approve access requests | — | — | ✓ | ✓ | — |\n\nReview / approve deployment requests | — | — | ✓ | ✓ | — |\n\nManage datasources | — | — | — | ✓ | — |\n\nManage users | — | — | — | ✓ | — |\n\nManage user groups | — | — | — | ✓ | — |\n\nManage review plans | — | — | — | ✓ | — |\n\nManage deployment pipelines | — | — | — | ✓ | — |\n\nView audit log | — | — | — | ✓ | — |\n\nManage notification channels | — | — | — | ✓ | — |\n\nManage external audit sinks | — | — | — | ✓ | — |"} -{"id":"4c1b4663ec7a67c8","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":1,"tokens":747,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 2 of 3)\n\nConfigure AI | — | — | — | ✓ | — |\n\nConfigure SAML / OAuth | — | — | — | ✓ | — |\n\nView / export compliance reports | — | — | — | ✓ | ✓ |\n\nView the over-provisioned access report | — | — | — | ✓ | ✓ |\n\nView the privileged-access report | — | — | — | ✓ | ✓ |\n\nWho can write to a table (effective-access lookup) | — | — | — | ✓ | ✓ |\n\nRun a decision trace (query, API call or deployment) | — | — | — | ✓ | — |\n\nView behavioural anomalies (UBA) | — | — | — | ✓ | ✓ |\n\nAcknowledge / dismiss anomalies | — | — | — | ✓ | — |\n\nBreak-glass / emergency execution† | ✓ | ✓ | ✓ | ✓ | — |\n\nView break-glass log | — | — | — | ✓ | ✓ |\n\nAcknowledge break-glass events | — | — | — | ✓ | — |\n\n† Break-glass / emergency execution is not granted by role — it is gated by a\nseparate per-user, per-datasource can_break_glass permission that an admin\ngrants explicitly (required for everyone, including admins; time-boxed). A user can\nbreak glass only on a datasource they hold that grant for, and only for query types they\nalready have the capability for.\n\nWhich role for what. Use READONLY for people who only\nneed to look at production data (analysts, on-call engineers reading dashboards).\nUse ANALYST for people who write data through reviewed queries.\nUse REVIEWER for people who approve other users' queries — typically\nsenior engineers or DBAs. Use ADMIN for the platform-team operators who\nconfigure the system itself. Use AUDITOR for a dedicated, read-only\ncompliance reviewer — it sees only the compliance dashboard (/admin/auditor):\npre-built PII/PCI/GDPR access and DDL/DELETE reports with signed PDF/CSV export, and\nnothing else.\n\nDatasource-level permissions. Role is the org-wide ceiling. On top of\nit, every user needs an explicit per-datasource permission grant to\naccess a given database — it controls read / write / DDL per\ndatasource, row caps, allowed schemas / tables, denied schemas / tables (everything\nexcept these — a denial always beats the allowed list), restricted columns (which are masked\nas *** in SELECT results), and denied columns (a query that\nreferences one is refused before it runs). See\ndocs/07-security.md\nfor the full authorization matrix."} -{"id":"052a0364ddf22c61","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":2,"tokens":359,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 3 of 3)\n\nGroup-based access grants. Rather than a row per person, an admin can grant\na user group access to a datasource or an API connector (same\nread / write / DDL / break-glass controls); every member inherits the grant, and adding\nsomeone to the group gives them access without a new grant. When a user has both a direct\ngrant and one or more group grants, their effective access is the most-permissive\nunion — capabilities are OR-ed, allow-lists merge, restricted-column masks apply\nonly where every grant restricts them, and each grant's expiry is honoured independently. Two\nthings work the other way. The row limit override: the smallest one wins, and it can only lower the\ndatasource's cap, never raise it. And denied schemas, tables and columns add up: anything denied by\nany one grant stays denied, so a wider group grant can never undo a denial, and a group's denial\napplies to every member. The flip side: revoking or expiring the grant that carries a denial removes it,\nand the user's other grants may then reach the table."} +{"id":"4c1b4663ec7a67c8","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":1,"tokens":769,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 2 of 3)\n\nConfigure AI | — | — | — | ✓ | — |\n\nConfigure SAML / OAuth | — | — | — | ✓ | — |\n\nView / export compliance reports | — | — | — | ✓ | ✓ |\n\nView the over-provisioned access report | — | — | — | ✓ | ✓ |\n\nView the privileged-access report | — | — | — | ✓ | ✓ |\n\nWho can write to a table (effective-access lookup) | — | — | — | ✓ | ✓ |\n\nRun a decision trace (query, API call or deployment) | — | — | — | ✓ | — |\n\nView behavioural anomalies (UBA) | — | — | — | ✓ | ✓ |\n\nAcknowledge / dismiss anomalies | — | — | — | ✓ | — |\n\nBreak-glass / emergency execution† | ✓ | ✓ | ✓ | ✓ | — |\n\nView break-glass log | — | — | — | ✓ | ✓ |\n\nAcknowledge break-glass events | — | — | — | ✓ | — |\n\n† Break-glass / emergency execution is not granted by role — it is gated by a\nseparate per-user, per-datasource can_break_glass permission that an admin\ngrants explicitly (required for everyone, including admins; time-boxed). A user can\nbreak glass only on a datasource they hold that grant for, and only for query types they\nalready have the capability for.\n\nWhich role for what. Use READONLY for people who only\nneed to look at production data (analysts, on-call engineers reading dashboards).\nUse ANALYST for people who write data through reviewed queries.\nUse REVIEWER for people who approve other users' queries — typically\nsenior engineers or DBAs. Use ADMIN for the platform-team operators who\nconfigure the system itself. Use AUDITOR for a dedicated, read-only\ncompliance reviewer — it sees only the compliance dashboard (/admin/auditor):\npre-built PII/PCI/GDPR access and DDL/DELETE reports with signed PDF/CSV export, and\nnothing else.\n\nDatasource-level permissions. Role is the org-wide ceiling. On top of\nit, every user needs an explicit per-datasource permission grant to\naccess a given database — it controls read / write / DDL per\ndatasource, row caps, allowed schemas / tables, denied schemas / tables (everything\nexcept these — a denial always beats the allowed list), restricted columns (which are masked\nas *** in SELECT results), denied columns (a query that\nreferences one is refused before it runs), and denied query shapes (for example no joins\nor aggregate functions). See\ndocs/07-security.md\nfor the full authorization matrix."} +{"id":"052a0364ddf22c61","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/","anchor":"","title":"What are the user roles in AccessFlow?","section":"Reference","order":2,"tokens":364,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 3 of 3)\n\nGroup-based access grants. Rather than a row per person, an admin can grant\na user group access to a datasource or an API connector (same\nread / write / DDL / break-glass controls); every member inherits the grant, and adding\nsomeone to the group gives them access without a new grant. When a user has both a direct\ngrant and one or more group grants, their effective access is the most-permissive\nunion — capabilities are OR-ed, allow-lists merge, restricted-column masks apply\nonly where every grant restricts them, and each grant's expiry is honoured independently. Two\nthings work the other way. The row limit override: the smallest one wins, and it can only lower the\ndatasource's cap, never raise it. And denied schemas, tables, columns and query shapes add up: anything denied by\nany one grant stays denied, so a wider group grant can never undo a denial, and a group's denial\napplies to every member. The flip side: revoking or expiring the grant that carries a denial removes it,\nand the user's other grants may then reach the table."} {"id":"cf357e69598c439d","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-access-requests","anchor":"cfg-access-requests","title":"Just-in-time (JIT) access requests","section":"Reference","order":0,"tokens":652,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Just-in-time (JIT) access requests\n\nInstead of an admin pre-granting a\npermission, any user can request temporary, scoped access from\n/access-requests — to a datasource (pick the capabilities they\nneed — read / write / DDL — and an optional schema/table scope) or to an API\nconnection (read / write plus an optional allow-list of specific operations from\nthe connector's schema catalog), with a duration. The request runs\nthrough the same reviewer-eligibility and multi-stage approval engine as query review\n(a requester can never approve their own); API-connection requests route through the\nconnector's assigned review plan. Admins are the backstop approver: an admin\nsees and can approve every pending access request from\n/admin/access-requests — even on resources with no review plan — so a\nrequest is never stuck waiting for an approver who was never configured. On final\napproval AccessFlow writes a time-boxed permission grant (expiring at\nnow + duration) — a datasource permission, or an API-connection permission\nvisible on the connector's Permissions tab alongside admin-granted rows; it's revoked\nautomatically on expiry, and an admin can revoke an\nactive grant early from /admin/access-requests. Tune the revocation cadence\nwith ACCESSFLOW_ACCESS_GRANT_EXPIRY_POLL_INTERVAL (default PT5M)\nand the allowed duration window with ACCESSFLOW_ACCESS_MIN_DURATION /\nACCESSFLOW_ACCESS_MAX_DURATION (defaults PT15M / P30D).\nA requester can additionally tick “Pre-approve queries under this grant” on the\nrequest form (off by default): while such a grant is active, queries it covers —\nmatching capability and schema/table scope — skip human review entirely and are\nauto-approved with the grant and its approver recorded on the query detail and in the\naudit log. The flag is shown as a highlighted tag in the approval queue so the reviewer\nsees exactly what they authorize; auto-reject and escalation routing policies, high-risk\nAI verdicts, and open behavioural anomalies still override the fast-path.\n\n/admin/access-requests — pending JIT access requests; admins approve, reject, or revoke an active grant."} {"id":"3d92254db5c80485","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-break-glass","anchor":"cfg-break-glass","title":"Break-glass / emergency access","section":"Reference","order":0,"tokens":385,"text":"AccessFlow Docs > Reference > Users, roles & organizations > Break-glass / emergency access\n\nFor genuine emergencies — production is\ndown and approvers are unreachable — an admin can grant a user the\ncan_break_glass permission on a datasource (a checkbox on the permission\ngrant, alongside read / write / DDL, time-boxed via the same expires_at).\nWith that grant, an Emergency access button appears on the editor for\nthat datasource: the user supplies a mandatory justification and the query\nexecutes immediately, bypassing review — but still through every proxy\nguard (schema/table allow-list, dynamic masking, row-level security, row caps). The grant\nis required for everyone, including admins. Each break-glass execution fires\ninstant notifications to all admins (including PagerDuty), writes a prominently-tagged\nQUERY_BREAK_GLASS_EXECUTED audit row, and opens a mandatory\nretro-review on the /admin/break-glass log that an admin —\nnever the submitter — must acknowledge after the fact. The executed query keeps\nits normal terminal state; the retro-review is tracked alongside it.\n\n/admin/break-glass — every emergency execution opens a mandatory retro-review here for an admin (never the submitter) to acknowledge."} {"id":"2fc7ae27667de7df","path":"website/docs/configuration/users-roles/index.html","url":"https://accessflow.io/docs/configuration/users-roles/#cfg-groups","anchor":"cfg-groups","title":"User groups","section":"Reference","order":0,"tokens":620,"text":"AccessFlow Docs > Reference > Users, roles & organizations > User groups\n\nWhat it is. Named, organisation-scoped collections of users. Use them to\n(1) bundle reviewers so you can attach a single group — instead of ten individual users —\nto a datasource as eligible reviewers, (2) grant a whole team data or API\naccess (a datasource or API-connector grant on a group is inherited by every\nmember, so you don't add a row per person), and (3) act as the target of IdP group mappings\nso SAML / OAuth2 logins keep membership in sync automatically.\n\nConfigure it. Manage groups from /admin/groups:\n\n- Create a group. Go to /admin/groups → Create\ngroup. Pick a name (e.g. Billing Reviewers) and an optional description.\n\n- Add members. Open the group, click Add member, and pick\nusers from the dropdown. Manually-added members are tagged\nManual and stay put regardless of the IdP sync.\n\n- Use the group. On a datasource's Reviewers tab\n(/datasources//settings), add the group as a reviewer. From\nthat point on, members of the group can see and decide queries against that\ndatasource (in addition to plan-approver rules). On the same page's\nPermissions tab (and an API connector's Permissions tab) you can also\ngrant the group access — switch the grant target from User to\nGroup and every member inherits the read / write / DDL / break-glass grant.\n\n- Optional: IdP-managed memberships. Configure\ngroup_mappings on the SAML or OAuth2 admin pages so an IdP group claim\nauto-maps to the AccessFlow group. On every login, AccessFlow replaces the user's\nIdP-sourced memberships with the mapped set; Manual memberships\nare never touched.\n\nPer-datasource reviewer scoping. Once a datasource has at least one\nassigned reviewer (a user or a group), only those reviewers see its queries. Datasources\nwith none fall back to the review-plan approvers — so adopting groups is purely additive,\nno migration required.\n\n/admin/groups — organisation-scoped user groups; open one to manage members."} @@ -332,7 +334,7 @@ {"id":"c934edba3bc5494c","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/","anchor":"","title":"What is the difference between a role and a grant?","section":"Guides","order":0,"tokens":135,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > What is the difference between a role and a grant?\n\nA role decides which parts of AccessFlow a person can use — whether\nthey can submit writes, review other people's queries, or open admin pages. A\ngrant decides which datasource they can query and with what\ncapabilities. Neither implies the other: an analyst with no grants can reach no data,\nand a grant on its own does not let anyone review."} {"id":"51f2d337362ea62b","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-create","anchor":"guide-team-create","title":"1. Create the accounts","section":"Guides","order":0,"tokens":451,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 1. Create the accounts\n\nSidebar → Security & Access → Users. The control at the\ntop right is a split button with two different paths behind it.\n\n- Invite via email — the main half of the button. Opens\nInvite a teammate: Email, an optional Display\nname, and a Role. Send invitation emails\nthem a link they use to set their own password. This is the path you want on a real\ndeployment.\n\n- Create with password — behind the small arrow. Opens Invite\nuser, which asks for an Initial password alongside the same\nfields and creates the account immediately. Despite its Send invite\nbutton, this path sends no mail at all.\n\nInvite via email needs system SMTP first. Without it the action fails\nwith 422 SYSTEM_SMTP_NOT_CONFIGURED_FOR_INVITE before it creates anything.\nSet email up with the notifications\nguide, or use the password path meanwhile.\n\nSent invitations appear in a Pending invitations table below the user\nlist, where you can resend or revoke one. Links expire after seven days by default\n(ACCESSFLOW_SECURITY_INVITATION_TTL), and they are built from\nACCESSFLOW_PUBLIC_BASE_URL — if that is wrong, your invitees get a link\npointing somewhere they cannot reach.\n\nSidebar → Security & Access → Users, with the create-with-password form open.\n\nSearching does not span pages. The search box and the role and provider\nfilters narrow the page you are looking at, twenty users at a time — they are not a\nserver-side search. On a large directory, page to the user rather than expecting the box\nto find them."} {"id":"75929b3b512c7e20","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-roles","anchor":"guide-team-roles","title":"2. Pick the right role","section":"Guides","order":0,"tokens":528,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 2. Pick the right role\n\nFive roles ship with AccessFlow. They are built in: you cannot edit or delete them, and\neach is a superset of the one above it apart from Auditor, which is a different shape\nentirely.\n\nRole | What it can do | Give it to |\n\nRead-only |\nSubmit SELECT queries, and nothing else. |\nPeople who only ever read, and contractors. |\n\nAnalyst |\nRead-only, plus INSERT, UPDATE and DELETE. No schema changes. |\nThe default for engineers and analysts. Most of your users. |\n\nReviewer |\nEverything an analyst can do, plus seeing every query in the organization and\ndeciding them — along with access requests, API calls, deployments, erasure\nrequests and recertification items. |\nTeam leads and data owners. Note it carries no admin or configuration\naccess. |\n\nAuditor |\nCompliance reports, recertification evidence, access-usage reports and the\nbreak-glass log. Cannot submit queries at all, which is why\nsigning in as one lands on the compliance dashboard rather than a dashboard with\nan editor. |\nCompliance and internal audit. |\n\nAdmin |\nEverything, including every permission added in future releases. |\nAs few people as the job allows — see the warning below. |\n\nAdmin is not just \"more access\". It carries the review-override\npermission, which bypasses the approver lists on review plans by design. Any scoping you\nconfigure elsewhere — including on\ndeployment\npipelines — is scoping over non-admins. Grant it deliberately.\n\nIf none of the five fits, build your own: Roles under the admin section\ncreates an organization-scoped role from the permission catalog. It needs a name and at\nleast one permission. Custom role names work as approver rules on review plans just as\nthe built-in ones do."} -{"id":"9dea8c599437b649","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":0,"tokens":703,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource\n\nOpen the datasource → Permissions tab → Grant access.\nGrants go to an individual or to a group, and carry rather more than an on/off switch:\n\n- Can read, Can write, Can run DDL\n— at least one is required.\n\n- Row limit override — a tighter cap than the datasource's default\nfor this person. It can only lower the limit, never raise it. If someone holds\nseveral grants on the same datasource (their own and their groups'), the smallest\noverride wins, so no grant can loosen a tighter cap set by another.\n\n- Allowed schemas and Allowed tables — leave empty\nfor everything, or narrow it. Submitting a query that touches anything outside the\nlist is refused.\n\n- Denied schemas and Denied tables — the exceptions:\nallow crm, deny crm.salary, and everything else in\ncrm stays reachable. A denial always beats the allowed lists, and a table\ndenied by any of this person's grants stays denied. While a schema is denied, they must\nwrite table names with their schema. Enter a schema as one name (hr) and a\ntable as table, schema.table or schema.*.\n\n- Restricted columns — masked in this person's results.\n\n- Denied columns — stricter than restricted: a query that touches one\nat all, including through SELECT *, is refused before it runs. Relational\ndatasources only. Administrators are not bound by it, and it holds only while every\ngrant this person has on the datasource denies the column.\n\n- Expires at — optional, and the single most useful field on the\nform. Access that removes itself is access nobody has to remember to remove.\n\nWhere someone has both a direct grant and one through a group, the effective result is\nthe most permissive combination of the two, with two exceptions: the row limit, where\nthe smallest override wins, and denied schemas and tables, which add up across grants.\n\nBreak-glass is granted here too, and it is not an ordinary capability.\nCan break-glass lets its holder bypass review entirely and execute\nimmediately. It is compensated rather than prevented: every admin is notified, the\naction is prominently audited, and an admin who is not the submitter has to acknowledge\nit afterwards. It does not stand alone — the user still needs the matching read, write\nor DDL capability. Set an expiry on it."} +{"id":"9dea8c599437b649","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":0,"tokens":786,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource\n\nOpen the datasource → Permissions tab → Grant access.\nGrants go to an individual or to a group, and carry rather more than an on/off switch:\n\n- Can read, Can write, Can run DDL\n— at least one is required.\n\n- Row limit override — a tighter cap than the datasource's default\nfor this person. It can only lower the limit, never raise it. If someone holds\nseveral grants on the same datasource (their own and their groups'), the smallest\noverride wins, so no grant can loosen a tighter cap set by another.\n\n- Allowed schemas and Allowed tables — leave empty\nfor everything, or narrow it. Submitting a query that touches anything outside the\nlist is refused.\n\n- Denied schemas and Denied tables — the exceptions:\nallow crm, deny crm.salary, and everything else in\ncrm stays reachable. A denial always beats the allowed lists, and a table\ndenied by any of this person's grants stays denied. While a schema is denied, they must\nwrite table names with their schema. Enter a schema as one name (hr) and a\ntable as table, schema.table or schema.*.\n\n- Restricted columns — masked in this person's results.\n\n- Denied columns — stricter than restricted: a query that touches one\nat all, including through SELECT *, is refused before it runs. Relational\ndatasources only. Administrators are not bound by it, and a column denied by any of\nthis person's grants stays denied.\n\n- Denied query shapes — refuse queries written a certain way, such as\njoins, subqueries, GROUP BY or aggregate functions like COUNT,\nwherever they appear in the query. Deny them all and only simple\nSELECT … WHERE lookups remain. Relational datasources only.\n\n- Expires at — optional, and the single most useful field on the\nform. Access that removes itself is access nobody has to remember to remove.\n\nWhere someone has both a direct grant and one through a group, the effective result is\nthe most permissive combination of the two, with two exceptions: the row limit, where\nthe smallest override wins, and anything denied — schemas, tables, columns and query shapes —\nwhich adds up across grants.\n\nBreak-glass is granted here too, and it is not an ordinary capability.\nCan break-glass lets its holder bypass review entirely and execute\nimmediately. It is compensated rather than prevented: every admin is notified, the\naction is prominently audited, and an admin who is not the submitter has to acknowledge\nit afterwards. It does not stand alone — the user still needs the matching read, write\nor DDL capability. Set an expiry on it."} {"id":"2a9ddd10a0c5d838","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-groups","anchor":"guide-team-groups","title":"4. Use groups once individual grants stop scaling","section":"Guides","order":0,"tokens":168,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 4. Use groups once individual grants stop scaling\n\nSidebar → User groups. A group is a named set of people that can hold\ndatasource and API-connector grants of its own, so joining the group is what confers\naccess and leaving it is what removes it.\n\nMembership can be manual, or synced from your identity provider — the members table\nshows each person's source as Manual, IdP or SCIM. If you are heading towards\nIdP-managed groups, connect single sign-on first and let\nthe group memberships arrive with the users."} {"id":"3d9f9581d7bc2503","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-jit","anchor":"guide-team-jit","title":"5. Let people ask, instead of granting up front","section":"Guides","order":0,"tokens":324,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 5. Let people ask, instead of granting up front\n\nStanding access is the thing you are trying to avoid. Any signed-in user can open\nRequest access and ask for a scoped, time-boxed grant: a datasource,\nthe capabilities they need, optionally specific schemas and tables, a duration, and a\njustification. Durations run from one hour to seven days.\n\nRequests land in the Access requests queue for anyone who can review\nthem. Approving materialises a real grant that expires by itself; a background job\nrevokes it when the clock runs out. Rejecting requires a comment.\n\nPre-approve queries under this grant is worth understanding before\nsomeone ticks it. It lets queries covered by the grant's capability and table scope skip\nhuman review for as long as the grant is active — useful for a bounded on-call window,\nand a much bigger decision than the checkbox looks. High-risk queries and routing\npolicies still apply.\n\nDuration bounds are configurable with\nACCESSFLOW_ACCESS_MIN_DURATION (15 minutes by default) and\nACCESSFLOW_ACCESS_MAX_DURATION (30 days)."} {"id":"dbbb45cb98e78a45","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-offboarding","anchor":"guide-team-offboarding","title":"6. When someone leaves","section":"Guides","order":0,"tokens":286,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 6. When someone leaves\n\nOn the users page, open the row's menu and choose Deactivate. Three\nthings happen: the account is disabled, every one of its sessions is signed out\nimmediately, and every active just-in-time grant it holds is revoked.\n\nStanding grants are not revoked. Deactivation removes the temporary\ngrants somebody requested, not the permanent rows an admin created on a datasource's\nPermissions tab. Those survive the account being disabled, and would apply again if it\nwere ever reactivated. Remove them explicitly.\n\nYou cannot deactivate yourself — the API refuses it, so an organization can never lock\nout its last admin by accident.\n\nIf your identity provider is the source of truth, this should not be a manual step at\nall: SCIM deprovisioning raises the same event and takes the same actions. See\nSCIM provisioning.\n\nFull reference for roles, the permission matrix, groups and grants:\nUsers & roles."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index e4360f8ef..1a1d0671b 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "ee4dcdb21c05", - "generatedAt": "2026-09-24T15:47:40.882Z", - "sourceCommit": "9fc3865e3a86beca9d1ceb6ca262ef4ba2cf13b3", - "chunkCount": 588, - "sha256": "ee4dcdb21c05e710f16d609534cee78c1fd779df1964d0e7faabc12a66e5abde", + "corpusVersion": "45a0913ffe08", + "generatedAt": "2026-09-25T07:19:45.600Z", + "sourceCommit": "ef197619924ca6c15ad2b7045ee0354429fe4341", + "chunkCount": 590, + "sha256": "45a0913ffe0846a4f50353f3c5fcafab21444e7dc6477f3318e4cfcc7f737256", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -204,8 +204,8 @@ "title": "Datasources", "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", - "chunks": 16, - "sha256": "ffb7497560178f7174e00fc268d7ed163ffbf599fa94a761d143ee615237c986" + "chunks": 17, + "sha256": "d88f04744fb44d7933f98e54eb1b158f4782c5c71c8d31f42fb39748bc47cfe0" }, { "path": "website/docs/configuration/notifications/index.html", @@ -220,8 +220,8 @@ "title": "Review workflows", "url": "https://accessflow.io/docs/configuration/review-workflows/", "section": "Reference", - "chunks": 17, - "sha256": "d4035212311b9ca60953da56bcab5254111e4c5fc6de66b8e708b8b75355affd" + "chunks": 18, + "sha256": "71962f656fdddfbad48d7f73401b0397d74236772133932372ab35efb225ba00" }, { "path": "website/docs/configuration/users-roles/index.html", @@ -229,7 +229,7 @@ "url": "https://accessflow.io/docs/configuration/users-roles/", "section": "Reference", "chunks": 15, - "sha256": "79ee7f19ff2c40988442d5ee22f16633ce00c35b5fb020b6c362eeafb20fa39a" + "sha256": "efc03f2a03fd58694b4a00f5811c89c9c47c57516a11452492813bd533108a5e" }, { "path": "website/docs/guides/ai-analysis/index.html", @@ -309,7 +309,7 @@ "url": "https://accessflow.io/docs/guides/team/", "section": "Guides", "chunks": 8, - "sha256": "ef3539a408cfee958283c77a304fdfd4052a42e19f82f4efb71adbbaa9db2f71" + "sha256": "3b144bd38fff8a84348de6f8f57ba21d8422ca515cd679d94988dad7715793e1" }, { "path": "website/docs/guides/terraform/index.html", @@ -429,7 +429,7 @@ "url": "https://accessflow.io/docs/", "section": "Navigation", "chunks": 9, - "sha256": "2d01ffc289a92b778c0babebe093e292b72d481b9a849da0a12436bd8165bd2d" + "sha256": "680ff0667f49d5dad67d0373e71bd93113b0b65719ad0c10107df310513a1615" } ] } diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html index fdfe97ec7..1aa9f1345 100644 --- a/website/docs/configuration/datasources/index.html +++ b/website/docs/configuration/datasources/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Configuration", "datePublished": "2026-08-03", - "dateModified": "2026-09-24", + "dateModified": "2026-09-25", "url": "https://accessflow.io/docs/configuration/datasources/", "mainEntityOfPage": "https://accessflow.io/docs/configuration/datasources/", "image": "https://accessflow.io/og-image.png", @@ -270,7 +270,7 @@ Documentation

Datasources.

-

Last updated

+

Last updated

@@ -348,7 +348,7 @@

What is a datasource in AccessFlow?

deployment-wide.

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

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

What is a datasource in AccessFlow?

  • Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column denied by any one of them stays denied. A wider grant that denies nothing cannot undo it, and a group's denied columns apply to every member. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.
  • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
  • +

    + Denied query shapes — limit how a query is written. Some grants should allow a table but not every way of querying it — for example, simple lookups but no joins or totals. Pick the query shapes to refuse under Denied query shapes: joins, set operations (UNION, INTERSECT, EXCEPT), subqueries, WITH clauses, GROUP BY, HAVING, aggregate functions and window functions. Denying all of them leaves plain SELECT … FROM … WHERE … ORDER BY. A query that uses a denied shape is refused before it runs: +

    +
      +
    • Anywhere in the query. A join inside a subquery or a WITH clause counts, and in a BEGIN; …; COMMIT; batch every statement is checked.
    • +
    • Aggregate functions. The standard ones — COUNT, SUM, AVG, MIN, MAX and similar. A custom aggregate function defined in your database is not recognised, so don't rely on this setting to block one.
    • +
    • When in doubt, refuse. If AccessFlow cannot work out a query's shape, it treats the query as using every shape the grant denies.
    • +
    • Several grants add up. A shape denied by any of a user's grants stays denied, and a just-in-time grant that replaces a user's own expiring grant keeps its denied shapes.
    • +
    • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
    • +
    • Rather review than refuse? Use the Query shape condition in a routing policy instead, for example to send every joined query to a second reviewer.
    • +

    Users only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.

    diff --git a/website/docs/configuration/review-workflows/index.html b/website/docs/configuration/review-workflows/index.html index 819d43416..e46db247d 100644 --- a/website/docs/configuration/review-workflows/index.html +++ b/website/docs/configuration/review-workflows/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Configuration", "datePublished": "2026-08-03", - "dateModified": "2026-09-23", + "dateModified": "2026-09-25", "url": "https://accessflow.io/docs/configuration/review-workflows/", "mainEntityOfPage": "https://accessflow.io/docs/configuration/review-workflows/", "image": "https://accessflow.io/og-image.png", @@ -273,7 +273,7 @@ Documentation

    Review workflows.

    -

    Last updated

    +

    Last updated

    @@ -526,7 +526,7 @@

    Routing policies

    1. Open /admin/routing-policies (the Routing policies entry in the Security nav group, next to Review plans) and click Add policy.
    2. Name the policy and optionally scope it to one datasource — leave the datasource blank for an org-wide rule. Set its priority (unique per organisation; lower runs first) and the enabled toggle.
    3. -
    4. Build the condition with the guided builder: pick match ALL (AND) or match ANY (OR), then add leaf conditions — each can be negated (NOT). Operands include query type, referenced tables (glob, e.g. payroll.*), AI risk level, AI risk score (with a comparison operator), requester role, requester group, time-of-day window, day-of-week, presence of a WHERE clause, presence of a LIMIT clause, the transactional (BEGIN…COMMIT) flag, and the pre-flight cost estimate — estimated rows (comparison against the engine's own EXPLAIN estimate, or the exact affected-row count for UPDATE/DELETE) and scan type (glob match on the plan's root operation, e.g. Seq*) — so a policy can route a 10-million-row sequential-scan DELETE differently from a 10-row indexed one.
    5. +
    6. Build the condition with the guided builder: pick match ALL (AND) or match ANY (OR), then add leaf conditions — each can be negated (NOT). Operands include query type, referenced tables (glob, e.g. payroll.*), AI risk level, AI risk score (with a comparison operator), requester role, requester group, time-of-day window, day-of-week, presence of a WHERE clause, presence of a LIMIT clause, query shape (the query contains a join, a set operation such as UNION, a subquery, a WITH clause, GROUP BY, HAVING, an aggregate function or a window function — anywhere, including inside subqueries), the transactional (BEGIN…COMMIT) flag, and the pre-flight cost estimate — estimated rows (comparison against the engine's own EXPLAIN estimate, or the exact affected-row count for UPDATE/DELETE) and scan type (glob match on the plan's root operation, e.g. Seq*) — so a policy can route a 10-million-row sequential-scan DELETE differently from a 10-row indexed one.
    7. Choose the action. Auto-approve (skip human review), Auto-reject (block the query), Require approvals (force human review with an absolute minimum number of approvers), or Escalate (force human review, adding a delta on top of the review plan's minimum). The approver count applies only to the last two actions.
    8. Reorder policies any time with the per-row up/down controls — the order is the evaluation order.
    @@ -536,7 +536,10 @@

    Routing policies

    AI analysis disabled, risk-based conditions never match (there's no AI signal); routing does not run when AI analysis fails — the query goes to a human instead. The cost-estimate conditions likewise never match while no estimate exists (engine without a plan concept, or the estimate - failed) — they fail closed rather than auto-approving blind. Every automated decision is + failed) — they fail closed rather than auto-approving blind. A query-shape condition never + matches a query AccessFlow cannot read as SQL, such as a MongoDB or Redis command. To refuse a + shape for one person or group outright, set Denied query shapes on their + datasource grant instead. Every automated decision is recorded in the audit log (QUERY_APPROVED / QUERY_REJECTED with source: "ROUTING_POLICY"), and the query detail page shows which policy matched. Routing policies are managed via the ADMIN-only diff --git a/website/docs/configuration/users-roles/index.html b/website/docs/configuration/users-roles/index.html index c107c53ac..f103b8156 100644 --- a/website/docs/configuration/users-roles/index.html +++ b/website/docs/configuration/users-roles/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Configuration", "datePublished": "2026-08-03", - "dateModified": "2026-09-24", + "dateModified": "2026-09-25", "url": "https://accessflow.io/docs/configuration/users-roles/", "mainEntityOfPage": "https://accessflow.io/docs/configuration/users-roles/", "image": "https://accessflow.io/og-image.png", @@ -274,7 +274,7 @@ Documentation

    Users, roles & organizations.

    -

    Last updated

    +

    Last updated

    @@ -576,8 +576,9 @@

    What are the user roles in AccessFlow?

    access a given database — it controls read / write / DDL per datasource, row caps, allowed schemas / tables, denied schemas / tables (everything except these — a denial always beats the allowed list), restricted columns (which are masked - as *** in SELECT results), and denied columns (a query that - references one is refused before it runs). See + as *** in SELECT results), denied columns (a query that + references one is refused before it runs), and denied query shapes (for example no joins + or aggregate functions). See docs/07-security.md for the full authorization matrix.

    @@ -590,7 +591,7 @@

    What are the user roles in AccessFlow?

    union — capabilities are OR-ed, allow-lists merge, restricted-column masks apply only where every grant restricts them, and each grant's expiry is honoured independently. Two things work the other way. The row limit override: the smallest one wins, and it can only lower the - datasource's cap, never raise it. And denied schemas, tables and columns add up: anything denied by + datasource's cap, never raise it. And denied schemas, tables, columns and query shapes add up: anything denied by any one grant stays denied, so a wider group grant can never undo a denial, and a group's denial applies to every member. The flip side: revoking or expiring the grant that carries a denial removes it, and the user's other grants may then reach the table. diff --git a/website/docs/guides/team/index.html b/website/docs/guides/team/index.html index 51c601db5..4efc2c289 100644 --- a/website/docs/guides/team/index.html +++ b/website/docs/guides/team/index.html @@ -64,7 +64,7 @@ "inLanguage": "en", "articleSection": "Guides", "datePublished": "2026-09-01", - "dateModified": "2026-09-24", + "dateModified": "2026-09-25", "url": "https://accessflow.io/docs/guides/team/", "mainEntityOfPage": "https://accessflow.io/docs/guides/team/", "image": "https://accessflow.io/og-image.png", @@ -280,7 +280,7 @@ Guides

    Invite your team and assign roles.

    -

    Last updated

    +

    Last updated

    @@ -437,15 +437,20 @@

    3. Grant access to a datasource

  • Restricted columns — masked in this person's results.
  • Denied columns — stricter than restricted: a query that touches one at all, including through SELECT *, is refused before it runs. Relational - datasources only. Administrators are not bound by it, and it holds only while every - grant this person has on the datasource denies the column.
  • + datasources only. Administrators are not bound by it, and a column denied by any of + this person's grants stays denied. +
  • Denied query shapes — refuse queries written a certain way, such as + joins, subqueries, GROUP BY or aggregate functions like COUNT, + wherever they appear in the query. Deny them all and only simple + SELECT … WHERE lookups remain. Relational datasources only.
  • Expires at — optional, and the single most useful field on the form. Access that removes itself is access nobody has to remember to remove.
  • Where someone has both a direct grant and one through a group, the effective result is the most permissive combination of the two, with two exceptions: the row limit, where - the smallest override wins, and denied schemas and tables, which add up across grants. + the smallest override wins, and anything denied — schemas, tables, columns and query shapes — + which adds up across grants.

    Break-glass is granted here too, and it is not an ordinary capability. diff --git a/website/sitemap.xml b/website/sitemap.xml index 33677adac..6c2dd6c8c 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -242,7 +242,7 @@ https://accessflow.io/docs/guides/team/ - 2026-09-24 + 2026-09-25 weekly 0.7 @@ -284,13 +284,13 @@ https://accessflow.io/docs/configuration/users-roles/ - 2026-09-24 + 2026-09-25 weekly 0.7 https://accessflow.io/docs/configuration/datasources/ - 2026-09-24 + 2026-09-25 weekly 0.7 @@ -302,7 +302,7 @@ https://accessflow.io/docs/configuration/review-workflows/ - 2026-09-23 + 2026-09-25 weekly 0.7 From 12f0de9a78f85e6956a6b216b24f2852bf22c072 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Fri, 25 Sep 2026 11:41:45 +0400 Subject: [PATCH 3/3] fix(AF-940): close shape-detector gaps found in review Flag parenthesised joins, MERGE, function-argument subqueries such as ARRAY(SELECT ...), JSON_ARRAYAGG/OBJECTAGG and more vendor aggregates; check OTHER request-group members; an unknown stored shape denies all. Scope the e2e routing policy to its datasource; correct docs scope. --- .../accessflow/core/api/DeniedShapes.java | 15 +++-- .../proxy/internal/QueryShapeDetector.java | 66 +++++++++++++++++-- .../accessflow/core/api/DeniedShapesTest.java | 17 +++-- .../internal/QueryShapeDetectorTest.java | 33 +++++++++- docs/03-data-model.md | 2 +- docs/04-api-spec.md | 9 +-- docs/05-backend.md | 15 +++-- docs/07-security.md | 12 ++-- e2e/tests/admin-routing-policies.spec.ts | 5 +- frontend/src/locales/en.json | 2 +- .../__tests__/DatasourceSettingsPage.test.tsx | 35 ++++++++++ help-corpus/corpus.jsonl | 9 +-- help-corpus/manifest.json | 20 +++--- .../docs/configuration/datasources/index.html | 5 +- .../configuration/review-workflows/index.html | 4 +- website/docs/guides/team/index.html | 5 +- 16 files changed, 201 insertions(+), 53 deletions(-) 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 index 32b819060..7b2be045d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedShapes.java @@ -30,14 +30,21 @@ public static List normalize(Collection raw) { return List.copyOf(out); } - /** Reads stored shape names (the {@code denied_shapes} TEXT[] column) back into shapes. */ + /** + * 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) { - out.add(QueryShape.valueOf(name)); + try { + out.add(QueryShape.valueOf(name)); + } catch (IllegalArgumentException unknown) { + return List.copyOf(EnumSet.allOf(QueryShape.class)); + } } return List.copyOf(out); } @@ -61,12 +68,12 @@ public static List union(Collection left, Collection rejected(Collection rawDenied, SqlParseResult parsed) { var denied = normalize(rawDenied); var out = new TreeSet(); - if (denied.isEmpty() || parsed == null || parsed.type() == QueryType.OTHER) { + if (denied.isEmpty() || parsed == null) { return out; } if (!parsed.shapesAnalyzed()) { 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 index 78bb19a59..199c97dd7 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java +++ b/backend/src/main/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetector.java @@ -5,13 +5,16 @@ 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; @@ -39,15 +42,25 @@ */ final class QueryShapeDetector extends TablesNamesFinder { - /** The standard aggregates, matched by unqualified, case-insensitive name. */ + /** + * 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", "stddev", "stddev_pop", "stddev_samp", "variance", - "var_pop", "var_samp", "bool_and", "bool_or", "every", "bit_and", "bit_or", "bit_xor", - "any_value", "median", "mode", "percentile_cont", "percentile_disc"); + "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() { } @@ -73,6 +86,7 @@ private void ownQuery(Select select) { return; } ownQueries.add(select); + covered.add(select); switch (select) { case ParenthesedSelect parenthesed -> ownQuery(parenthesed.getSelect()); case SetOperationList list -> list.getSelects().forEach(this::ownQuery); @@ -89,6 +103,7 @@ public Void visit(WithItem withItem, S context) { @Override public Void visit(PlainSelect plainSelect, S context) { + flagIfUncovered(plainSelect); if (notEmpty(plainSelect.getJoins())) { shapes.add(QueryShape.JOIN); } @@ -106,6 +121,8 @@ public Void visit(PlainSelect plainSelect, S context) { @Override public Void visit(SetOperationList list, S context) { + flagIfUncovered(list); + covered.addAll(list.getSelects()); shapes.add(QueryShape.UNION); return super.visit(list, context); } @@ -115,6 +132,8 @@ 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); } @@ -136,6 +155,32 @@ public Void visit(AnyComparisonExpression any, S context) { 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 @@ -181,9 +226,16 @@ static boolean isAggregate(String name) { if (name == null) { return false; } - var bare = name.substring(name.lastIndexOf('.') + 1); - return AGGREGATE_FUNCTIONS.contains(SqlParserServiceImpl.normalizeIdentifier(bare).strip() - .toLowerCase(Locale.ROOT)); + 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) { 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 index f579b71ea..4b751565d 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedShapesTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedShapesTest.java @@ -7,7 +7,6 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; class DeniedShapesTest { @@ -46,8 +45,8 @@ void namesRoundTripThroughStorage() { assertThat(DeniedShapes.toNames(List.of())).isNull(); assertThat(DeniedShapes.toNames(null)).isNull(); assertThat(DeniedShapes.fromNames(null)).isEmpty(); - assertThatThrownBy(() -> DeniedShapes.fromNames(List.of("NOPE"))) - .isInstanceOf(IllegalArgumentException.class); + // A name this version does not know denies every shape rather than none. + assertThat(DeniedShapes.fromNames(List.of("JOIN", "NOPE"))).containsExactly(QueryShape.values()); } @Test @@ -68,11 +67,19 @@ void rejectedFailsClosedOnAnUnanalyzedParse() { } @Test - void rejectedIgnoresAnEmptyDenyListOtherStatementsAndANullParse() { + 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(); - assertThat(DeniedShapes.rejected(List.of(QueryShape.JOIN), null)).isEmpty(); } @Test 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 index 2d1f2f67f..aba99de30 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetectorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/proxy/internal/QueryShapeDetectorTest.java @@ -45,6 +45,8 @@ void aSimpleQueryHasNoShape(String sql) throws JSQLParserException { "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" }) @@ -72,7 +74,9 @@ void detectsSetOperationsWithoutCountingBranchesAsSubqueries(String sql) throws "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)" + "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); @@ -117,7 +121,14 @@ void detectsGroupByHavingAndAggregates() throws JSQLParserException { "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 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); @@ -142,6 +153,24 @@ void detectsShapesInsideSubqueries() throws JSQLParserException { .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")) diff --git a/docs/03-data-model.md b/docs/03-data-model.md index ca2f50d3d..48bf1fbe6 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -820,7 +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 — JSqlParser cannot parse the text, typically a non-SQL engine | +| `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 818036b0c..43e8309de 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -649,7 +649,7 @@ 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 standard aggregate set by name (`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `STRING_AGG`, `ARRAY_AGG`, `GROUP_CONCAT`, `LISTAGG`, `JSON_AGG`, the `STDDEV*` / `VAR*` family, `BOOL_AND` / `BOOL_OR`, `BIT_*`, `MEDIAN`, `MODE`, `PERCENTILE_*` and similar), plus any ordered-set (`WITHIN GROUP`) or `FILTER`ed call; a user-defined aggregate is not detected. 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`. 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. +`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 @@ -685,7 +685,8 @@ or the blank / too-many keys); the service re-checks the entry shape and raises 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. `denied_shapes` (#940) is optional, at most 8 non-null `QueryShape` names; an unknown name is 400 -`VALIDATION_ERROR` (`error.datasource_body_unreadable`), too many is 400 (`validation.denied_shapes.too_many`). +`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 @@ -4000,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`. 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. 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. +`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`. @@ -4633,7 +4634,7 @@ that did not apply is reported with `outcome: "SKIP"` rather than omitted. `outc |---|---|---| | `DATASOURCE_GATES` | `db_type`, `active`, `ai_analysis_enabled`, `visible_to_user` | always | | `QUOTA` | `quota_type`, `limit`, `current` | `DENY` only; `{}` on `ALLOW` | -| `SQL_PARSE` | `query_type`, `referenced_tables`, `transactional`, `has_where_clause`, `has_limit_clause`, `query_shapes` (#940 — the statement's shapes in declaration order), `shapes_analyzed` (`false` when the shape could not be read — a non-SQL engine) | whenever the statement parsed; `{}` when it did not | +| `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) | diff --git a/docs/05-backend.md b/docs/05-backend.md index b4d771b76..8335aec4e 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -780,10 +780,17 @@ the `core.api.QueryShape` names: `JOIN`, `UNION` (every set operation), `SUBQUER 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`, `JSON[B]_AGG`, the `STDDEV*` / `VAR*` family, - `BOOL_AND` / `BOOL_OR` / `EVERY`, `BIT_*`, `ANY_VALUE`, `MEDIAN`, `MODE`, `PERCENTILE_*`) plus any - `WITHIN GROUP` or `FILTER`ed call; a user-defined aggregate is **not** detected — say so when an - admin relies on it. + `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 diff --git a/docs/07-security.md b/docs/07-security.md index 0da7d70e2..4b41992ac 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -991,8 +991,9 @@ allow-list and **always wins**; it also works with no allow-list at all. All gat `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` and -`AGGREGATE`. +`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 @@ -1001,9 +1002,10 @@ just the statement type and the tables: an analyst can be limited to single-tabl 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 standard aggregate set matched by name, plus any `WITHIN - GROUP` / `FILTER`ed call. A user-defined aggregate, or an aggregate wrapped in a view or function, - is not detected — pair the deny-list with database-side privileges where that matters. +- **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, diff --git a/e2e/tests/admin-routing-policies.spec.ts b/e2e/tests/admin-routing-policies.spec.ts index 22f646f7d..ce7847f2b 100644 --- a/e2e/tests/admin-routing-policies.spec.ts +++ b/e2e/tests/admin-routing-policies.spec.ts @@ -166,7 +166,10 @@ test.describe.serial('/admin/routing-policies — routing engine', () => { test('matches the query_shape condition on a joined query only', async ({ request }) => { const policy = await createRoutingPolicyViaApi(request, adminAccessToken, { name: JOIN_REJECT_POLICY_NAME, - priority: 3, + // 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', diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index ef67a9bc8..a344ddc38 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -5755,7 +5755,7 @@ "row_security_outcome": "Row-security outcome", "scheduled_for": "Scheduled for", "scope": "Scope", - "shapes_analyzed": "Shape analyzed", + "shapes_analyzed": "Shapes analyzed", "sql_review_suppressed": "Suppressed by SQL review", "status": "Status", "submitter_excluded": "Submitter excluded", diff --git a/frontend/src/pages/datasources/__tests__/DatasourceSettingsPage.test.tsx b/frontend/src/pages/datasources/__tests__/DatasourceSettingsPage.test.tsx index fbbd88e17..aa2476cd4 100644 --- a/frontend/src/pages/datasources/__tests__/DatasourceSettingsPage.test.tsx +++ b/frontend/src/pages/datasources/__tests__/DatasourceSettingsPage.test.tsx @@ -1149,4 +1149,39 @@ describe('DatasourceSettingsPage — denied query shapes (#940)', () => { fireEvent.mouseEnter(tag); expect(await screen.findByText('Join, GROUP BY')).toBeInTheDocument(); }); + + it('shows the denied-shape count on a group permission row', async () => { + listGroupPermissions.mockResolvedValue([ + { + id: 'gp-1', + datasource_id: 'ds-1', + group_id: 'g-1', + group_name: 'Analysts', + member_count: 3, + can_read: true, + can_write: false, + can_ddl: false, + can_break_glass: false, + row_limit_override: null, + allowed_schemas: null, + allowed_tables: null, + restricted_columns: null, + denied_columns: null, + denied_schemas: null, + denied_tables: null, + denied_shapes: ['WINDOW_FUNCTION'], + expires_at: null, + created_by: 'admin', + created_at: '2026-05-01T00:00:00Z', + }, + ]); + render(wrap()); + + await waitFor(() => expect(screen.getByRole('tab', { name: /Permissions/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('tab', { name: /Permissions/ })); + + const groupCell = await screen.findByText('Analysts'); + const row = groupCell.closest('tr')!; + expect(within(row).getByText('1 shape')).toBeInTheDocument(); + }); }); diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index fe33d0919..055685b90 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -205,8 +205,8 @@ {"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 11)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."} {"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":718,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 11)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), denied schemas and tables, denied columns, and denied query shapes. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied schemas and tables — everything except. Sometimes it is easier to say what a user may not touch. Allow the schema crm and deny the table crm.salary, and the user can query every table in crm — including tables created later — except crm.salary. A query that touches a denied table is refused before it runs:\n\n- A denial always wins. It is checked after the allowed schemas and tables, and it works on its own too, with no allowed list at all.\n\n- Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.\n\n- Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.\n\n- Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.\n\n- Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the same way (below).\n\n- Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks."} {"id":"6982bd47758e3828","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":5,"tokens":788,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 6 of 11)\n\n- Just-in-time access keeps denials. A just-in-time access request cannot add a denied schema or table, and approving one never removes a denial: denials from every grant add up, and when the approval replaces the user's own expiring grant, that grant's denials carry over to the new one.\n\n- How to write entries. A denied schema is a single name, such as hr — not analytics.hr. A denied table is table, schema.table, or schema.* for a whole schema. Other wildcards and empty parts are refused when you save the grant.\n\n- Tricky names are refused, not guessed. SQL Server's db..salary (default schema) is treated as matching any schema, an Oracle database link (hr.salary@remote) does not get around a denial, and a name pattern such as the Elasticsearch index pattern sal* is refused whenever the grant denies anything.\n\n- Works on every datasource type. Denied schemas and tables apply to relational, NoSQL and warehouse datasources alike. On datasources whose objects have no schema — MongoDB collections, DynamoDB tables, Redis keys — a denied schema refuses every query, so use denied tables there. A denial can only catch the tables AccessFlow sees in the query: MongoDB $lookup, $unionWith and $graphLookup stages, a Neo4j MATCH (n) with no label, and a Redis KEYS pattern are not caught. The allowed lists share this limit.\n\n- Removing a grant can widen access. A denial belongs to the grant that carries it. Revoke that grant, let it expire, or revoke it in an access review, and its denial goes with it — another grant the user still holds may then let them reach the table. Check the user's other grants first.\n\nDenied columns — block instead of mask. A restricted column can still be queried; only its value is hidden. For a column that must never be read at all, list it under Denied columns as table.column or schema.table.column. A query that uses it is refused before it runs:\n\n- What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.\n\n- Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this."} -{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":721,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 11)\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column denied by any one of them stays denied. A wider grant that denies nothing cannot undo it, and a group's denied columns apply to every member. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\nDenied query shapes — limit how a query is written. Some grants should allow a table but not every way of querying it — for example, simple lookups but no joins or totals. Pick the query shapes to refuse under Denied query shapes: joins, set operations (UNION, INTERSECT, EXCEPT), subqueries, WITH clauses, GROUP BY, HAVING, aggregate functions and window functions. Denying all of them leaves plain SELECT … FROM … WHERE … ORDER BY. A query that uses a denied shape is refused before it runs:\n\n- Anywhere in the query. A join inside a subquery or a WITH clause counts, and in a BEGIN; …; COMMIT; batch every statement is checked.\n\n- Aggregate functions. The standard ones — COUNT, SUM, AVG, MIN, MAX and similar. A custom aggregate function defined in your database is not recognised, so don't rely on this setting to block one.\n\n- When in doubt, refuse. If AccessFlow cannot work out a query's shape, it treats the query as using every shape the grant denies.\n\n- Several grants add up. A shape denied by any of a user's grants stays denied, and a just-in-time grant that replaces a user's own expiring grant keeps its denied shapes.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\n- Rather review than refuse? Use the Query shape condition in a routing policy instead, for example to send every joined query to a second reviewer."} -{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":554,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 11)\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits."} +{"id":"f146d35d5deed80d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":6,"tokens":754,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 11)\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column denied by any one of them stays denied. A wider grant that denies nothing cannot undo it, and a group's denied columns apply to every member. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\nDenied query shapes — limit how a query is written. Some grants should allow a table but not every way of querying it — for example, simple lookups but no joins or totals. Pick the query shapes to refuse under Denied query shapes: joins, set operations (UNION, INTERSECT, EXCEPT), subqueries, WITH clauses, GROUP BY, HAVING, aggregate functions and window functions. Denying all of them leaves plain single-table queries such as SELECT … FROM … WHERE … ORDER BY; shapes don't change whether the grant can write, so keep can write off for a read-only grant. A query that uses a denied shape is refused before it runs:\n\n- Anywhere in the query. A join inside a subquery or a WITH clause counts, and in a BEGIN; …; COMMIT; batch every statement is checked.\n\n- Aggregate functions. The built-in ones — COUNT, SUM, AVG, MIN, MAX, JSON_ARRAYAGG, the statistics functions and similar. A custom aggregate function defined in your database, or a rarely used built-in, may not be recognised, so pair this setting with database permissions where it must hold.\n\n- When in doubt, refuse. If AccessFlow cannot work out a query's shape, it treats the query as using every shape the grant denies.\n\n- Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks when they submit a query. Emergency (break-glass) queries are still checked, for everyone.\n\n- Several grants add up. A shape denied by any of a user's grants stays denied, and a just-in-time grant that replaces a user's own expiring grant keeps its denied shapes."} +{"id":"1c4cee538562bb8d","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":7,"tokens":650,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 8 of 11)\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\n- Rather review than refuse? Use the Query shape condition in a routing policy instead, for example to send every joined query to a second reviewer.\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits."} {"id":"9b654aff5b19dadf","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":8,"tokens":747,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 9 of 11)\n\nMasking policies. The datasource Masking tab adds per-column\ndynamic data masking on top of the static restricted-columns masking above. Each\npolicy targets a schema.table.column and picks a strategy —\nfull (***), partial (keep the last N characters),\nhash (stable SHA-256), email (j***@domain), or\nformat-preserving — with an optional reveal-to condition. A query\nsubmitter whose role, group, or user id is listed in reveal to sees the unmasked\nvalue; everyone else sees the strategy output. A live preview shows how a sample value will\nrender. Masking is applied at result-read time before results are serialized or stored, so\nunmasked values never persist, and the ids of the policies that applied are recorded in the\nexecution's audit metadata. Reveal is explicit — there is no implicit admin bypass.\n\n/datasources//settings → Masking. Per-column dynamic masking with role / group / user reveal conditions.\n\nRow security policies. The datasource Row security tab adds\nrow-level security: per-table predicates the proxy injects into the parsed SQL so a\nscoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for.\nEach policy is a structured column operator value predicate where the value is a\nfixed literal or a :user.* variable — the built-in\n:user.id / :user.email / :user.role /\n:user.groups, or an admin-set per-user attribute (the Attributes\nkey/value editor on Admin → Users). The applies to roles / groups / users\nscope it (empty = everyone, no implicit admin bypass — the inverse of masking's\nreveal to). Values are bound as parameters, never concatenated; an unresolved\nvariable filters out every row (fail-closed); and a query the engine can't safely rewrite\n(a policied table inside a UNION, CTE, sub-select, or join-onto-another-policied-table) is\nrejected rather than run unfiltered. Applied policy ids are recorded in the execution's audit\nmetadata, and the query's detail page keeps the effective SQL — the statement as it\nactually ran, with the policy's filter in place and its values shown as ? — so\nan auditor sees what executed even after the policy is later changed or deleted.\n\n/datasources//settings → Row security. Per-table predicates injected into the parsed SQL; values bound as parameters."} {"id":"d179f3a2f88d48a0","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":9,"tokens":551,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 10 of 11)\n\nSimulate a policy before you save it. Both the Masking and\nRow security forms have a Simulate button that dry-runs the draft\nagainst this datasource's own past queries, so you see the blast radius first. Pick a\ndate range (up to 90 days) and AccessFlow replays that traffic twice —\nonce against the policies in place today, once with the draft added or replacing the one\nyou are editing — then reports the difference: for masking, which columns would start (or\nstop) being hidden, in how many past queries, and for whom; for row security, which\nqueries would newly come back filtered, come back empty, or be rejected outright because\nthe engine cannot safely apply the predicate to that shape. Redis is the clearest case —\na row rule has no meaning over a key-value store, so the simulation lists exactly the\ncommands the policy would start refusing. The same button sits on the\nrouting policy\nform.\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it —\nrow rules are worked out on the stored query text alone. It compares policies against\npolicies — today's rules versus the draft — rather than against what actually\nhappened, because a past result may have come from an emergency, a ticket, or a standing\ngrant the draft has no say over. The results name their own limits: roles and group\nmemberships are read as they stand today, masking is matched on the column name alone\n(so a name two tables share can be over-counted), and where an engine cannot work out\noffline what a row rule would do — Cassandra and ScyllaDB need live key information —\nthose queries are listed as unclassifiable rather than counted as unaffected.\nSimulating is always optional; nothing blocks you from saving."} {"id":"1107d71956e97891","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":10,"tokens":569,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 11 of 11)\n\nRow limits. The datasource Row limits tab caps how many rows a\nquery may return when it reads a particular table, so two tables on the same database\ncan have different limits and one team can be held tighter than another on the same\ntable. Each policy names a table (and optionally its schema), a maximum number of rows,\nand the applies to roles / groups / users it covers (empty = everyone, admins\nincluded). A row limit can only ever lower the cap: the datasource's\nMax rows per query and any per-user limit on the access grant still apply, and\nthe smallest number wins. A query that joins several limited tables gets the lowest of\ntheir limits. A policy with a schema also catches queries that name the table without\none or with a database name in front, so neither gets anyone more rows. Results that hit\nthe limit are marked as truncated, the table preview obeys the same limit, and when a\npolicy's limit is the one that applied it is recorded in the query's audit entry.\n\nExport policies. Masking and row security govern what a user\nsees; the datasource Export policy tab governs what leaves.\nEach policy sets a mode — allow, watermark, row cap, or\ndeny when classified (optionally scoped to specific classifications) — and an\napplies to roles / groups / users target (empty = every exporter, no implicit\nadmin bypass). When several policies apply, the most restrictive wins. The policies gate\nthe signed CSV/PDF result download on the query detail page and the results attachment\non recurring-run emails: a denied exporter sees a disabled export button with the\nreason, a watermarked download carries the exporter, timestamp, and query id baked into\nthe signed bytes (the modal previews the exact stamp), and every export lands in the\naudit log as RESULT_EXPORTED — with an admin notification whenever a\nclassified result leaves."} @@ -226,7 +226,7 @@ {"id":"da27d4223286793a","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-review-escalation","anchor":"cfg-review-escalation","title":"Escalation & reminders","section":"Reference","order":0,"tokens":556,"text":"AccessFlow Docs > Reference > Review workflows > Escalation & reminders\n\nWhat it is. Between a request arriving and the approval timeout\nauto-rejecting it, nothing used to happen — a stalled chain was silent until the\nsubmitter found out by being rejected. Two optional settings on a review plan add the\nwarning shots.\n\nWhere. Admin → Review plans, alongside\nApproval timeout: Escalate after (hours) and\nNudge every (hours). Both are blank by default, and blank means off — an\nexisting plan behaves exactly as it did before you set them.\n\n-\nEscalate after — when nobody has decided within this window, the\nrequest is raised to the reviewers it is waiting on and your organization's admins,\nonce. It appears with an escalation notice on the request page and goes out over the\nplan's notification channels; PagerDuty pages for it on channels that enable the\nREVIEW_STALLED trigger. It must be shorter than the approval timeout\n— a longer window could never fire, so AccessFlow rejects it rather than\nletting you save a setting that quietly does nothing.\n\n-\nNudge every — re-notifies the reviewers who still have not\ndecided, on this cadence. It reaches only the people already on the hook, never\nadmins, and never pages: a reminder is not an incident.\n\nWhat it does not do. Escalation is notify-only. It\nnever changes who is allowed to approve, and it never approves, rejects, or times out\nanything — waiting must not become a way around the approvers you configured.\nThe request stays exactly where it was, with the same people able to act on it.\n\nGrouped requests have no plan of their own, so a bundle uses the\nshortest escalation window among its members' plans. A member whose plan has\nescalation switched off simply does not contribute one. For bundles the escalation is\nrecorded on the request group and nothing more — no notification and no queue badge\nyet, since grouped requests have no notification path at all."} {"id":"a2145564ed8ff33c","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-review-delegation","anchor":"cfg-review-delegation","title":"Out-of-office delegation","section":"Reference","order":0,"tokens":614,"text":"AccessFlow Docs > Reference > Review workflows > Out-of-office delegation\n\nWhat it is. An approval chain moves at the speed of its slowest human.\nWhen a named reviewer goes on holiday, a request sits in PENDING_REVIEW\nuntil the approval timeout auto-rejects it. Delegation lets a reviewer hand their review\nduty to a named colleague for a set window instead.\n\nWhere. Any reviewer sets this themselves on Profile settings\n→ Out-of-office delegation. No admin involvement, and no permission is\nrequired to delegate — a delegation from someone with no review rights simply\nconfers nothing.\n\nHow to set one up. Choose a colleague, optionally narrow the delegation\nto a single datasource, pick a start and end time, and save. During the window the\ndelegate becomes an eligible approver everywhere you were — query review, governed\nAPI requests, and grouped requests — and the requests show up in their review queue\nwith a Delegated tag. Revoking is immediate and takes effect on the next\ndecision; the record itself is kept as evidence for anything already approved under it.\n\nWhat it cannot do. These limits are enforced by the server, not the\ninterface:\n\n-\nA delegation never grants a permission. The delegate still needs\nreview rights of their own; delegation only widens which requests they may act\non.\n\n-\nThe delegate can never act on a request the delegator submitted\n— the no-self-approval rule follows the borrowed identity, not just the person\nclicking.\n\n-\nDelegation does not chain. If A delegates to B and B delegates to C, C\ngains nothing from A.\n\n-\nOne human still gets one vote. Covering for two absent approvers at\nonce does not let someone satisfy a two-approval requirement alone.\n\n-\nIt stops the moment either party is deactivated, including via SCIM\ndeprovisioning.\n\nAudit. Every decision made under a delegation records both people\n— who clicked approve, and whose authority they used — so the trail never\nimplies the absent reviewer acted. Admins can see every delegation in the organization\nunder Admin, which is what makes an “on behalf of” entry\ninterpretable months later."} {"id":"24fe190f5a746622","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":0,"tokens":741,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 1 of 3)\n\nWhat it is. Policy-as-code that decides a query's path automatically,\nafter AI analysis and before reviewers see it. Use it to auto-approve\nroutine reads, hard-block dangerous patterns, or escalate sensitive ones — instead of\nsending everything through the same review plan. Policies run in ascending priority and the\nfirst enabled one whose condition matches wins; anything unmatched falls\nthrough to the datasource's review plan exactly as before.\n\nConfigure it. Manage them at /admin/routing-policies (the\nRouting policies entry in the Security nav group):\n\n- Open /admin/routing-policies (the Routing policies entry in the Security nav group, next to Review plans) and click Add policy.\n\n- Name the policy and optionally scope it to one datasource — leave the datasource blank for an org-wide rule. Set its priority (unique per organisation; lower runs first) and the enabled toggle.\n\n- Build the condition with the guided builder: pick match ALL (AND) or match ANY (OR), then add leaf conditions — each can be negated (NOT). Operands include query type, referenced tables (glob, e.g. payroll.*), AI risk level, AI risk score (with a comparison operator), requester role, requester group, time-of-day window, day-of-week, presence of a WHERE clause, presence of a LIMIT clause, query shape (the query contains a join, a set operation such as UNION, a subquery, a WITH clause, GROUP BY, HAVING, an aggregate function or a window function — anywhere, including inside subqueries), the transactional (BEGIN…COMMIT) flag, and the pre-flight cost estimate — estimated rows (comparison against the engine's own EXPLAIN estimate, or the exact affected-row count for UPDATE/DELETE) and scan type (glob match on the plan's root operation, e.g. Seq*) — so a policy can route a 10-million-row sequential-scan DELETE differently from a 10-row indexed one.\n\n- Choose the action. Auto-approve (skip human review), Auto-reject (block the query), Require approvals (force human review with an absolute minimum number of approvers), or Escalate (force human review, adding a delta on top of the review plan's minimum). The approver count applies only to the last two actions.\n\n- Reorder policies any time with the per-row up/down controls — the order is the evaluation order."} -{"id":"ef446fedd00559b9","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":1,"tokens":600,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 2 of 3)\n\nHow it routes. Time-of-day and day-of-week conditions are evaluated in the\nserver's local timezone (overnight windows wrap around midnight). On datasources with\nAI analysis disabled, risk-based conditions never match (there's no AI signal); routing does not\nrun when AI analysis fails — the query goes to a human instead. The cost-estimate conditions\nlikewise never match while no estimate exists (engine without a plan concept, or the estimate\nfailed) — they fail closed rather than auto-approving blind. A query-shape condition never\nmatches a query AccessFlow cannot read as SQL, such as a MongoDB or Redis command. To refuse a\nshape for one person or group outright, set Denied query shapes on their\ndatasource grant instead. Every automated decision is\nrecorded in the audit log (QUERY_APPROVED /\nQUERY_REJECTED with source: \"ROUTING_POLICY\"), and the query detail page\nshows which policy matched. Routing policies are managed via the ADMIN-only\n/api/v1/admin/routing-policies CRUD and /reorder endpoints.\n\n/admin/routing-policies — ordered, attribute-based auto-decision rules; first match by priority wins, unmatched falls through to the review plan.\n\nSimulate it before you save it. A routing rule is easy to write and hard\nto predict — the same condition that blocks one dangerous DELETE can quietly block a\nnightly job nobody remembered. Every policy form has a Simulate button that\ndry-runs the draft against your own past queries and shows the blast radius first.\nPick a date range (up to 90 days) and AccessFlow replays that traffic twice\n— once against the policies you have today, once with the draft added or replacing the one\nyou are editing — then reports the difference between those two runs: how many past\nqueries would take a different path, which of them, and which people would feel it. The\nsame button sits on the masking\nand row security forms, so you can preview those before saving too."} +{"id":"ef446fedd00559b9","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":1,"tokens":653,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 2 of 3)\n\nHow it routes. Time-of-day and day-of-week conditions are evaluated in the\nserver's local timezone (overnight windows wrap around midnight). On datasources with\nAI analysis disabled, risk-based conditions never match (there's no AI signal); routing does not\nrun when AI analysis fails — the query goes to a human instead. The cost-estimate conditions\nlikewise never match while no estimate exists (engine without a plan concept, or the estimate\nfailed) — they fail closed rather than auto-approving blind. A query-shape condition never\nmatches a query AccessFlow cannot read as standard SQL — a MongoDB or Redis command, or a\nwarehouse query that uses syntax only that warehouse understands — so on NoSQL datasources\nit never fires, and on a warehouse it fires only for queries written in standard SQL. To refuse a\nshape for one person or group outright, set Denied query shapes on their\ndatasource grant instead. Every automated decision is\nrecorded in the audit log (QUERY_APPROVED /\nQUERY_REJECTED with source: \"ROUTING_POLICY\"), and the query detail page\nshows which policy matched. Routing policies are managed via the ADMIN-only\n/api/v1/admin/routing-policies CRUD and /reorder endpoints.\n\n/admin/routing-policies — ordered, attribute-based auto-decision rules; first match by priority wins, unmatched falls through to the review plan.\n\nSimulate it before you save it. A routing rule is easy to write and hard\nto predict — the same condition that blocks one dangerous DELETE can quietly block a\nnightly job nobody remembered. Every policy form has a Simulate button that\ndry-runs the draft against your own past queries and shows the blast radius first.\nPick a date range (up to 90 days) and AccessFlow replays that traffic twice\n— once against the policies you have today, once with the draft added or replacing the one\nyou are editing — then reports the difference between those two runs: how many past\nqueries would take a different path, which of them, and which people would feel it. The\nsame button sits on the masking\nand row security forms, so you can preview those before saving too."} {"id":"3279d01a87ac8c08","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-routing-policies","anchor":"cfg-routing-policies","title":"Routing policies","section":"Reference","order":2,"tokens":243,"text":"AccessFlow Docs > Reference > Review workflows > Routing policies (part 3 of 3)\n\nWhat a simulation is, and is not. It is strictly a preview: nothing is\nsaved, no query is re-run, and AccessFlow never connects to your database to produce it.\nIt compares policies against policies — today's rules versus the draft — rather\nthan against what actually happened, because a past decision may have come from an\nemergency, a ticket, or a standing grant the draft has no say over. Some things simply\ncannot be reconstructed from months-old traffic, so the results say so: memberships and\nroles are read as they stand today, and where an engine cannot work out offline what a\nrow rule would do, those queries are listed as unclassifiable — never counted as\nunaffected. Simulating is always optional; nothing blocks you from saving."} {"id":"16c52a767cb82ddb","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-sql-review","anchor":"cfg-sql-review","title":"SQL review rules","section":"Reference","order":0,"tokens":768,"text":"AccessFlow Docs > Reference > Review workflows > SQL review rules (part 1 of 2)\n\nWhat it is. A catalog of named, deterministic checks that every SQL query\nis measured against — the third opinion next to the\nAI analysis and\nrouting policies. Where the AI gives a judgement and a\nrouting policy gives a single verdict, a rule gives a named, repeatable finding:\n\"this DELETE has no WHERE clause\", \"this touches a protected\ntable\", \"this pattern starts with a wildcard\". The same query always produces the same\nfindings, they cost nothing to compute, and they keep working with AI analysis switched off.\nAuthors see them in the editor as they type, before anything is submitted.\n\nFourteen built-in rules — missing WHERE on\nUPDATE or DELETE, an always-true WHERE such as\n1 = 1 that would otherwise defeat those two, SELECT *, a\nSELECT with no row limit, ORDER BY without a limit, cross joins,\nLIKE '%…', DROP, TRUNCATE, any DDL, a call to a banned\nfunction (pg_sleep, sleep, benchmark,\nload_file out of the box), any statement touching a protected\ntable you name by pattern (payroll.*, *.audit_log), and a\ndata change submitted outside a BEGIN … COMMIT transaction. Every rule is\nderived from the parsed statement alone — AccessFlow never connects to your database to\nevaluate one.\n\nThree severities, per rule. Each rule runs at one of\nOff (not checked), Warn (the finding is recorded and shown to the author\nand the reviewers, and changes nothing about the approval path), or Block (the\nfinding is recorded and the query can no longer be approved automatically —\na person must look at it). Out of the box the dangerous ones block — missing\nWHERE, always-true WHERE, DROP,\nTRUNCATE, banned functions, protected tables — and the rest warn: the\nperformance hints, any other DDL, and a data change outside a transaction.\n\nBlock means a person must look. It never rejects. A blocking finding\nswitches off every path that would have approved the query without a human —\na routing policy's auto-approve, a standing grant's pre-approval, a review plan that\nauto-approves reads or needs no human sign-off — and sends the query to the reviewers\ninstead. It does not refuse the query, and it never overrides a routing policy that\nrejects: nothing in this feature adds a new way for a query to be turned down\nwithout someone deciding it. The author can still submit; the Submit button's tooltip says\nhow many blocking findings will require human approval. Every time a block changed the\noutcome, the audit log\nrecords which rules did it."} {"id":"dd7676e238d930a9","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-sql-review","anchor":"cfg-sql-review","title":"SQL review rules","section":"Reference","order":1,"tokens":735,"text":"AccessFlow Docs > Reference > Review workflows > SQL review rules (part 2 of 2)\n\nConfigure it. Rulesets live at /admin/sql-review (the\nSQL review entry in the Security nav group, next to Routing\npolicies; needs the Manage SQL review rules permission, which the built-in\nAdmin role holds):\n\n- Give every datasource an environment. On the datasource's create wizard or settings page, set Environment to Development, Test, Staging or Production — or leave it unset. See Datasources.\n\n- Open /admin/sql-review and click Add ruleset. Name it, and bind it to one environment — or pick Organization default, the ruleset that applies to every datasource with no environment set (and to any environment that has no ruleset of its own). One ruleset per environment, one organization default.\n\n- Set the severities. The rules table lists every built-in rule with its default severity underneath; change only the ones you want to. For Protected table add the table patterns; for Disallowed function add or replace the banned names. Rules left at their default are not stored, so a ruleset stays a short list of what you changed.\n\n- Enable it. A ruleset can be switched off with the Enabled toggle — note that a disabled ruleset bound to an environment evaluates no rules and does not fall back to the organization default, so switching production off never quietly re-enables the default there.\n\nHow it decides. A query is checked at submission, before the AI is\nasked, so its findings exist even on datasources where AI analysis is disabled, and\neven when the AI provider is down — the cases where a written-down rule is the only signal\nthere is. The datasource's environment picks the ruleset; no environment means the\norganization default; no default means no rules. Findings are shown to reviewers in their\nown language on the query's detail page, as a block count on the review queue, on a\nrequest group's member list, and on the retro-review of an emergency\nbreak-glass run — which is\nrecorded but never held up by a rule, because emergency access stays an emergency path.\nRules apply to the SQL engines (PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom\nJDBC drivers); on MongoDB, Couchbase, Redis, Cassandra, ScyllaDB, Elasticsearch, OpenSearch,\nNeo4j, DynamoDB and the cloud warehouses the check reports not applicable and never blocks — an engine without rule\nsupport must never make its queries harder to approve than they are today."} @@ -334,7 +334,8 @@ {"id":"c934edba3bc5494c","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/","anchor":"","title":"What is the difference between a role and a grant?","section":"Guides","order":0,"tokens":135,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > What is the difference between a role and a grant?\n\nA role decides which parts of AccessFlow a person can use — whether\nthey can submit writes, review other people's queries, or open admin pages. A\ngrant decides which datasource they can query and with what\ncapabilities. Neither implies the other: an analyst with no grants can reach no data,\nand a grant on its own does not let anyone review."} {"id":"51f2d337362ea62b","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-create","anchor":"guide-team-create","title":"1. Create the accounts","section":"Guides","order":0,"tokens":451,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 1. Create the accounts\n\nSidebar → Security & Access → Users. The control at the\ntop right is a split button with two different paths behind it.\n\n- Invite via email — the main half of the button. Opens\nInvite a teammate: Email, an optional Display\nname, and a Role. Send invitation emails\nthem a link they use to set their own password. This is the path you want on a real\ndeployment.\n\n- Create with password — behind the small arrow. Opens Invite\nuser, which asks for an Initial password alongside the same\nfields and creates the account immediately. Despite its Send invite\nbutton, this path sends no mail at all.\n\nInvite via email needs system SMTP first. Without it the action fails\nwith 422 SYSTEM_SMTP_NOT_CONFIGURED_FOR_INVITE before it creates anything.\nSet email up with the notifications\nguide, or use the password path meanwhile.\n\nSent invitations appear in a Pending invitations table below the user\nlist, where you can resend or revoke one. Links expire after seven days by default\n(ACCESSFLOW_SECURITY_INVITATION_TTL), and they are built from\nACCESSFLOW_PUBLIC_BASE_URL — if that is wrong, your invitees get a link\npointing somewhere they cannot reach.\n\nSidebar → Security & Access → Users, with the create-with-password form open.\n\nSearching does not span pages. The search box and the role and provider\nfilters narrow the page you are looking at, twenty users at a time — they are not a\nserver-side search. On a large directory, page to the user rather than expecting the box\nto find them."} {"id":"75929b3b512c7e20","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-roles","anchor":"guide-team-roles","title":"2. Pick the right role","section":"Guides","order":0,"tokens":528,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 2. Pick the right role\n\nFive roles ship with AccessFlow. They are built in: you cannot edit or delete them, and\neach is a superset of the one above it apart from Auditor, which is a different shape\nentirely.\n\nRole | What it can do | Give it to |\n\nRead-only |\nSubmit SELECT queries, and nothing else. |\nPeople who only ever read, and contractors. |\n\nAnalyst |\nRead-only, plus INSERT, UPDATE and DELETE. No schema changes. |\nThe default for engineers and analysts. Most of your users. |\n\nReviewer |\nEverything an analyst can do, plus seeing every query in the organization and\ndeciding them — along with access requests, API calls, deployments, erasure\nrequests and recertification items. |\nTeam leads and data owners. Note it carries no admin or configuration\naccess. |\n\nAuditor |\nCompliance reports, recertification evidence, access-usage reports and the\nbreak-glass log. Cannot submit queries at all, which is why\nsigning in as one lands on the compliance dashboard rather than a dashboard with\nan editor. |\nCompliance and internal audit. |\n\nAdmin |\nEverything, including every permission added in future releases. |\nAs few people as the job allows — see the warning below. |\n\nAdmin is not just \"more access\". It carries the review-override\npermission, which bypasses the approver lists on review plans by design. Any scoping you\nconfigure elsewhere — including on\ndeployment\npipelines — is scoping over non-admins. Grant it deliberately.\n\nIf none of the five fits, build your own: Roles under the admin section\ncreates an organization-scoped role from the permission catalog. It needs a name and at\nleast one permission. Custom role names work as approver rules on review plans just as\nthe built-in ones do."} -{"id":"9dea8c599437b649","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":0,"tokens":786,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource\n\nOpen the datasource → Permissions tab → Grant access.\nGrants go to an individual or to a group, and carry rather more than an on/off switch:\n\n- Can read, Can write, Can run DDL\n— at least one is required.\n\n- Row limit override — a tighter cap than the datasource's default\nfor this person. It can only lower the limit, never raise it. If someone holds\nseveral grants on the same datasource (their own and their groups'), the smallest\noverride wins, so no grant can loosen a tighter cap set by another.\n\n- Allowed schemas and Allowed tables — leave empty\nfor everything, or narrow it. Submitting a query that touches anything outside the\nlist is refused.\n\n- Denied schemas and Denied tables — the exceptions:\nallow crm, deny crm.salary, and everything else in\ncrm stays reachable. A denial always beats the allowed lists, and a table\ndenied by any of this person's grants stays denied. While a schema is denied, they must\nwrite table names with their schema. Enter a schema as one name (hr) and a\ntable as table, schema.table or schema.*.\n\n- Restricted columns — masked in this person's results.\n\n- Denied columns — stricter than restricted: a query that touches one\nat all, including through SELECT *, is refused before it runs. Relational\ndatasources only. Administrators are not bound by it, and a column denied by any of\nthis person's grants stays denied.\n\n- Denied query shapes — refuse queries written a certain way, such as\njoins, subqueries, GROUP BY or aggregate functions like COUNT,\nwherever they appear in the query. Deny them all and only simple\nSELECT … WHERE lookups remain. Relational datasources only.\n\n- Expires at — optional, and the single most useful field on the\nform. Access that removes itself is access nobody has to remember to remove.\n\nWhere someone has both a direct grant and one through a group, the effective result is\nthe most permissive combination of the two, with two exceptions: the row limit, where\nthe smallest override wins, and anything denied — schemas, tables, columns and query shapes —\nwhich adds up across grants.\n\nBreak-glass is granted here too, and it is not an ordinary capability.\nCan break-glass lets its holder bypass review entirely and execute\nimmediately. It is compensated rather than prevented: every admin is notified, the\naction is prominently audited, and an admin who is not the submitter has to acknowledge\nit afterwards. It does not stand alone — the user still needs the matching read, write\nor DDL capability. Set an expiry on it."} +{"id":"9dea8c599437b649","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":0,"tokens":682,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource (part 1 of 2)\n\nOpen the datasource → Permissions tab → Grant access.\nGrants go to an individual or to a group, and carry rather more than an on/off switch:\n\n- Can read, Can write, Can run DDL\n— at least one is required.\n\n- Row limit override — a tighter cap than the datasource's default\nfor this person. It can only lower the limit, never raise it. If someone holds\nseveral grants on the same datasource (their own and their groups'), the smallest\noverride wins, so no grant can loosen a tighter cap set by another.\n\n- Allowed schemas and Allowed tables — leave empty\nfor everything, or narrow it. Submitting a query that touches anything outside the\nlist is refused.\n\n- Denied schemas and Denied tables — the exceptions:\nallow crm, deny crm.salary, and everything else in\ncrm stays reachable. A denial always beats the allowed lists, and a table\ndenied by any of this person's grants stays denied. While a schema is denied, they must\nwrite table names with their schema. Enter a schema as one name (hr) and a\ntable as table, schema.table or schema.*.\n\n- Restricted columns — masked in this person's results.\n\n- Denied columns — stricter than restricted: a query that touches one\nat all, including through SELECT *, is refused before it runs. Relational\ndatasources only. Administrators are not bound by it, and a column denied by any of\nthis person's grants stays denied.\n\n- Denied query shapes — refuse queries written a certain way, such as\njoins, subqueries, GROUP BY or aggregate functions like COUNT,\nwherever they appear in the query. Deny them all and only single-table\nqueries remain; it does not stop writes, so leave can write off for a\nread-only grant. Relational datasources only.\n\n- Expires at — optional, and the single most useful field on the\nform. Access that removes itself is access nobody has to remember to remove.\n\nWhere someone has both a direct grant and one through a group, the effective result is\nthe most permissive combination of the two, with two exceptions: the row limit, where\nthe smallest override wins, and anything denied — schemas, tables, columns and query shapes —\nwhich adds up across grants."} +{"id":"d1698f12f6a5714f","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-grants","anchor":"guide-team-grants","title":"3. Grant access to a datasource","section":"Guides","order":1,"tokens":165,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 3. Grant access to a datasource (part 2 of 2)\n\nBreak-glass is granted here too, and it is not an ordinary capability.\nCan break-glass lets its holder bypass review entirely and execute\nimmediately. It is compensated rather than prevented: every admin is notified, the\naction is prominently audited, and an admin who is not the submitter has to acknowledge\nit afterwards. It does not stand alone — the user still needs the matching read, write\nor DDL capability. Set an expiry on it."} {"id":"2a9ddd10a0c5d838","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-groups","anchor":"guide-team-groups","title":"4. Use groups once individual grants stop scaling","section":"Guides","order":0,"tokens":168,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 4. Use groups once individual grants stop scaling\n\nSidebar → User groups. A group is a named set of people that can hold\ndatasource and API-connector grants of its own, so joining the group is what confers\naccess and leaving it is what removes it.\n\nMembership can be manual, or synced from your identity provider — the members table\nshows each person's source as Manual, IdP or SCIM. If you are heading towards\nIdP-managed groups, connect single sign-on first and let\nthe group memberships arrive with the users."} {"id":"3d9f9581d7bc2503","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-jit","anchor":"guide-team-jit","title":"5. Let people ask, instead of granting up front","section":"Guides","order":0,"tokens":324,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 5. Let people ask, instead of granting up front\n\nStanding access is the thing you are trying to avoid. Any signed-in user can open\nRequest access and ask for a scoped, time-boxed grant: a datasource,\nthe capabilities they need, optionally specific schemas and tables, a duration, and a\njustification. Durations run from one hour to seven days.\n\nRequests land in the Access requests queue for anyone who can review\nthem. Approving materialises a real grant that expires by itself; a background job\nrevokes it when the clock runs out. Rejecting requires a comment.\n\nPre-approve queries under this grant is worth understanding before\nsomeone ticks it. It lets queries covered by the grant's capability and table scope skip\nhuman review for as long as the grant is active — useful for a bounded on-call window,\nand a much bigger decision than the checkbox looks. High-risk queries and routing\npolicies still apply.\n\nDuration bounds are configurable with\nACCESSFLOW_ACCESS_MIN_DURATION (15 minutes by default) and\nACCESSFLOW_ACCESS_MAX_DURATION (30 days)."} {"id":"dbbb45cb98e78a45","path":"website/docs/guides/team/index.html","url":"https://accessflow.io/docs/guides/team/#guide-team-offboarding","anchor":"guide-team-offboarding","title":"6. When someone leaves","section":"Guides","order":0,"tokens":286,"text":"AccessFlow Docs > Guides > Invite your team and assign roles > 6. When someone leaves\n\nOn the users page, open the row's menu and choose Deactivate. Three\nthings happen: the account is disabled, every one of its sessions is signed out\nimmediately, and every active just-in-time grant it holds is revoked.\n\nStanding grants are not revoked. Deactivation removes the temporary\ngrants somebody requested, not the permanent rows an admin created on a datasource's\nPermissions tab. Those survive the account being disabled, and would apply again if it\nwere ever reactivated. Remove them explicitly.\n\nYou cannot deactivate yourself — the API refuses it, so an organization can never lock\nout its last admin by accident.\n\nIf your identity provider is the source of truth, this should not be a manual step at\nall: SCIM deprovisioning raises the same event and takes the same actions. See\nSCIM provisioning.\n\nFull reference for roles, the permission matrix, groups and grants:\nUsers & roles."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index 1a1d0671b..7b83b74b1 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "45a0913ffe08", - "generatedAt": "2026-09-25T07:19:45.600Z", - "sourceCommit": "ef197619924ca6c15ad2b7045ee0354429fe4341", - "chunkCount": 590, - "sha256": "45a0913ffe0846a4f50353f3c5fcafab21444e7dc6477f3318e4cfcc7f737256", + "corpusVersion": "db82ce629b4c", + "generatedAt": "2026-09-25T07:39:58.431Z", + "sourceCommit": "289c67dd6084830c338a941dfd4523a35eee817e", + "chunkCount": 591, + "sha256": "db82ce629b4c8478f46c001f7aaad553305b9c9ab6f1ddd0947f78f58ddd38df", "quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66", "sources": [ { @@ -205,7 +205,7 @@ "url": "https://accessflow.io/docs/configuration/datasources/", "section": "Reference", "chunks": 17, - "sha256": "d88f04744fb44d7933f98e54eb1b158f4782c5c71c8d31f42fb39748bc47cfe0" + "sha256": "a05d3d761de46fe17853402ce2a9e720c3342b31fe6b26c677aac36e25739361" }, { "path": "website/docs/configuration/notifications/index.html", @@ -221,7 +221,7 @@ "url": "https://accessflow.io/docs/configuration/review-workflows/", "section": "Reference", "chunks": 18, - "sha256": "71962f656fdddfbad48d7f73401b0397d74236772133932372ab35efb225ba00" + "sha256": "d4d47000785893d03aba9a55aba7598ebd90fb015bde3602ed810e94680ba64c" }, { "path": "website/docs/configuration/users-roles/index.html", @@ -308,8 +308,8 @@ "title": "Invite your team and assign roles", "url": "https://accessflow.io/docs/guides/team/", "section": "Guides", - "chunks": 8, - "sha256": "3b144bd38fff8a84348de6f8f57ba21d8422ca515cd679d94988dad7715793e1" + "chunks": 9, + "sha256": "408b7c7b922debc5a346afd21975d8ac86faa7b67600732b0029dc2e6557e663" }, { "path": "website/docs/guides/terraform/index.html", @@ -429,7 +429,7 @@ "url": "https://accessflow.io/docs/", "section": "Navigation", "chunks": 9, - "sha256": "680ff0667f49d5dad67d0373e71bd93113b0b65719ad0c10107df310513a1615" + "sha256": "f994012656b4b281b744b19226fba52562ca91b9aa1245ac2fad4c8be7e60d08" } ] } diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html index 1aa9f1345..11cc97ef9 100644 --- a/website/docs/configuration/datasources/index.html +++ b/website/docs/configuration/datasources/index.html @@ -377,12 +377,13 @@

    What is a datasource in AccessFlow?

  • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
  • - Denied query shapes — limit how a query is written. Some grants should allow a table but not every way of querying it — for example, simple lookups but no joins or totals. Pick the query shapes to refuse under Denied query shapes: joins, set operations (UNION, INTERSECT, EXCEPT), subqueries, WITH clauses, GROUP BY, HAVING, aggregate functions and window functions. Denying all of them leaves plain SELECT … FROM … WHERE … ORDER BY. A query that uses a denied shape is refused before it runs: + Denied query shapes — limit how a query is written. Some grants should allow a table but not every way of querying it — for example, simple lookups but no joins or totals. Pick the query shapes to refuse under Denied query shapes: joins, set operations (UNION, INTERSECT, EXCEPT), subqueries, WITH clauses, GROUP BY, HAVING, aggregate functions and window functions. Denying all of them leaves plain single-table queries such as SELECT … FROM … WHERE … ORDER BY; shapes don't change whether the grant can write, so keep can write off for a read-only grant. A query that uses a denied shape is refused before it runs:

    • Anywhere in the query. A join inside a subquery or a WITH clause counts, and in a BEGIN; …; COMMIT; batch every statement is checked.
    • -
    • Aggregate functions. The standard ones — COUNT, SUM, AVG, MIN, MAX and similar. A custom aggregate function defined in your database is not recognised, so don't rely on this setting to block one.
    • +
    • Aggregate functions. The built-in ones — COUNT, SUM, AVG, MIN, MAX, JSON_ARRAYAGG, the statistics functions and similar. A custom aggregate function defined in your database, or a rarely used built-in, may not be recognised, so pair this setting with database permissions where it must hold.
    • When in doubt, refuse. If AccessFlow cannot work out a query's shape, it treats the query as using every shape the grant denies.
    • +
    • Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks when they submit a query. Emergency (break-glass) queries are still checked, for everyone.
    • Several grants add up. A shape denied by any of a user's grants stays denied, and a just-in-time grant that replaces a user's own expiring grant keeps its denied shapes.
    • Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.
    • Rather review than refuse? Use the Query shape condition in a routing policy instead, for example to send every joined query to a second reviewer.
    • diff --git a/website/docs/configuration/review-workflows/index.html b/website/docs/configuration/review-workflows/index.html index e46db247d..6572a3622 100644 --- a/website/docs/configuration/review-workflows/index.html +++ b/website/docs/configuration/review-workflows/index.html @@ -537,7 +537,9 @@

      Routing policies

      run when AI analysis fails — the query goes to a human instead. The cost-estimate conditions likewise never match while no estimate exists (engine without a plan concept, or the estimate failed) — they fail closed rather than auto-approving blind. A query-shape condition never - matches a query AccessFlow cannot read as SQL, such as a MongoDB or Redis command. To refuse a + matches a query AccessFlow cannot read as standard SQL — a MongoDB or Redis command, or a + warehouse query that uses syntax only that warehouse understands — so on NoSQL datasources + it never fires, and on a warehouse it fires only for queries written in standard SQL. To refuse a shape for one person or group outright, set Denied query shapes on their datasource grant instead. Every automated decision is recorded in the audit log (QUERY_APPROVED / diff --git a/website/docs/guides/team/index.html b/website/docs/guides/team/index.html index 4efc2c289..d6271d2ee 100644 --- a/website/docs/guides/team/index.html +++ b/website/docs/guides/team/index.html @@ -441,8 +441,9 @@

      3. Grant access to a datasource

      this person's grants stays denied.
    • Denied query shapes — refuse queries written a certain way, such as joins, subqueries, GROUP BY or aggregate functions like COUNT, - wherever they appear in the query. Deny them all and only simple - SELECT … WHERE lookups remain. Relational datasources only.
    • + wherever they appear in the query. Deny them all and only single-table + queries remain; it does not stop writes, so leave can write off for a + read-only grant. Relational datasources only.
    • Expires at — optional, and the single most useful field on the form. Access that removes itself is access nobody has to remember to remove.