Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ A glance at the day-to-day flows engineers and approvers actually use.

- **Proxy-first execution** — no user ever holds production credentials; the proxy holds them encrypted and opens connections only after approval. Single SQL statements run with autocommit; multi-statement INSERT/UPDATE/DELETE batches wrapped in `BEGIN; … COMMIT;` execute atomically inside one JDBC transaction (mixed SELECT/DML batches are rejected at parse time), with homogeneous INSERT runs collapsed into JDBC `executeBatch()` for bulk-load throughput. Optional **multi-replica read load balancing**: attach any number of replica endpoints to a datasource and SELECT traffic round-robins across the healthy ones — per-node health checks with circuit-breaker failover skip downed replicas, and only full replica-set exhaustion falls back to the primary (with an audit row). Optional **SELECT result caching**: opt a datasource into a Redis-backed result cache (per-datasource TTL) keyed over the security-rewritten query — masking and row-level security still apply — and invalidated on any proxied write to a referenced table.
- **Configurable review workflows** — per-datasource review plans, multi-stage sequential approval chains, optional auto-approve for reads, approval timeouts with auto-reject. Reviewers can be scoped **per-datasource** (directly or via groups) so different teams see only the queues that belong to them. Reviewers going away can set an **out-of-office delegation** naming a colleague to cover their review duty for a window — across queries, governed API calls, and grouped requests — with every decision recording both identities. A delegate can never act on the delegator's own requests, delegation never grants a permission they lack, and it does not chain. Plans can also **escalate** a request that nobody has decided on to the reviewers at its current stage plus your admins before the approval timeout auto-rejects it, and **nudge** those same reviewers on a cadence — both optional, both notify-only, so waiting never changes who may approve.
- **Policy-as-code routing** — ordered, attribute-based routing policies decide a query's path after AI analysis and before reviewers see it: **auto-approve**, **auto-reject**, **require N approvals**, or **escalate**. Conditions match on query type, referenced tables (glob), AI risk level / score, requester role or group, time-of-day / day-of-week, WHERE / LIMIT presence, the transactional flag, and the submission **client context** — source IP / CIDR, user-agent, time-since-last-approval, and CI/CD origin (API key or `X-AccessFlow-CI` header) — combined with AND / OR / NOT. Client-context conditions **fail closed** (missing context never auto-approves), so an off-network or stale-approval query escalates to stricter review instead. First match by priority wins; on no match the query falls through to the datasource's review plan. Every automated decision is recorded in the audit log.
- **Policy-as-code routing** — ordered, attribute-based routing policies decide a query's path after AI analysis and before reviewers see it: **auto-approve**, **auto-reject**, **require N approvals**, or **escalate**. Conditions match on query type, referenced tables (glob), AI risk level / score, requester role or group, time-of-day / day-of-week, WHERE / LIMIT presence, **query shape** (joins, set operations, subqueries, CTEs, GROUP BY / HAVING, aggregates, window functions), the transactional flag, and the submission **client context** — source IP / CIDR, user-agent, time-since-last-approval, and CI/CD origin (API key or `X-AccessFlow-CI` header) — combined with AND / OR / NOT. Client-context conditions **fail closed** (missing context never auto-approves), so an off-network or stale-approval query escalates to stricter review instead. First match by priority wins; on no match the query falls through to the datasource's review plan. Every automated decision is recorded in the audit log.
- **Policy simulator** — dry-run a draft routing, row-security, or masking policy against your own historical query traffic before you save it. AccessFlow replays the window **twice** — once against your current policies, once with the draft applied — and reports the difference between those two runs: how many past queries would change, which users would be affected, and which queries a row predicate would newly filter, deny, or reject outright. It is strictly read-only — no connection to your database, nothing executed, nothing persisted — and it names its own approximations (memberships are read as they are now; an engine that cannot classify a query shape offline is reported as *unclassifiable*, never as safe) instead of implying a precision the data does not have.
- **Deterministic SQL review rules (#860)** — a named rule catalog that judges every SQL query alongside the AI verdict and routing policies, and shows its findings **in the editor as you type**. Fourteen built-in rules derived from the parsed statement alone (missing `WHERE` on `UPDATE` / `DELETE`, an always-true `WHERE`, `SELECT *`, unbounded reads, cross joins, leading-wildcard `LIKE`, `DROP` / `TRUNCATE` / DDL, banned functions, protected tables by glob, DML outside a transaction), each at an admin-set **`OFF` / `WARN` / `BLOCK`** severity configured per **environment** (`DEVELOPMENT` / `TEST` / `STAGING` / `PRODUCTION`, plus an organisation default). **`BLOCK` escalates, it never rejects**: a blocking finding suppresses every auto-approve path and sends the query to a human reviewer; a routing auto-reject still rejects. Evaluated synchronously at submission so findings exist even when AI analysis is off or fails, rendered in each reader's language on the query detail, review queue, break-glass retro-review and request-group detail; break-glass runs record findings but are never gated by them, and non-relational engines report *not applicable*. See [`docs/19-sql-review.md`](https://github.com/bablsoft/accessflow/blob/main/docs/19-sql-review.md).
- **Just-in-time (JIT) access requests** — users self-request temporary, scoped access to a datasource (read/write/DDL, optional schema/table scope) or an API connection (read/write, optional operation allow-list) for an ISO-8601 duration. Requests flow through the same approval engine, a time-boxed permission is granted on approval, and a clustered scheduler auto-revokes it on expiry (admins can also revoke early). A grant can opt into **query pre-approval**: while it is active, queries it covers skip human review and are auto-approved with the grant recorded as the approval provenance — routing policies, high-risk AI verdicts, and behavioural anomalies still override.
- **Break-glass / emergency access** — a gated emergency path for when production is on fire and approvers are asleep. A per-user/per-datasource `can_break_glass` permission (required for everyone, including admins; time-boxed) lets a query **execute immediately, bypassing review** — still through every proxy guard (allow-list, masking, row-level security, row caps). Compensating controls: a mandatory justification, instant fanout to all org admins (incl. PagerDuty), a prominently-tagged audit row, and a **mandatory retro-review** an admin (never the submitter) must acknowledge on the `/admin/break-glass` log.
- **Dynamic data masking** — per-column masking policies (full, partial last-N, stable hash, email-preserving, format-preserving) with role / group / user **reveal** conditions evaluated per requester. Masking is applied at result-read time before results are serialized or stored, so unmasked values never persist; applied policy ids are recorded in the audit log. Extends the static `restricted_columns` masking; for a column that must never be read at all, a grant's `denied_columns` rejects any query that references it — including through `SELECT *` — before it runs (relational engines).
- **Row-level security** — per-table row predicates the proxy injects into the parsed SQL so a scoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for. Admins author a structured `column operator value` predicate where the value is a fixed literal or a `:user.*` variable (built-in id / email / role / groups, or an admin-set per-user attribute). Values are bound as JDBC parameters — never concatenated; predicates that can't be safely applied are rejected, never run unfiltered. Composes with column masking and the schema/table allow-list — and with table/schema **deny-lists** that always beat it ("all of `crm` except `crm.salary`", tables created later included).
- **Row-level security** — per-table row predicates the proxy injects into the parsed SQL so a scoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for. Admins author a structured `column operator value` predicate where the value is a fixed literal or a `:user.*` variable (built-in id / email / role / groups, or an admin-set per-user attribute). Values are bound as JDBC parameters — never concatenated; predicates that can't be safely applied are rejected, never run unfiltered. Composes with column masking and the schema/table allow-list — and with table/schema **deny-lists** that always beat it ("all of `crm` except `crm.salary`", tables created later included) — and with **query-shape deny-lists** on a grant, which refuse joins, unions, subqueries, CTEs, grouping, aggregates or window functions anywhere in a statement before it runs (relational engines, fail-closed).
- **Per-table row limits** — cap how many rows a SELECT may return from a specific table, for everyone or for chosen roles, groups or users, so two tables on one datasource (or two teams on one table) get different limits. A limit only ever lowers the cap set by the datasource and the access grant; a query across several limited tables takes the lowest one, and an unqualified table name still matches a schema-qualified policy. The policies that applied are recorded on the execution's audit entry.
- **Data classification tagging** — tag tables and columns as PII, PCI, PHI, GDPR, FINANCIAL, or SENSITIVE right in the schema explorer. Tagging a column auto-applies a masking policy, the AI analyzer raises a query's risk score when it touches a tagged object, and a derivation preview suggests a stricter review posture. Tags are audited and queryable org-wide as the evidence base for compliance reporting.
- **Automated sensitive-data discovery** — an opt-in per-datasource scanner samples column data through the same governed sampling path, detects sensitive values with local regex + checksum detectors (emails, credit-card PANs with Luhn, SSNs, IBANs, phone numbers) and optionally your bound AI analyzer (which only ever sees column names, types, and redacted samples), and **proposes** classification tags in a review worklist. Confirming a finding applies the tag — deriving masking automatically — while dismissing suppresses it permanently; scans and decisions are audited, and an on-demand "Scan now" complements the scheduled cadence. Proposals the scanner keeps sampling but no longer finds — the column was dropped, the data cleaned up, or masking added by hand — are marked **stale** after a few consecutive misses and leave the active worklist for a filtered bulk dismissal, reversibly: re-detection returns them to pending, and a run cut short by its table cap or time budget never ages proposals it did not look at.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ void materialize(UUID accessRequestId, UUID approvedByUserId) {
replaced.map(DatasourceUserPermissionView::deniedColumns).orElse(null),
replaced.map(DatasourceUserPermissionView::deniedSchemas).orElse(null),
replaced.map(DatasourceUserPermissionView::deniedTables).orElse(null),
replaced.map(DatasourceUserPermissionView::deniedShapes).orElse(null),
expiresAt,
entity.getId());
var granted = datasourceAdminService.grantPermission(entity.getDatasourceId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ private String toSnapshotJson(DatasourcePermissionView view) {
putStringArray(node, "denied_columns", view.deniedColumns());
putStringArray(node, "denied_schemas", view.deniedSchemas());
putStringArray(node, "denied_tables", view.deniedTables());
putStringArray(node, "denied_shapes", view.deniedShapes() == null ? null
: view.deniedShapes().stream().map(Enum::name).toList());
node.put("expires_at", view.expiresAt() != null ? view.expiresAt().toString() : null);
node.put("created_by", view.createdBy() != null ? view.createdBy().toString() : null);
node.put("created_at", view.createdAt() != null ? view.createdAt().toString() : null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ public record CreateDatasourceGroupPermissionCommand(
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
List<QueryShape> deniedShapes,
Instant expiresAt) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public record CreatePermissionCommand(
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
List<QueryShape> deniedShapes,
Instant expiresAt,
UUID accessGrantRequestId
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed class DatasourceAdminException extends RuntimeException
DatasourceConnectionTestException,
IllegalDatasourcePermissionException,
DeniedColumnsNotSupportedException,
DeniedShapesNotSupportedException,
MissingAiConfigForDatasourceException,
TableNotFoundException {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public record DatasourceGroupPermissionView(
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
List<QueryShape> deniedShapes,
Instant expiresAt,
UUID createdBy,
Instant createdAt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public record DatasourcePermissionContribution(
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
List<QueryShape> deniedShapes,
Integer rowLimitOverride,
Instant expiresAt,
UUID accessGrantRequestId) {
Expand All @@ -50,5 +51,6 @@ public record DatasourcePermissionContribution(
deniedColumns = deniedColumns == null ? List.of() : List.copyOf(deniedColumns);
deniedSchemas = deniedSchemas == null ? List.of() : List.copyOf(deniedSchemas);
deniedTables = deniedTables == null ? List.of() : List.copyOf(deniedTables);
deniedShapes = deniedShapes == null ? List.of() : List.copyOf(deniedShapes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public record DatasourcePermissionView(
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
List<QueryShape> deniedShapes,
Instant expiresAt,
UUID createdBy,
Instant createdAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ public interface DatasourceUserPermissionLookupService {
* direct grant (if any) and every unexpired group grant for a group they belong to (AF-530).
* Boolean flags are OR-ed; allow-lists (allowed schemas/tables) merge to their union (any
* contributor with no restriction ⇒ all allowed); the restricted-columns mask merges to the
* intersection (a column is masked only when every contributor masks it), and so do denied
* columns (#935). Two fields deliberately merge the other way: the row-limit override merges
* to the <b>smallest</b> non-null value (most restrictive), so a wide group grant can never
* raise a tight per-user cap, and is {@code null} only when no contributor sets one (#933);
* denied schemas and tables merge to their <b>union</b>, so no contributor can lift another's
* denial (#939). Expired grants contribute nothing;
* intersection (a column is masked only when every contributor masks it). Two kinds of field
* deliberately merge the other way: the row-limit override merges to the <b>smallest</b>
* non-null value (most restrictive), so a wide group grant can never raise a tight per-user
* cap, and is {@code null} only when no contributor sets one (#933); the deny-lists — denied
* schemas and tables (#939), columns (#1099) and query shapes (#940) — merge to their
* <b>union</b>, so no contributor can lift another's denial. Expired grants contribute nothing;
* returns empty when no unexpired grant applies.
*/
Optional<DatasourceUserPermissionView> findFor(UUID userId, UUID datasourceId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public record DatasourceUserPermissionView(
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
List<QueryShape> deniedShapes,
Integer rowLimitOverride,
Instant expiresAt) {
}
Loading
Loading