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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ A glance at the day-to-day flows engineers and approvers actually use.
- **Just-in-time (JIT) access requests** — users self-request temporary, scoped access to a datasource (read/write/DDL, optional schema/table scope) or an API connection (read/write, optional operation allow-list) for an ISO-8601 duration. Requests flow through the same approval engine, a time-boxed permission is granted on approval, and a clustered scheduler auto-revokes it on expiry (admins can also revoke early). A grant can opt into **query pre-approval**: while it is active, queries it covers skip human review and are auto-approved with the grant recorded as the approval provenance — routing policies, high-risk AI verdicts, and behavioural anomalies still override.
- **Break-glass / emergency access** — a gated emergency path for when production is on fire and approvers are asleep. A per-user/per-datasource `can_break_glass` permission (required for everyone, including admins; time-boxed) lets a query **execute immediately, bypassing review** — still through every proxy guard (allow-list, masking, row-level security, row caps). Compensating controls: a mandatory justification, instant fanout to all org admins (incl. PagerDuty), a prominently-tagged audit row, and a **mandatory retro-review** an admin (never the submitter) must acknowledge on the `/admin/break-glass` log.
- **Dynamic data masking** — per-column masking policies (full, partial last-N, stable hash, email-preserving, format-preserving) with role / group / user **reveal** conditions evaluated per requester. Masking is applied at result-read time before results are serialized or stored, so unmasked values never persist; applied policy ids are recorded in the audit log. Extends the static `restricted_columns` masking; for a column that must never be read at all, a grant's `denied_columns` rejects any query that references it — including through `SELECT *` — before it runs (relational engines).
- **Row-level security** — per-table row predicates the proxy injects into the parsed SQL so a scoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for. Admins author a structured `column operator value` predicate where the value is a fixed literal or a `:user.*` variable (built-in id / email / role / groups, or an admin-set per-user attribute). Values are bound as JDBC parameters — never concatenated; predicates that can't be safely applied are rejected, never run unfiltered. Composes with column masking and the schema/table allow-list.
- **Row-level security** — per-table row predicates the proxy injects into the parsed SQL so a scoped user only sees (SELECT) or affects (UPDATE/DELETE) the rows they are authorised for. Admins author a structured `column operator value` predicate where the value is a fixed literal or a `:user.*` variable (built-in id / email / role / groups, or an admin-set per-user attribute). Values are bound as JDBC parameters — never concatenated; predicates that can't be safely applied are rejected, never run unfiltered. Composes with column masking and the schema/table allow-list — and with table/schema **deny-lists** that always beat it ("all of `crm` except `crm.salary`", tables created later included).
- **Per-table row limits** — cap how many rows a SELECT may return from a specific table, for everyone or for chosen roles, groups or users, so two tables on one datasource (or two teams on one table) get different limits. A limit only ever lowers the cap set by the datasource and the access grant; a query across several limited tables takes the lowest one, and an unqualified table name still matches a schema-qualified policy. The policies that applied are recorded on the execution's audit entry.
- **Data classification tagging** — tag tables and columns as PII, PCI, PHI, GDPR, FINANCIAL, or SENSITIVE right in the schema explorer. Tagging a column auto-applies a masking policy, the AI analyzer raises a query's risk score when it touches a tagged object, and a derivation preview suggests a stricter review posture. Tags are audited and queryable org-wide as the evidence base for compliance reporting.
- **Automated sensitive-data discovery** — an opt-in per-datasource scanner samples column data through the same governed sampling path, detects sensitive values with local regex + checksum detectors (emails, credit-card PANs with Luhn, SSNs, IBANs, phone numbers) and optionally your bound AI analyzer (which only ever sees column names, types, and redacted samples), and **proposes** classification tags in a review worklist. Confirming a finding applies the tag — deriving masking automatically — while dismissing suppresses it permanently; scans and decisions are audited, and an on-demand "Scan now" complements the scheduled cadence. Proposals the scanner keeps sampling but no longer finds — the column was dropped, the data cleaned up, or masking added by hand — are marked **stale** after a few consecutive misses and leave the active worklist for a filtered bulk dismissal, reversibly: re-detection returns them to pending, and a run cut short by its table cap or time budget never ages proposals it did not look at.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
Expand Down Expand Up @@ -52,7 +53,9 @@ void materialize(UUID accessRequestId, UUID approvedByUserId) {
materializeConnectorGrant(entity, approvedByUserId, expiresAt);
return;
}
replaceExistingTimeBoxedPermission(entity);
// A replaced row's denials carry over (#939): a JIT approval widens capabilities and expiry,
// never lifts a denial an admin set — JIT requests cannot even ask for deny-lists.
var replaced = replaceExistingTimeBoxedPermission(entity);
var command = new CreatePermissionCommand(
entity.getRequesterId(),
entity.isCanRead(),
Expand All @@ -63,7 +66,9 @@ void materialize(UUID accessRequestId, UUID approvedByUserId) {
toList(entity.getAllowedSchemas()),
toList(entity.getAllowedTables()),
null,
null,
replaced.map(DatasourceUserPermissionView::deniedColumns).orElse(null),
replaced.map(DatasourceUserPermissionView::deniedSchemas).orElse(null),
replaced.map(DatasourceUserPermissionView::deniedTables).orElse(null),
expiresAt,
entity.getId());
var granted = datasourceAdminService.grantPermission(entity.getDatasourceId(),
Expand Down Expand Up @@ -108,13 +113,14 @@ private void requireNoStandingConnectorPermission(AccessGrantRequestEntity entit
});
}

