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
Original file line number Diff line number Diff line change
Expand Up @@ -86,35 +86,33 @@ public static SortedSet<String> rejected(List<String> 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<String> intersect(List<String> left, List<String> right) {
var out = new ArrayList<String>();
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<String> union(List<String> left, List<String> right) {
var all = new ArrayList<String>(normalize(left));
for (String entry : normalize(right)) {
if (!all.contains(entry)) {
all.add(entry);
}
}
var out = new ArrayList<String>(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]);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DatasourcePermissionContribution> parts) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -257,10 +257,7 @@ private static List<String> 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<String> intersect(
List<DatasourcePermissionContribution> parts,
Function<DatasourcePermissionContribution, List<String>> field) {
Expand All @@ -283,17 +280,13 @@ private static List<String> 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<String> intersectDenied(List<DatasourcePermissionContribution> parts) {
List<String> denied = null;
/** Like {@link #unionDenied}; overlapping spellings of a column keep the broader entry. */
private static List<String> unionDeniedColumns(List<DatasourcePermissionContribution> parts) {
List<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ void findForUnionsAllowListsAndIntersectsRestrictions() {
}

@Test
void findForIntersectsDeniedColumnsCaseInsensitively() {
void findForUnionsDeniedColumnsCaseInsensitively() {
var userId = UUID.randomUUID();
var datasourceId = UUID.randomUUID();
var groupId = UUID.randomUUID();
Expand All @@ -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();
Expand All @@ -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) ─────────────────────────────────────
Expand Down
10 changes: 5 additions & 5 deletions docs/03-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/04-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions docs/05-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading