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 @@ -8,6 +8,7 @@
import com.bablsoft.accessflow.audit.api.AuditLogService;
import com.bablsoft.accessflow.audit.api.AuditResourceType;
import com.bablsoft.accessflow.core.api.AiAnalysisLookupService;
import com.bablsoft.accessflow.core.api.AllowedTables;
import com.bablsoft.accessflow.core.api.DatasourceLookupService;
import com.bablsoft.accessflow.core.api.DatasourceRef;
import com.bablsoft.accessflow.core.api.DatasourceUserPermissionLookupService;
Expand Down Expand Up @@ -59,6 +60,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;

@Service
Expand Down Expand Up @@ -304,18 +306,37 @@ private SqlParseResult parseQuery(UUID datasourceId, String sql) {
}

/**
* A member may not reach a table or schema (#939) or a column (#935) its submitter is denied,
* break-glass included.
* A member may not reach a table outside its submitter's allow-list, a table or schema they
* are denied (#939), nor a column they are denied (#935) — break-glass included, as for a
* standalone break-glass query. The allow-list rule is
* {@code DatasourcePermissionChecker.rejectedTables}: both lists empty means no restriction,
* and a bare entry covers only an unqualified reference.
*/
private void verifyDenials(RequestGroupItemEntity item,
DatasourceUserPermissionView permission) {
private void verifyTableAndColumnScope(RequestGroupItemEntity item,
DatasourceUserPermissionView permission) {
var allowedSchemas = AllowedTables.normalize(permission.allowedSchemas());
var allowedTables = AllowedTables.normalize(permission.allowedTables());
var restrictsTables = !allowedSchemas.isEmpty() || !allowedTables.isEmpty();
var tablesDenied = !DeniedTables.normalize(permission.deniedSchemas()).isEmpty()
|| !DeniedTables.normalize(permission.deniedTables()).isEmpty();
var columnsDenied = !DeniedColumns.normalize(permission.deniedColumns()).isEmpty();
if (!tablesDenied && !columnsDenied) {
if (!restrictsTables && !tablesDenied && !columnsDenied) {
return;
}
var parsed = parseQuery(item.getDatasourceId(), item.getSqlText());
if (restrictsTables) {
var outsideAllowList = new TreeSet<String>();
for (String table : parsed.referencedTables()) {
if (AllowedTables.coveringEntry(allowedSchemas, allowedTables, table) == null) {
outsideAllowList.add(table);
}
}
if (!outsideAllowList.isEmpty()) {
throw new RequestGroupPermissionException(
"Tables outside the allow-list referenced: "
+ String.join(", ", outsideAllowList));
}
}
var rejectedTables = DeniedTables.rejected(permission.deniedSchemas(),
permission.deniedTables(), parsed.referencedTables());
if (!rejectedTables.isEmpty()) {
Expand All @@ -338,7 +359,7 @@ private void validatePermission(RequestGroupItemEntity item, UUID submitterId, b
throw new RequestGroupPermissionException(
"Break-glass requires can_break_glass on every member target");
}
verifyDenials(item, perm.get());
verifyTableAndColumnScope(item, perm.get());
return;
}
if (admin) {
Expand All @@ -351,7 +372,7 @@ private void validatePermission(RequestGroupItemEntity item, UUID submitterId, b
throw new RequestGroupPermissionException(
"You are not permitted to run this query on the selected datasource");
}
verifyDenials(item, perm.get());
verifyTableAndColumnScope(item, perm.get());
} else {
var perm = apiConnectorPermissionLookupService.findFor(item.getApiConnectorId(), submitterId);
if (breakGlass) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,113 @@ private void stubDeniedColumnParse() {
java.util.Set.of("customer"), "ssn")), true));
}

@Test
void submitRejectsAMemberThatReferencesATableOutsideTheAllowList() {
var group = stubAllowListSubmit(List.of(), List.of("public.customers"), false,
"public.orders");

assertThatThrownBy(() -> submit(group, false))
.isInstanceOf(RequestGroupPermissionException.class)
.hasMessageContaining("public.orders");
verify(stateService, org.mockito.Mockito.never()).apply(any(), any());
}

@Test
void submitAcceptsAMemberWhoseTablesAreCoveredByTableOrSchemaEntries() {
var group = stubAllowListSubmit(List.of("sales"), List.of("public.customers"), false,
"public.customers", "sales.orders");

submit(group, false);

verify(stateService).apply(group, RequestGroupStatus.PENDING_AI);
}

@Test
void aBareAllowListEntryCoversOnlyAnUnqualifiedReference() {
var covered = stubAllowListSubmit(List.of(), List.of("orders"), false, "orders");
submit(covered, false);
verify(stateService).apply(covered, RequestGroupStatus.PENDING_AI);

var qualified = stubAllowListSubmit(List.of(), List.of("orders"), false, "public.orders");
assertThatThrownBy(() -> submit(qualified, false))
.isInstanceOf(RequestGroupPermissionException.class);
}

@Test
void emptyAllowListsAreUnrestrictedAndSkipTheParse() {
var group = draftGroup();
when(groupRepository.findByIdAndOrganizationId(group.getId(), orgId)).thenReturn(Optional.of(group));
when(itemRepository.findByGroupIdOrderBySequenceOrderAsc(group.getId()))
.thenReturn(List.of(allowListItem()));
when(datasourcePermissionLookupService.findFor(userId, datasourceId))
.thenReturn(Optional.of(dsPerm(true, false, false)));

submit(group, false);

verify(queryParser, org.mockito.Mockito.never()).parse(any(), any());
verify(stateService).apply(group, RequestGroupStatus.PENDING_AI);
}

