@@ -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 ) {
0 commit comments