Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ flowchart LR

| Service | Routing | Notable operations |
|-------------------------|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Blob Storage** | `/{account}/` | Create/delete containers, upload/download/delete blobs, list blobs; ADLS Gen2 DFS host alias and user delegation key vending |
| **Blob Storage** | `/{account}/` | Create/delete containers, upload/download/delete blobs, list blobs; ADLS Gen2 DFS host alias, user delegation key vending, user delegation SAS enforcement |
| **Queue Storage** | `/{account}-queue/` | Create/delete queues, send/receive/peek/delete messages, visibility timeout |
| **Table Storage** | `/{account}-table/` | Create/delete tables, insert/get/update/upsert/delete entities; OData `$filter` / `$select` / `$top`; server-side pagination (continuation tokens); ETag optimistic concurrency; Entity Group Transactions (`$batch`) |
| **Azure Functions** | `/{account}-functions/` | Deploy & invoke HTTP-triggered functions (node, python, java, dotnet); warm-container pool |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
import com.azure.storage.file.datalake.DataLakeFileSystemClient;
import com.azure.storage.file.datalake.DataLakeServiceClient;
import com.azure.storage.file.datalake.DataLakeServiceClientBuilder;
import com.azure.storage.file.datalake.models.DataLakeStorageException;
import com.azure.storage.file.datalake.models.UserDelegationKey;
import com.azure.storage.file.datalake.sas.DataLakeServiceSasSignatureValues;
import com.azure.storage.file.datalake.sas.FileSystemSasPermission;
import com.azure.storage.file.datalake.sas.PathSasPermission;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand All @@ -22,6 +24,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
Expand All @@ -31,8 +34,9 @@ class DataLakeCompatibilityTest {
private DataLakeServiceClient client;

@BeforeAll
void setup() {
void setup() throws Exception {
EmulatorConfig.assumeEmulatorRunning();
EmulatorConfig.installEmulatorTlsCert();
client = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase())
.credential(new StorageSharedKeyCredential(EmulatorConfig.ACCOUNT, EmulatorConfig.DEV_KEY))
Expand All @@ -58,7 +62,6 @@ void fileCreateUsesDfsEndpoint() {
void userDelegationKeyCanGenerateUsableDfsSas() throws Exception {
OffsetDateTime start = OffsetDateTime.now().minusMinutes(5);
OffsetDateTime expiry = OffsetDateTime.now().plusHours(1);
EmulatorConfig.installEmulatorTlsCert();
DataLakeServiceClient bearerClient = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase().replaceFirst("^http://", "https://"))
.credential(request -> Mono.just(new AccessToken("fake-token", expiry)))
Expand All @@ -71,26 +74,80 @@ void userDelegationKeyCanGenerateUsableDfsSas() throws Exception {

String name = "test-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
DataLakeFileSystemClient fileSystem = bearerClient.createFileSystem(name);
FileSystemSasPermission permissions = new FileSystemSasPermission()
.setCreatePermission(true)
.setReadPermission(true)
.setWritePermission(true);
DataLakeServiceSasSignatureValues values = new DataLakeServiceSasSignatureValues(expiry, permissions)
.setStartTime(start);
String sas = fileSystem.generateUserDelegationSas(values, key, EmulatorConfig.ACCOUNT, Context.NONE);
assertTrue(sas.contains("sig="));

DataLakeServiceClient sasClient = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase())
.sasToken(sas)
try {
FileSystemSasPermission permissions = new FileSystemSasPermission()
.setCreatePermission(true)
.setReadPermission(true)
.setWritePermission(true);
DataLakeServiceSasSignatureValues values = new DataLakeServiceSasSignatureValues(expiry, permissions)
.setStartTime(start);
String sas = fileSystem.generateUserDelegationSas(values, key, EmulatorConfig.ACCOUNT, Context.NONE);
assertTrue(sas.contains("sig="));

DataLakeServiceClient sasClient = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase())
.sasToken(sas)
.addPolicy(dfsHostPolicy())
.buildClient();
DataLakeFileSystemClient sasFileSystem = sasClient.getFileSystemClient(name);
DataLakeFileClient file = sasFileSystem.createFile("dir/sas-file.txt");

assertTrue(file.exists());
} finally {
client.deleteFileSystem(name);
}
}

@Test
@DisplayName("user delegation SAS: path scope and permissions are enforced")
void userDelegationSasEnforcesPathScopeAndPermissions() {
OffsetDateTime start = OffsetDateTime.now().minusMinutes(5);
OffsetDateTime expiry = OffsetDateTime.now().plusHours(1);
DataLakeServiceClient bearerClient = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase().replaceFirst("^http://", "https://"))
.credential(request -> Mono.just(new AccessToken("fake-token", expiry)))
.addPolicy(dfsHostPolicy())
.buildClient();
DataLakeFileSystemClient sasFileSystem = sasClient.getFileSystemClient(name);
DataLakeFileClient file = sasFileSystem.createFile("dir/sas-file.txt");

assertTrue(file.exists());
UserDelegationKey key = bearerClient.getUserDelegationKey(start, expiry);

bearerClient.deleteFileSystem(name);
String name = "test-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
DataLakeFileSystemClient fileSystem = bearerClient.createFileSystem(name);
try {
fileSystem.createFile("allowed.txt");
fileSystem.createFile("denied.txt");

DataLakeServiceSasSignatureValues pathValues = new DataLakeServiceSasSignatureValues(
expiry, new PathSasPermission().setReadPermission(true))
.setStartTime(start);
String pathSas = fileSystem.getFileClient("allowed.txt")
.generateUserDelegationSas(pathValues, key, EmulatorConfig.ACCOUNT, Context.NONE);
DataLakeServiceClient pathSasClient = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase())
.sasToken(pathSas)
.addPolicy(dfsHostPolicy())
.buildClient();

assertTrue(pathSasClient.getFileSystemClient(name).getFileClient("allowed.txt").exists());
DataLakeStorageException siblingFailure = assertThrows(DataLakeStorageException.class,
() -> pathSasClient.getFileSystemClient(name).getFileClient("denied.txt").exists());
assertEquals(403, siblingFailure.getStatusCode());

DataLakeServiceSasSignatureValues readOnlyValues = new DataLakeServiceSasSignatureValues(
expiry, new FileSystemSasPermission().setReadPermission(true))
.setStartTime(start);
String readOnlySas = fileSystem.generateUserDelegationSas(
readOnlyValues, key, EmulatorConfig.ACCOUNT, Context.NONE);
DataLakeServiceClient readOnlyClient = new DataLakeServiceClientBuilder()
.endpoint(EmulatorConfig.httpBase())
.sasToken(readOnlySas)
.addPolicy(dfsHostPolicy())
.buildClient();
DataLakeStorageException permissionFailure = assertThrows(DataLakeStorageException.class,
() -> readOnlyClient.getFileSystemClient(name).createFile("cannot-create.txt"));
assertEquals(403, permissionFailure.getStatusCode());
} finally {
client.deleteFileSystem(name);
}
}