private void replaceExistingTimeBoxedPermission(AccessGrantRequestEntity entity) {
private Optional<DatasourceUserPermissionView> replaceExistingTimeBoxedPermission(
AccessGrantRequestEntity entity) {
// JIT access manages the per-user datasource_user_permissions row specifically, so it must
// look at the direct grant only — never a group grant (whose id it could not revoke here).
var existing = permissionLookupService.findDirectFor(entity.getRequesterId(),
entity.getDatasourceId());
if (existing.isEmpty()) {
return;
return Optional.empty();
}
DatasourceUserPermissionView permission = existing.get();
if (permission.expiresAt() == null) {
Expand All @@ -126,6 +132,7 @@ private void replaceExistingTimeBoxedPermission(AccessGrantRequestEntity entity)
permission.id(), entity.getRequesterId(), entity.getDatasourceId(), entity.getId());
datasourceAdminService.revokePermission(entity.getDatasourceId(),
entity.getOrganizationId(), permission.id());
return existing;
}

private static List<String> toList(String[] values) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ private String toSnapshotJson(DatasourcePermissionView view) {
putStringArray(node, "allowed_tables", view.allowedTables());
putStringArray(node, "restricted_columns", view.restrictedColumns());
putStringArray(node, "denied_columns", view.deniedColumns());
putStringArray(node, "denied_schemas", view.deniedSchemas());
putStringArray(node, "denied_tables", view.deniedTables());
node.put("expires_at", view.expiresAt() != null ? view.expiresAt().toString() : null);
node.put("created_by", view.createdBy() != null ? view.createdBy().toString() : null);
node.put("created_at", view.createdAt() != null ? view.createdAt().toString() : null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ public record CreateDatasourceGroupPermissionCommand(
List<String> allowedTables,
List<String> restrictedColumns,
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
Instant expiresAt) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public record CreatePermissionCommand(
List<String> allowedTables,
List<String> restrictedColumns,
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
Instant expiresAt,
UUID accessGrantRequestId
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public record DatasourceGroupPermissionView(
List<String> allowedTables,
List<String> restrictedColumns,
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
Instant expiresAt,
UUID createdBy,
Instant createdAt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public record DatasourcePermissionContribution(
List<String> allowedTables,
List<String> restrictedColumns,
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
Integer rowLimitOverride,
Instant expiresAt,
UUID accessGrantRequestId) {
Expand All @@ -46,5 +48,7 @@ public record DatasourcePermissionContribution(
allowedTables = allowedTables == null ? List.of() : List.copyOf(allowedTables);
restrictedColumns = restrictedColumns == null ? List.of() : List.copyOf(restrictedColumns);
deniedColumns = deniedColumns == null ? List.of() : List.copyOf(deniedColumns);
deniedSchemas = deniedSchemas == null ? List.of() : List.copyOf(deniedSchemas);
deniedTables = deniedTables == null ? List.of() : List.copyOf(deniedTables);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public record DatasourcePermissionView(
List<String> allowedTables,
List<String> restrictedColumns,
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
Instant expiresAt,
UUID createdBy,
Instant createdAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ public interface DatasourceUserPermissionLookupService {
* direct grant (if any) and every unexpired group grant for a group they belong to (AF-530).
* Boolean flags are OR-ed; allow-lists (allowed schemas/tables) merge to their union (any
* contributor with no restriction ⇒ all allowed); the restricted-columns mask merges to the
* intersection (a column is masked only when every contributor masks it). The row-limit
* override is the one deliberate inversion: it merges to the <b>smallest</b> non-null value
* (most restrictive), so a wide group grant can never raise a tight per-user cap; it is
* {@code null} only when no contributor sets one (#933). Expired grants contribute nothing;
* intersection (a column is masked only when every contributor masks it), and so do denied
* columns (#935). Two fields deliberately merge the other way: the row-limit override merges
* to the <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;
* 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 @@ -16,6 +16,8 @@ public record DatasourceUserPermissionView(
List<String> allowedTables,
List<String> restrictedColumns,
List<String> deniedColumns,
List<String> deniedSchemas,
List<String> deniedTables,
Integer rowLimitOverride,
Instant expiresAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package com.bablsoft.accessflow.core.api;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;

/**
* The one matcher behind a permission's {@code denied_schemas} / {@code denied_tables} (#939), shared
* by the query gates, the dry-run, the table preview, request groups and the schema view, so they
* never disagree about a schema-qualified name. A denial always beats the allow-list.
*
* <p>Every rule fails closed, because the gate cannot know where the database resolves a name:
* a {@code denied_tables} entry matches a reference when either name is a dot-aligned suffix of the
* other ({@code salary} denies {@code crm.salary}; {@code crm.salary} denies a bare {@code salary}),
* and a {@code denied_schemas} entry matches any qualified reference carrying it as a non-final
* segment, and every unqualified reference — so the schema view can list {@code public.orders} while
* a bare {@code FROM orders} is refused: under a schema denial, users must schema-qualify.
*/
public final class DeniedTables {

/** One name, no dots and no wildcards: a pattern here could never match a segment. */
public static final String SCHEMA_ENTRY_PATTERN = "^[^.*?]*[^.*?\\s][^.*?]*$";

/** {@code table} or {@code schema.table} (any depth), or a whole schema as {@code schema.*}. */
public static final String TABLE_ENTRY_PATTERN =
"^[^.*?]*[^.*?\\s][^.*?]*(\\.[^.*?]*[^.*?\\s][^.*?]*)*(\\.\\*)?$";

private DeniedTables() {
}

/** Whether {@code entry} is a well-formed {@code denied_schemas} entry. */
public static boolean isValidSchemaEntry(String entry) {
return entry != null && entry.matches(SCHEMA_ENTRY_PATTERN);
}

/** Whether {@code entry} is a well-formed {@code denied_tables} entry. */
public static boolean isValidTableEntry(String entry) {
return entry != null && entry.matches(TABLE_ENTRY_PATTERN);
}

/** {@link AllowedTables#normalize}, with duplicates dropped. */
public static List<String> normalize(List<String> raw) {
var out = new ArrayList<String>();
for (String entry : AllowedTables.normalize(raw)) {
if (!out.contains(entry)) {
out.add(entry);
}
}
return List.copyOf(out);
}

/**
* Which entry denies {@code table}, or {@code null} when none does. Both lists and {@code table}
* must already be {@link #normalize}d.
*
* <p>Names are compared segment by segment from the right. A reference segment the database
* would fill in itself — the empty schema of SQL Server's {@code db..table} — matches anything,
* an Oracle {@code @dblink} suffix is ignored, and a reference that is a pattern rather than a
* name (an Elasticsearch {@code sal*} index) is denied by any entry at all, since it may expand
* to a denied object.
*/
public static String denyingEntry(List<String> deniedSchemas, List<String> deniedTables,
String table) {
if (table == null || (deniedSchemas.isEmpty() && deniedTables.isEmpty())) {
return null;
}
var reference = segments(table);
if (isPattern(table)) {
return deniedTables.isEmpty() ? deniedSchemas.get(0) : deniedTables.get(0);
}
var schemas = new ArrayList<>(deniedSchemas);
for (String entry : deniedTables) {
if (entry.endsWith(".*")) {
// "crm.*" in the table list means the schema — the way the UI displays one.
schemas.add(entry.substring(0, entry.length() - 2));
} else if (alignedFromTheRight(segments(entry), reference)) {
return entry;
}
}
return schemaDenial(schemas, reference);
}

private static String schemaDenial(List<String> deniedSchemas, String[] reference) {
if (deniedSchemas.isEmpty()) {
return null;
}
if (reference.length == 1) {
// Unqualified: the gate cannot tell which schema it resolves to.
return deniedSchemas.get(0);
}
for (int i = 0; i < reference.length - 1; i++) {
if (reference[i].isEmpty()) {
return deniedSchemas.get(0);
}
if (deniedSchemas.contains(reference[i])) {
return reference[i];
}
}
return null;
}

/**
* Whether the shorter name is the tail of the longer one: {@code salary} and {@code crm.salary}
* align, so do {@code crm.salary} and {@code db.crm.salary}. An empty reference segment matches
* any entry segment.
*/
private static boolean alignedFromTheRight(String[] entry, String[] reference) {
int overlap = Math.min(entry.length, reference.length);
for (int i = 1; i <= overlap; i++) {
var referenceSegment = reference[reference.length - i];
if (!referenceSegment.isEmpty() && !referenceSegment.equals(entry[entry.length - i])) {
return false;
}
}
return true;
}

private static String[] segments(String name) {
var at = name.indexOf('@');
var withoutLink = at > 0 ? name.substring(0, at) : name;
return withoutLink.split("\\.", -1);
}

private static boolean isPattern(String reference) {
return reference.indexOf('*') >= 0 || reference.indexOf('?') >= 0;
}

/** @return the referenced tables a denial reaches, sorted; empty when none is denied. */
public static SortedSet<String> rejected(List<String> rawDeniedSchemas,
List<String> rawDeniedTables,
Collection<String> referencedTables) {
var out = new TreeSet<String>();
var deniedSchemas = normalize(rawDeniedSchemas);
var deniedTables = normalize(rawDeniedTables);
if ((deniedSchemas.isEmpty() && deniedTables.isEmpty()) || referencedTables == null) {
return out;
}
for (String table : referencedTables) {
if (denyingEntry(deniedSchemas, deniedTables, AllowedTables.normalizeEntry(table))
!= null) {
out.add(table);
}
}
return out;
}

/**
* @return whether the introspected {@code schema.table} is denied — the schema view and the
* table preview. {@code schema} may be null, which reads as an unqualified reference.
*/
public static boolean deniesTable(List<String> rawDeniedSchemas, List<String> rawDeniedTables,
String schema, String table) {
var bare = AllowedTables.normalizeEntry(table);
if (bare == null) {
return false;
}
var normalizedSchema = AllowedTables.normalizeEntry(schema);
var qualified = normalizedSchema == null ? bare : normalizedSchema + "." + bare;
return denyingEntry(normalize(rawDeniedSchemas), normalize(rawDeniedTables), qualified)
!= null;
}

/** @return whether a whole schema is denied, so the schema view never lists it. */
public static boolean deniesSchema(List<String> rawDeniedSchemas, String schema) {
var normalized = AllowedTables.normalizeEntry(schema);
return normalized != null && normalize(rawDeniedSchemas).contains(normalized);
}
}
Loading
Loading