diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml
index 49a4060..a37d83c 100644
--- a/.github/actions/integration-tests/action.yml
+++ b/.github/actions/integration-tests/action.yml
@@ -34,3 +34,11 @@ runs:
run: cds bind --exec -- mvn clean verify -ntp -B -f pom.xml
working-directory: integration-tests
shell: bash
+
+ - name: Cleanup AI Core test resource groups
+ if: always()
+ working-directory: integration-tests
+ shell: bash
+ env:
+ RESOURCE_GROUP_PREFIX: itest-${{ github.run_id }}-${{ github.run_attempt }}-j${{ inputs.java-version }}
+ run: cds bind --exec -- node ${{ github.workspace }}/.github/scripts/cleanup-resource-groups.js
diff --git a/.github/actions/scan-with-sonar/action.yml b/.github/actions/scan-with-sonar/action.yml
index c01bf24..9201134 100644
--- a/.github/actions/scan-with-sonar/action.yml
+++ b/.github/actions/scan-with-sonar/action.yml
@@ -48,6 +48,14 @@ runs:
working-directory: integration-tests
shell: bash
+ - name: Cleanup AI Core test resource groups
+ if: always()
+ working-directory: integration-tests
+ shell: bash
+ env:
+ RESOURCE_GROUP_PREFIX: sonar-${{ github.run_id }}-${{ github.run_attempt }}
+ run: cds bind --exec -- node ${{ github.workspace }}/.github/scripts/cleanup-resource-groups.js
+
- name: Generate aggregate coverage report
run: mvn verify -ntp -B -pl coverage-report -am -DskipTests
shell: bash
diff --git a/.github/scripts/cleanup-resource-groups.js b/.github/scripts/cleanup-resource-groups.js
new file mode 100644
index 0000000..257513c
--- /dev/null
+++ b/.github/scripts/cleanup-resource-groups.js
@@ -0,0 +1,115 @@
+/**
+ * Cleans up AI Core test resource groups.
+ *
+ * Required environment variables:
+ * RESOURCE_GROUP_PREFIX - The prefix identifying resource groups owned by this run
+ * (e.g. "itest-12345-1-j17" or "sonar-12345-1")
+ *
+ * Optional environment variables:
+ * STALE_PREFIXES - Comma-separated list of additional prefixes to clean up
+ * (defaults to "itest-rg-,cds-itest-")
+ *
+ * Credentials are resolved from VCAP_SERVICES (via cds bind) or AICORE_SERVICE_KEY.
+ */
+const https = require("https");
+
+const DEFAULT_STALE_PREFIXES = ["itest-rg-", "cds-itest-"];
+
+function getCredentials() {
+ const vcap = JSON.parse(process.env.VCAP_SERVICES || "{}");
+ return (
+ (vcap.aicore || vcap["ai-core"] || [{}])[0].credentials ||
+ JSON.parse(process.env.AICORE_SERVICE_KEY || "null")
+ );
+}
+
+function request(url, opts = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(url);
+ const req = https.request(
+ {
+ hostname: u.hostname,
+ path: u.pathname + u.search,
+ method: opts.method || "GET",
+ headers: opts.headers || {},
+ },
+ (res) => {
+ let data = "";
+ res.on("data", (chunk) => (data += chunk));
+ res.on("end", () => resolve({ status: res.statusCode, body: data }));
+ }
+ );
+ req.on("error", reject);
+ if (opts.body) req.write(opts.body);
+ req.end();
+ });
+}
+
+async function getAccessToken(credentials) {
+ const tokenUrl = credentials.url + "/oauth/token";
+ const params = new URLSearchParams({ grant_type: "client_credentials" });
+ const authHeader =
+ "Basic " +
+ Buffer.from(credentials.clientid + ":" + credentials.clientsecret).toString(
+ "base64"
+ );
+ const res = await request(tokenUrl + "?" + params.toString(), {
+ headers: { Authorization: authHeader },
+ });
+ return JSON.parse(res.body).access_token;
+}
+
+async function deleteResourceGroups(apiUrl, headers, prefixes) {
+ const res = await request(apiUrl + "/v2/admin/resourceGroups", { headers });
+ const groups = JSON.parse(res.body).resources || [];
+ const toDelete = groups.filter(
+ (rg) =>
+ rg.resourceGroupId &&
+ prefixes.some((p) => rg.resourceGroupId.startsWith(p))
+ );
+
+ for (const rg of toDelete) {
+ const delRes = await request(
+ apiUrl + "/v2/admin/resourceGroups/" + rg.resourceGroupId,
+ { method: "DELETE", headers }
+ );
+ console.log("Delete", rg.resourceGroupId, "->", delRes.status);
+ }
+
+ console.log("Cleaned up", toDelete.length, "resource groups");
+}
+
+async function main() {
+ const ownPrefix = process.env.RESOURCE_GROUP_PREFIX;
+ if (!ownPrefix) {
+ console.error("RESOURCE_GROUP_PREFIX environment variable is required");
+ process.exit(1);
+ }
+
+ const credentials = getCredentials();
+ if (!credentials) {
+ console.log("No AI Core credentials found, skipping cleanup");
+ return;
+ }
+
+ const stalePrefixes = process.env.STALE_PREFIXES
+ ? process.env.STALE_PREFIXES.split(",").map((s) => s.trim())
+ : DEFAULT_STALE_PREFIXES;
+
+ const prefixes = [ownPrefix, ...stalePrefixes];
+
+ const apiUrl = credentials.serviceurls.AI_API_URL;
+ const token = await getAccessToken(credentials);
+ const headers = {
+ Authorization: "Bearer " + token,
+ "AI-Resource-Group": "default",
+ };
+
+ console.log("Cleaning resource groups matching prefixes:", prefixes);
+ await deleteResourceGroups(apiUrl, headers, prefixes);
+}
+
+main().catch((e) => {
+ console.error(e.message);
+ process.exit(0);
+});
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 2e7b238..251b68a 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -65,67 +65,6 @@ jobs:
java-version: ${{ matrix.java-version }}
maven-version: ${{ env.MAVEN_VERSION }}
- integration-tests-cleanup:
- name: Cleanup Integration Test Resources
- runs-on: ubuntu-latest
- timeout-minutes: 10
- needs: [integration-tests, sonarqube-scan]
- if: always()
- permissions:
- contents: read
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
-
- - name: Bind CF Services
- uses: ./.github/actions/cf-bind
- with:
- cf-api: ${{ secrets.CF_API_AWS }}
- cf-username: ${{ secrets.CF_USERNAME }}
- cf-password: ${{ secrets.CF_PASSWORD }}
- cf-org: ${{ secrets.CF_ORG_AWS }}
- cf-space: ${{ secrets.CF_SPACE_AWS }}
-
- - name: Cleanup resource groups
- working-directory: integration-tests
- shell: bash
- run: |
- cds bind --exec -- node -e "
- const https = require('https');
- const vcap = JSON.parse(process.env.VCAP_SERVICES || '{}');
- const key = (vcap.aicore || vcap['ai-core'] || [{}])[0].credentials || JSON.parse(process.env.AICORE_SERVICE_KEY || 'null');
- if (!key) { console.log('No AI Core credentials found, skipping cleanup'); process.exit(0); }
- const tokenUrl = key.url + '/oauth/token';
- const apiUrl = key.serviceurls.AI_API_URL;
- function fetch(url, opts) {
- return new Promise((resolve, reject) => {
- const u = new URL(url);
- const req = https.request({hostname:u.hostname, path:u.pathname+u.search, method:opts.method||'GET', headers:opts.headers||{}}, res => {
- let data=''; res.on('data',c=>data+=c); res.on('end',()=>resolve({status:res.statusCode,body:data}));
- });
- req.on('error',reject);
- if(opts.body) req.write(opts.body);
- req.end();
- });
- }
- (async () => {
- const tokenParams = new URLSearchParams({grant_type:'client_credentials'});
- const authHeader = 'Basic ' + Buffer.from(key.clientid+':'+key.clientsecret).toString('base64');
- const tokenRes = await fetch(tokenUrl+'?'+tokenParams.toString(), {headers:{'Authorization':authHeader}});
- const token = JSON.parse(tokenRes.body).access_token;
- const headers = {'Authorization':'Bearer '+token, 'AI-Resource-Group':'default'};
- const prefixes = ['itest-${{ github.run_id }}-${{ github.run_attempt }}', 'sonar-${{ github.run_id }}-${{ github.run_attempt }}'];
- const rgRes = await fetch(apiUrl+'/v2/admin/resourceGroups', {headers});
- const groups = JSON.parse(rgRes.body).resources || [];
- const toDelete = groups.filter(rg => rg.resourceGroupId && prefixes.some(p => rg.resourceGroupId.startsWith(p)));
- for (const rg of toDelete) {
- const res = await fetch(apiUrl+'/v2/admin/resourceGroups/'+rg.resourceGroupId, {method:'DELETE', headers});
- console.log('Delete', rg.resourceGroupId, '->', res.status);
- }
- console.log('Cleaned up', toDelete.length, 'resource groups for run ${{ github.run_id }}');
- })().catch(e => { console.error(e.message); process.exit(1); });
- "
-
local-mtx-tests:
name: Local MTX Tests (Java ${{ matrix.java-version }})
runs-on: ubuntu-latest
diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md
index c447859..1b1125f 100644
--- a/cds-feature-ai-core/README.md
+++ b/cds-feature-ai-core/README.md
@@ -36,19 +36,21 @@ Without a binding the plugin registers a mock implementation.
## Configuration
-All configuration is under the `cds.requires.AICore` namespace in `application.yaml`:
+All configuration is under the `cds.ai.core` namespace in `application.yaml`:
```yaml
cds:
- requires:
- AICore:
+ ai:
+ core:
resourceGroup: default # Resource group for single-tenant mode
resourceGroupPrefix: "cds-" # Prefix for auto-created tenant resource groups
- multiTenancy: false # Enable per-tenant resource groups
maxRetries: 10 # Max retry attempts for transient AI Core errors
initialDelayMs: 300 # Initial backoff delay (ms)
```
+Multi-tenancy is auto-detected from CAP Java's standard `cds.multiTenancy.sidecar.url` setting
+and the presence of a `DeploymentService`. No additional configuration flag is required.
+
## CDS Service: `AICore`
The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs as CDS entities:
@@ -61,11 +63,11 @@ The plugin registers a CAP service named `AICore` that proxies AI Core REST APIs
| `AICore.deployments` | READ, CREATE, DELETE | Deployment management with status tracking |
| `AICore.configurations` | READ, CREATE | Configuration management for scenarios and executables |
-### Functions & Actions
+### Programmatic API
```java
-// Get the resource group ID for a CDS tenant
-String rgId = aiCoreService.resourceGroupForTenant(tenantId);
+// Get the resource group for the current tenant
+String rgId = aiCoreService.resourceGroup();
// Get (or auto-create) a deployment ID for a model spec in the given resource group
String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1());
@@ -73,7 +75,7 @@ String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1());
## Multi-Tenancy
-When `cds.requires.AICore.multiTenancy=true`:
+When multi-tenancy is active (detected via `cds.multiTenancy.sidecar.url`):
1. **Subscribe** - Creates resource group `{prefix}{tenantId}` with label `ext.ai.sap.com/CDS_TENANT_ID`
2. **Unsubscribe** - Deletes the tenant's resource group
@@ -92,7 +94,7 @@ AICoreService aiCore = runtime.getServiceCatalog()
Result rgs = aiCore.run(Select.from("AICore.resourceGroups"));
// Resolve a deployment and obtain a configured ApiClient for it
-String resourceGroupId = aiCore.resourceGroupForTenant(tenantId);
+String resourceGroupId = aiCore.resourceGroup();
String deploymentId = aiCore.deploymentId(resourceGroupId, RptModelSpec.rpt1());
ApiClient client = aiCore.inferenceClient(resourceGroupId, deploymentId);
```
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java
index 6c90866..40374bb 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java
@@ -5,7 +5,6 @@
import com.sap.cds.services.cds.CqnService;
import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
-import io.github.resilience4j.retry.Retry;
/**
* CAP service contract for SAP AI Core integration.
@@ -15,20 +14,16 @@
* provides programmatic helpers to:
*
*
- * - Resolve the resource group ID for a CDS tenant ({@link #resourceGroupForTenant(String)}),
- * creating it on-demand when multi-tenancy is enabled.
+ *
- Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating
+ * it on-demand when multi-tenancy is enabled.
*
- Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link
* #deploymentId(String, ModelDeploymentSpec)}).
*
- Build an {@link ApiClient} preconfigured for inference against a specific deployment
* ({@link #inferenceClient(String, String)}).
- *
- Expose a shared retry/backoff policy ({@link #getRetry()}) for downstream callers that want
- * consistent transient-error handling.
*
*
- * Two implementations are provided: {@link com.sap.cds.feature.aicore.core.AICoreServiceImpl}
- * (when an SAP AI Core service binding is detected) and {@link
- * com.sap.cds.feature.aicore.core.MockAICoreServiceImpl} (in-memory fallback for local
- * development).
+ *
The implementation is tenant-aware: it reads the current tenant from the {@code
+ * RequestContext}. Callers do not need to pass tenant identifiers explicitly.
*/
public interface AICoreService extends CqnService {
@@ -45,16 +40,15 @@ public interface AICoreService extends CqnService {
String CONFIGURATIONS = "AICore.configurations";
/**
- * Returns the AI Core resource group ID associated with the given CDS tenant.
+ * Returns the AI Core resource group ID associated with the current tenant.
*
- *
When multi-tenancy is disabled the configured {@code cds.requires.AICore.resourceGroup} is
- * returned for every tenant. When enabled, the resource group is looked up by the {@code
- * ext.ai.sap.com/CDS_TENANT_ID} label and created on first call if it does not exist.
+ *
When multi-tenancy is disabled the configured default resource group is returned. When
+ * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and
+ * created on first call if it does not exist.
*
- * @param tenantId the CDS tenant identifier; may be {@code null} when multi-tenancy is disabled
- * @return the AI Core resource group ID
+ * @return the AI Core resource group ID for the current tenant
*/
- String resourceGroupForTenant(String tenantId);
+ String resourceGroup();
/**
* Returns the deployment ID for the given model spec inside the given resource group.
@@ -80,13 +74,4 @@ public interface AICoreService extends CqnService {
* @return a configured {@link ApiClient} pointing at the deployment's inference endpoint
*/
ApiClient inferenceClient(String resourceGroupId, String deploymentId);
-
- /** Returns whether multi-tenancy is enabled for this service. */
- boolean isMultiTenancyEnabled();
-
- /**
- * Returns the shared {@link Retry} used internally for transient AI Core errors. Exposed so
- * downstream inference clients can reuse the same backoff policy.
- */
- Retry getRetry();
}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java
index 1e31c9b..bfd4c8c 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java
@@ -14,6 +14,8 @@
import com.sap.cds.feature.aicore.core.handler.DeploymentHandler;
import com.sap.cds.feature.aicore.core.handler.MockEntityHandler;
import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler;
+import com.sap.cds.services.environment.CdsProperties;
+import com.sap.cds.services.mt.DeploymentService;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.runtime.CdsRuntimeConfiguration;
import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
@@ -50,16 +52,27 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) {
return envKey != null && !envKey.isBlank();
}
+ /**
+ * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url}
+ * property or the presence of a {@link DeploymentService} in the service catalog. This aligns
+ * with the standard CAP Java convention — no custom property flag is needed.
+ */
+ private static boolean detectMultiTenancy(CdsRuntime runtime) {
+ CdsProperties props = runtime.getEnvironment().getCdsProperties();
+ String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl();
+ if (sidecarUrl != null && !sidecarUrl.isBlank()) {
+ return true;
+ }
+ return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null;
+ }
+
@Override
public void services(CdsRuntimeConfigurer configurer) {
CdsRuntime runtime = configurer.getCdsRuntime();
boolean hasBinding = hasAICoreBinding(runtime);
- boolean multiTenancyEnabled =
- runtime
- .getEnvironment()
- .getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false);
+ boolean multiTenancyEnabled = detectMultiTenancy(runtime);
if (hasBinding) {
AICoreServiceImpl service =
@@ -75,7 +88,7 @@ public void services(CdsRuntimeConfigurer configurer) {
logger.info("Registered AICoreService backed by AI Core binding.");
} else {
MockAICoreServiceImpl mockService =
- new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime);
+ new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled);
configurer.service(mockService);
logger.info("Registered MockAICoreService (no AI Core binding found).");
}
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java
index 91174cd..52eeb4f 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java
@@ -25,7 +25,6 @@
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.environment.CdsEnvironment;
-import com.sap.cds.services.request.RequestContext;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient;
import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException;
@@ -100,14 +99,14 @@ public AICoreServiceImpl(
this.multiTenancyEnabled = multiTenancyEnabled;
CdsEnvironment env = runtime.getEnvironment();
this.maxRetries =
- env.getProperty("cds.requires.AICore.maxRetries", Integer.class, DEFAULT_MAX_RETRIES);
+ env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES);
this.initialDelayMs =
- env.getProperty("cds.requires.AICore.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS);
+ env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS);
this.defaultResourceGroup =
- env.getProperty("cds.requires.AICore.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP);
+ env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP);
this.resourceGroupPrefix =
env.getProperty(
- "cds.requires.AICore.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX);
+ "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX);
this.retry = buildRetry(maxRetries, initialDelayMs);
this.tenantResourceGroupCache = newCache();
this.resourceGroupDeploymentCache = newCache();
@@ -126,8 +125,8 @@ private static Cache newCache() {
@Override
public String resourceGroupForTenant(String tenantId) {
- if (!multiTenancyEnabled) {
- logger.debug("Multi-tenancy disabled, using resource group {}", defaultResourceGroup);
+ if (!multiTenancyEnabled || tenantId == null) {
+ logger.debug("Using default resource group {}", defaultResourceGroup);
return defaultResourceGroup;
}
return getOrCreateResourceGroupForTenant(tenantId);
@@ -243,8 +242,7 @@ public String resolveResourceGroupFromKeys(Map keys) {
if (rgObj instanceof Map, ?> rgMap && rgMap.containsKey("resourceGroupId")) {
return (String) rgMap.get("resourceGroupId");
}
- String tenantId = RequestContext.getCurrent(runtime).getUserInfo().getTenant();
- return resourceGroupForTenant(tenantId);
+ return resourceGroup();
}
@Override
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
index 8b8d080..0e7ec79 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java
@@ -4,8 +4,11 @@
package com.sap.cds.feature.aicore.core;
import com.sap.cds.feature.aicore.api.AICoreService;
+import com.sap.cds.services.request.RequestContext;
+import com.sap.cds.services.request.UserInfo;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.utils.services.AbstractCqnService;
+import io.github.resilience4j.retry.Retry;
import java.util.Map;
/**
@@ -24,6 +27,50 @@ public CdsRuntime getRuntime() {
return runtime;
}
+ /**
+ * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no
+ * tenant is set (e.g. in single-tenant mode).
+ */
+ public String currentTenantId() {
+ return RequestContext.getCurrent(runtime).getUserInfo().getTenant();
+ }
+
+ /**
+ * Returns whether the current request is running as a system/provider user. Provider users are
+ * allowed to see all tenants' resources.
+ */
+ public boolean isProviderUser() {
+ UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo();
+ return userInfo.isSystemUser() || userInfo.isInternalUser();
+ }
+
+ /**
+ * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService}
+ * interface — callers should not need to be aware of multi-tenancy.
+ */
+ public abstract boolean isMultiTenancyEnabled();
+
+ /**
+ * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the
+ * public {@link AICoreService} interface but accessible to internal callers (e.g. the
+ * recommendations module) that need consistent backoff behaviour.
+ */
+ public abstract Retry getRetry();
+
+ /**
+ * Returns the resource group for the given tenant ID. This is an internal method used by setup
+ * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context.
+ *
+ * @param tenantId the CDS tenant identifier
+ * @return the AI Core resource group ID
+ */
+ public abstract String resourceGroupForTenant(String tenantId);
+
+ @Override
+ public String resourceGroup() {
+ return resourceGroupForTenant(currentTenantId());
+ }
+
/** Returns the configured default resource group identifier. */
public abstract String getDefaultResourceGroup();
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java
index c54b4b9..aaee071 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java
@@ -26,16 +26,19 @@ public class MockAICoreServiceImpl extends AbstractAICoreService {
private final boolean multiTenancyEnabled;
public MockAICoreServiceImpl(String name, CdsRuntime runtime) {
+ this(name, runtime, false);
+ }
+
+ public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) {
super(name, runtime);
logger.info("MockAICoreService initialized - all operations use in-memory storage.");
this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build());
CdsEnvironment env = runtime.getEnvironment();
this.defaultResourceGroup =
- env.getProperty("cds.requires.AICore.resourceGroup", String.class, "default");
+ env.getProperty("cds.ai.core.resourceGroup", String.class, "default");
this.resourceGroupPrefix =
- env.getProperty("cds.requires.AICore.resourceGroupPrefix", String.class, "cds-");
- this.multiTenancyEnabled =
- env.getProperty("cds.requires.AICore.multiTenancy", Boolean.class, false);
+ env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-");
+ this.multiTenancyEnabled = multiTenancyEnabled;
}
@Override
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java
index 402ab9b..0549daa 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java
@@ -3,7 +3,10 @@
*/
package com.sap.cds.feature.aicore.core.handler;
+import com.sap.ai.sdk.core.model.BckndResourceGroup;
import com.sap.cds.feature.aicore.core.AICoreServiceImpl;
+import com.sap.cds.services.ErrorStatuses;
+import com.sap.cds.services.ServiceException;
import com.sap.cds.services.handler.EventHandler;
import java.util.HashMap;
import java.util.List;
@@ -22,6 +25,31 @@ protected String resolveResourceGroup(Map keys) {
return service.resolveResourceGroupFromKeys(keys);
}
+ /**
+ * Validates that the given resource group is accessible by the current tenant. Provider/system
+ * users may access any resource group. In single-tenancy mode, no restriction is applied. Throws
+ * 404 if the resource group does not belong to the current tenant.
+ */
+ protected void ensureResourceGroupAccessible(String resourceGroupId) {
+ if (service.isProviderUser() || !service.isMultiTenancyEnabled()) {
+ return;
+ }
+ String currentTenant = service.currentTenantId();
+ if (currentTenant == null) {
+ return;
+ }
+ BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId);
+ if (rg.getLabels() != null
+ && rg.getLabels().stream()
+ .anyMatch(
+ l ->
+ AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())
+ && currentTenant.equals(l.getValue()))) {
+ return;
+ }
+ throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found");
+ }
+
protected static Map merge(Map keys, Map values) {
Map merged = new HashMap<>(values);
keys.forEach(
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java
index b454a2f..810068b 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java
@@ -12,7 +12,6 @@
import com.sap.cds.services.EventContext;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
-import com.sap.cds.services.request.RequestContext;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,18 +25,6 @@ public ActionHandler(AICoreServiceImpl service) {
super(service);
}
- @On(event = "resourceGroupForTenant")
- public void onResourceGroupForTenant(EventContext context) {
- Map params = asMap(context.get("data"));
- String tenant = (String) params.get("tenant");
- if (tenant == null) {
- tenant = RequestContext.getCurrent(service.getRuntime()).getUserInfo().getTenant();
- }
- String result = service.resourceGroupForTenant(tenant);
- context.put("result", result);
- context.setCompleted();
- }
-
@On(event = "stop", entity = AICoreService.DEPLOYMENTS)
public void onStop(EventContext context) {
Map keys = asMap(context.get("keys"));
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java
index 7ad70de..bafdbff 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java
@@ -52,6 +52,7 @@ public void onRead(CdsReadEventContext context) {
Map values = analysis.targetValues();
String resourceGroupId = resolveResourceGroup(merge(keys, values));
+ ensureResourceGroupAccessible(resourceGroupId);
logger.debug(
"Reading configurations for resourceGroup={}, keys={}, values={}",
resourceGroupId,
@@ -79,6 +80,7 @@ public void onCreate(CdsCreateEventContext context, List entries
for (Configurations entry : entries) {
String resourceGroupId = resolveResourceGroup(entry);
+ ensureResourceGroupAccessible(resourceGroupId);
AiConfigurationBaseData request =
AiConfigurationBaseData.create()
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java
index d5907b7..0b3df10 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java
@@ -56,6 +56,7 @@ public void onRead(CdsReadEventContext context) {
Map values = analysis.targetValues();
String resourceGroupId = resolveResourceGroup(merge(keys, values));
+ ensureResourceGroupAccessible(resourceGroupId);
String id = (String) keys.get(Deployments.ID);
if (id != null) {
@@ -75,6 +76,7 @@ public void onCreate(CdsCreateEventContext context, List entries) {
for (Deployments entry : entries) {
String resourceGroupId = resolveResourceGroup(entry);
+ ensureResourceGroupAccessible(resourceGroupId);
String configurationId = entry.getConfigurationId();
AiDeploymentCreationRequest request =
@@ -111,6 +113,7 @@ public void onUpdate(CdsUpdateEventContext context, List entries) {
String deploymentId = (String) keys.get(Deployments.ID);
String resourceGroupId = resolveResourceGroup(merge(keys, data));
+ ensureResourceGroupAccessible(resourceGroupId);
AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create();
@@ -135,6 +138,7 @@ public void onDelete(CdsDeleteEventContext context) {
String deploymentId = (String) keys.get(Deployments.ID);
String resourceGroupId = resolveResourceGroup(keys);
+ ensureResourceGroupAccessible(resourceGroupId);
deploymentApi.delete(resourceGroupId, deploymentId);
logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId);
diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java
index 3295ebe..b0e7094 100644
--- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java
+++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java
@@ -19,6 +19,8 @@
import com.sap.cds.ql.cqn.CqnSelect;
import com.sap.cds.ql.cqn.CqnUpdate;
import com.sap.cds.reflect.CdsModel;
+import com.sap.cds.services.ErrorStatuses;
+import com.sap.cds.services.ServiceException;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.cds.CdsDeleteEventContext;
import com.sap.cds.services.cds.CdsReadEventContext;
@@ -60,13 +62,10 @@ public void onRead(CdsReadEventContext context) {
if (resourceGroupId != null) {
BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId);
+ ensureOwnedByCurrentTenant(rg);
context.setResult(List.of(toMap(rg)));
} else {
- List labelSelector = null;
- if (values.containsKey(ResourceGroups.TENANT_ID)) {
- String tenantId = (String) values.get(ResourceGroups.TENANT_ID);
- labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId);
- }
+ List labelSelector = buildTenantLabelSelector(values);
BckndResourceGroupList result =
resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector);
context.setResult(mapResources(result.getResources(), this::toMap));
@@ -124,6 +123,7 @@ public void onUpdate(CdsUpdateEventContext context) {
Map keys = analyzer.analyze(update).targetKeys();
String resourceGroupId = resolveResourceGroupId(keys);
+ ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId));
Map data = update.entries().get(0);
BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create();
@@ -147,6 +147,8 @@ public void onDelete(CdsDeleteEventContext context) {
Map keys = analyzer.analyze(delete).targetKeys();
String resourceGroupId = resolveResourceGroupId(keys);
+ ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId));
+
resourceGroupApi.delete(resourceGroupId);
logger.debug("Deleted resource group {}", resourceGroupId);
context.setResult(List.of());
@@ -159,7 +161,54 @@ private String resolveResourceGroupId(Map keys) {
if (keys.containsKey(ResourceGroups.TENANT_ID)) {
return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID));
}
- return service.getDefaultResourceGroup();
+ return service.resourceGroup();
+ }
+
+ /**
+ * Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider
+ * users are restricted to their own tenant's resource groups.
+ */
+ private List buildTenantLabelSelector(Map values) {
+ // If a specific tenantId is requested in the query, use that
+ if (values.containsKey(ResourceGroups.TENANT_ID)) {
+ String tenantId = (String) values.get(ResourceGroups.TENANT_ID);
+ return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId);
+ }
+ // In MT mode, restrict non-provider users to their own tenant
+ if (service.isMultiTenancyEnabled() && !service.isProviderUser()) {
+ String currentTenant = service.currentTenantId();
+ if (currentTenant != null) {
+ return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Verifies that the given resource group is owned by the current tenant. Provider/system users
+ * are allowed to access any resource group. Throws 404 if the resource group belongs to a
+ * different tenant.
+ */
+ private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) {
+ if (service.isProviderUser()) {
+ return;
+ }
+ if (!service.isMultiTenancyEnabled()) {
+ return;
+ }
+ String currentTenant = service.currentTenantId();
+ if (currentTenant == null) {
+ return;
+ }
+ if (rg.getLabels() != null
+ && rg.getLabels().stream()
+ .anyMatch(
+ l ->
+ AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())
+ && currentTenant.equals(l.getValue()))) {
+ return;
+ }
+ throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource group not found");
}
private static List toSdkLabels(List