private static HttpPipelinePolicy dfsHostPolicy() {
Expand Down
11 changes: 9 additions & 2 deletions docs/services/blob.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ Blob XML responses, and the Data Lake Storage Gen2 DFS host alias.
Blob backend so ADLS SDK path clients can create, read, write, and delete paths through the same
local data store
- **User delegation key vending** — `POST ?restype=service&comp=userdelegationkey` returns
deterministic Azure-shaped XML for SDK-generated user delegation SAS flows
Azure-shaped XML for SDK-generated user delegation SAS flows
- **User delegation SAS enforcement** — validates SDK-generated user delegation SAS signatures,
expiry, signed key validity, permissions, and container/blob/directory resource scope for Blob
and ADLS path operations
- **Range download** — `Range: bytes=…` returns `206 Partial Content`
- **Conditional download** — `If-Match` / `If-None-Match` honored; a stale ETag is rejected
- **Metadata** — `x-ms-meta-*` set on upload and returned on Get, round-tripped exactly
Expand Down Expand Up @@ -91,7 +94,11 @@ floci-az:

- **Shared Key signatures are accepted but not cryptographically verified** — the emulator is a
local dev target; any well-formed `Authorization` header (or the Azurite key) is honored.
- **No SAS enforcement** — SAS query parameters are parsed but not validated.
- **SAS enforcement is scoped to user delegation SAS** — SDK-generated user delegation SAS tokens
for container (`sr=c`), blob (`sr=b`), and ADLS directory (`sr=d`) resources are validated.
Account SAS, stored access policies, IP/protocol restrictions, and the full SAS feature matrix
are not fully modeled. User delegation keys are protected by a process-local secret, so SAS
tokens issued by a previous emulator process are invalid after restart.
- **Snapshots, versioning, leases, and tiering are not modeled.** `Get Blob` and
`Get Container Properties` still report the lease headers Azure always returns, fixed at the
unleased values (`x-ms-lease-status: unlocked`, `x-ms-lease-state: available`), because strict SDK
Expand Down
2 changes: 1 addition & 1 deletion docs/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Floci-AZ provides emulation for several core Azure services.
| Service | Endpoint | Implementation Status |
|---|---|---|
| **Azure Resource Manager** | `/subscriptions/...` + `/providers/...` | ✅ Subscriptions, resource groups, resource/provider listing; management-plane fallthrough for the `Microsoft.*` providers |
| **Blob Storage** | `/{account}/` | ✅ Full CRUD; ADLS Gen2 DFS host alias and user delegation key vending |
| **Blob Storage** | `/{account}/` | ✅ Full CRUD; ADLS Gen2 DFS host alias, user delegation key vending, user delegation SAS enforcement |
| **Queue Storage** | `/{account}-queue/` | ✅ Full CRUD |
| **Table Storage** | `/{account}-table/` | ✅ Full CRUD |
| **Azure Functions** | `/{account}-functions/` | ✅ HTTP Triggers, Docker runtimes |
Expand Down
12 changes: 10 additions & 2 deletions src/main/java/io/floci/az/core/AuthContext.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package io.floci.az.core;

import io.floci.az.core.auth.StorageSasToken;
import java.util.Optional;

public record AuthContext(
String accountName,
AuthType type,
boolean isValid
) {}
boolean isValid,
Optional<StorageSasToken> storageSas
) {
public AuthContext(String accountName, AuthType type, boolean isValid) {
this(accountName, type, isValid, Optional.empty());
}
}
34 changes: 15 additions & 19 deletions src/main/java/io/floci/az/core/auth/SasTokenVerifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,29 @@
import io.floci.az.core.AzureRequest;
import jakarta.enterprise.context.ApplicationScoped;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.Optional;

