Skip to content

Commit 07772b4

Browse files
fix: parse schema-scoped column constraints from policy text
Replace the amount/currency heuristic with generic deny/allow clauses that match SQL type families and actual schema column names, still limited to allowed schemas. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 7dd9d62 commit 07772b4

2 files changed

Lines changed: 198 additions & 24 deletions

File tree

backend/src/main/java/com/dbaagent/service/ConnectionChatAccessPolicyService.java

Lines changed: 155 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ public class ConnectionChatAccessPolicyService {
4040
"access\\s+only\\s+to\\s+(?:schema\\s+)?([a-z_][a-z0-9_]*)",
4141
Pattern.CASE_INSENSITIVE
4242
);
43+
private static final Pattern DENY_CLAUSE_PATTERN = Pattern.compile(
44+
"(?:cannot|can't|must not|do not|don't|never)\\s+(?:query|access|see|select|read|use|return|expose)\\s+(.+?)(?=\\s+(?:but|except|however|strictly)\\b|[.;]|$)"
45+
+ "|(?:redact|block|deny|hide)\\s+(.+?)(?=\\s+(?:but|except|however)\\b|[.;]|$)",
46+
Pattern.CASE_INSENSITIVE | Pattern.DOTALL
47+
);
48+
private static final Pattern ALLOW_CLAUSE_PATTERN = Pattern.compile(
49+
"(?:but\\s+|except(?:\\s+that)?\\s+)?\\bcan\\s+(?:query|access|see|select|read|use|return)\\s+(.+?)(?=\\s+(?:strictly)\\b|[.;]|$)",
50+
Pattern.CASE_INSENSITIVE | Pattern.DOTALL
51+
);
52+
private static final List<TypeFamily> TYPE_FAMILIES = List.of(
53+
new TypeFamily("integer", Set.of("int", "integer", "bigint", "smallint", "tinyint", "serial", "bigserial", "int2", "int4", "int8")),
54+
new TypeFamily("float", Set.of("float", "double", "real", "numeric", "decimal", "number", "money", "float4", "float8")),
55+
new TypeFamily("string", Set.of("varchar", "character varying", "character", "char", "text", "clob", "uuid", "json", "jsonb", "string", "citext")),
56+
new TypeFamily("boolean", Set.of("bool", "boolean")),
57+
new TypeFamily("temporal", Set.of("date", "time", "timestamp", "timestamptz", "datetime", "interval"))
58+
);
4359

4460
private final ConnectionChatAccessPolicyRepository policyRepository;
4561
private final TableClassificationRepository tableClassificationRepository;
@@ -216,7 +232,7 @@ private ParsedPolicy parsePolicy(String connectionId, String plainEnglishPolicy)
216232
});
217233
}
218234
}
219-
addNumericAmountDenials(normalized, allowedSchemas, schemaMetadata, deniedColumns);
235+
applyColumnConstraints(normalized, allowedSchemas, schemaMetadata, deniedColumns);
220236
}
221237

