Skip to content
Open
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
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Haulmont.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.jmix.aitools;

import io.jmix.core.entity.annotation.MetaAnnotation;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;

/**
* Marks an entity or attribute as off-limits to the AI: its data must never reach the model, and a
* generated query may not read it. On a type the whole entity is excluded; on a field or getter only
* that attribute is, and the exclusion is inherited by entity subclasses.
* <p>
* Unlike the {@code jmix.aitools.dataload.*} include/exclude properties, this is a code-level boundary
* that cannot be overridden at deployment time &mdash; use it for data whose exposure decision must
* live in code, such as PII. Prefer it over {@code @Secret} (which hides an attribute everywhere in
* the framework) and {@code @SystemLevel} (an overridable, discovery-only flag) when the intent is
* precisely &quot;usable in the application, closed to the AI&quot;.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({TYPE, FIELD, METHOD})
@MetaAnnotation
public @interface ExcludeFromAi {
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package io.jmix.aitools.dataload.execution;

import io.jmix.aitools.AiToolsDataLoadProperties;
import io.jmix.aitools.ExcludeFromAi;
import io.jmix.aitools.dataload.execution.JpqlValidationAndRepairService.OperationResult;
import io.jmix.aitools.dataload.validation.JpqlValidationResult;
import io.jmix.core.AccessManager;
Expand All @@ -27,6 +28,7 @@
import io.jmix.core.common.util.Preconditions;
import io.jmix.core.entity.KeyValueEntity;
import io.jmix.core.metamodel.model.MetaClass;
import io.jmix.core.metamodel.model.MetaProperty;
import io.jmix.core.metamodel.model.MetaPropertyPath;
import io.jmix.core.metamodel.model.MetadataObject;
import io.jmix.core.security.AccessDeniedException;
Expand Down Expand Up @@ -173,65 +175,71 @@ protected List<Integer> resolveDeniedSelectedIndexes(String jpqlQuery) {

/**
* Resolves the positions of the selected columns that must be dropped from the result: those the
* current user is not allowed to read, plus those mapping to a {@link io.jmix.core.annotation.Secret}
* attribute. The secret guard is defense in depth — a secret attribute is already unknown to
* introspection and rejected by JPQL validation — so a secret value is never returned even if a
* query reaches execution through another path.
* current user is not allowed to read, plus those mapping to an attribute hidden from the AI
* (annotated {@link io.jmix.core.annotation.Secret} or {@link ExcludeFromAi}). The hidden-attribute
* guard is defense in depth — such an attribute is already unknown to introspection and rejected by
* JPQL validation — so its value is never returned even if a query reaches execution through another
* path.
*
* @param jpqlQuery query whose selected columns are being resolved
* @return positions (in select-clause order) of the columns to drop, without duplicates
* @throws AccessDeniedException if the current user cannot read the queried entity
*/
protected List<Integer> resolveExcludedSelectedIndexes(String jpqlQuery) {
List<Integer> excluded = new ArrayList<>(resolveDeniedSelectedIndexes(jpqlQuery));
for (Integer secretIndex : resolveSecretSelectedIndexes(jpqlQuery)) {
if (!excluded.contains(secretIndex)) {
excluded.add(secretIndex);
for (Integer hiddenIndex : resolveHiddenSelectedIndexes(jpqlQuery)) {
if (!excluded.contains(hiddenIndex)) {
excluded.add(hiddenIndex);
}
}
return excluded;
}

/**
* Resolves the positions of the selected columns that map to a {@link io.jmix.core.annotation.Secret}
* attribute.
* Resolves the positions of the selected columns that map to an attribute hidden from the AI
* (annotated {@link io.jmix.core.annotation.Secret} or {@link ExcludeFromAi}).
*
* @param jpqlQuery query whose selected columns are being inspected
* @return positions (in select-clause order) of the secret columns, or an empty list when the
* @return positions (in select-clause order) of the hidden columns, or an empty list when the
* metadata collaborators are unavailable or the query cannot be parsed
*/
protected List<Integer> resolveSecretSelectedIndexes(String jpqlQuery) {
protected List<Integer> resolveHiddenSelectedIndexes(String jpqlQuery) {
if (metadataTools == null || queryTransformerFactory == null || metadata == null) {
return List.of();
}

try {
QueryParser queryParser = queryTransformerFactory.parser(jpqlQuery);
List<Integer> secretIndexes = new ArrayList<>();
List<Integer> hiddenIndexes = new ArrayList<>();
int selectedIndex = 0;
for (QueryParser.QueryPath queryPath : queryParser.getQueryPaths()) {
if (queryPath.isSelectedPath()) {
if (isSecretSelectedPath(queryPath)) {
secretIndexes.add(selectedIndex);
if (isHiddenSelectedPath(queryPath)) {
hiddenIndexes.add(selectedIndex);
}
selectedIndex++;
}
}
return secretIndexes;
return hiddenIndexes;
} catch (RuntimeException e) {
return List.of();
}
}

protected boolean isSecretSelectedPath(QueryParser.QueryPath queryPath) {
protected boolean isHiddenSelectedPath(QueryParser.QueryPath queryPath) {
MetaClass metaClass = metadata.getClass(queryPath.getEntityName());
MetaPropertyPath propertyPath = metaClass.getPropertyPath(queryPath.getPropertyPath());
return propertyPath != null && metadataTools.isSecret(propertyPath.getMetaProperty());
if (propertyPath == null) {
return false;
}
MetaProperty metaProperty = propertyPath.getMetaProperty();
return metadataTools.isSecret(metaProperty)
|| metaProperty.getAnnotations().containsKey(ExcludeFromAi.class.getName());
}

/**
* Returns the result properties that stay in the result, dropping the ones at the excluded select
* positions (denied by security or mapping to a secret attribute).
* positions (denied by security or mapping to an attribute hidden from the AI).
*
* @param resultProperties result property names in select-clause order
* @param excludedSelectedIndexes positions of the columns to drop
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package io.jmix.aitools.dataload.introspection;

import io.jmix.aitools.AiToolsDataLoadProperties;
import io.jmix.aitools.ExcludeFromAi;
import io.jmix.core.MessageTools;
import io.jmix.core.Metadata;
import io.jmix.core.MetadataTools;
Expand Down Expand Up @@ -70,7 +71,9 @@ protected void onApplicationStarted(ContextRefreshedEvent event) {
* <p>
* Each entity is checked against the rules below, in order; the first matching rule wins:
* <ol>
* <li>listed in {@code excludeEntities} — excluded (takes precedence over everything else);</li>
* <li>annotated {@link ExcludeFromAi} — excluded unconditionally (a code-level trust boundary,
* above every rule below, so no configuration can bring it back);</li>
* <li>listed in {@code excludeEntities} — excluded (takes precedence over every include rule);</li>
* <li>listed in {@code includeEntities} or matching {@code includePackages} — included,
* overriding the system-level, DTO and {@code excludePackages} rules below;</li>
* <li>system-level entity while {@code excludeSystemLevelEntities} is enabled — excluded;</li>
Expand Down Expand Up @@ -274,6 +277,11 @@ public EntityDescriptor introspect(MetaClass metaClass) {
protected List<EntityPropertyDescriptor> introspectProperties(MetaClass metaClass) {
List<EntityPropertyDescriptor> propertyDescriptors = new ArrayList<>();
for (MetaProperty property : metaClass.getProperties()) {
// A @ExcludeFromAi attribute is a code-level trust boundary: like @Secret it is never
// indexed, so it is hidden from discovery and rejected by JPQL validation.
if (isExcludedFromAi(property)) {
continue;
}
// A @Secret attribute is never indexed: it is hidden from discovery and, because the index
// also backs JPQL validation, rejected if a generated query references it anyway.
if (metadataTools.isSecret(property)) {
Expand Down Expand Up @@ -320,7 +328,13 @@ protected boolean isEntityCaptionFallback(MetaClass metaClass, String localizedN
}

protected boolean shouldInclude(MetaClass metaClass) {
// Explicit exclude takes precedence over everything.
// A @ExcludeFromAi entity is a code-level trust boundary: excluded unconditionally, above every
// rule below (including force-include), so a deployment-time property override cannot expose it.
if (isExcludedFromAi(metaClass)) {
return false;
}

// Explicit exclude takes precedence over every include rule.
if (isExplicitlyExcluded(metaClass)) {
return false;
}
Expand All @@ -347,6 +361,14 @@ protected boolean shouldInclude(MetaClass metaClass) {
return true;
}

protected boolean isExcludedFromAi(MetaClass metaClass) {
return metaClass.getAnnotations().containsKey(ExcludeFromAi.class.getName());
}

protected boolean isExcludedFromAi(MetaProperty metaProperty) {
return metaProperty.getAnnotations().containsKey(ExcludeFromAi.class.getName());
}

protected boolean isExplicitlyIncluded(MetaClass metaClass) {
return matchesEntityName(metaClass, dataLoadProperties.getIncludeEntities())
|| matchesAnyPackagePrefix(metaClass.getJavaClass().getPackageName(), dataLoadProperties.getIncludePackages());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright 2026 Haulmont.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package execution;

import io.jmix.aitools.AiToolsDataLoadProperties;
import io.jmix.aitools.dataload.execution.GeneratedJpqlResult;
import io.jmix.aitools.dataload.execution.JpqlExecutionRequest;
import io.jmix.aitools.dataload.execution.JpqlExecutionResult;
import io.jmix.aitools.dataload.execution.JpqlExecutionService;
import io.jmix.aitools.dataload.execution.JpqlParameterConversionService;
import io.jmix.aitools.dataload.execution.JpqlValidationAndRepairService;
import io.jmix.aitools.dataload.execution.JpqlValidationAndRepairService.OperationResult;
import io.jmix.aitools.dataload.validation.JpqlValidationResult;
import io.jmix.core.Metadata;
import io.jmix.core.MetadataTools;
import io.jmix.data.QueryTransformerFactory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.util.ReflectionTestUtils;
import test_support.AiToolsTestConfiguration;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
* Verifies the defense-in-depth guard that keeps a {@code @ExcludeFromAi} attribute out of the result
* rows even when the query bypasses JPQL validation (here the validate/repair step is stubbed to
* success).
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = AiToolsTestConfiguration.class)
class JpqlExecutionServiceExcludeFromAiColumnTest {

private static final String HIDDEN_AND_PLAIN_JPQL =
"select c.id as cid, c.piiPhone as cphone from aitls_Customer c";
private static final String HIDDEN_ONLY_JPQL =
"select c.piiPhone as cphone from aitls_Customer c";

@Autowired
QueryTransformerFactory queryTransformerFactory;
@Autowired
Metadata metadata;
@Autowired
MetadataTools metadataTools;

@Test
@DisplayName("Drops a @ExcludeFromAi column and keeps the readable ones")
void testDropsHiddenColumnAmongSeveral() {
TestJpqlExecutionService service = createService(HIDDEN_AND_PLAIN_JPQL,
List.of(Map.of("cid", 1L, "cphone", "+1-555")));

JpqlExecutionResult result = service.execute(new JpqlExecutionRequest(
"Show customers", HIDDEN_AND_PLAIN_JPQL, List.of(), List.of("cid", "cphone"), null, null));

assertTrue(result.isExecuted());
assertEquals(1, result.getRows().size());

Map<String, Object> row = result.getRows().get(0);
assertTrue(row.containsKey("cid"));
assertFalse(row.containsKey("cphone"));
assertEquals(1L, row.get("cid"));
}

@Test
@DisplayName("Returns an empty, non-executed result when the only column is @ExcludeFromAi")
void testReturnsNonExecutedResultWhenOnlyColumnHidden() {
TestJpqlExecutionService service = createService(HIDDEN_ONLY_JPQL,
List.of(Map.of("cphone", "+1-555")));

JpqlExecutionResult result = service.execute(new JpqlExecutionRequest(
"Show customers", HIDDEN_ONLY_JPQL, List.of(), List.of("cphone"), null, null));

assertFalse(result.isExecuted());
assertTrue(result.getRows().isEmpty());
}

TestJpqlExecutionService createService(String jpql, List<Map<String, Object>> stubbedRows) {
GeneratedJpqlResult generatedResult = new GeneratedJpqlResult(jpql, List.of(), "", List.of());

JpqlValidationAndRepairService validateAndRepair = mock(JpqlValidationAndRepairService.class);
when(validateAndRepair.validateAndRepair(any()))
.thenReturn(OperationResult.success(new JpqlExecutionRequest(), generatedResult,
new JpqlValidationResult(true, List.of()), null));

JpqlParameterConversionService parameterConversionService = mock(JpqlParameterConversionService.class);
when(parameterConversionService.convert(anyList())).thenReturn(Map.of());

TestJpqlExecutionService service = new TestJpqlExecutionService(stubbedRows);
ReflectionTestUtils.setField(service, "validateAndRepair", validateAndRepair);
ReflectionTestUtils.setField(service, "jpqlParameterConversionService", parameterConversionService);
// accessManager is left null: this isolates the @ExcludeFromAi guard from the security column filtering.
ReflectionTestUtils.setField(service, "queryTransformerFactory", queryTransformerFactory);
ReflectionTestUtils.setField(service, "metadata", metadata);
ReflectionTestUtils.setField(service, "metadataTools", metadataTools);
ReflectionTestUtils.setField(service, "dataLoadProperties",
new AiToolsDataLoadProperties(true, true, true, 1, 20, 200, null, null, null, null));
return service;
}

static class TestJpqlExecutionService extends JpqlExecutionService {

private final List<Map<String, Object>> stubbedRows;

TestJpqlExecutionService(List<Map<String, Object>> stubbedRows) {
this.stubbedRows = stubbedRows;
}

@Override
protected ExecutionRows executeQuery(JpqlExecutionRequest request,
GeneratedJpqlResult generatedJpqlResult,
Map<String, Object> executionParameters,
Integer maxResults,
Integer firstResult) {
return createExecutionRows(stubbedRows, false);
}
}
}
Loading