@Test
void breakGlassSubmitAlsoRejectsATableOutsideTheAllowList() {
var group = stubAllowListSubmit(List.of("sales"), List.of(), true, "hr.salaries");

assertThatThrownBy(() -> submit(group, true))
.isInstanceOf(RequestGroupPermissionException.class)
.hasMessageContaining("hr.salaries");
verify(executionService, org.mockito.Mockito.never()).execute(any(), any(), any());
}

@Test
void adminSubmitIsNotBoundByTheAllowList() {
var group = draftGroup();
when(groupRepository.findByIdAndOrganizationId(group.getId(), orgId)).thenReturn(Optional.of(group));
when(itemRepository.findByGroupIdOrderBySequenceOrderAsc(group.getId()))
.thenReturn(List.of(allowListItem()));
when(datasourcePermissionLookupService.findFor(userId, datasourceId))
.thenReturn(Optional.of(allowListPerm(List.of(), List.of("public.customers"), false)));

service.submit(new SubmitRequestGroupCommand(group.getId(), orgId, userId, true, false, null,
"1.2.3.4", "ua"));

verify(queryParser, org.mockito.Mockito.never()).parse(any(), any());
verify(stateService).apply(group, RequestGroupStatus.PENDING_AI);
}

private RequestGroupEntity stubAllowListSubmit(List<String> schemas, List<String> tables,
boolean breakGlass, String... referenced) {
var group = draftGroup();
when(groupRepository.findByIdAndOrganizationId(group.getId(), orgId)).thenReturn(Optional.of(group));
when(itemRepository.findByGroupIdOrderBySequenceOrderAsc(group.getId()))
.thenReturn(List.of(allowListItem()));
when(datasourcePermissionLookupService.findFor(userId, datasourceId))
.thenReturn(Optional.of(allowListPerm(schemas, tables, breakGlass)));
lenient().when(datasourceLookupService.findById(datasourceId)).thenReturn(Optional.empty());
when(queryParser.parse(any(), any())).thenReturn(new SqlParseResult(QueryType.SELECT, false,
List.of("SELECT 1"), java.util.Set.of(referenced)));
return group;
}

private void submit(RequestGroupEntity group, boolean breakGlass) {
service.submit(new SubmitRequestGroupCommand(group.getId(), orgId, userId, false, breakGlass,
null, "1.2.3.4", "ua"));
}

private RequestGroupItemEntity allowListItem() {
var item = new RequestGroupItemEntity();
item.setTargetKind(com.bablsoft.accessflow.requestgroups.api.RequestGroupTargetKind.QUERY);
item.setDatasourceId(datasourceId);
item.setQueryType(QueryType.SELECT);
item.setSqlText("SELECT 1");
return item;
}

private DatasourceUserPermissionView allowListPerm(List<String> schemas, List<String> tables,
boolean breakGlass) {
return new DatasourceUserPermissionView(UUID.randomUUID(), userId, datasourceId, true, false,
false, breakGlass, schemas, tables, List.of(), List.of(), List.of(), List.of(), null, null);
}

@Test
void submitRecordsSqlReviewFindingsForEveryQueryMemberAndSkipsApiMembers() {
var group = draftGroup();
Expand Down
10 changes: 10 additions & 0 deletions docs/05-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -4686,6 +4686,16 @@ rows) and publishes `RequestGroupItemExecutedEvent`; the group publishes
permission for its target — `core.api.DatasourceUserPermissionLookupService` for query members, a new
`apigov.api` connector-permission lookup for API members. A **break-glass group**
(`submission_reason = EMERGENCY_ACCESS`) requires `can_break_glass` on **every** member target.
A `QUERY` member is also held to the permission's table scope, exactly as a standalone query is:
every referenced table must be covered by `allowed_schemas` / `allowed_tables` (matched through
`core.api.AllowedTables`, with `DatasourcePermissionChecker.rejectedTables` semantics — both lists
empty means unrestricted, a schema entry covers any table qualified with it, and a bare table entry
covers only an unqualified reference), no denied table or schema may be referenced (#939), and no
denied column may be referenced (#935). These checks bind break-glass groups too — standalone break-glass enforces the allow-list, and break-glass waives
approval, never data-protection controls — and neither binds `QUERY_ADMIN` holders, who skip the
per-datasource gate on this path as on standard submission. A miss is `RequestGroupPermissionException`
(403) and the group stays `DRAFT`. The query is parsed only when the permission carries an allow-list
or a deny list.

**Audit & realtime.** New `AuditResourceType.REQUEST_GROUP` and `AuditAction.REQUEST_GROUP_*` values
record the group lifecycle alongside each member's own query/API audit row. New
Expand Down
2 changes: 2 additions & 0 deletions docs/07-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,8 @@ Are allowed_schemas / allowed_tables set?
intersect with allow-list; reject (403, `error.permission.table_not_allowed`) on any miss.
Unqualified references match `allowed_tables` only when the bare name is listed —
a schemas-only allow-list does NOT cover them.
The same check binds every `QUERY` member of a request group, break-glass groups
included (`DefaultRequestGroupService.validatePermission`).
Violation → 403
↓
Are denied_schemas / denied_tables set? (#939 — checked with or without an allow-list)
Expand Down
Loading