@ApplicationScoped
public class SasTokenVerifier implements AuthVerifier {
@Override
public Optional<AuthContext> verify(AzureRequest request) {
if (request.queryParams().containsKey("sig") && request.queryParams().containsKey("sv")) {
String signedExpiry = request.queryParams().get("se");
if (signedExpiry != null) {
try {
String decodedExpiry = URLDecoder.decode(signedExpiry, StandardCharsets.UTF_8);
Instant expiry = OffsetDateTime.parse(decodedExpiry).toInstant();
if (!expiry.isAfter(Instant.now())) {
return Optional.of(new AuthContext(request.accountName(), AuthType.SAS, false));
}
} catch (DateTimeParseException e) {
return Optional.of(new AuthContext(request.accountName(), AuthType.SAS, false));
return StorageSasToken.from(request.queryParams())
.map(sas -> {
Instant now = Instant.now();
boolean valid = true;
if (sas.startTime() != null) {
valid = sas.parsedStartTime().map(OffsetDateTime::toInstant)
.filter(start -> !start.isAfter(now))
.isPresent();
}
}
// Accept any sig in dev mode
return Optional.of(new AuthContext(request.accountName(), AuthType.SAS, true));
}
return Optional.empty();
if (sas.expiryTime() != null) {
valid = valid && sas.parsedExpiryTime().map(OffsetDateTime::toInstant)
.filter(expiry -> expiry.isAfter(now))
.isPresent();
}
return new AuthContext(request.accountName(), AuthType.SAS, valid, Optional.of(sas));
});
}
}
Loading