222238
Map<String, ProtectionDescriptor> descriptors = buildProtectionDescriptors(
@@ -395,46 +411,162 @@ private Map<String, List<String>> indexSchemasByBareTable(SchemaMetadata schemaM
395411
return schemasByBareTable;
396412
}
397413

398-
private void addNumericAmountDenials(
414+
private void applyColumnConstraints(
399415
String normalized,
400416
Set<String> allowedSchemas,
401417
SchemaMetadata schemaMetadata,
402418
Set<String> deniedColumns
403419
) {
404-
if (!containsAny(normalized, "integer", "float", "numeric", "decimal", "int")
405-
|| !containsAny(normalized, "amount")) {
420+
Set<String> knownColumnNames = collectColumnNames(schemaMetadata, allowedSchemas);
421+
List<ColumnConstraint> denials = extractConstraints(normalized, DENY_CLAUSE_PATTERN, knownColumnNames);
422+
List<ColumnConstraint> allowances = extractConstraints(normalized, ALLOW_CLAUSE_PATTERN, knownColumnNames);
423+
if (denials.isEmpty()) {
406424
return;
407425
}
408-
boolean allowCurrencyStrings = containsAny(normalized, "currency")
409-
&& containsAny(normalized, "string", "varchar", "text", "code");
410426

411427
for (TableMetadata table : schemaMetadata.getTables()) {
412-
if (!schemaInScope(table.getSchema(), allowedSchemas)) {
413-
continue;
414-
}
415-
if (table.getColumns() == null) {
428+
if (!schemaInScope(table.getSchema(), allowedSchemas) || table.getColumns() == null) {
416429
continue;
417430
}
418431
String qualifiedTable = qualifyTable(table.getSchema(), table.getName());
419432
for (ColumnMetadata column : table.getColumns()) {
420-
String columnName = normalizeName(column.getName());
421-
if (!columnName.contains("amount")) {
422-
continue;
433+
boolean denied = denials.stream().anyMatch(constraint -> constraint.matches(column));
434+
boolean allowed = allowances.stream().anyMatch(constraint -> constraint.matches(column));
435+
if (denied && !allowed) {
436+
deniedColumns.add(qualifiedTable + "." + column.getName());
423437
}
424-
if (allowCurrencyStrings && columnName.contains("currency")) {
425-
continue;
438+
}
439+
}
440+
}
441+
442+
private List<ColumnConstraint> extractConstraints(String normalized, Pattern clausePattern, Set<String> knownColumnNames) {
443+
List<ColumnConstraint> constraints = new ArrayList<>();
444+
Matcher matcher = clausePattern.matcher(normalized);
445+
while (matcher.find()) {
446+
String snippet = firstNonBlank(matcher);
447+
if (snippet == null) {
448+
continue;
449+
}
450+
LinkedHashSet<String> typeKeys = new LinkedHashSet<>();
451+
for (TypeFamily family : TYPE_FAMILIES) {
452+
if (family.mentionedIn(snippet)) {
453+
typeKeys.add(family.key());
426454
}
427-
String dataType = normalizeName(column.getDataType());
428-
if (dataType.contains("int")
429-
|| dataType.contains("numeric")
430-
|| dataType.contains("decimal")
431-
|| dataType.contains("float")
432-
|| dataType.contains("double")
433-
|| dataType.contains("real")) {
434-
deniedColumns.add(qualifiedTable + "." + column.getName());
455+
}
456+
LinkedHashSet<String> nameTokens = new LinkedHashSet<>();
457+
knownColumnNames.stream()
458+
.sorted((left, right) -> Integer.compare(right.length(), left.length()))
459+
.filter(name -> containsWholeWord(snippet, name))
460+
.forEach(nameTokens::add);
461+
if (!typeKeys.isEmpty() || !nameTokens.isEmpty()) {
462+
constraints.add(new ColumnConstraint(typeKeys, nameTokens));
463+
}
464+
}
465+
return constraints;
466+
}
467+
468+
private Set<String> collectColumnNames(SchemaMetadata schemaMetadata, Set<String> allowedSchemas) {
469+
LinkedHashSet<String> names = new LinkedHashSet<>();
470+
if (schemaMetadata == null || schemaMetadata.getTables() == null) {
471+
return names;
472+
}
473+
for (TableMetadata table : schemaMetadata.getTables()) {
474+
if (!schemaInScope(table.getSchema(), allowedSchemas) || table.getColumns() == null) {
475+
continue;
476+
}
477+
for (ColumnMetadata column : table.getColumns()) {
478+
String name = normalizeName(column.getName());
479+
if (!name.isBlank() && !isTypeToken(name)) {
480+
names.add(name);
435481
}
436482
}
437483
}
484+
return names;
485+
}
486+
487+
private boolean isTypeToken(String name) {
488+
return TYPE_FAMILIES.stream().anyMatch(family -> family.aliases().contains(name) || family.key().equals(name));
489+
}
490+
491+
private String firstNonBlank(Matcher matcher) {
492+
for (int i = 1; i <= matcher.groupCount(); i++) {
493+
String group = matcher.group(i);
494+
if (group != null && !group.isBlank()) {
495+
return group.toLowerCase(Locale.ROOT);
496+
}
497+
}
498+
return null;
499+
}
500+
501+
private boolean containsWholeWord(String haystack, String needle) {
502+
if (haystack == null || needle == null || needle.isBlank()) {
503+
return false;
504+
}
505+
return Pattern.compile("\\b" + Pattern.quote(needle) + "\\b", Pattern.CASE_INSENSITIVE)
506+
.matcher(haystack)
507+
.find();
508+
}
509+
510+
private record TypeFamily(String key, Set<String> aliases) {
511+
boolean mentionedIn(String snippet) {
512+
if (containsWholeWordStatic(snippet, key)) {
513+
return true;
514+
}
515+
return aliases.stream().anyMatch(alias -> containsWholeWordStatic(snippet, alias));
516+
}
517+
518+
boolean matchesDataType(String dataType) {
519+
String normalized = dataType == null
520+
? ""
521+
: dataType.toLowerCase(Locale.ROOT).replaceAll("\\([^)]*\\)", " ").trim();
522+
if (normalized.isBlank()) {
523+
return false;
524+
}
525+
if (containsWholeWordStatic(normalized, key) || normalized.equals(key)) {
526+
return true;
527+
}
528+
return aliases.stream().anyMatch(alias ->
529+
containsWholeWordStatic(normalized, alias) || normalized.equals(alias)
530+
);
531+
}
532+
533+
private static boolean containsWholeWordStatic(String haystack, String needle) {
534+
if (haystack == null || needle == null || needle.isBlank()) {
535+
return false;
536+
}
537+
return Pattern.compile("\\b" + Pattern.quote(needle) + "\\b", Pattern.CASE_INSENSITIVE)
538+
.matcher(haystack)
539+
.find();
540+
}
541+
}
542+
543+
private record ColumnConstraint(Set<String> typeKeys, Set<String> nameTokens) {
544+
boolean matches(ColumnMetadata column) {
545+
if ((typeKeys == null || typeKeys.isEmpty()) && (nameTokens == null || nameTokens.isEmpty())) {
546+
return false;
547+
}
548+
boolean typeOk = typeKeys == null || typeKeys.isEmpty()
549+
|| typeKeys.stream().anyMatch(key -> TYPE_FAMILIES.stream()
550+
.filter(family -> family.key().equals(key))
551+
.anyMatch(family -> family.matchesDataType(column.getDataType())));
552+
boolean nameOk = nameTokens == null || nameTokens.isEmpty()
553+
|| nameTokens.stream().anyMatch(token -> columnNameMatches(column.getName(), token));
554+
return typeOk && nameOk;
555+
}
556+
557+
private static boolean columnNameMatches(String columnName, String token) {
558+
String column = columnName == null ? "" : columnName.trim().replace("\"", "").replace("`", "").toLowerCase(Locale.ROOT);
559+
String needle = token == null ? "" : token.trim().toLowerCase(Locale.ROOT);
560+
if (column.isBlank() || needle.isBlank()) {
561+
return false;
562+
}
563+
if (column.equals(needle)) {
564+
return true;
565+
}
566+
return column.startsWith(needle + "_")
567+
|| column.endsWith("_" + needle)
568+
|| column.contains("_" + needle + "_");
569+
}
438570
}
439571

440572
private Set<String> extractAllowedSchemas(String normalized, SchemaMetadata schemaMetadata) {

backend/src/test/java/com/dbaagent/service/ConnectionChatAccessPolicyServiceTest.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ void previewPolicy_normalizesPiiAndFinancialRestrictions() {
8787
}
8888

8989
@Test
90-
void previewPolicy_scopesNumericAmountRedactionToAllowedSchemaOnly() throws SQLException {
90+
void previewPolicy_scopesTypedColumnConstraintsToAllowedSchema() throws SQLException {
9191
SchemaMetadata multiSchema = new SchemaMetadata();
9292
multiSchema.setTables(List.of(
9393
schemaTable("crm", "customers", "amount", "numeric", "name", "varchar"),
@@ -117,6 +117,48 @@ void previewPolicy_scopesNumericAmountRedactionToAllowedSchemaOnly() throws SQLE
117117
);
118118
}
119119

120+
@Test
121+
void previewPolicy_appliesTypedColumnConstraintsToAnyColumnName() throws SQLException {
122+
SchemaMetadata schema = new SchemaMetadata();
123+
schema.setTables(List.of(
124+
schemaTable("hr", "employees", "salary", "numeric", "email", "varchar"),
125+
schemaTable("finance", "ledger", "amount", "numeric", "account_code", "varchar")
126+
));
127+
when(schemaScannerService.scanSchema("conn-hr")).thenReturn(schema);
128+
lenient().when(tableClassificationRepository.findLatestByConnectionIdOrderByTableNameAsc("conn-hr"))
129+
.thenReturn(List.of());
130+
131+
PolicyPreviewResponse preview = service.previewPolicy(
132+
"conn-hr",
133+
"This user should have access only to schema hr. The user cannot query numeric salary columns."
134+
);
135+
136+
assertThat(preview.getImpactedColumns())
137+
.contains("hr.employees.salary")
138+
.doesNotContain("finance.ledger.amount", "hr.employees.email");
139+
}
140+
141+
@Test
142+
void previewPolicy_typeOnlyConstraintIsStillSchemaScoped() throws SQLException {
143+
SchemaMetadata schema = new SchemaMetadata();
144+
schema.setTables(List.of(
145+
schemaTable("finance", "ledger", "amount", "numeric", "account_code", "varchar"),
146+
schemaTable("sales", "orders", "amount", "numeric", "status", "varchar")
147+
));
148+
when(schemaScannerService.scanSchema("conn-fin")).thenReturn(schema);
149+
lenient().when(tableClassificationRepository.findLatestByConnectionIdOrderByTableNameAsc("conn-fin"))
150+
.thenReturn(List.of());
151+
152+
PolicyPreviewResponse preview = service.previewPolicy(
153+
"conn-fin",
154+
"Access only to schema finance. Redact numeric columns."
155+
);
156+
157+
assertThat(preview.getImpactedColumns())
158+
.contains("finance.ledger.amount")
159+
.doesNotContain("sales.orders.amount", "finance.ledger.account_code");
160+
}
161+
120162
@Test
121163
void previewPolicy_resolvesExplicitTableAndColumnMentions() {
122164
PolicyPreviewResponse preview = service.previewPolicy(

0 commit comments

Comments
 (0)