diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java
index 550748e6..9972f5de 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DeniedColumns.java
@@ -86,35 +86,33 @@ public static SortedSet rejected(List rawDenied, SqlParseResult
}
/**
- * The columns two deny lists both deny, compared by what each entry matches rather than by its
- * spelling: {@code users.ssn} and {@code public.users.ssn} overlap in {@code public.users.ssn}.
- * Used to merge a user's grants, where a column stays denied only if every grant denies it.
+ * The columns either deny list denies, deduplicated by what each entry matches rather than by
+ * its spelling: {@code users.ssn} already denies {@code public.users.ssn} (an entry without a
+ * schema matches the table in every schema), so the union keeps only {@code users.ssn}. Used to
+ * merge a user's grants, where a column stays denied if any grant denies it (#1099).
*/
- public static List intersect(List left, List right) {
- var out = new ArrayList();
- for (String a : normalize(left)) {
- for (String b : normalize(right)) {
- var overlap = overlap(a, b);
- if (overlap != null && !out.contains(overlap)) {
- out.add(overlap);
- }
+ public static List union(List left, List right) {
+ var all = new ArrayList(normalize(left));
+ for (String entry : normalize(right)) {
+ if (!all.contains(entry)) {
+ all.add(entry);
+ }
+ }
+ var out = new ArrayList(all.size());
+ for (String entry : all) {
+ if (all.stream().noneMatch(other -> covers(other, entry))) {
+ out.add(entry);
}
}
return List.copyOf(out);
}
- private static String overlap(String a, String b) {
- var pa = a.split("\\.");
- var pb = b.split("\\.");
- if (!pa[pa.length - 1].equals(pb[pb.length - 1])
- || pa.length < 2 || pb.length < 2
- || !pa[pa.length - 2].equals(pb[pb.length - 2])) {
- return null;
- }
- if (pa.length > 2 && pb.length > 2) {
- return pa[0].equals(pb[0]) ? a : null;
- }
- return pa.length >= pb.length ? a : b;
+ /** {@code true} when {@code broader} is {@code table.column} and {@code narrower} pins a schema. */
+ private static boolean covers(String broader, String narrower) {
+ var pb = broader.split("\\.");
+ var pn = narrower.split("\\.");
+ return pb.length == 2 && pn.length == 3
+ && pb[0].equals(pn[1]) && pb[1].equals(pn[2]);
}
/**
diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java
index 0d940e41..fd40ee2a 100644
--- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupService.java
@@ -173,10 +173,10 @@ private static boolean isActive(Instant expiresAt, Instant now) {
/**
* Merge one datasource's contributing grants into a single effective view. Boolean flags OR;
* allow-lists union (null wins = all allowed); restricted-columns intersect (empty wins =
- * nothing masked), and so do denied-columns (#935 — empty wins = nothing denied); expiry is
- * the latest among contributors (null wins = never expires). The row limit is the inversion:
- * the smallest non-null override wins (#933). Denied schemas and tables are the other
- * inversion: they union, so no contributing grant can lift another's denial (#939).
+ * nothing masked); expiry is the latest among contributors (null wins = never expires). The
+ * row limit is the inversion: the smallest non-null override wins (#933). Deny-lists — denied
+ * schemas, tables (#939) and columns (#1099) — are the other inversion: they union, so no
+ * contributing grant can lift another's denial.
*/
private static DatasourceUserPermissionView merge(UUID userId, UUID datasourceId,
List parts) {
@@ -208,7 +208,7 @@ private static DatasourceUserPermissionView merge(UUID userId, UUID datasourceId
unionAllowList(parts, DatasourcePermissionContribution::allowedSchemas),
unionAllowList(parts, DatasourcePermissionContribution::allowedTables),
intersect(parts, DatasourcePermissionContribution::restrictedColumns),
- intersectDenied(parts),
+ unionDeniedColumns(parts),
unionDenied(parts, DatasourcePermissionContribution::deniedSchemas),
unionDenied(parts, DatasourcePermissionContribution::deniedTables),
minRowLimit(parts),
@@ -257,10 +257,7 @@ private static List unionAllowList(
return List.copyOf(union);
}
- /**
- * Restriction intersection: a column is masked (or denied) only when every contribution masks
- * (or denies) it.
- */
+ /** Restriction intersection: a column is masked only when every contribution masks it. */
private static List intersect(
List parts,
Function> field) {
@@ -283,17 +280,13 @@ private static List intersect(
return intersection == null ? List.of() : List.copyOf(intersection);
}
- /** Like {@link #intersect}, but entries meet by the column they name, not by spelling. */
- private static List intersectDenied(List parts) {
- List denied = null;
+ /** Like {@link #unionDenied}; overlapping spellings of a column keep the broader entry. */
+ private static List unionDeniedColumns(List parts) {
+ List denied = List.of();
for (var p : parts) {
- var values = DeniedColumns.normalize(p.deniedColumns());
- denied = denied == null ? values : DeniedColumns.intersect(denied, values);
- if (denied.isEmpty()) {
- return List.of();
- }
+ denied = DeniedColumns.union(denied, p.deniedColumns());
}
- return denied == null ? List.of() : denied;
+ return denied;
}
private static DatasourceUserPermissionView toDirectView(DatasourceUserPermissionEntity entity) {
diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java
index 2bea091c..a6fb0453 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/DeniedColumnsTest.java
@@ -97,16 +97,22 @@ void wholeTableReadReachesEveryEntryOnThatTable() {
}
@Test
- void intersectMeetsEntriesByTheColumnTheyName() {
- assertThat(DeniedColumns.intersect(List.of("users.ssn", "users.email"),
- List.of("public.users.ssn")))
- .containsExactly("public.users.ssn");
- assertThat(DeniedColumns.intersect(List.of("public.users.ssn"), List.of("Users.SSN")))
- .containsExactly("public.users.ssn");
- assertThat(DeniedColumns.intersect(List.of("a.users.ssn"), List.of("b.users.ssn"))).isEmpty();
- assertThat(DeniedColumns.intersect(List.of("users.ssn"), List.of("orders.ssn"))).isEmpty();
- assertThat(DeniedColumns.intersect(List.of("users.ssn"), List.of("users.email"))).isEmpty();
- assertThat(DeniedColumns.intersect(List.of("users.ssn"), List.of())).isEmpty();
+ void unionKeepsEveryDeniedColumnAndCollapsesOverlappingSpellings() {
+ // users.ssn denies ssn in every schema's users table, so it covers public.users.ssn.
+ assertThat(DeniedColumns.union(List.of("public.users.ssn", "users.email"),
+ List.of("Users.SSN")))
+ .containsExactly("users.email", "users.ssn");
+ assertThat(DeniedColumns.union(List.of("users.ssn"), List.of("public.users.ssn")))
+ .containsExactly("users.ssn");
+ assertThat(DeniedColumns.union(List.of("a.users.ssn"), List.of("b.users.ssn")))
+ .containsExactly("a.users.ssn", "b.users.ssn");
+ assertThat(DeniedColumns.union(List.of("users.ssn"), List.of("orders.ssn")))
+ .containsExactly("users.ssn", "orders.ssn");
+ assertThat(DeniedColumns.union(List.of("public.users.ssn"), List.of("public.orders.ssn",
+ "`PUBLIC`.`USERS`.`SSN`")))
+ .containsExactly("public.users.ssn", "public.orders.ssn");
+ assertThat(DeniedColumns.union(List.of("users.ssn"), List.of())).containsExactly("users.ssn");
+ assertThat(DeniedColumns.union(null, null)).isEmpty();
}
@Test
diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java
index de64f94d..8c7aa37f 100644
--- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java
+++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultDatasourceUserPermissionLookupServiceTest.java
@@ -183,7 +183,7 @@ void findForUnionsAllowListsAndIntersectsRestrictions() {
}
@Test
- void findForIntersectsDeniedColumnsCaseInsensitively() {
+ void findForUnionsDeniedColumnsCaseInsensitively() {
var userId = UUID.randomUUID();
var datasourceId = UUID.randomUUID();
var groupId = UUID.randomUUID();
@@ -201,12 +201,13 @@ void findForIntersectsDeniedColumnsCaseInsensitively() {
var view = service.findFor(userId, datasourceId).orElseThrow();
- // A column is denied only when every contributing grant denies it (least-restrictive).
- assertThat(view.deniedColumns()).containsExactly("public.users.ssn");
+ // A column is denied when any contributing grant denies it (#1099); the group's
+ // schema-less users.ssn covers the direct grant's public.users.ssn.
+ assertThat(view.deniedColumns()).containsExactly("public.users.email", "users.ssn");
}
@Test
- void findForDeniesNothingWhenOneGrantDeniesNothing() {
+ void permissiveGroupGrantCannotLiftADirectColumnDenial() {
var userId = UUID.randomUUID();
var datasourceId = UUID.randomUUID();
var groupId = UUID.randomUUID();
@@ -215,13 +216,40 @@ void findForDeniesNothingWhenOneGrantDeniesNothing() {
direct.setDeniedColumns(new String[] {"public.users.ssn"});
var group = newGroupPermission(groupId, datasourceId);
group.setCanRead(true);
+ group.setCanWrite(true);
+ group.setDeniedColumns(new String[0]);
+ when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId))
+ .thenReturn(Optional.of(direct));
+ when(membershipRepository.findGroupIdsForUser(userId)).thenReturn(List.of(groupId));
+ when(groupPermissionRepository.findAllByGroup_IdIn(List.of(groupId)))
+ .thenReturn(List.of(group));
+
+ var view = service.findFor(userId, datasourceId).orElseThrow();
+
+ assertThat(view.canWrite()).isTrue();
+ assertThat(view.deniedColumns()).containsExactly("public.users.ssn");
+ assertThat(com.bablsoft.accessflow.core.api.DeniedColumns.deniesColumn(
+ view.deniedColumns(), "public", "users", "ssn")).isTrue();
+ }
+
+ @Test
+ void groupColumnDenialBindsAMemberWhoseDirectGrantDeniesNothing() {
+ var userId = UUID.randomUUID();
+ var datasourceId = UUID.randomUUID();
+ var groupId = UUID.randomUUID();
+ var direct = newPermission(UUID.randomUUID(), userId, datasourceId);
+ direct.setCanRead(true);
+ var group = newGroupPermission(groupId, datasourceId);
+ group.setCanRead(true);
+ group.setDeniedColumns(new String[] {"users.ssn"});
when(permissionRepository.findByUser_IdAndDatasource_Id(userId, datasourceId))
.thenReturn(Optional.of(direct));
when(membershipRepository.findGroupIdsForUser(userId)).thenReturn(List.of(groupId));
when(groupPermissionRepository.findAllByGroup_IdIn(List.of(groupId)))
.thenReturn(List.of(group));
- assertThat(service.findFor(userId, datasourceId).orElseThrow().deniedColumns()).isEmpty();
+ assertThat(service.findFor(userId, datasourceId).orElseThrow().deniedColumns())
+ .containsExactly("users.ssn");
}
// ── Table / schema deny-lists (#939) ─────────────────────────────────────
diff --git a/docs/03-data-model.md b/docs/03-data-model.md
index dc836d55..76e2b9e8 100644
--- a/docs/03-data-model.md
+++ b/docs/03-data-model.md
@@ -327,12 +327,12 @@ Grants a **user group** access to a datasource; every member inherits the grant.
constraint and restriction columns clean), keyed on `group_id` instead of `user_id`. A user's
**effective** permission is the most-permissive union of their direct grant and every unexpired group
grant they belong to — resolved in `DefaultDatasourceUserPermissionLookupService` (flags OR-ed;
-allow-lists unioned; `restricted_columns` and `denied_columns` intersected so a column is masked — or
-denied — only when every contributing grant masks or denies it; each grant's `expires_at` honoured independently). Two deliberate inversions:
+allow-lists unioned; `restricted_columns` intersected so a column is masked only when every
+contributing grant masks it; each grant's `expires_at` honoured independently). Two deliberate inversions:
`row_limit_override` merges to the **smallest** non-null value so a wide group grant can never
-raise a tight per-user cap (#933), and `denied_schemas` / `denied_tables` merge to their **union**
-(#939), so a permissive grant can never lift another grant's denial and a group grant's denial binds
-every member. (`denied_columns` still intersects — an intentional, documented asymmetry.) A denial
+raise a tight per-user cap (#933), and the deny-lists — `denied_schemas` / `denied_tables` (#939) and
+`denied_columns` (#1099) — merge to their **union**, so a permissive grant can never lift another
+grant's denial and a group grant's denial binds every member. A denial
lives on its row, so revoking or expiring that row (including an attestation revoke) drops the denial
and can widen the user's effective access through their remaining grants. Mirrors how groups already drive
masking-reveal and row-security.
diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md
index 95a9abb0..7fb205a0 100644
--- a/docs/04-api-spec.md
+++ b/docs/04-api-spec.md
@@ -698,10 +698,10 @@ default `false`) grants the emergency break-glass submission mode on this dataso
Group-based access grants (AF-530). A grant to a **user group** is inherited by every member; a user's
**effective** access is the most-permissive union of their direct grant and every unexpired group grant
-for a group they belong to (flags OR-ed; allow-lists unioned; `restricted_columns` and `denied_columns`
-intersected so a column is masked — or denied — only when every contributing grant masks or denies it;
-`denied_schemas` / `denied_tables` **unioned** (#939), so a group grant's denial binds every member and no
-permissive grant can lift a denial from another). Same shape as the per-user list, keyed on
+for a group they belong to (flags OR-ed; allow-lists unioned; `restricted_columns` intersected so a
+column is masked only when every contributing grant masks it; `denied_schemas` / `denied_tables` (#939)
+and `denied_columns` (#1099) **unioned**, so a group grant's denial binds every member and no permissive
+grant can lift a denial from another). Same shape as the per-user list, keyed on
the group instead of a user:
```json
diff --git a/docs/05-backend.md b/docs/05-backend.md
index c268fb37..63105d0c 100644
--- a/docs/05-backend.md
+++ b/docs/05-backend.md
@@ -719,10 +719,10 @@ it applies equally with no allow-list at all.
(it contains `*` or `?` — an Elasticsearch / OpenSearch index pattern such as `sal*`) is denied by
any deny entry at all, since it may expand to a denied object.
- **Merge.** `DefaultDatasourceUserPermissionLookupService` unions both lists across the direct, group
- and JIT contributions — the inverse of every other merged field (booleans OR, allow-lists union with
- empty = all, restricted/denied columns intersect). A permissive group grant can never lift a denial
- from a direct grant, and a group grant's denial binds every member. `denied_columns` (#935) still
- merges by intersection; the asymmetry is intentional and documented, and may be aligned later.
+ and JIT contributions — the inverse of the other merged fields (booleans OR, allow-lists union with
+ empty = all, restricted columns intersect). A permissive group grant can never lift a denial from a
+ direct grant, and a group grant's denial binds every member. `denied_columns` merges the same way
+ (#1099).
- **Enforcement.** Everywhere the allow-list applies: `DatasourcePermissionVerifier.verify` (submission
and the recurring per-occurrence recheck, 403 `error.permission.table_denied`),
`DefaultBreakGlassService` (`BreakGlassNotPermittedException`), `DefaultQueryDryRunService` (403),
@@ -769,10 +769,10 @@ it applies equally with no allow-list at all.
permission: the most-permissive union of their direct `datasource_user_permissions` row and every
unexpired `datasource_group_permissions` grant for a group they belong to (group ids via
`UserGroupMembershipRepository.findGroupIdsForUser`). Booleans OR; `allowed_schemas`/`allowed_tables`
-merge to their union (any contributor with no allow-list ⇒ all allowed); `restricted_columns` and
-`denied_columns` (#935, compared normalised) merge to the **intersection** (a column is masked — or
-denied — only when every contributing grant masks or denies it); `denied_schemas` / `denied_tables`
-(#939) merge to their **union**, so no contributor can lift another's denial; expired grants
+merge to their union (any contributor with no allow-list ⇒ all allowed); `restricted_columns` merge to
+the **intersection** (a column is masked only when every contributing grant masks it); the deny-lists —
+`denied_schemas` / `denied_tables` (#939) and `denied_columns` (#935/#1099, compared normalised) — merge
+to their **union**, so no contributor can lift another's denial; expired grants
contribute nothing. Because `findFor` is the single choke-point every enforcement path already reads
through (proxy dry-run/sample-data, `access` materialiser, AI analyzer, text-to-SQL, workflow
submission/lifecycle/break-glass, `requestgroups`), group grants are honoured everywhere without touching
@@ -829,7 +829,8 @@ audited as `PERMISSION_GROUP_GRANTED` / `PERMISSION_GROUP_REVOKED` (connector si
schema must match only when both sides carry one) and whose column matches, or which a wildcard
reaches. It fails closed: any statement that was not column-analysed rejects every entry, and a DDL
statement also rejects every entry on a table it touches. OTHER returns nothing. `rejectedForWholeTable` answers the
- table preview, and `intersect` merges grants by the column an entry names rather than its spelling.
+ table preview, and `union` merges grants (#1099): a column stays denied when any grant denies it,
+ and overlapping spellings collapse to the broader entry (`users.ssn` covers `public.users.ssn`).
- **Enforcement.** The following all call it: `DatasourcePermissionVerifier.verify` (submission and
the recurring per-occurrence recheck, 403 `error.permission.column_not_allowed`),
`DefaultBreakGlassService` (`BreakGlassNotPermittedException`), `DefaultQueryDryRunService` (same
diff --git a/docs/07-security.md b/docs/07-security.md
index 86121cb0..e7d9b052 100644
--- a/docs/07-security.md
+++ b/docs/07-security.md
@@ -713,14 +713,13 @@ Beyond platform roles, every action against a customer database is validated aga
**effective permission** — the most-permissive union of their direct `datasource_user_permissions` row
and every unexpired `datasource_group_permissions` grant for a group they belong to (AF-530). Boolean
capabilities are OR-ed, allow-lists (`allowed_schemas`/`allowed_tables`) unioned, and `restricted_columns`
-and `denied_columns` intersected (a column is masked — or denied — only when **every** contributing grant
-masks or denies it; #935), each grant's `expires_at`
+intersected (a column is masked only when **every** contributing grant masks it), each grant's `expires_at`
honoured independently. Two fields are deliberate inversions. `row_limit_override`: the **smallest** non-null value
wins, so a wide group grant can never raise a tight per-user cap, and the proxy clamps it to the datasource
-cap and the global ceiling (#933). `denied_schemas` / `denied_tables` (#939) are **unioned**: a table stays
-denied when **any** contributing grant — direct, group or JIT — denies it, so a permissive group grant can
-never lift a denial from a direct grant, and a group grant's denial binds every member. (`denied_columns`
-still intersects — an intentional asymmetry, documented here so nobody "fixes" one without the other.) The union is computed once in `DefaultDatasourceUserPermissionLookupService.findFor`,
+cap and the global ceiling (#933). The deny-lists — `denied_schemas` / `denied_tables` (#939) and
+`denied_columns` (#935, #1099) — are **unioned**: a table or column stays denied when **any** contributing
+grant — direct, group or JIT — denies it, so a permissive group grant can never lift a denial from a direct
+grant, and a group grant's denial binds every member. The merge is computed once in `DefaultDatasourceUserPermissionLookupService.findFor`,
the single choke-point every enforcement path (proxy, JIT/break-glass gates, masking/row-security scoping,
`requestgroups` checks) reads through, so groups behave here exactly as they already do for
masking-reveal and row-security. Granting a group access lets an admin onboard a whole team without a
@@ -924,10 +923,11 @@ refuses the preview), and the access simulator, which reports the refusal as
restricted is rejected. A column that is only restricted keeps masking exactly as before.
- **Who it binds.** Like the table allow-list, `QUERY_ADMIN` holders skip the per-datasource gate at
submission. Break-glass enforces it for everyone. A JIT request cannot ask for denied columns; a
- JIT grant carries only those of the expiring direct row it replaces (#939), and the intersection merge
- means a grant that denies nothing lifts the deny for its holder. Entries meet by
- the column they name, not by spelling: `users.ssn` on one grant and `public.users.ssn` on another
- still deny `public.users.ssn`.
+ JIT grant carries only those of the expiring direct row it replaces (#939). Denied columns union
+ across grants (#1099), so a grant that denies nothing never lifts another grant's deny, and a group
+ grant's deny binds every member. Entries are compared by the column they name, not by spelling:
+ `users.ssn` on one grant and `public.users.ssn` on another merge to `users.ssn`, which denies `ssn`
+ in every schema's `users` table.
### Table / schema deny-lists (#939)
@@ -952,7 +952,7 @@ allow-list and **always wins**; it also works with no allow-list at all. All gat
refused at grant time (400), and again at the service layer for non-web callers.
- **Union merge.** Denials union across direct, group and JIT grants (the inverse of every other
merged field). A permissive grant cannot dissolve a denial, and a group grant's denial binds every
- member. `denied_columns` still merges by intersection — an intentional asymmetry for now.
+ member. `denied_columns` merges the same way (#1099).
- **Removing a grant can widen access.** A denial lives on its grant row, so revoking or expiring that
row (an attestation revoke, expiry, an admin revoke) removes the denial too, and another grant the user
still holds may then expose the table.
diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl
index d3c93531..36ae15ad 100644
--- a/help-corpus/corpus.jsonl
+++ b/help-corpus/corpus.jsonl
@@ -203,9 +203,9 @@
{"id":"5a567ae041ce49c2","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":1,"tokens":793,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 2 of 10)\n\n- Connection details. Name the datasource, then enter host, port, database name, service-account username, and password. SSL mode is pre-filled from the engine's own default rather than one global value — PostgreSQL starts at VERIFY_FULL, several NoSQL engines start at DISABLE, and the rest at REQUIRE. Check it rather than assuming it, and prefer VERIFY_FULL in production. For Cassandra and ScyllaDB the wizard also requires a local datacenter (the driver's load-balancing datacenter); this field is unused for every other engine. For Elasticsearch and OpenSearch the wizard offers an Authentication toggle — basic (username + password) or an API key — and the database-name field is optional. For Amazon DynamoDB the connection is cloud credentials, not host/port: the wizard hides host/port and instead asks for the AWS region (the database-name field), the access key ID and secret access key (the username/password fields), and an optional custom endpoint (DynamoDB Local / VPC; blank for AWS). For Neo4j the wizard takes the standard host/port/database/username/password (the SSL mode is encoded in the Bolt scheme) plus an optional Bolt connection URI (advanced) — a full bolt:// / neo4j+s:// URI for Neo4j Aura or clustered routing that, when set, overrides host/port. For Snowflake the wizard asks for the account host (.snowflakecomputing.com; the port field is hidden — always 443), the database, the user, a credential that is either a password or a PKCS#8 private key (PEM) for key-pair authentication, an optional private key passphrase (only for a passphrase-protected key, which is what Snowflake's own openssl instructions produce), and an optional JDBC URL override — a full jdbc:snowflake:// URL carrying warehouse / role / schema parameters. For Google BigQuery the connection is cloud credentials: the wizard hides host/port/username and asks for the GCP project (optionally project.dataset to pin a default dataset) and the service-account key JSON, plus an optional custom endpoint (BigQuery emulator). For Databricks SQL the wizard asks for the workspace host, the required warehouse HTTP path (/sql/1.0/warehouses/ from the warehouse's connection details), an optional Unity Catalog catalog, and a personal access token. The password (and API key / secret access key) are AES-256-GCM encrypted on write, decrypted once into the connection pool, and never returned in any GET response. When an external secrets manager is enabled (see Run & deploy), any credential field also"}
{"id":"ab70ab607bfeefd9","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":2,"tokens":406,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 3 of 10)\n\naccepts a secret reference — vault:/#, aws:[#jsonField], or azure: — stored as-is and resolved through the store at connection time; the form shows the syntax hints for whichever providers are enabled.\n\n- Connection test. AccessFlow opens a real JDBC connection, runs a heartbeat query, and surfaces any SSL / authentication errors before you save.\n\n- Configuration. Pick the Review plan that gates this datasource, toggle Require review on reads / writes, and (optionally) enable AI analysis and/or text-to-query + pick an AI configuration. The AI configuration is shared by both features, so it is required whenever either toggle is on. With text-to-query on, users can draft a query from a natural-language prompt in the editor — in the engine's native query language (SQL or a NoSQL query) — and the draft still flows through the normal review pipeline. Pool size, max rows, and statement timeout default sensibly but can be tightened per datasource. An optional Environment (Development, Test, Staging or Production) picks which SQL review ruleset applies to queries on this datasource — leave it unset to use the organization default."}
{"id":"14f5f59d8aefa7ee","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":3,"tokens":680,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 4 of 10)\n\nRead replicas & load balancing (optional). On the datasource\nsettings page, the Read replicas card takes any number of replica endpoints\n(JDBC URL plus optional username and password per endpoint — blank credentials reuse\nthe primary's). AccessFlow opens one connection pool per endpoint and load-balances\nevery query classified as SELECT round-robin across the healthy replicas;\nINSERT / UPDATE / DELETE / DDL and transactional BEGIN … COMMIT batches\nalways hit the primary. Replicas must use the same database engine as the primary\n(they reuse the primary's JDBC driver), and credentials are AES-256-GCM encrypted with\nthe same ENCRYPTION_KEY. Per-node health checks (a background prober plus\na circuit breaker) take a failed endpoint out of rotation for a cooldown\n(ACCESSFLOW_PROXY_REPLICA_COOLDOWN, default 30s) and its health shows on\nthe Datasource health dashboard; only when every replica is down does the\nread fall back to the primary, with one DATASOURCE_REPLICA_FALLBACK audit\nrow visible at /admin/audit-log. Click Test replica on any row\nto validate its URL + credentials live without persisting; leaving the password blank\nreuses that endpoint's saved password. Remove every endpoint to disable replica\nrouting. Replica pools reuse the same ACCESSFLOW_PROXY_* connection-pool\ntuning as the primary; the health checks are tuned by the\nACCESSFLOW_PROXY_REPLICA_* variables.\n\nSELECT result caching (optional). The settings page's\nPerformance card opts a datasource into a Redis-backed result cache for\nrepeated identical SELECTs, with a per-datasource TTL (1–86,400 seconds;\nblank uses ACCESSFLOW_PROXY_CACHE_DEFAULT_TTL, default 60s). Caching is\nsecurity-safe by construction — entries are keyed over the row-security-rewritten\nquery and the caller's masking scope, so masking and row-level security always apply —\nand any write executed through AccessFlow to a referenced table (including GDPR\nerasure and retention deletes) immediately invalidates the affected entries. Note that\nwrites made outside AccessFlow are invisible to the cache and are served\nstale until the TTL expires, so pick a TTL that matches how the datasource is written.\nACCESSFLOW_PROXY_CACHE_ENABLED=false switches the feature off\ndeployment-wide."}
-{"id":"3b0c42e1a4a75633","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/","anchor":"","title":"What is a datasource in AccessFlow?","section":"Reference","order":4,"tokens":714,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 5 of 10)\n\nGrant a user access. Open the datasource → Permissions tab and add a row per user — can read / can write / can DDL, allowed schemas, allowed tables, restricted columns (masked as *** in SELECT results), denied schemas and tables, and denied columns. Without a permission row, a user can't see or query the datasource at all. The allowed schemas / allowed tables lists are enforced when a query is submitted: every table it references — across joins, subqueries, CTEs, and BEGIN; …; COMMIT; batches — must appear in allowed tables or live in an allowed schema, or the query is rejected before it runs. Matching is case-insensitive, and an unqualified table name (FROM users) only matches an unqualified entry in allowed tables. Leave both fields empty to allow every table.\n\nDenied schemas and tables — everything except. Sometimes it is easier to say what a user may not touch. Allow the schema crm and deny the table crm.salary, and the user can query every table in crm — including tables created later — except crm.salary. A query that touches a denied table is refused before it runs:\n\n- A denial always wins. It is checked after the allowed schemas and tables, and it works on its own too, with no allowed list at all.\n\n- Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.\n\n- Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.\n\n- Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.\n\n- Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the other way round (below).\n\n- Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks."}
+{"id":"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":773,"text":"AccessFlow Docs > Reference > Datasources > What is a datasource in AccessFlow? (part 7 of 10)\n\n- Deny beats mask. A query that uses a column that is both restricted and denied is refused.\n\n- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing lifts the deny. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.\n\n- Supported datasources. PostgreSQL, MySQL, MariaDB, Oracle, SQL Server and custom JDBC. The field is not offered for NoSQL or cloud data-warehouse datasources.\n\nUsers only see the tables they are granted. The same lists decide what a user can browse. The schema tree in the query editor, autocomplete, AI query drafting and the AI agent tools show a user only the tables their allowed schemas and tables cover, leave out their denied schemas and tables, and leave out their denied columns. Administrators still see every table. If the same table name exists in more than one schema, write the entry as schema.table; an entry with just the name then shows neither table. Two places still list every table name on purpose: the just-in-time access request form, because asking for access to a table you cannot see yet is its whole purpose, and the automatic AI review of a submitted query, which reads the whole schema, so its comments may mention other tables.\n\nSchema explorer & ER diagram. Each datasource also carries\nSchema and ER diagram tabs alongside Configuration /\nPermissions. The schema view introspects the live database (cached and\nrefreshable from the UI) and renders a searchable object tree — one\nfilter matches across schema, table, and column names. Click any table to open a\nsample-data preview: a small, read-only set of rows fetched through the\nsame governance path as a real query, so row-level security filters the rows and column\nmasking redacts sensitive values (masked columns show ***, never the raw\nvalue). The same searchable tree and preview are available in the query editor sidebar.\nThe ER tab lays those tables out as a node-and-edge graph with PK/FK badges and column\ntypes so reviewers and operators can sanity-check what a query is touching without\nleaving AccessFlow.\n\n/datasources//settings → ER diagram. Auto-laid-out via dagre; node positions persist after manual edits."}
+{"id":"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."}
@@ -246,7 +246,7 @@
{"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":365,"text":"AccessFlow Docs > Reference > Users, roles & organizations > What are the user roles in AccessFlow? (part 3 of 3)\n\nGroup-based access grants. Rather than a row per person, an admin can grant\na user group access to a datasource or an API connector (same\nread / write / DDL / break-glass controls); every member inherits the grant, and adding\nsomeone to the group gives them access without a new grant. When a user has both a direct\ngrant and one or more group grants, their effective access is the most-permissive\nunion — capabilities are OR-ed, allow-lists merge, restricted-column masks and denied columns apply\nonly where every grant restricts or denies them, and each grant's expiry is honoured independently. Two\nthings work the other way. The row limit override: the smallest one wins, and it can only lower the\ndatasource's cap, never raise it. And denied schemas and tables add up: a table denied by any one\ngrant stays denied, so a wider group grant can never undo a denial, and a group's denial applies to\nevery member. The flip side: revoking or expiring the grant that carries a denial removes it,\nand the user's other grants may then reach the table."}
+{"id":"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":"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."}
diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json
index 96e387f3..e4360f8e 100644
--- a/help-corpus/manifest.json
+++ b/help-corpus/manifest.json
@@ -1,10 +1,10 @@
{
"schemaVersion": 1,
- "corpusVersion": "65a223d6ac42",
- "generatedAt": "2026-09-24T14:33:43.202Z",
- "sourceCommit": "cfae20ebca9c845b65aec9466fbd2bf2bd8ef0db",
+ "corpusVersion": "ee4dcdb21c05",
+ "generatedAt": "2026-09-24T15:47:40.882Z",
+ "sourceCommit": "9fc3865e3a86beca9d1ceb6ca262ef4ba2cf13b3",
"chunkCount": 588,
- "sha256": "65a223d6ac42b7d3164cd1ebf8d570eef3bbf36037ea318b999fdcec033dd1cc",
+ "sha256": "ee4dcdb21c05e710f16d609534cee78c1fd779df1964d0e7faabc12a66e5abde",
"quickReferenceSha256": "44221c19498905ac000898669ae79b5db00daf813cf0c9e48be04e706f00ed66",
"sources": [
{
@@ -205,7 +205,7 @@
"url": "https://accessflow.io/docs/configuration/datasources/",
"section": "Reference",
"chunks": 16,
- "sha256": "9639b26e745e192035dfa9d3f05ecf559b11255fb2d4e23c95b25f595db40132"
+ "sha256": "ffb7497560178f7174e00fc268d7ed163ffbf599fa94a761d143ee615237c986"
},
{
"path": "website/docs/configuration/notifications/index.html",
@@ -229,7 +229,7 @@
"url": "https://accessflow.io/docs/configuration/users-roles/",
"section": "Reference",
"chunks": 15,
- "sha256": "0eb9d8f708ff314a05a5e1662c0bb93f14302571ef4be08c7f6ac1e4cca80ef3"
+ "sha256": "79ee7f19ff2c40988442d5ee22f16633ce00c35b5fb020b6c362eeafb20fa39a"
},
{
"path": "website/docs/guides/ai-analysis/index.html",
diff --git a/website/docs/configuration/datasources/index.html b/website/docs/configuration/datasources/index.html
index a4ef5a54..fdfe97ec 100644
--- a/website/docs/configuration/datasources/index.html
+++ b/website/docs/configuration/datasources/index.html
@@ -358,7 +358,7 @@ What is a datasource in AccessFlow?
Name tables with their schema. An entry written as just salary denies a table called salary in every schema. crm.salary denies that table, and also a query that writes plain salary, because AccessFlow cannot tell which schema the database would pick.
Denying a schema. Every table in a denied schema is refused. While any schema is denied, the user must write table names with their schema (crm.customer, not customer); an unqualified name is refused for the same reason as above.
Hidden, not just refused. Denied tables and schemas disappear from the schema tree, autocomplete, the table preview, AI query drafting and the AI agent tools.
- Several grants add up. If a user holds their own grant and group grants, a table denied by any one of them stays denied — a wider group grant cannot undo it, and a group's denial applies to every member. Denied columns work the other way round (below).
+ 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).
Who it does not bind. Administrators with query-admin rights skip per-datasource permission checks.
Just-in-time access keeps denials. A just-in-time access request cannot add a denied schema or table, and approving one never removes a denial: denials from every grant add up, and when the approval replaces the user's own expiring grant, that grant's denials carry over to the new one.
How to write entries. A denied schema is a single name, such as hr — not analytics.hr. A denied table is table, schema.table, or schema.* for a whole schema. Other wildcards and empty parts are refused when you save the grant.
@@ -373,7 +373,7 @@ What is a datasource in AccessFlow?
What counts as using it. Selecting it, filtering, joining, grouping or sorting on it, or reading its whole table through SELECT *, TABLE t or a whole-row value such as row_to_json(t). Spell out the columns you need instead of *. The table preview on the Schema tab reads every column, so it is refused on a table with a denied column.
Joins. A column written without its table in a query that joins several tables is refused if any of those tables denies a column of that name. Prefix it with the table to avoid this.
Deny beats mask. A query that uses a column that is both restricted and denied is refused.
- Who it does not bind. Administrators (any role with query-admin rights) skip per-datasource permission checks, so a denied column does not stop them. If a user holds several grants on the datasource — their own and their groups' — a column stays denied only while every one of those grants denies it. A grant that denies nothing lifts the deny. A temporary just-in-time grant that replaces a user's own expiring grant keeps that grant's denied columns.
+ 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.
diff --git a/website/docs/configuration/users-roles/index.html b/website/docs/configuration/users-roles/index.html
index dc7f595b..c107c53a 100644
--- a/website/docs/configuration/users-roles/index.html
+++ b/website/docs/configuration/users-roles/index.html
@@ -587,12 +587,12 @@
What are the user roles in AccessFlow?
read / write / DDL / break-glass controls); every member inherits the grant, and adding
someone to the group gives them access without a new grant. When a user has both a direct
grant and one or more group grants, their effective access is the most-permissive
- union — capabilities are OR-ed, allow-lists merge, restricted-column masks and denied columns apply
- only where every grant restricts or denies them, and each grant's expiry is honoured independently. Two
+ 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 and tables add up: a table denied by any one
- grant stays denied, so a wider group grant can never undo a denial, and a group's denial applies to
- every member. The flip side: revoking or expiring the grant that carries a denial removes it,
+ datasource's cap, never raise it. And denied schemas, tables and columns 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.
Just-in-time (JIT) access requests