Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ only covers cloud-specific, non-obvious caveats.
`HERMES_WEBUI_ALLOWED_ORIGINS` (upstream env name).
- A demo target DB `demo_shop` (same Postgres server, sample `customers`/`products`/`orders`)
exists for exercising connection/schema features without an external database.
- A multi-schema fixture DB `acme_erp` (schemas: `crm`, `sales`, `finance`, `inventory`,
`hr`, `marts`) exists for chat-access-policy and multi-schema tests. Seed with:
`sudo -u postgres psql -f docker/postgres/init/11_create_acme_erp.sql` then
`bash scripts/seed-acme-erp.sh` (registers `ACME ERP (Multi-Schema)` when backend auth
is disabled or you have an admin session cookie).

### Non-obvious setup caveats (each cost real debugging time)

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,13 @@ public QueryGuardDecision enforcePreExecution(
return QueryGuardDecision.allow(policy);
}

Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> protectedObjects = policyService.buildProtectionDescriptors(
connectionId,
new ArrayList<>(policy.blockedSensitivityCategories()),
new ArrayList<>(policy.deniedTables()),
new ArrayList<>(policy.deniedColumns())
);
Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> protectedObjects =
policyService.buildProtectionDescriptors(policy);

try {
Statement parsed = CCJSqlParserUtil.parse(queryRequest.getQuery());
if (parsed instanceof Select select && select.getPlainSelect() != null) {
enforceAllowedSchemas(select.getPlainSelect(), policy.allowedSchemas());
QueryInspection inspection = inspectPlainSelect(select.getPlainSelect(), protectedObjects);
if (inspection.selectsWildcardFromProtectedTable || inspection.rawProtectedColumnsSelected) {
logPolicyEvent(SecurityEventType.CHAT_ACCESS_POLICY_BLOCKED, executionContext.actorUsername(), connectionId, "sql_blocked", Map.of(
Expand Down Expand Up @@ -169,22 +166,21 @@ public QueryResult redactResult(
return result;
}

Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> descriptors = policyService.buildProtectionDescriptors(
connectionId,
new ArrayList<>(policy.blockedSensitivityCategories()),
new ArrayList<>(policy.deniedTables()),
new ArrayList<>(policy.deniedColumns())
);
Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> descriptors =
policyService.buildProtectionDescriptors(policy);

if (result.getColumns() == null || result.getRows() == null) {
return result;
}

Set<String> protectedColumnNames = new LinkedHashSet<>();
descriptors.values().forEach(descriptor -> {
protectedColumnNames.addAll(descriptor.restrictedColumns().stream().map(value -> value.toLowerCase(Locale.ROOT)).toList());
String qualifiedTable = descriptor.qualifiedTableName();
descriptor.restrictedColumns().forEach(column ->
protectedColumnNames.add(normalizeName(qualifiedTable + "." + column))
);
if (descriptor.protectWholeTable()) {
protectedColumnNames.add(descriptor.tableName().toLowerCase(Locale.ROOT));
protectedColumnNames.add(normalizeName(qualifiedTable));
}
});

Expand Down Expand Up @@ -234,16 +230,53 @@ private boolean mentionsProtectedTables(String normalized, ConnectionChatAccessP
return policy.impactedTables().stream().anyMatch(table -> normalized.contains(table.toLowerCase(Locale.ROOT)));
}

private void enforceAllowedSchemas(PlainSelect select, Set<String> allowedSchemas) {
if (allowedSchemas == null || allowedSchemas.isEmpty()) {
return;
}
Map<String, String> aliasToTable = buildAliasMap(select);
Set<String> referencedSchemas = new LinkedHashSet<>();
collectReferencedSchemas(select.getFromItem(), aliasToTable, referencedSchemas);
if (select.getJoins() != null) {
for (Join join : select.getJoins()) {
collectReferencedSchemas(join.getRightItem(), aliasToTable, referencedSchemas);
}
}
for (String schema : referencedSchemas) {
if (!allowedSchemas.contains(normalizeName(schema))) {
throw new UserDataAccessPolicyException(
"This query references schema '" + schema + "' which is outside your allowed schema scope.",
"POLICY_SCHEMA_BLOCKED"
);
}
}
}

private void collectReferencedSchemas(FromItem fromItem, Map<String, String> aliasToTable, Set<String> schemas) {
if (fromItem instanceof Table table) {
String schema = table.getSchemaName();
if (schema != null && !schema.isBlank()) {
schemas.add(schema);
return;
}
String qualified = aliasToTable.get(normalizeName(table.getName()));
if (qualified != null && qualified.contains(".")) {
schemas.add(qualified.substring(0, qualified.indexOf('.')));
}
}
}

private boolean containsDangerousProtectedReference(
String normalizedSql,
Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> protectedObjects
) {
for (ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor : protectedObjects.values()) {
if (descriptor.protectWholeTable() && normalizedSql.contains(descriptor.tableName().toLowerCase(Locale.ROOT))) {
String qualified = normalizeName(descriptor.qualifiedTableName());
if (descriptor.protectWholeTable() && normalizedSql.contains(qualified)) {
return true;
}
for (String column : descriptor.restrictedColumns()) {
if (normalizedSql.contains(column.toLowerCase(Locale.ROOT))) {
if (normalizedSql.contains(qualified + "." + normalizeName(column))) {
return true;
}
}
Expand All @@ -265,18 +298,22 @@ private QueryInspection inspectPlainSelect(
select.getSelectItems().forEach(item -> {
Expression expression = item.getExpression();
if (expression instanceof AllColumns) {
inspection.selectsWildcardFromProtectedTable = !protectedObjects.isEmpty();
inspection.reason = "SELECT * touches protected objects";
inspection.protectedTables.addAll(protectedObjects.values().stream().map(ConnectionChatAccessPolicyService.ProtectionDescriptor::tableName).toList());
String fromTable = resolveDefaultTableName(select, aliasToTable);
ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor = lookupDescriptor(protectedObjects, fromTable);
if (descriptor != null && (descriptor.protectWholeTable() || !descriptor.restrictedColumns().isEmpty())) {
inspection.selectsWildcardFromProtectedTable = true;
inspection.reason = "SELECT * touches protected objects";
inspection.protectedTables.add(descriptor.qualifiedTableName());
}
return;
}
if (expression instanceof AllTableColumns allTableColumns) {
String tableName = resolveTableName(allTableColumns.getTable(), aliasToTable);
ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor = protectedObjects.get(normalizeName(tableName));
String tableName = resolveQualifiedTableName(allTableColumns.getTable(), aliasToTable);
ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor = lookupDescriptor(protectedObjects, tableName);
if (descriptor != null) {
inspection.selectsWildcardFromProtectedTable = true;
inspection.reason = "SELECT table.* touches protected table";
inspection.protectedTables.add(tableName);
inspection.protectedTables.add(descriptor.qualifiedTableName());
}
return;
}
Expand All @@ -285,15 +322,15 @@ private QueryInspection inspectPlainSelect(
collectColumns(expression, referencedColumns, aliasToTable, defaultTableName);
boolean aggregateExpression = containsAggregate(expression);
for (ColumnReference reference : referencedColumns) {
ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor = protectedObjects.get(normalizeName(reference.tableName()));
ConnectionChatAccessPolicyService.ProtectionDescriptor descriptor = lookupDescriptor(protectedObjects, reference.tableName());
if (descriptor == null) {
continue;
}
boolean protectedColumn = descriptor.protectWholeTable()
|| descriptor.restrictedColumns().stream().anyMatch(column -> normalizeName(column).equals(normalizeName(reference.columnName())));
if (protectedColumn) {
inspection.protectedTables.add(reference.tableName());
inspection.protectedColumns.add(reference.tableName() + "." + reference.columnName());
inspection.protectedTables.add(descriptor.qualifiedTableName());
inspection.protectedColumns.add(descriptor.qualifiedTableName() + "." + reference.columnName());
if (!aggregateExpression) {
inspection.rawProtectedColumnsSelected = true;
inspection.reason = "Raw protected column selected";
Expand All @@ -310,12 +347,12 @@ private String resolveDefaultTableName(PlainSelect select, Map<String, String> a
}
Set<String> concreteTables = new LinkedHashSet<>();
if (select.getFromItem() instanceof Table table) {
concreteTables.add(resolveTableName(table, aliasToTable));
concreteTables.add(resolveQualifiedTableName(table, aliasToTable));
}
if (select.getJoins() != null) {
for (Join join : select.getJoins()) {
if (join.getRightItem() instanceof Table table) {
concreteTables.add(resolveTableName(table, aliasToTable));
concreteTables.add(resolveQualifiedTableName(table, aliasToTable));
}
}
}
Expand All @@ -335,9 +372,16 @@ private Map<String, String> buildAliasMap(PlainSelect select) {

private void extractAlias(FromItem fromItem, Map<String, String> aliasMap) {
if (fromItem instanceof Table table) {
aliasMap.put(normalizeName(table.getName()), table.getName());
String qualified;
if (table.getSchemaName() != null && !table.getSchemaName().isBlank()) {
qualified = ConnectionChatAccessPolicyService.qualifyTable(table.getSchemaName(), table.getName());
} else {
qualified = table.getName();
}
aliasMap.put(normalizeName(table.getName()), qualified);
aliasMap.put(normalizeName(qualified), qualified);
if (table.getAlias() != null) {
aliasMap.put(normalizeName(table.getAlias().getName()), table.getName());
aliasMap.put(normalizeName(table.getAlias().getName()), qualified);
}
} else if (fromItem instanceof ParenthesedSelect parenthesedSelect
&& parenthesedSelect.getAlias() != null) {
Expand All @@ -355,7 +399,7 @@ private void collectColumns(
return;
}
if (expression instanceof Column column) {
String resolvedTableName = resolveTableName(column.getTable(), aliasToTable);
String resolvedTableName = resolveQualifiedTableName(column.getTable(), aliasToTable);
if (resolvedTableName.isBlank()) {
resolvedTableName = defaultTableName;
}
Expand All @@ -380,13 +424,38 @@ private void collectColumns(
}
}

private String resolveTableName(Table table, Map<String, String> aliasToTable) {
private String resolveQualifiedTableName(Table table, Map<String, String> aliasToTable) {
if (table == null || table.getName() == null) {
return "";
}
if (table.getSchemaName() != null && !table.getSchemaName().isBlank()) {
return ConnectionChatAccessPolicyService.qualifyTable(table.getSchemaName(), table.getName());
}
return aliasToTable.getOrDefault(normalizeName(table.getName()), table.getName());
}

private ConnectionChatAccessPolicyService.ProtectionDescriptor lookupDescriptor(
Map<String, ConnectionChatAccessPolicyService.ProtectionDescriptor> protectedObjects,
String tableRef
) {
if (tableRef == null || tableRef.isBlank()) {
return null;
}
ConnectionChatAccessPolicyService.ProtectionDescriptor direct = protectedObjects.get(normalizeName(tableRef));
if (direct != null) {
return direct;
}
if (!tableRef.contains(".")) {
List<ConnectionChatAccessPolicyService.ProtectionDescriptor> matches = protectedObjects.values().stream()
.filter(descriptor -> normalizeName(descriptor.tableName()).equals(normalizeName(tableRef)))
.toList();
if (matches.size() == 1) {
return matches.getFirst();
}
}
return null;
}

private boolean containsAggregate(Expression expression) {
if (expression instanceof Function function) {
String name = function.getName();
Expand All @@ -409,11 +478,9 @@ private boolean shouldRedactColumn(String column, Set<String> protectedColumnNam
if (protectedColumnNames.contains(normalized)) {
return true;
}
if (normalized.contains(".")) {
String bare = normalized.substring(normalized.indexOf('.') + 1);
return protectedColumnNames.contains(bare);
}
return false;
return protectedColumnNames.stream().anyMatch(protectedName ->
protectedName.endsWith("." + normalized) || protectedName.equals(normalized)
);
}

private Object redactValue(Object value) {
Expand Down
Loading
Loading