diff --git a/README.md b/README.md index 7c16d1ccd..eaa174717 100644 --- a/README.md +++ b/README.md @@ -232,8 +232,8 @@ without putting the raw address in the log. | storage.bucket | - | No | Blob storage bucket. | | storage.overrides.* | - | No | Key-value pairs to override storage settings. `*` might be any specific blob storage setting to be overridden. Refer to [examples](#temporary-credentials-1) in the sections below. | | storage.createBucket | false | No | Indicates whether bucket should be created on start-up. | -| storageLayout.tenantRooted | false | No | Places every bucket under a tenant root instead of storing it at the top level. Changing it on a populated deployment re-addresses existing data and requires migration; leave disabled otherwise. | -| storageLayout.defaultTenant | default | No | Tenant that existing buckets are placed under when `storageLayout.tenantRooted` is enabled. | +| storage.layout.tenantRooted | false | No | Places every bucket under a tenant root instead of storing it at the top level. Changing it on a populated deployment re-addresses existing data and requires migration; leave disabled otherwise. | +| storage.layout.defaultTenant | default | No | Tenant that existing buckets are placed under when `storage.layout.tenantRooted` is enabled. | | storage.prefix | - | No | Base prefix for all stored resources. The purpose to use the same bucket for different environments, e.g. dev, prod, pre-prod. Must not contain path separators or any invalid chars. | | storage.maxUploadedFileSize | 536870912 | No | Maximum size in bytes of uploaded file. If a size of uploaded file exceeds the limit the server returns HTTP code 413 | diff --git a/server/src/main/java/com/epam/aidial/core/server/AiDial.java b/server/src/main/java/com/epam/aidial/core/server/AiDial.java index 445c8e906..e41c80f6a 100644 --- a/server/src/main/java/com/epam/aidial/core/server/AiDial.java +++ b/server/src/main/java/com/epam/aidial/core/server/AiDial.java @@ -210,10 +210,15 @@ void start() throws Exception { accessTokenValidator = new AccessTokenValidator(settings("identityProviders"), vertx, taskExecutor, client, clientOptions, claimsLogLevel); } - StorageLayouts.useLayout(createStorageLayout(settings("storageLayout"))); + StorageLayouts.useLayout(createStorageLayout( + settings("storage").getJsonObject("layout", new JsonObject()))); if (storage == null) { - Storage storageConfig = Json.decodeValue(settings("storage").toBuffer(), Storage.class); + // The layout block configures path composition, not the blob store; it is stripped + // before the decode because the codec rejects unknown properties. + JsonObject storageSettings = settings("storage").copy(); + storageSettings.remove("layout"); + Storage storageConfig = Json.decodeValue(storageSettings.toBuffer(), Storage.class); storage = new BlobStorage(storageConfig); } encryptionService = new EncryptionService(settings("encryption")); diff --git a/server/src/main/java/com/epam/aidial/core/server/config/SecretFieldProcessor.java b/server/src/main/java/com/epam/aidial/core/server/config/SecretFieldProcessor.java index 02a4b7109..3268d3b9f 100644 --- a/server/src/main/java/com/epam/aidial/core/server/config/SecretFieldProcessor.java +++ b/server/src/main/java/com/epam/aidial/core/server/config/SecretFieldProcessor.java @@ -40,16 +40,14 @@ public void encryptFields(Object entity, ResourceDescriptor descriptor) { if (entity == null) { return; } - byte[] aad = descriptor.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8); - walk(entity, aad, true); + walk(entity, aad(descriptor), true); } public void decryptFields(Object entity, ResourceDescriptor descriptor) { if (entity == null) { return; } - byte[] aad = descriptor.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8); - walk(entity, aad, false); + walk(entity, aad(descriptor), false); } public String resolveSecret(String value, ResourceDescriptor descriptor) { @@ -57,12 +55,15 @@ public String resolveSecret(String value, ResourceDescriptor descriptor) { return null; } if (value.startsWith(ENC_PREFIX) && value.endsWith(ENC_SUFFIX)) { - byte[] aad = descriptor.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8); - return decryptEnvelope(value, aad, "value"); + return decryptEnvelope(value, aad(descriptor), "value"); } return value; } + private static byte[] aad(ResourceDescriptor descriptor) { + return descriptor.getLegacyFilePath().getBytes(StandardCharsets.UTF_8); + } + /** * Strip every {@link EncryptedField}-annotated value (and any nested array elements that carry * the annotation) from {@code payload}. Used to project invalid-entity payloads on the admin diff --git a/server/src/main/java/com/epam/aidial/core/server/security/ApiKeyStore.java b/server/src/main/java/com/epam/aidial/core/server/security/ApiKeyStore.java index 1421fc4cb..1ddabc89d 100644 --- a/server/src/main/java/com/epam/aidial/core/server/security/ApiKeyStore.java +++ b/server/src/main/java/com/epam/aidial/core/server/security/ApiKeyStore.java @@ -11,6 +11,7 @@ import com.epam.aidial.core.storage.http.HttpStatus; import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.SystemResourceRegistry; import com.epam.aidial.core.storage.util.RedisUtil; import io.vertx.core.Future; import io.vertx.core.json.JsonObject; @@ -28,7 +29,6 @@ import java.util.function.Function; import static com.epam.aidial.core.server.security.ApiKeyGenerator.generateKey; -import static com.epam.aidial.core.storage.resource.ResourceDescriptor.PATH_SEPARATOR; /** * The store keeps per request and project API key data. @@ -40,9 +40,6 @@ @Slf4j public class ApiKeyStore { - public static final String API_KEY_DATA_BUCKET = "api_key_data"; - public static final String API_KEY_DATA_LOCATION = API_KEY_DATA_BUCKET + PATH_SEPARATOR; - private final AsyncTaskExecutor taskExecutor; private final RedissonClient redis; private final String prefix; @@ -271,7 +268,7 @@ private void validateProjectKey(Key key) { private String toRedisKey(String apiKey) { ResourceDescriptor resource = ResourceDescriptorFactory.fromDecoded( - ResourceTypes.API_KEY_DATA, API_KEY_DATA_BUCKET, API_KEY_DATA_LOCATION, apiKey); + ResourceTypes.API_KEY_DATA, SystemResourceRegistry.API_KEY_DATA.bucket(), SystemResourceRegistry.API_KEY_DATA.location(), apiKey); return RedisUtil.redisKey(resource, prefix); } diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java b/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java index dd4fbbdd8..e6b792050 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java @@ -814,14 +814,19 @@ private String deploymentLockKey(ResourceDescriptor resource) { } private String encodeTargetFolder(ResourceDescriptor resource, String id) { - String location = resource.getBucketLocation() - + DEPLOYMENTS_NAME + ResourceDescriptor.PATH_SEPARATOR - + id + ResourceDescriptor.PATH_SEPARATOR; - + String location = deploymentFolderLocation(resource.getBucketLocation(), id); String name = encryptionService.encrypt(location); return ResourceDescriptorFactory.fromDecoded(ResourceTypes.FILE, name, location, null).getUrl(); } + /** + * The synthesized sub-bucket location a function's deployment folder is keyed by. Package-visible so the + * layout test composes the same shape the service does rather than pinning a literal that can drift. + */ + static String deploymentFolderLocation(String bucketLocation, String id) { + return bucketLocation + DEPLOYMENTS_NAME + ResourceDescriptor.PATH_SEPARATOR + id + ResourceDescriptor.PATH_SEPARATOR; + } + public static boolean isActive(Application application) { return application != null && application.getFunction() != null && application.getFunction().getStatus().isActive(); } diff --git a/server/src/main/java/com/epam/aidial/core/server/service/BackgroundJobService.java b/server/src/main/java/com/epam/aidial/core/server/service/BackgroundJobService.java index 6bd5c69ba..da3c10156 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/BackgroundJobService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/BackgroundJobService.java @@ -274,16 +274,18 @@ private Future processResult( private String encryptKey(ResourceDescriptor descriptor, String key) { BucketInfo bucketInfo = new BucketInfo(descriptor.getBucketName(), descriptor.getBucketLocation()); - byte[] aad = descriptor.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8); - byte[] cipher = encryptionService.encrypt(bucketInfo, key.getBytes(StandardCharsets.UTF_8), aad); + byte[] cipher = encryptionService.encrypt(bucketInfo, key.getBytes(StandardCharsets.UTF_8), aad(descriptor)); return Base64.getEncoder().encodeToString(cipher); } private String decryptKey(ResourceDescriptor descriptor, String key) { BucketInfo bucketInfo = new BucketInfo(descriptor.getBucketName(), descriptor.getBucketLocation()); - byte[] aad = descriptor.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8); byte[] raw = Base64.getDecoder().decode(key); - return new String(encryptionService.decrypt(bucketInfo, raw, aad), StandardCharsets.UTF_8); + return new String(encryptionService.decrypt(bucketInfo, raw, aad(descriptor)), StandardCharsets.UTF_8); + } + + private static byte[] aad(ResourceDescriptor descriptor) { + return descriptor.getLegacyFilePath().getBytes(StandardCharsets.UTF_8); } private Future completeAndProcess( diff --git a/server/src/main/java/com/epam/aidial/core/server/service/InvitationService.java b/server/src/main/java/com/epam/aidial/core/server/service/InvitationService.java index ebbae1122..c9de9c506 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/InvitationService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/InvitationService.java @@ -269,7 +269,10 @@ public ResourceDescriptor getInvitationResource(String invitationId) { return ResourceDescriptorFactory.fromDecoded(resourceType, bucket, location, INVITATION_RESOURCE_FILENAME); } + // The id is handed out as a link and stored inside the invitations map, and getInvitationResource parses + // the location back out of it. It must therefore not carry the physical path, which the storage layout is + // free to change: an invitation issued before a layout change has to keep resolving after it. private String generateInvitationId(ResourceDescriptor resource) { - return encryptionService.encrypt(resource.getAbsoluteFilePath() + ResourceDescriptor.PATH_SEPARATOR + ApiKeyGenerator.generateKey()); + return encryptionService.encrypt(resource.getLegacyFilePath() + ResourceDescriptor.PATH_SEPARATOR + ApiKeyGenerator.generateKey()); } } diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ResponseMappingService.java b/server/src/main/java/com/epam/aidial/core/server/service/ResponseMappingService.java index 45a860073..3084400b7 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ResponseMappingService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ResponseMappingService.java @@ -12,6 +12,7 @@ import com.epam.aidial.core.storage.data.ResourceItemMetadata; import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.SystemResourceRegistry; import com.epam.aidial.core.storage.service.ResourceService; import com.epam.aidial.core.storage.util.EtagHeader; import io.vertx.core.Vertx; @@ -67,7 +68,7 @@ private Void cleanExpiredMappings() { log.debug("Housekeeping: scanning for expired response mappings"); try { ResourceDescriptor root = ResourceDescriptorFactory.fromDecoded( - ResourceTypes.RESPONSE_MAPPING, ResponseIdUtil.RESPONSE_MAPPINGS_BUCKET, ResponseIdUtil.RESPONSE_MAPPINGS_BUCKET_LOCATION, null); + ResourceTypes.RESPONSE_MAPPING, SystemResourceRegistry.RESPONSE_MAPPINGS.bucket(), SystemResourceRegistry.RESPONSE_MAPPINGS.location(), null); cleanDeploymentSubfolders(root); } catch (Throwable e) { log.warn("Housekeeping: failed to clean expired response mappings", e); @@ -96,7 +97,7 @@ private void cleanDeploymentSubfolders(ResourceDescriptor root) { private void cleanItemsInDeploymentFolder(String deploymentName) { ResourceDescriptor subfolder = ResourceDescriptorFactory.fromDecoded( - ResourceTypes.RESPONSE_MAPPING, ResponseIdUtil.RESPONSE_MAPPINGS_BUCKET, ResponseIdUtil.RESPONSE_MAPPINGS_BUCKET_LOCATION, deploymentName + "/"); + ResourceTypes.RESPONSE_MAPPING, SystemResourceRegistry.RESPONSE_MAPPINGS.bucket(), SystemResourceRegistry.RESPONSE_MAPPINGS.location(), deploymentName + "/"); long now = System.currentTimeMillis(); String token = null; diff --git a/server/src/main/java/com/epam/aidial/core/server/service/UserExternalServiceService.java b/server/src/main/java/com/epam/aidial/core/server/service/UserExternalServiceService.java index e88382d84..f17cc6184 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/UserExternalServiceService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/UserExternalServiceService.java @@ -78,7 +78,7 @@ private static List applicationSegments(String appPart) { public ExternalService put(String ownerUserId, String appPart, String serviceId, ExternalService service, String author) { ResourceDescriptor resource = descriptor(ownerUserId, appPart, serviceId); BucketInfo bucket = new BucketInfo(resource.getBucketName(), resource.getBucketLocation()); - String aad = resource.getAbsoluteFilePath(); + String aad = resource.getLegacyFilePath(); MutableObject result = new MutableObject<>(); PersistedSecret persisted = new PersistedSecret(); resourceService.computeResource(resource, EtagHeader.ANY, author, json -> { @@ -104,7 +104,7 @@ public ExternalService get(String ownerUserId, String appPart, String serviceId) return null; } ExternalService service = ProxyUtil.convertToObject(stored.getValue(), ExternalService.class); - decryptSecret(resource.getAbsoluteFilePath(), new BucketInfo(resource.getBucketName(), resource.getBucketLocation()), service); + decryptSecret(resource.getLegacyFilePath(), new BucketInfo(resource.getBucketName(), resource.getBucketLocation()), service); return service; } diff --git a/server/src/main/java/com/epam/aidial/core/server/token/TokenStatsTracker.java b/server/src/main/java/com/epam/aidial/core/server/token/TokenStatsTracker.java index 36780fcf7..44ebbc592 100644 --- a/server/src/main/java/com/epam/aidial/core/server/token/TokenStatsTracker.java +++ b/server/src/main/java/com/epam/aidial/core/server/token/TokenStatsTracker.java @@ -6,6 +6,7 @@ import com.epam.aidial.core.server.vertx.AsyncTaskExecutor; import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.SystemResourceRegistry; import com.epam.aidial.core.storage.service.ResourceService; import com.epam.aidial.core.storage.util.EtagHeader; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -21,14 +22,9 @@ import java.util.List; import java.util.Map; -import static com.epam.aidial.core.storage.resource.ResourceDescriptor.PATH_SEPARATOR; - @Slf4j @RequiredArgsConstructor public class TokenStatsTracker { - public static final String DEPLOYMENT_COST_STATS_BUCKET = "deployment_cost_stats"; - public static final String DEPLOYMENT_COST_STATS_LOCATION = DEPLOYMENT_COST_STATS_BUCKET + PATH_SEPARATOR; - private final AsyncTaskExecutor taskExecutor; private final ResourceService resourceService; @@ -216,6 +212,6 @@ public record UsageStats(TokenUsage total, List usagePerModel) { private static ResourceDescriptor toResource(String traceId) { return ResourceDescriptorFactory.fromDecoded( - ResourceTypes.DEPLOYMENT_COST_STATS, DEPLOYMENT_COST_STATS_BUCKET, DEPLOYMENT_COST_STATS_LOCATION, traceId); + ResourceTypes.DEPLOYMENT_COST_STATS, SystemResourceRegistry.DEPLOYMENT_COST_STATS.bucket(), SystemResourceRegistry.DEPLOYMENT_COST_STATS.location(), traceId); } } diff --git a/server/src/main/java/com/epam/aidial/core/server/util/BucketBuilder.java b/server/src/main/java/com/epam/aidial/core/server/util/BucketBuilder.java index b9a3124c7..febe449e9 100644 --- a/server/src/main/java/com/epam/aidial/core/server/util/BucketBuilder.java +++ b/server/src/main/java/com/epam/aidial/core/server/util/BucketBuilder.java @@ -3,6 +3,7 @@ import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.data.AuthBucket; import com.epam.aidial.core.server.security.EncryptionService; +import com.epam.aidial.core.storage.resource.ResourceDescriptor; import lombok.experimental.UtilityClass; import java.util.Objects; @@ -12,8 +13,10 @@ public class BucketBuilder { public static final String APPDATA_PATTERN = "appdata/%s"; - public static final String USER_BUCKET_PATTERN = "Users/%s/"; - public static final String API_KEY_BUCKET_PATTERN = "Keys/%s/"; + // Prefixes declared in ResourceDescriptor: a storage layout recognizes principal locations by the + // same literals this builder formats them with. + public static final String USER_BUCKET_PATTERN = ResourceDescriptor.USERS_LOCATION_PREFIX + "%s" + ResourceDescriptor.PATH_SEPARATOR; + public static final String API_KEY_BUCKET_PATTERN = ResourceDescriptor.KEYS_LOCATION_PREFIX + "%s" + ResourceDescriptor.PATH_SEPARATOR; public String buildUserBucket(ProxyContext context) { if (context.getApiKeyData().getPerRequestKey() == null) { diff --git a/server/src/main/java/com/epam/aidial/core/server/util/ResponseIdUtil.java b/server/src/main/java/com/epam/aidial/core/server/util/ResponseIdUtil.java index 6c7476173..9cdd36bba 100644 --- a/server/src/main/java/com/epam/aidial/core/server/util/ResponseIdUtil.java +++ b/server/src/main/java/com/epam/aidial/core/server/util/ResponseIdUtil.java @@ -2,14 +2,11 @@ import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.SystemResourceRegistry; import lombok.experimental.UtilityClass; @UtilityClass public class ResponseIdUtil { - public static final String RESPONSE_MAPPINGS_BUCKET = "response_mappings"; - public static final String RESPONSE_MAPPINGS_BUCKET_LOCATION = RESPONSE_MAPPINGS_BUCKET + "/"; - public static final String BACKGROUND_JOB_BUCKET = "background_jobs"; - public static final String BACKGROUND_JOB_BUCKET_LOCATION = BACKGROUND_JOB_BUCKET + "/"; public static final String RESPONSE_ID_PREFIX = "dial_"; public String createResponseId(String deploymentName, String uuid) { @@ -28,11 +25,11 @@ public ResourceDescriptor getResponseMappingDescriptor(String dialResponseId) { String uuid = dialResponseId.substring(underscore + 1); String relativePath = deploymentName + "/" + uuid; return ResourceDescriptorFactory.fromDecoded( - ResourceTypes.RESPONSE_MAPPING, RESPONSE_MAPPINGS_BUCKET, RESPONSE_MAPPINGS_BUCKET_LOCATION, relativePath); + ResourceTypes.RESPONSE_MAPPING, SystemResourceRegistry.RESPONSE_MAPPINGS.bucket(), SystemResourceRegistry.RESPONSE_MAPPINGS.location(), relativePath); } public ResourceDescriptor getBackgroundJobDescriptor(String jobId) { return ResourceDescriptorFactory.fromDecoded( - ResourceTypes.BACKGROUND_JOB, BACKGROUND_JOB_BUCKET, BACKGROUND_JOB_BUCKET_LOCATION, jobId); + ResourceTypes.BACKGROUND_JOB, SystemResourceRegistry.BACKGROUND_JOBS.bucket(), SystemResourceRegistry.BACKGROUND_JOBS.location(), jobId); } } diff --git a/server/src/main/resources/aidial.settings.json b/server/src/main/resources/aidial.settings.json index db960d2b3..3b3b8d2f3 100644 --- a/server/src/main/resources/aidial.settings.json +++ b/server/src/main/resources/aidial.settings.json @@ -53,12 +53,12 @@ "createBucket": true, "overrides": { "jclouds.filesystem.basedir": "data" + }, + "layout": { + "tenantRooted": false, + "defaultTenant": "default" } }, - "storageLayout": { - "tenantRooted": false, - "defaultTenant": "default" - }, "resources": { "maxSize" : 67108864, "maxSizeToCache": 1048576, diff --git a/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java b/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java index 72c3d4589..5d442c20e 100644 --- a/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java @@ -14,10 +14,11 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Drives the resource API with {@code storageLayout.tenantRooted} enabled: the whole stack — descriptor, + * Drives the resource API with {@code storage.layout.tenantRooted} enabled: the whole stack — descriptor, * cache and blob store — has to agree on the tenant-rooted paths, which unit tests cannot show. */ public class TenantRootedLayoutApiTest extends ResourceBaseTest { @@ -26,9 +27,9 @@ public class TenantRootedLayoutApiTest extends ResourceBaseTest { @Override protected JsonObject additionalSettingsOverrides() { - return new JsonObject().put("storageLayout", new JsonObject() + return new JsonObject().put("storage", new JsonObject().put("layout", new JsonObject() .put("tenantRooted", true) - .put("defaultTenant", TENANT)); + .put("defaultTenant", TENANT))); } @AfterEach @@ -66,21 +67,19 @@ public void testBlobIsStoredUnderTenantRoot() throws IOException { Response flushed = resourceRequest(HttpMethod.GET, "/folder/conversation"); assertEquals(200, flushed.status()); - List storedPaths = findStoredPaths(""); + List storedPaths = findStoredPaths(); List tenantRootedPaths = storedPaths.stream() .filter(path -> path.toString().contains(".org/" + TENANT)) .toList(); - assertTrue(!tenantRootedPaths.isEmpty(), + assertFalse(tenantRootedPaths.isEmpty(), () -> "No blob stored under the tenant root, found: " + storedPaths); assertTrue(tenantRootedPaths.stream().anyMatch(path -> path.toString().contains(".conversations")), () -> "Conversations are not stored in a reserved type folder: " + tenantRootedPaths); } - private List findStoredPaths(String marker) throws IOException { + private List findStoredPaths() throws IOException { try (Stream paths = Files.walk(testDir)) { - return paths.filter(Files::isRegularFile) - .filter(path -> path.toString().contains(marker)) - .toList(); + return paths.filter(Files::isRegularFile).toList(); } } } diff --git a/server/src/test/java/com/epam/aidial/core/server/config/SecretFieldProcessorLayoutTest.java b/server/src/test/java/com/epam/aidial/core/server/config/SecretFieldProcessorLayoutTest.java new file mode 100644 index 000000000..50a22d909 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/config/SecretFieldProcessorLayoutTest.java @@ -0,0 +1,97 @@ +package com.epam.aidial.core.server.config; + +import com.epam.aidial.core.config.Key; +import com.epam.aidial.core.credentials.data.configuration.EncryptionSettings; +import com.epam.aidial.core.credentials.data.credentials.BucketInfo; +import com.epam.aidial.core.credentials.encryption.ContentEncryptionKeyService; +import com.epam.aidial.core.credentials.encryption.CredentialEncryptionService; +import com.epam.aidial.core.credentials.encryption.DataEncryptionService; +import com.epam.aidial.core.server.util.ResourceDescriptorFactory; +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; +import com.epam.aidial.core.storage.resource.ResourceDescriptor; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import com.epam.aidial.core.storage.resource.TenantRootedStorageLayout; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The AAD of an encrypted field lives on inside the stored ciphertext, so it must not change when the + * storage layout does. These tests use real AES-GCM — a mocked cipher ignores the AAD and would pass + * regardless of which path it was derived from. + */ +public class SecretFieldProcessorLayoutTest { + + private static final BucketInfo BUCKET = new BucketInfo("platform", "platform/"); + + private CredentialEncryptionService encryptionService; + private SecretFieldProcessor processor; + private ResourceDescriptor descriptor; + + @BeforeEach + public void setUp() { + EncryptionSettings settings = EncryptionSettings.builder() + .algorithm("AES") + .keySize(256) + .cipherTransformation("AES/GCM/NoPadding") + .ivLengthBytes(12) + .gcmTagLengthBits(128) + .build(); + SecureRandom random = new SecureRandom(); + byte[] contentEncryptionKey = new byte[32]; + random.nextBytes(contentEncryptionKey); + ContentEncryptionKeyService keyService = mock(ContentEncryptionKeyService.class); + when(keyService.getOrCreateKey(any(BucketInfo.class))).thenReturn(contentEncryptionKey); + + encryptionService = new CredentialEncryptionService(keyService, new DataEncryptionService(settings, random)); + processor = new SecretFieldProcessor(encryptionService, BUCKET); + descriptor = ResourceDescriptorFactory.fromDecoded( + ResourceTypes.PROJECT_KEY, "platform", "platform/", "test-key"); + } + + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + public void testSecretEncryptedUnderLegacyLayoutDecryptsUnderTenantRootedLayout() { + Key key = new Key(); + key.setKey("plain-secret"); + processor.encryptFields(key, descriptor); + assertTrue(key.getKey().startsWith(SecretFieldProcessor.ENC_PREFIX)); + + StorageLayouts.useLayout(new TenantRootedStorageLayout("acme")); + processor.decryptFields(key, descriptor); + + assertEquals("plain-secret", key.getKey()); + } + + // Under the legacy layout the physical and legacy paths coincide, so the divergence only exists on the + // tenant-rooted side: ciphertext bound to the tenant-shaped physical path must not decrypt against the + // legacy-path AAD. This is also the guard proving the AAD participates at all — with a cipher that ignored + // it, the round-trip test above would pass for any path. + @Test + public void testPhysicalPathAadDoesNotMatchTheLegacyAad() { + StorageLayouts.useLayout(new TenantRootedStorageLayout("acme")); + + byte[] physicalPathAad = descriptor.getAbsoluteFilePath().getBytes(StandardCharsets.UTF_8); + byte[] cipher = encryptionService.encrypt(BUCKET, "plain-secret".getBytes(StandardCharsets.UTF_8), physicalPathAad); + Key key = new Key(); + key.setKey(SecretFieldProcessor.ENC_PREFIX + Base64.getEncoder().encodeToString(cipher) + SecretFieldProcessor.ENC_SUFFIX); + + assertThrows(SecurityException.class, () -> processor.decryptFields(key, descriptor)); + } +} diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ApplicationDeploymentLayoutTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ApplicationDeploymentLayoutTest.java new file mode 100644 index 000000000..f19546d7d --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/service/ApplicationDeploymentLayoutTest.java @@ -0,0 +1,59 @@ +package com.epam.aidial.core.server.service; + +import com.epam.aidial.core.server.util.ResourceDescriptorFactory; +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; +import com.epam.aidial.core.storage.resource.ResourceDescriptor; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import com.epam.aidial.core.storage.resource.TenantRootedStorageLayout; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code ApplicationService} keys a function app's deployment folder as a synthesized sub-bucket of the + * owner's location, so these shapes never pass through the bucket builder. Each of them has to compose a + * physical path under the tenant-rooted layout, and the locations are composed by the service itself — + * a literal here could drift from what production synthesizes. + */ +public class ApplicationDeploymentLayoutTest { + + @BeforeEach + public void useTenantRootedLayout() { + StorageLayouts.useLayout(new TenantRootedStorageLayout("acme")); + } + + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + public void testPublicDeploymentFolder() { + ResourceDescriptor folder = deploymentFolder(ResourceDescriptor.PUBLIC_LOCATION); + + assertEquals(".org/acme/deployments/fn-1/.files/", folder.getAbsoluteFilePath()); + assertEquals("public/deployments/fn-1/files/", folder.getLegacyFilePath()); + } + + @Test + public void testPrivateDeploymentFolder() { + ResourceDescriptor folder = deploymentFolder(ResourceDescriptor.USERS_LOCATION_PREFIX + "u1/"); + + assertEquals(".org/acme/.users/u1/deployments/fn-1/.files/", folder.getAbsoluteFilePath()); + } + + @Test + public void testReviewDeploymentFolder() { + ResourceDescriptor folder = deploymentFolder(ResourceDescriptor.USERS_LOCATION_PREFIX + "u1/publications/p1/"); + + assertEquals(".org/acme/.users/u1/publications/p1/deployments/fn-1/.files/", folder.getAbsoluteFilePath()); + } + + private static ResourceDescriptor deploymentFolder(String ownerLocation) { + String location = ApplicationService.deploymentFolderLocation(ownerLocation, "fn-1"); + return ResourceDescriptorFactory.fromDecoded(ResourceTypes.FILE, "bucket", location, null); + } +} diff --git a/server/src/test/java/com/epam/aidial/core/server/util/ResponseIdUtilTest.java b/server/src/test/java/com/epam/aidial/core/server/util/ResponseIdUtilTest.java index 7ad08e1de..756bb42c7 100644 --- a/server/src/test/java/com/epam/aidial/core/server/util/ResponseIdUtilTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/util/ResponseIdUtilTest.java @@ -2,6 +2,7 @@ import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.SystemResourceRegistry; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -28,8 +29,8 @@ public void testGetResponseMappingDescriptor() { ResourceDescriptor descriptor = ResponseIdUtil.getResponseMappingDescriptor("dial_gpt-4_abc123"); assertEquals(ResourceTypes.RESPONSE_MAPPING, descriptor.getType()); - assertEquals(ResponseIdUtil.RESPONSE_MAPPINGS_BUCKET, descriptor.getBucketName()); - assertEquals(ResponseIdUtil.RESPONSE_MAPPINGS_BUCKET_LOCATION, descriptor.getBucketLocation()); + assertEquals(SystemResourceRegistry.RESPONSE_MAPPINGS.bucket(), descriptor.getBucketName()); + assertEquals(SystemResourceRegistry.RESPONSE_MAPPINGS.location(), descriptor.getBucketLocation()); assertEquals("gpt-4", descriptor.getParentPath()); assertEquals("abc123", descriptor.getName()); } diff --git a/server/src/test/java/com/epam/aidial/core/server/util/SystemBucketLayoutTest.java b/server/src/test/java/com/epam/aidial/core/server/util/SystemBucketLayoutTest.java new file mode 100644 index 000000000..08af21590 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/util/SystemBucketLayoutTest.java @@ -0,0 +1,79 @@ +package com.epam.aidial.core.server.util; + +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; +import com.epam.aidial.core.storage.resource.ResourceDescriptor; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import com.epam.aidial.core.storage.resource.SystemResourceRegistry; +import com.epam.aidial.core.storage.resource.TenantRootedStorageLayout; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Every descriptor the platform builds against a system bucket has to compose a path under the tenant-rooted + * layout, not just the ones the resource API can reach. + * + *

These paths are built off the request path, so an unmapped location does not surface as a failed + * request — it throws inside a background task and takes the subsystem out silently. + */ +public class SystemBucketLayoutTest { + + private static final String TENANT = "acme"; + + @BeforeEach + public void useTenantRootedLayout() { + StorageLayouts.useLayout(new TenantRootedStorageLayout(TENANT)); + } + + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + public void testResponseMappingPath() { + ResourceDescriptor descriptor = ResponseIdUtil.getResponseMappingDescriptor("dial_gpt-4_abc123"); + + assertEquals(".system/response_mappings/.response_mappings/gpt-4/abc123", descriptor.getAbsoluteFilePath()); + assertEquals("response_mappings/response_mappings/gpt-4/abc123", descriptor.getLegacyFilePath()); + } + + @Test + public void testBackgroundJobPath() { + ResourceDescriptor descriptor = ResponseIdUtil.getBackgroundJobDescriptor("job-1"); + + assertEquals(".system/background_jobs/.background_jobs/job-1", descriptor.getAbsoluteFilePath()); + } + + /** + * The scheduler scans the whole bucket, and cost accounting deletes by trace id — both address the folder + * itself, which is a separate composition path from an item. + */ + @Test + public void testBackgroundJobRootFolderPath() { + ResourceDescriptor root = ResponseIdUtil.getBackgroundJobDescriptor(null); + + assertEquals(".system/background_jobs/.background_jobs/", root.getAbsoluteFilePath()); + } + + @Test + public void testApiKeyDataPath() { + ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(ResourceTypes.API_KEY_DATA, + SystemResourceRegistry.API_KEY_DATA.bucket(), SystemResourceRegistry.API_KEY_DATA.location(), "some-key"); + + assertEquals(".system/api_key_data/.api_key_data/some-key", descriptor.getAbsoluteFilePath()); + } + + @Test + public void testDeploymentCostStatsPath() { + ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(ResourceTypes.DEPLOYMENT_COST_STATS, + SystemResourceRegistry.DEPLOYMENT_COST_STATS.bucket(), SystemResourceRegistry.DEPLOYMENT_COST_STATS.location(), + "trace-id"); + + assertEquals(".system/deployment_cost_stats/.deployment_cost_stats/trace-id", descriptor.getAbsoluteFilePath()); + } + +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java index d124908e3..95fe8177c 100644 --- a/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java @@ -16,7 +16,7 @@ public String resolveLocationPrefix(String bucketLocation) { } @Override - public String resolveTypeFolder(String group) { - return group; + public String resolveTypeFolder(String typeGroup) { + return typeGroup; } } diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java index caa6a8c98..2cde51925 100644 --- a/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java @@ -23,6 +23,13 @@ public class ResourceDescriptor { public static final String PLATFORM_BUCKET = "platform"; public static final String PLATFORM_LOCATION = PLATFORM_BUCKET + PATH_SEPARATOR; + /** + * Prefixes of the two principal bucket-location shapes, {@code Users//} and {@code Keys//}. + * The server-side bucket builder formats locations with them, and a storage layout recognizes principal + * locations by them — one set of literals for both, or the two drift. + */ + public static final String USERS_LOCATION_PREFIX = "Users" + PATH_SEPARATOR; + public static final String KEYS_LOCATION_PREFIX = "Keys" + PATH_SEPARATOR; ResourceType type; /** @@ -106,8 +113,24 @@ public String getDecodedUrl() { * Returns an absolute path to the resource in a persistent storage. */ public String getAbsoluteFilePath() { + return getStoragePrefix(StorageLayouts.resolveActive()) + getPathWithinType(); + } + + /** + * The path {@link #getAbsoluteFilePath()} produces under the legacy layout, whichever layout is active. + * Anything durable derived from a path — an identifier handed to a user, an encryption AAD — must use + * this: a physical path is free to change when the layout does, and the stored artifact is not. + */ + public String getLegacyFilePath() { + return getStoragePrefix(LegacyStorageLayout.INSTANCE) + getPathWithinType(); + } + + private String getStoragePrefix(StorageLayout layout) { + return layout.resolveLocationPrefix(bucketLocation) + layout.resolveTypeFolder(type.group()) + PATH_SEPARATOR; + } + + private String getPathWithinType() { StringBuilder builder = new StringBuilder(); - builder.append(getStoragePrefix()); if (!parentFolders.isEmpty()) { builder.append(getParentPath()) @@ -125,15 +148,6 @@ public String getAbsoluteFilePath() { return builder.toString(); } - /** - * Returns the layout-dependent prefix every physical path of this resource starts with: the bucket - * location followed by the resource-type folder. - */ - private String getStoragePrefix() { - StorageLayout layout = StorageLayouts.resolveActive(); - return layout.resolveLocationPrefix(bucketLocation) + layout.resolveTypeFolder(type.group()) + PATH_SEPARATOR; - } - /** * Returns the parent resource if any. */ @@ -218,7 +232,7 @@ public ResourceDescriptor resolveByUrl(String url) { * @param path - to the resource with decrypted bucket */ public ResourceDescriptor resolveByPath(String path) { - String prefix = getStoragePrefix(); + String prefix = getStoragePrefix(StorageLayouts.resolveActive()); if (!isFolder) { throw new IllegalStateException("Resource must be a folder"); } diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java index 7a25bc276..d0a037587 100644 --- a/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java @@ -9,5 +9,8 @@ public interface StorageLayout { String resolveLocationPrefix(String bucketLocation); - String resolveTypeFolder(String group); + /** + * @param typeGroup {@link ResourceType#group()} — the storage folder name of the resource type + */ + String resolveTypeFolder(String typeGroup); } diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/SystemResourceRegistry.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/SystemResourceRegistry.java new file mode 100644 index 000000000..29e8da7cd --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/SystemResourceRegistry.java @@ -0,0 +1,37 @@ +package com.epam.aidial.core.storage.resource; + +/** + * The platform-internal buckets holding runtime state rather than anyone's content. They belong to no + * principal, so a layout has to place them somewhere other than the branches it uses for users and + * projects; this registry is how a layout enumerates them. + */ +public enum SystemResourceRegistry { + + DEPLOYMENT_COST_STATS("deployment_cost_stats"), + BACKGROUND_JOBS("background_jobs"), + RESPONSE_MAPPINGS("response_mappings"), + API_KEY_DATA("api_key_data"); + + private final String bucket; + + SystemResourceRegistry(String bucket) { + this.bucket = bucket; + } + + public String bucket() { + return bucket; + } + + public String location() { + return bucket + ResourceDescriptor.PATH_SEPARATOR; + } + + public static boolean isSystemLocation(String location) { + for (SystemResourceRegistry entry : values()) { + if (entry.location().equals(location)) { + return true; + } + } + return false; + } +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransform.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransform.java deleted file mode 100644 index b9a0b41bc..000000000 --- a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransform.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.epam.aidial.core.storage.resource; - -import lombok.experimental.UtilityClass; - -import javax.annotation.Nullable; - -/** - * Converts the two halves of a physical storage path between the legacy bucket-rooted layout and the - * tenant-rooted one: the bucket location prefix and the resource-type folder. Path composition itself - * stays in {@link ResourceDescriptor#getAbsoluteFilePath()}. - * - *

The conversion is total and reversible in both directions, so a migrated path can always be mapped - * back to its origin. - * - *

Legacy locations are produced by the server-side bucket builder; the prefixes are repeated here - * because this module cannot depend on it. - */ -@UtilityClass -public class TenantLayoutTransform { - - private static final String LEGACY_USERS_PREFIX = "Users/"; - private static final String LEGACY_KEYS_PREFIX = "Keys/"; - - private static final String ORG_PREFIX = ".org/"; - private static final String USERS_SEGMENT = ".users/"; - private static final String KEYS_SEGMENT = ".keys/"; - - /** - * The platform scope is the root of the tenant-rooted tree, above any tenant, so it has no prefix. - */ - private static final String PLATFORM_LOCATION = ""; - - private static final char TYPE_FOLDER_MARKER = '.'; - - public String toTenantLocation(String legacyLocation, String tenantId) { - if (ResourceDescriptor.PLATFORM_LOCATION.equals(legacyLocation)) { - return PLATFORM_LOCATION; - } - - String tenantRoot = tenantRoot(tenantId); - if (ResourceDescriptor.PUBLIC_LOCATION.equals(legacyLocation)) { - return tenantRoot; - } - - String userId = principalId(legacyLocation, LEGACY_USERS_PREFIX); - if (userId != null) { - return tenantRoot + USERS_SEGMENT + userId; - } - - String project = principalId(legacyLocation, LEGACY_KEYS_PREFIX); - if (project != null) { - return tenantRoot + KEYS_SEGMENT + project; - } - - throw new IllegalArgumentException("Unsupported legacy bucket location: " + legacyLocation); - } - - public String toLegacyLocation(String tenantLocation, String tenantId) { - if (PLATFORM_LOCATION.equals(tenantLocation)) { - return ResourceDescriptor.PLATFORM_LOCATION; - } - - String tenantRoot = tenantRoot(tenantId); - if (!tenantLocation.startsWith(tenantRoot)) { - throw new IllegalArgumentException("Location does not belong to tenant " + tenantId + ": " + tenantLocation); - } - - String scope = tenantLocation.substring(tenantRoot.length()); - if (scope.isEmpty()) { - return ResourceDescriptor.PUBLIC_LOCATION; - } - - String userId = principalId(scope, USERS_SEGMENT); - if (userId != null) { - return LEGACY_USERS_PREFIX + userId; - } - - String project = principalId(scope, KEYS_SEGMENT); - if (project != null) { - return LEGACY_KEYS_PREFIX + project; - } - - throw new IllegalArgumentException("Unsupported tenant bucket location: " + tenantLocation); - } - - public String toTenantTypeFolder(String legacyTypeFolder) { - if (legacyTypeFolder.isEmpty() || legacyTypeFolder.charAt(0) == TYPE_FOLDER_MARKER) { - throw new IllegalArgumentException("Unsupported legacy resource type folder: " + legacyTypeFolder); - } - - return TYPE_FOLDER_MARKER + legacyTypeFolder; - } - - public String toLegacyTypeFolder(String tenantTypeFolder) { - if (tenantTypeFolder.length() < 2 || tenantTypeFolder.charAt(0) != TYPE_FOLDER_MARKER) { - throw new IllegalArgumentException("Unsupported tenant resource type folder: " + tenantTypeFolder); - } - - return tenantTypeFolder.substring(1); - } - - private String tenantRoot(String tenantId) { - if (tenantId.isEmpty()) { - throw new IllegalArgumentException("Tenant id must not be empty"); - } - - return ORG_PREFIX + tenantId + ResourceDescriptor.PATH_SEPARATOR; - } - - /** - * Returns the principal id following the given prefix, trailing separator included, or null when the - * location does not carry that prefix. The id may span several segments: an application's own bucket - * is keyed by the application url. - */ - @Nullable - private String principalId(String location, String prefix) { - if (!location.startsWith(prefix)) { - return null; - } - - String id = location.substring(prefix.length()); - return id.length() > 1 && id.endsWith(ResourceDescriptor.PATH_SEPARATOR) ? id : null; - } -} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformer.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformer.java new file mode 100644 index 000000000..1a0fdecf8 --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformer.java @@ -0,0 +1,208 @@ +package com.epam.aidial.core.storage.resource; + +import lombok.experimental.UtilityClass; + +import java.util.Set; +import javax.annotation.Nullable; + +/** + * Converts the two halves of a physical storage path between the legacy bucket-rooted layout and the + * tenant-rooted one: the bucket location prefix and the resource-type folder. Path composition itself + * stays in {@link ResourceDescriptor#getAbsoluteFilePath()}. + * + *

The conversion is total and reversible in both directions, so a migrated path can always be mapped + * back to its origin. Nothing on the request path converts backwards — the legacy direction exists for + * migration tooling and the layout verifier. + */ +@UtilityClass +public class TenantLayoutTransformer { + + private static final String ORG_PREFIX = ".org/"; + private static final String USERS_SEGMENT = ".users/"; + private static final String KEYS_SEGMENT = ".keys/"; + + /** + * The platform scope maps to the root of the tenant-rooted tree, above any tenant, so it has no prefix. + */ + private static final String TENANT_TREE_ROOT = ""; + + /** + * Where the system buckets of {@link SystemResourceRegistry} land — at the root, above any + * tenant, which preserves whole-bucket scans and globally unique keys; the bucket name is kept so the + * mapping stays reversible. + */ + private static final String SYSTEM_SEGMENT = ".system/"; + + private static final char TYPE_FOLDER_MARKER = '.'; + + /** + * The dotted names with a structural meaning in the tenant-rooted tree. A resource type whose group + * produced one of these as its type folder would make full paths unparseable — nothing distinguishes + * the public {@code .users} type folder from the {@code .users} principal branch — so such a group is + * rejected outright. Nothing else keeps a future {@code ResourceTypes} entry off these names. + */ + private static final Set RESERVED_SEGMENTS = Set.of(ORG_PREFIX, USERS_SEGMENT, KEYS_SEGMENT, SYSTEM_SEGMENT); + + /** + * A location that is not the platform, a system bucket, public or a principal is rejected rather than + * passed through: a silent fallback would let the next unmapped bucket reach production unnoticed. + */ + public String toTenantLocation(String legacyLocation, String tenantId) { + if (ResourceDescriptor.PLATFORM_LOCATION.equals(legacyLocation)) { + return TENANT_TREE_ROOT; + } + + if (SystemResourceRegistry.isSystemLocation(legacyLocation)) { + return SYSTEM_SEGMENT + legacyLocation; + } + + String tenantRoot = tenantRoot(tenantId); + if (legacyLocation.startsWith(ResourceDescriptor.PUBLIC_LOCATION)) { + // Not only "public/" itself: the platform synthesizes sub-buckets under it, e.g. a public + // function app's source folder is keyed as "public/deployments//". The suffix keeps its + // legacy shape under the tenant root. + String scope = legacyLocation.substring(ResourceDescriptor.PUBLIC_LOCATION.length()); + requirePublicScope(scope, legacyLocation); + return tenantRoot + scope; + } + + String userId = principalId(legacyLocation, ResourceDescriptor.USERS_LOCATION_PREFIX); + if (userId != null) { + return tenantRoot + USERS_SEGMENT + userId; + } + + String project = principalId(legacyLocation, ResourceDescriptor.KEYS_LOCATION_PREFIX); + if (project != null) { + return tenantRoot + KEYS_SEGMENT + project; + } + + throw new IllegalArgumentException("Unsupported legacy bucket location: " + legacyLocation); + } + + public String toLegacyLocation(String tenantLocation, String tenantId) { + if (TENANT_TREE_ROOT.equals(tenantLocation)) { + return ResourceDescriptor.PLATFORM_LOCATION; + } + + if (tenantLocation.startsWith(SYSTEM_SEGMENT)) { + String system = tenantLocation.substring(SYSTEM_SEGMENT.length()); + if (!SystemResourceRegistry.isSystemLocation(system)) { + throw new IllegalArgumentException("Unknown system bucket location: " + tenantLocation); + } + return system; + } + + String tenantRoot = tenantRoot(tenantId); + if (!tenantLocation.startsWith(tenantRoot)) { + throw new IllegalArgumentException("Location does not belong to tenant " + tenantId + ": " + tenantLocation); + } + + String scope = tenantLocation.substring(tenantRoot.length()); + if (scope.isEmpty()) { + return ResourceDescriptor.PUBLIC_LOCATION; + } + + String userId = principalId(scope, USERS_SEGMENT); + if (userId != null) { + return ResourceDescriptor.USERS_LOCATION_PREFIX + userId; + } + + String project = principalId(scope, KEYS_SEGMENT); + if (project != null) { + return ResourceDescriptor.KEYS_LOCATION_PREFIX + project; + } + + if (scope.charAt(0) != TYPE_FOLDER_MARKER) { + requirePublicScope(scope, tenantLocation); + return ResourceDescriptor.PUBLIC_LOCATION + scope; + } + + throw new IllegalArgumentException("Unsupported tenant bucket location: " + tenantLocation); + } + + /** + * A public sub-bucket suffix must end at a path boundary, and none of its segments may start with + * the marker character: dotted names under the tenant root are reserved for principal branches and + * resource-type folders, so a dotted segment here would make the mapping irreversible. + */ + private void requirePublicScope(String scope, String location) { + if (scope.isEmpty()) { + return; + } + + if (!scope.endsWith(ResourceDescriptor.PATH_SEPARATOR) || hasDottedSegment(scope)) { + throw new IllegalArgumentException("Unsupported public bucket location: " + location); + } + } + + public String toTenantTypeFolder(String legacyTypeFolder) { + if (legacyTypeFolder.isEmpty() || legacyTypeFolder.charAt(0) == TYPE_FOLDER_MARKER) { + throw new IllegalArgumentException("Unsupported legacy resource type folder: " + legacyTypeFolder); + } + + String folder = TYPE_FOLDER_MARKER + legacyTypeFolder; + requireUnreserved(folder, legacyTypeFolder); + return folder; + } + + public String toLegacyTypeFolder(String tenantTypeFolder) { + if (tenantTypeFolder.length() < 2 || tenantTypeFolder.charAt(0) != TYPE_FOLDER_MARKER) { + throw new IllegalArgumentException("Unsupported tenant resource type folder: " + tenantTypeFolder); + } + + requireUnreserved(tenantTypeFolder, tenantTypeFolder); + return tenantTypeFolder.substring(1); + } + + private void requireUnreserved(String dottedFolder, String input) { + if (RESERVED_SEGMENTS.contains(dottedFolder + ResourceDescriptor.PATH_SEPARATOR)) { + throw new IllegalArgumentException("Resource type folder collides with a reserved name: " + input); + } + } + + /** + * A tenant id is a single undotted path segment: a separator would let one tenant's root nest inside + * another's, and a leading marker would collide with the reserved dotted names. + */ + void requireTenantId(String tenantId) { + if (tenantId.isEmpty() + || tenantId.contains(ResourceDescriptor.PATH_SEPARATOR) + || tenantId.charAt(0) == TYPE_FOLDER_MARKER) { + throw new IllegalArgumentException("Unsupported tenant id: " + tenantId); + } + } + + private String tenantRoot(String tenantId) { + requireTenantId(tenantId); + return ORG_PREFIX + tenantId + ResourceDescriptor.PATH_SEPARATOR; + } + + /** + * Returns the principal id following the given prefix, trailing separator included, or null when the + * location does not carry that prefix. The id may span several segments: an application's own bucket + * is keyed by the application url. + */ + @Nullable + private String principalId(String location, String prefix) { + if (!location.startsWith(prefix)) { + return null; + } + + String id = location.substring(prefix.length()); + if (id.length() <= 1 || !id.endsWith(ResourceDescriptor.PATH_SEPARATOR)) { + return null; + } + + // A dotted segment would escape the tree ("..") or collide with the dotted names reserved for + // type folders and principal branches; no legitimate producer emits one. + if (hasDottedSegment(id)) { + throw new IllegalArgumentException("Unsupported principal id in location: " + location); + } + return id; + } + + private boolean hasDottedSegment(String path) { + return path.charAt(0) == TYPE_FOLDER_MARKER + || path.contains(ResourceDescriptor.PATH_SEPARATOR + TYPE_FOLDER_MARKER); + } +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java index ffabeffce..6b968df07 100644 --- a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java @@ -2,7 +2,7 @@ /** * The tenant-rooted layout: every bucket location is placed under its tenant, and resource-type folders - * are reserved names. Conversion rules live in {@link TenantLayoutTransform}. + * are reserved names. Conversion rules live in {@link TenantLayoutTransformer}. */ public final class TenantRootedStorageLayout implements StorageLayout { @@ -13,16 +13,18 @@ public TenantRootedStorageLayout(String tenantId) { throw new IllegalArgumentException("Tenant id must not be blank"); } + // Same rule the transform applies on every composition, but failing here fails at start-up. + TenantLayoutTransformer.requireTenantId(tenantId); this.tenantId = tenantId; } @Override public String resolveLocationPrefix(String bucketLocation) { - return TenantLayoutTransform.toTenantLocation(bucketLocation, tenantId); + return TenantLayoutTransformer.toTenantLocation(bucketLocation, tenantId); } @Override - public String resolveTypeFolder(String group) { - return TenantLayoutTransform.toTenantTypeFolder(group); + public String resolveTypeFolder(String typeGroup) { + return TenantLayoutTransformer.toTenantTypeFolder(typeGroup); } } diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformTest.java deleted file mode 100644 index 34560c63f..000000000 --- a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.epam.aidial.core.storage.resource; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class TenantLayoutTransformTest { - - private static final String TENANT = "default-tenant"; - - @Test - public void testPlatformLocation() { - assertEquals("", TenantLayoutTransform.toTenantLocation("platform/", TENANT)); - assertEquals("platform/", TenantLayoutTransform.toLegacyLocation("", TENANT)); - } - - @Test - public void testPublicLocation() { - assertEquals(".org/default-tenant/", TenantLayoutTransform.toTenantLocation("public/", TENANT)); - assertEquals("public/", TenantLayoutTransform.toLegacyLocation(".org/default-tenant/", TENANT)); - } - - @Test - public void testUserLocation() { - assertEquals(".org/default-tenant/.users/u1/", TenantLayoutTransform.toTenantLocation("Users/u1/", TENANT)); - assertEquals("Users/u1/", TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.users/u1/", TENANT)); - } - - @Test - public void testKeyLocation() { - assertEquals(".org/default-tenant/.keys/EPM-RTC-GPT/", TenantLayoutTransform.toTenantLocation("Keys/EPM-RTC-GPT/", TENANT)); - assertEquals("Keys/EPM-RTC-GPT/", TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.keys/EPM-RTC-GPT/", TENANT)); - } - - @Test - public void testMultiSegmentKeyLocation() { - String legacy = "Keys/applications/abc123/my-app/"; - String tenant = ".org/default-tenant/.keys/applications/abc123/my-app/"; - - assertEquals(tenant, TenantLayoutTransform.toTenantLocation(legacy, TENANT)); - assertEquals(legacy, TenantLayoutTransform.toLegacyLocation(tenant, TENANT)); - } - - @Test - public void testLocationRoundTrip() { - for (String legacy : new String[] {"platform/", "public/", "Users/u1/", "Keys/proj/", "Keys/applications/abc/app/"}) { - String tenant = TenantLayoutTransform.toTenantLocation(legacy, TENANT); - assertEquals(legacy, TenantLayoutTransform.toLegacyLocation(tenant, TENANT)); - } - } - - @Test - public void testUnsupportedLegacyLocation() { - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("Unknown/u1/", TENANT)); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("", TENANT)); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("Users/", TENANT)); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("Users/u1", TENANT)); - } - - @Test - public void testUnsupportedTenantLocation() { - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.other/u1/", TENANT)); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.users/", TENANT)); - } - - @Test - public void testForeignTenantRejected() { - assertThrows(IllegalArgumentException.class, - () -> TenantLayoutTransform.toLegacyLocation(".org/other-tenant/.users/u1/", TENANT)); - } - - @Test - public void testEmptyTenantRejected() { - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("public/", "")); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyLocation(".org//", "")); - } - - @Test - public void testTypeFolder() { - assertEquals(".files", TenantLayoutTransform.toTenantTypeFolder("files")); - assertEquals("files", TenantLayoutTransform.toLegacyTypeFolder(".files")); - } - - @Test - public void testTypeFolderRoundTripsForEveryResourceType() { - for (ResourceTypes type : ResourceTypes.values()) { - String tenantFolder = TenantLayoutTransform.toTenantTypeFolder(type.group()); - assertEquals(type.group(), TenantLayoutTransform.toLegacyTypeFolder(tenantFolder)); - } - } - - @Test - public void testUnsupportedTypeFolder() { - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantTypeFolder("")); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantTypeFolder(".files")); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyTypeFolder("files")); - assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyTypeFolder(".")); - } -} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformerTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformerTest.java new file mode 100644 index 000000000..33a859ce9 --- /dev/null +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformerTest.java @@ -0,0 +1,204 @@ +package com.epam.aidial.core.storage.resource; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TenantLayoutTransformerTest { + + private static final String TENANT = "default-tenant"; + + @Test + public void testPlatformLocation() { + assertEquals("", TenantLayoutTransformer.toTenantLocation("platform/", TENANT)); + assertEquals("platform/", TenantLayoutTransformer.toLegacyLocation("", TENANT)); + } + + @Test + public void testPublicLocation() { + assertEquals(".org/default-tenant/", TenantLayoutTransformer.toTenantLocation("public/", TENANT)); + assertEquals("public/", TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/", TENANT)); + } + + @Test + public void testUserLocation() { + assertEquals(".org/default-tenant/.users/u1/", TenantLayoutTransformer.toTenantLocation("Users/u1/", TENANT)); + assertEquals("Users/u1/", TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/.users/u1/", TENANT)); + } + + @Test + public void testKeyLocation() { + assertEquals(".org/default-tenant/.keys/EPM-RTC-GPT/", TenantLayoutTransformer.toTenantLocation("Keys/EPM-RTC-GPT/", TENANT)); + assertEquals("Keys/EPM-RTC-GPT/", TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/.keys/EPM-RTC-GPT/", TENANT)); + } + + @Test + public void testMultiSegmentKeyLocation() { + String legacy = "Keys/applications/abc123/my-app/"; + String tenant = ".org/default-tenant/.keys/applications/abc123/my-app/"; + + assertEquals(tenant, TenantLayoutTransformer.toTenantLocation(legacy, TENANT)); + assertEquals(legacy, TenantLayoutTransformer.toLegacyLocation(tenant, TENANT)); + } + + /** + * The platform synthesizes sub-buckets of {@code public/}: a public function app's source and target + * folder is keyed as {@code public/deployments//}. The suffix keeps its legacy shape under the + * tenant root — it stays parseable because reserved names there are dotted and the suffix may not be. + */ + @Test + public void testPublicDeploymentsLocation() { + assertEquals(".org/default-tenant/deployments/abc123/", + TenantLayoutTransformer.toTenantLocation("public/deployments/abc123/", TENANT)); + assertEquals("public/deployments/abc123/", + TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/deployments/abc123/", TENANT)); + } + + @Test + public void testDottedOrUnterminatedPublicScopeRejected() { + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toTenantLocation("public/.deployments/abc123/", TENANT)); + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toTenantLocation("public/deployments/.abc123/", TENANT)); + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toTenantLocation("public/deployments/abc123", TENANT)); + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/deployments/abc123", TENANT)); + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/deployments/.x/", TENANT)); + } + + @Test + public void testLocationRoundTrip() { + for (String legacy : new String[] {"platform/", "public/", "public/deployments/abc123/", "Users/u1/", "Keys/proj/", "Keys/applications/abc/app/"}) { + String tenant = TenantLayoutTransformer.toTenantLocation(legacy, TENANT); + assertEquals(legacy, TenantLayoutTransformer.toLegacyLocation(tenant, TENANT)); + } + } + + @Test + public void testSystemLocations() { + assertEquals(".system/background_jobs/", TenantLayoutTransformer.toTenantLocation("background_jobs/", TENANT)); + assertEquals("background_jobs/", TenantLayoutTransformer.toLegacyLocation(".system/background_jobs/", TENANT)); + } + + /** + * Every system bucket has to be mapped, not just the ones a test happened to name: an unmapped one throws + * at the point a path is composed, which takes out whatever subsystem owns it. + */ + @Test + public void testEverySystemLocationRoundTrips() { + for (SystemResourceRegistry entry : SystemResourceRegistry.values()) { + String tenant = TenantLayoutTransformer.toTenantLocation(entry.location(), TENANT); + assertEquals(entry.location(), TenantLayoutTransformer.toLegacyLocation(tenant, TENANT)); + } + } + + /** + * System buckets sit above the tenant, so they must not move when the tenant does. + */ + @Test + public void testSystemLocationsAreTenantIndependent() { + for (SystemResourceRegistry entry : SystemResourceRegistry.values()) { + assertEquals(TenantLayoutTransformer.toTenantLocation(entry.location(), TENANT), + TenantLayoutTransformer.toTenantLocation(entry.location(), "another-tenant")); + } + } + + @Test + public void testUnknownSystemLocationRejected() { + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toLegacyLocation(".system/not_a_system_bucket/", TENANT)); + } + + /** + * A dotted principal-id segment would escape the tree ("..") or collide with the dotted names + * reserved for type folders and principal branches. No legitimate producer emits one, so the + * transform rejects rather than composes. + */ + @Test + public void testDottedPrincipalSegmentsRejected() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Users/../", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Users/.evil/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Keys/applications/../app/", TENANT)); + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/.users/../", TENANT)); + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/.keys/../", TENANT)); + } + + @Test + public void testUnsupportedLegacyLocation() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Unknown/u1/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Users/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Users//", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("Users/u1", TENANT)); + } + + @Test + public void testUnsupportedTenantLocation() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/.other/u1/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyLocation(".org/default-tenant/.users/", TENANT)); + } + + @Test + public void testForeignTenantRejected() { + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransformer.toLegacyLocation(".org/other-tenant/.users/u1/", TENANT)); + } + + @Test + public void testEmptyTenantRejected() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("public/", "")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyLocation(".org//", "")); + } + + /** + * A separator would let one tenant's root nest inside another's ("acme/.users" collides with tenant + * acme's users branch), and a leading marker collides with the reserved dotted names. + */ + @Test + public void testInvalidTenantRejected() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("public/", "a/b")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("public/", "acme/.users")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("public/", "..")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantLocation("public/", ".acme")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyLocation(".org/a/b/", "a/b")); + } + + @Test + public void testTypeFolder() { + assertEquals(".files", TenantLayoutTransformer.toTenantTypeFolder("files")); + assertEquals("files", TenantLayoutTransformer.toLegacyTypeFolder(".files")); + } + + @Test + public void testTypeFolderRoundTripsForEveryResourceType() { + for (ResourceTypes type : ResourceTypes.values()) { + String tenantFolder = TenantLayoutTransformer.toTenantTypeFolder(type.group()); + assertEquals(type.group(), TenantLayoutTransformer.toLegacyTypeFolder(tenantFolder)); + } + } + + /** + * With the guard in place, {@link #testTypeFolderRoundTripsForEveryResourceType} doubles as the + * build-time tripwire: a future {@code ResourceTypes} group taking a reserved name fails that test. + */ + @Test + public void testReservedTypeFolderNamesRejected() { + for (String reserved : new String[] {"org", "system", "users", "keys"}) { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantTypeFolder(reserved)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyTypeFolder("." + reserved)); + } + } + + @Test + public void testUnsupportedTypeFolder() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantTypeFolder("")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toTenantTypeFolder(".files")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyTypeFolder("files")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransformer.toLegacyTypeFolder(".")); + } +} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java index bc87cab9a..6a836043b 100644 --- a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java @@ -1,7 +1,10 @@ package com.epam.aidial.core.storage.resource; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -9,6 +12,11 @@ public class TenantRootedStorageLayoutTest { private final StorageLayout layout = new TenantRootedStorageLayout("acme"); + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + @Test public void testLocationPrefixIsTenantRooted() { assertEquals(".org/acme/.users/u1/", layout.resolveLocationPrefix("Users/u1/")); @@ -23,6 +31,46 @@ public void testTypeFolderIsReserved() { assertEquals(".conversations", layout.resolveTypeFolder("conversations")); } + @Test + public void testSystemLocationsResolve() { + assertEquals(".system/deployment_cost_stats/", layout.resolveLocationPrefix("deployment_cost_stats/")); + assertEquals(".system/background_jobs/", layout.resolveLocationPrefix("background_jobs/")); + assertEquals(".system/response_mappings/", layout.resolveLocationPrefix("response_mappings/")); + } + + /** + * The composed path for a system bucket, end to end. These buckets name themselves twice — the location + * and the resource type carry the same word — which the layout has to preserve rather than tidy up. + */ + @Test + public void testSystemBucketPath() { + StorageLayouts.useLayout(layout); + + ResourceDescriptor job = new ResourceDescriptor(ResourceTypes.BACKGROUND_JOB, "job-1", + List.of(), SystemResourceRegistry.BACKGROUND_JOBS.bucket(), + SystemResourceRegistry.BACKGROUND_JOBS.location(), false); + + assertEquals(".system/background_jobs/.background_jobs/job-1", job.getAbsoluteFilePath()); + assertEquals("background_jobs/background_jobs/job-1", job.getLegacyFilePath()); + } + + /** + * {@code resolveByPath} re-derives a descriptor from a listed physical path, so it has to parse the + * tenant-rooted shape, not just compose it. + */ + @Test + public void testResolveByPathUnderTenantRootedLayout() { + StorageLayouts.useLayout(layout); + + ResourceDescriptor folder = new ResourceDescriptor(ResourceTypes.CONVERSATION, null, + List.of(), "bucket", "Users/u1/", true); + ResourceDescriptor resolved = folder.resolveByPath(".org/acme/.users/u1/.conversations/chats/chat1"); + + assertEquals("chat1", resolved.getName()); + assertEquals(List.of("chats"), resolved.getParentFolders()); + assertEquals(".org/acme/.users/u1/.conversations/chats/chat1", resolved.getAbsoluteFilePath()); + } + @Test public void testUnsupportedLocationRejected() { assertThrows(IllegalArgumentException.class, () -> layout.resolveLocationPrefix("Unknown/u1/")); @@ -33,4 +81,15 @@ public void testBlankTenantRejected() { assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout(null)); assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout(" ")); } + + /** + * The constructor applies the same tenant-id rule the transform applies on every composition, so a + * misconfigured tenant fails at start-up rather than on the first request. + */ + @Test + public void testInvalidTenantRejectedAtConstruction() { + assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout("acme/.users")); + assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout("..")); + assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout(".acme")); + } }