diff --git a/compatibility-tests/sdk-test-java/pom.xml b/compatibility-tests/sdk-test-java/pom.xml index e22aca3..31464bd 100644 --- a/compatibility-tests/sdk-test-java/pom.xml +++ b/compatibility-tests/sdk-test-java/pom.xml @@ -105,6 +105,14 @@ com.google.cloud google-cloud-service-usage + + com.google.cloud + google-cloud-iamcredentials + + + com.google.auth + google-auth-library-oauth2-http + com.google.firebase firebase-admin diff --git a/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java index c2eca00..6d1aece 100644 --- a/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java +++ b/compatibility-tests/sdk-test-java/src/main/java/io/floci/gcp/test/TestFixtures.java @@ -2,6 +2,7 @@ import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider; +import com.google.auth.Credentials; import com.google.cloud.NoCredentials; import com.google.cloud.datastore.Datastore; import com.google.cloud.datastore.DatastoreOptions; @@ -9,6 +10,8 @@ import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.functions.v2.FunctionServiceClient; import com.google.cloud.functions.v2.FunctionServiceSettings; +import com.google.cloud.iam.credentials.v1.IamCredentialsClient; +import com.google.cloud.iam.credentials.v1.IamCredentialsSettings; import com.google.cloud.run.v2.RevisionsClient; import com.google.cloud.run.v2.RevisionsSettings; import com.google.cloud.run.v2.ServicesClient; @@ -64,6 +67,15 @@ public static Storage storageClient() { .getService(); } + public static Storage storageClient(Credentials credentials) { + return StorageOptions.newBuilder() + .setHost(endpoint()) + .setProjectId(projectId()) + .setCredentials(credentials) + .build() + .getService(); + } + /** * Creates a Firestore client pointing at the emulator. * GrpcFirestoreRpc uses plaintext when host contains "localhost"; setHost routes traffic there. @@ -215,6 +227,14 @@ public static SQLAdmin sqlAdminClient() { .build(); } + public static IamCredentialsClient iamCredentialsClient() throws IOException { + IamCredentialsSettings settings = IamCredentialsSettings.newHttpJsonBuilder() + .setEndpoint(endpoint()) + .setCredentialsProvider(NoCredentialsProvider.create()) + .build(); + return IamCredentialsClient.create(settings); + } + /** * Creates a Cloud Logging client pointing at the emulator. * No standard emulator env var exists; configure explicitly via plaintext gRPC channel. diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GcsDownscopedTokenTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GcsDownscopedTokenTest.java new file mode 100644 index 0000000..b0e6172 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/GcsDownscopedTokenTest.java @@ -0,0 +1,104 @@ +package io.floci.gcp.test; + +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.CredentialAccessBoundary; +import com.google.auth.oauth2.DownscopedCredentials; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.BucketInfo; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import org.junit.jupiter.api.Test; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GcsDownscopedTokenTest { + + @Test + void storageClientEnforcesDownscopedTokenPrefix() throws Exception { + String bucket = TestFixtures.uniqueName("downscoped-bucket"); + try (Storage setup = TestFixtures.storageClient()) { + setup.create(BucketInfo.of(bucket)); + } + + AccessToken accessToken = downscopedAccessToken(bucket); + try (Storage scoped = TestFixtures.storageClient(OAuth2Credentials.create(accessToken))) { + BlobId allowed = BlobId.of(bucket, "allowed/file.txt"); + scoped.create(BlobInfo.newBuilder(allowed).build(), "allowed".getBytes(StandardCharsets.UTF_8)); + + assertThat(new String(scoped.readAllBytes(allowed), StandardCharsets.UTF_8)).isEqualTo("allowed"); + assertThat(scoped.list(bucket, Storage.BlobListOption.prefix("allowed/")).iterateAll()) + .extracting(blob -> blob.getName()) + .contains("allowed/file.txt"); + + assertThatThrownBy(() -> scoped.create( + BlobInfo.newBuilder(BlobId.of(bucket, "allowed_sibling/file.txt")).build(), + "denied".getBytes(StandardCharsets.UTF_8))) + .isInstanceOfSatisfying(StorageException.class, + exception -> assertThat(exception.getCode()).isEqualTo(403)); + assertThatThrownBy(() -> scoped.readAllBytes(BlobId.of(bucket, "allowed_sibling/file.txt"))) + .isInstanceOfSatisfying(StorageException.class, + exception -> assertThat(exception.getCode()).isEqualTo(403)); + assertThatThrownBy(() -> scoped.list(bucket, Storage.BlobListOption.prefix("allowed_sibling/")) + .iterateAll().iterator().hasNext()) + .isInstanceOfSatisfying(StorageException.class, + exception -> assertThat(exception.getCode()).isEqualTo(403)); + + assertThat(scoped.delete(allowed)).isTrue(); + } + } + + private static AccessToken downscopedAccessToken(String bucket) throws Exception { + GoogleCredentials sourceCredentials = GoogleCredentials.create(new AccessToken( + "source-token", + Date.from(Instant.now().plusSeconds(3600)))); + CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder() + .addRule(CredentialAccessBoundary.AccessBoundaryRule.newBuilder() + .setAvailableResource("//storage.googleapis.com/projects/_/buckets/" + bucket) + .setAvailablePermissions(List.of( + "inRole:roles/storage.legacyObjectReader", + "inRole:roles/storage.objectViewer", + "inRole:roles/storage.legacyBucketWriter")) + .setAvailabilityCondition( + CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder() + .setExpression("resource.name.startsWith(" + + "'projects/_/buckets/" + bucket + "/objects/allowed/')" + + " || api.getAttribute(" + + "'storage.googleapis.com/objectListPrefix', '').startsWith(" + + "'allowed/')") + .build()) + .build()) + .build(); + + DownscopedCredentials credentials = DownscopedCredentials.newBuilder() + .setSourceCredential(sourceCredentials) + .setCredentialAccessBoundary(cab) + .setHttpTransportFactory(stsTransportFactory()) + .build(); + return credentials.refreshAccessToken(); + } + + private static HttpTransportFactory stsTransportFactory() { + return () -> new NetHttpTransport.Builder() + .setConnectionFactory(url -> { + if ("sts.googleapis.com".equals(url.getHost())) { + URL rewritten = new URL(TestFixtures.endpoint() + url.getFile()); + return (HttpURLConnection) rewritten.openConnection(); + } + return (HttpURLConnection) url.openConnection(); + }) + .build(); + } +} diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/IamCredentialsTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/IamCredentialsTest.java new file mode 100644 index 0000000..aa0df86 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/IamCredentialsTest.java @@ -0,0 +1,30 @@ +package io.floci.gcp.test; + +import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse; +import com.google.cloud.iam.credentials.v1.IamCredentialsClient; +import com.google.protobuf.Duration; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class IamCredentialsTest { + + @Test + void generateAccessTokenWithHttpJsonClient() throws Exception { + String email = "compat@" + TestFixtures.projectId() + ".iam.gserviceaccount.com"; + + try (IamCredentialsClient client = TestFixtures.iamCredentialsClient()) { + GenerateAccessTokenResponse response = client.generateAccessToken( + "projects/-/serviceAccounts/" + email, + List.of(), + List.of("https://www.googleapis.com/auth/cloud-platform"), + Duration.newBuilder().setSeconds(3600).build()); + + assertThat(response.getAccessToken()).startsWith("floci-gcp-impersonated-"); + assertThat(response.getExpireTime().getSeconds()).isGreaterThan(Instant.now().getEpochSecond()); + } + } +} diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/StsTokenExchangeTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/StsTokenExchangeTest.java new file mode 100644 index 0000000..c6da5b6 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/gcp/test/StsTokenExchangeTest.java @@ -0,0 +1,61 @@ +package io.floci.gcp.test; + +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.CredentialAccessBoundary; +import com.google.auth.oauth2.DownscopedCredentials; +import com.google.auth.oauth2.GoogleCredentials; +import org.junit.jupiter.api.Test; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class StsTokenExchangeTest { + + @Test + void downscopedCredentialsExchangeTokenWithCustomStsTransport() throws Exception { + GoogleCredentials sourceCredentials = GoogleCredentials.create(new AccessToken( + "source-token", + Date.from(Instant.now().plusSeconds(3600)))); + CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder() + .addRule(CredentialAccessBoundary.AccessBoundaryRule.newBuilder() + .setAvailableResource("//storage.googleapis.com/projects/_/buckets/compat-bucket") + .setAvailablePermissions(List.of("inRole:roles/storage.legacyObjectReader")) + .setAvailabilityCondition( + CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder() + .setExpression("resource.name.startsWith(" + + "'projects/_/buckets/compat-bucket/objects/allowed/')") + .build()) + .build()) + .build(); + + DownscopedCredentials credentials = DownscopedCredentials.newBuilder() + .setSourceCredential(sourceCredentials) + .setCredentialAccessBoundary(cab) + .setHttpTransportFactory(stsTransportFactory()) + .build(); + + AccessToken accessToken = credentials.refreshAccessToken(); + + assertThat(accessToken.getTokenValue()).startsWith("floci-gcp-downscoped-"); + assertThat(accessToken.getExpirationTime()).isAfter(Date.from(Instant.now())); + } + + private static HttpTransportFactory stsTransportFactory() { + return () -> new NetHttpTransport.Builder() + .setConnectionFactory(url -> { + if ("sts.googleapis.com".equals(url.getHost())) { + URL rewritten = new URL(TestFixtures.endpoint() + url.getFile()); + return (HttpURLConnection) rewritten.openConnection(); + } + return (HttpURLConnection) url.openConnection(); + }) + .build(); + } +} diff --git a/src/main/java/io/floci/gcp/config/EmulatorConfig.java b/src/main/java/io/floci/gcp/config/EmulatorConfig.java index 9bc8647..d51a15c 100644 --- a/src/main/java/io/floci/gcp/config/EmulatorConfig.java +++ b/src/main/java/io/floci/gcp/config/EmulatorConfig.java @@ -83,6 +83,10 @@ interface ServicesConfig { IamServiceConfig iam(); + IamCredentialsServiceConfig iamcredentials(); + + StsServiceConfig sts(); + SecretManagerServiceConfig secretmanager(); LoggingServiceConfig logging(); @@ -186,6 +190,16 @@ interface IamServiceConfig { boolean enabled(); } + interface IamCredentialsServiceConfig { + @WithDefault("true") + boolean enabled(); + } + + interface StsServiceConfig { + @WithDefault("true") + boolean enabled(); + } + interface SecretManagerServiceConfig { @WithDefault("true") boolean enabled(); diff --git a/src/main/java/io/floci/gcp/core/common/GcpException.java b/src/main/java/io/floci/gcp/core/common/GcpException.java index d1c9df6..6fc3535 100644 --- a/src/main/java/io/floci/gcp/core/common/GcpException.java +++ b/src/main/java/io/floci/gcp/core/common/GcpException.java @@ -71,6 +71,10 @@ public static GcpException permissionDenied(String message) { return new GcpException(403, "PERMISSION_DENIED", Status.Code.PERMISSION_DENIED, message); } + public static GcpException unauthenticated(String message) { + return new GcpException(401, "UNAUTHENTICATED", Status.Code.UNAUTHENTICATED, message); + } + public static GcpException resourceExhausted(String message) { return new GcpException(429, "RESOURCE_EXHAUSTED", Status.Code.RESOURCE_EXHAUSTED, message); } diff --git a/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java b/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java index 32a63be..b196735 100644 --- a/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java +++ b/src/main/java/io/floci/gcp/core/common/GcpExceptionMapper.java @@ -42,6 +42,7 @@ private static String reasonFor(String gcpStatus) { case "FAILED_PRECONDITION" -> "failedPrecondition"; case "CONDITION_NOT_MET" -> "conditionNotMet"; case "PERMISSION_DENIED" -> "forbidden"; + case "UNAUTHENTICATED" -> "authError"; case "RESOURCE_EXHAUSTED" -> "rateLimitExceeded"; case "UNIMPLEMENTED" -> "notImplemented"; case "DEADLINE_EXCEEDED" -> "deadlineExceeded"; diff --git a/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParser.java b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParser.java new file mode 100644 index 0000000..904774c --- /dev/null +++ b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParser.java @@ -0,0 +1,231 @@ +package io.floci.gcp.services.credentials; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.floci.gcp.core.common.GcpException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@ApplicationScoped +public class CredentialAccessBoundaryParser { + + static final String LEGACY_OBJECT_READER = "inRole:roles/storage.legacyObjectReader"; + static final String OBJECT_VIEWER = "inRole:roles/storage.objectViewer"; + static final String LEGACY_BUCKET_WRITER = "inRole:roles/storage.legacyBucketWriter"; + + private static final Set SUPPORTED_PERMISSIONS = Set.of( + LEGACY_OBJECT_READER, + OBJECT_VIEWER, + LEGACY_BUCKET_WRITER); + private static final Pattern RESOURCE_PATTERN = + Pattern.compile("^//storage\\.googleapis\\.com/projects/_/buckets/([^/]+)$"); + private static final Pattern RESOURCE_NAME_PREFIX_PATTERN = + Pattern.compile("^resource\\.name\\.startsWith\\((.+)\\)$"); + private static final Pattern LIST_PREFIX_PATTERN = + Pattern.compile("^api\\.getAttribute\\((.+),\\s*(.+)\\)\\.startsWith\\((.+)\\)$"); + + private final ObjectMapper objectMapper; + + @Inject + public CredentialAccessBoundaryParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public List parse(String options) { + if (options == null || options.isBlank()) { + throw invalidGrant("options is required"); + } + + JsonNode root; + try { + root = objectMapper.readTree(options); + } catch (IOException e) { + throw invalidGrant("options must be valid Credential Access Boundary JSON"); + } + + JsonNode rulesNode = root.path("accessBoundary").path("accessBoundaryRules"); + if (!rulesNode.isArray() || rulesNode.isEmpty()) { + throw invalidGrant("accessBoundaryRules is required"); + } + + List rules = new ArrayList<>(); + for (JsonNode ruleNode : rulesNode) { + String bucket = parseBucket(requiredText(ruleNode, "availableResource")); + List permissions = parsePermissions(ruleNode.path("availablePermissions")); + String expression = ruleNode.path("availabilityCondition").path("expression").asText(null); + if (expression == null || expression.isBlank()) { + throw invalidGrant("availabilityCondition.expression is required"); + } + String prefix = parsePrefixExpression(bucket, expression); + rules.add(new CredentialAccessBoundaryRule(bucket, prefix, permissions)); + } + return rules; + } + + private static String requiredText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || !value.isTextual() || value.asText().isBlank()) { + throw invalidGrant(field + " is required"); + } + return value.asText(); + } + + private static String parseBucket(String availableResource) { + Matcher matcher = RESOURCE_PATTERN.matcher(availableResource); + if (!matcher.matches() || matcher.group(1).isBlank()) { + throw invalidGrant("unsupported availableResource"); + } + return matcher.group(1); + } + + private static List parsePermissions(JsonNode permissionsNode) { + if (!permissionsNode.isArray() || permissionsNode.isEmpty()) { + throw invalidGrant("availablePermissions is required"); + } + List permissions = new ArrayList<>(); + for (JsonNode permissionNode : permissionsNode) { + if (!permissionNode.isTextual()) { + throw invalidGrant("availablePermissions entries must be strings"); + } + String permission = permissionNode.asText(); + if (!SUPPORTED_PERMISSIONS.contains(permission)) { + throw invalidGrant("unsupported availablePermission"); + } + permissions.add(permission); + } + return permissions; + } + + private static String parsePrefixExpression(String bucket, String expression) { + Set prefixes = new LinkedHashSet<>(); + for (String part : splitOrExpression(expression)) { + prefixes.add(parseSingleExpression(bucket, part.trim())); + } + if (prefixes.size() != 1) { + throw invalidGrant("all supported expressions must use the same object prefix"); + } + return prefixes.iterator().next(); + } + + private static List splitOrExpression(String expression) { + List parts = new ArrayList<>(); + int start = 0; + Character quote = null; + boolean escaped = false; + for (int i = 0; i < expression.length(); i++) { + char ch = expression.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (quote != null) { + if (ch == '\\') { + escaped = true; + } else if (ch == quote) { + quote = null; + } + continue; + } + if (ch == '\'' || ch == '"') { + quote = ch; + } else if (ch == '|' && i + 1 < expression.length() && expression.charAt(i + 1) == '|') { + parts.add(expression.substring(start, i)); + start = i + 2; + i++; + } + } + if (quote != null) { + throw invalidGrant("unterminated string literal in expression"); + } + parts.add(expression.substring(start)); + return parts; + } + + private static String parseSingleExpression(String bucket, String expression) { + Matcher resourceMatcher = RESOURCE_NAME_PREFIX_PATTERN.matcher(expression); + if (resourceMatcher.matches()) { + String resourcePrefix = parseOnlyStringArgument(resourceMatcher.group(1)); + String expectedStart = "projects/_/buckets/" + bucket + "/objects/"; + if (!resourcePrefix.startsWith(expectedStart)) { + throw invalidGrant("resource.name prefix does not match availableResource bucket"); + } + return normalizePrefix(resourcePrefix.substring(expectedStart.length())); + } + + Matcher listMatcher = LIST_PREFIX_PATTERN.matcher(expression); + if (listMatcher.matches()) { + String attribute = parseOnlyStringArgument(listMatcher.group(1)); + String defaultValue = parseOnlyStringArgument(listMatcher.group(2)); + String objectPrefix = parseOnlyStringArgument(listMatcher.group(3)); + if (!"storage.googleapis.com/objectListPrefix".equals(attribute) || !defaultValue.isEmpty()) { + throw invalidGrant("unsupported api.getAttribute expression"); + } + return normalizePrefix(objectPrefix); + } + + throw invalidGrant("unsupported availabilityCondition expression"); + } + + private static String parseOnlyStringArgument(String argument) { + String trimmed = argument.trim(); + if (trimmed.length() < 2) { + throw invalidGrant("expected string literal"); + } + char quote = trimmed.charAt(0); + if ((quote != '\'' && quote != '"') || trimmed.charAt(trimmed.length() - 1) != quote) { + throw invalidGrant("expected string literal"); + } + return decodeCelString(trimmed.substring(1, trimmed.length() - 1)); + } + + private static String decodeCelString(String value) { + StringBuilder decoded = new StringBuilder(value.length()); + boolean escaped = false; + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (!escaped) { + if (ch == '\\') { + escaped = true; + } else { + decoded.append(ch); + } + continue; + } + switch (ch) { + case '\\' -> decoded.append('\\'); + case '\'' -> decoded.append('\''); + case '"' -> decoded.append('"'); + case 'n' -> decoded.append('\n'); + case 'r' -> decoded.append('\r'); + case 't' -> decoded.append('\t'); + case 'b' -> decoded.append('\b'); + case 'f' -> decoded.append('\f'); + default -> throw invalidGrant("unsupported string escape"); + } + escaped = false; + } + if (escaped) { + throw invalidGrant("unterminated string escape"); + } + return decoded.toString(); + } + + private static String normalizePrefix(String prefix) { + if (prefix == null || prefix.isBlank()) { + throw invalidGrant("object prefix is required"); + } + return prefix.endsWith("/") ? prefix : prefix + "/"; + } + + private static GcpException invalidGrant(String message) { + return GcpException.invalidArgument(message).withReason("invalid_grant"); + } +} diff --git a/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryRule.java b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryRule.java new file mode 100644 index 0000000..e4e65ed --- /dev/null +++ b/src/main/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryRule.java @@ -0,0 +1,49 @@ +package io.floci.gcp.services.credentials; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.ArrayList; +import java.util.List; + +@RegisterForReflection +public class CredentialAccessBoundaryRule { + + private String bucket; + private String objectPrefix; + private List availablePermissions = new ArrayList<>(); + + public CredentialAccessBoundaryRule() { + } + + public CredentialAccessBoundaryRule(String bucket, String objectPrefix, List availablePermissions) { + this.bucket = bucket; + this.objectPrefix = objectPrefix; + this.availablePermissions = new ArrayList<>(availablePermissions); + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getObjectPrefix() { + return objectPrefix; + } + + public void setObjectPrefix(String objectPrefix) { + this.objectPrefix = objectPrefix; + } + + public List getAvailablePermissions() { + return availablePermissions; + } + + public void setAvailablePermissions(List availablePermissions) { + this.availablePermissions = availablePermissions == null + ? new ArrayList<>() + : new ArrayList<>(availablePermissions); + } +} diff --git a/src/main/java/io/floci/gcp/services/credentials/CredentialTokenService.java b/src/main/java/io/floci/gcp/services/credentials/CredentialTokenService.java new file mode 100644 index 0000000..b0a815d --- /dev/null +++ b/src/main/java/io/floci/gcp/services/credentials/CredentialTokenService.java @@ -0,0 +1,98 @@ +package io.floci.gcp.services.credentials; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.storage.StorageBackend; +import io.floci.gcp.core.storage.StorageFactory; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class CredentialTokenService { + + public static final String FLOCI_TOKEN_PREFIX = "floci-gcp-"; + public static final String IMPERSONATED_TOKEN_PREFIX = "floci-gcp-impersonated-"; + public static final String DOWNSCOPED_TOKEN_PREFIX = "floci-gcp-downscoped-"; + public static final long DEFAULT_LIFETIME_SECONDS = 3600; + + private final StorageBackend tokenStore; + private final Clock clock; + + @Inject + public CredentialTokenService(StorageFactory storageFactory) { + this(storageFactory.createGlobal("credential-tokens", "credential-tokens.json", + new TypeReference>() {}), + Clock.systemUTC()); + } + + public CredentialTokenService(StorageBackend tokenStore, Clock clock) { + this.tokenStore = tokenStore; + this.clock = clock; + } + + public StoredCredentialToken mintImpersonatedToken(String principal, Instant expireTime) { + if (principal == null || principal.isBlank()) { + throw GcpException.invalidArgument("service account is required"); + } + if (expireTime == null || !expireTime.isAfter(clock.instant())) { + throw GcpException.invalidArgument("expireTime must be in the future"); + } + + String tokenValue = IMPERSONATED_TOKEN_PREFIX + UUID.randomUUID(); + StoredCredentialToken token = new StoredCredentialToken( + tokenValue, + StoredCredentialToken.TokenKind.IMPERSONATED, + expireTime, + null, + principal, + List.of()); + tokenStore.put(tokenValue, token); + return token; + } + + public StoredCredentialToken mintDownscopedToken(String sourceToken, + List gcsRules) { + if (sourceToken == null || sourceToken.isBlank()) { + throw GcpException.invalidArgument("subject_token is required"); + } + if (gcsRules == null || gcsRules.isEmpty()) { + throw GcpException.invalidArgument("credential access boundary rules are required"); + } + + String tokenValue = DOWNSCOPED_TOKEN_PREFIX + UUID.randomUUID(); + StoredCredentialToken token = new StoredCredentialToken( + tokenValue, + StoredCredentialToken.TokenKind.DOWNSCOPED, + clock.instant().plusSeconds(DEFAULT_LIFETIME_SECONDS), + null, + null, + gcsRules); + tokenStore.put(tokenValue, token); + return token; + } + + public Optional lookupBearerToken(String bearerToken) { + if (bearerToken == null || bearerToken.isBlank() || !bearerToken.startsWith(FLOCI_TOKEN_PREFIX)) { + return Optional.empty(); + } + + StoredCredentialToken token = tokenStore.get(bearerToken) + .orElseThrow(() -> GcpException.unauthenticated("Unknown Floci credential token")); + if (token.getExpireTime() == null || !token.getExpireTime().isAfter(clock.instant())) { + tokenStore.delete(bearerToken); + throw GcpException.unauthenticated("Expired Floci credential token"); + } + return Optional.of(token); + } + + public void clear() { + tokenStore.clear(); + } +} diff --git a/src/main/java/io/floci/gcp/services/credentials/GcsAuthorizationService.java b/src/main/java/io/floci/gcp/services/credentials/GcsAuthorizationService.java new file mode 100644 index 0000000..1824783 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/credentials/GcsAuthorizationService.java @@ -0,0 +1,133 @@ +package io.floci.gcp.services.credentials; + +import io.floci.gcp.core.common.GcpException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +@ApplicationScoped +public class GcsAuthorizationService { + + private static final String BEARER_PREFIX = "Bearer "; + + private final CredentialTokenService tokenService; + + @Inject + public GcsAuthorizationService(CredentialTokenService tokenService) { + this.tokenService = tokenService; + } + + public void requireObjectRead(String authorization, String bucket, String objectName) { + lookupDownscopedToken(authorization) + .ifPresent(token -> requireMatchingRule(token, bucket, objectName, + List.of(CredentialAccessBoundaryParser.LEGACY_OBJECT_READER, + CredentialAccessBoundaryParser.OBJECT_VIEWER))); + } + + public void requireObjectWrite(String authorization, String bucket, String objectName) { + lookupDownscopedToken(authorization) + .ifPresent(token -> requireMatchingRule(token, bucket, objectName, + List.of(CredentialAccessBoundaryParser.LEGACY_BUCKET_WRITER))); + } + + public void requireObjectDelete(String authorization, String bucket, String objectName) { + requireObjectWrite(authorization, bucket, objectName); + } + + public void requireObjectList(String authorization, String bucket, String prefix) { + lookupDownscopedToken(authorization).ifPresent(token -> { + if (prefix == null || prefix.isBlank()) { + throw permissionDenied(); + } + requireMatchingPrefix(token, bucket, prefix, + List.of(CredentialAccessBoundaryParser.OBJECT_VIEWER, + CredentialAccessBoundaryParser.LEGACY_BUCKET_WRITER)); + }); + } + + public void requireSourceReadAndDestinationWrite(String authorization, String srcBucket, String srcObject, + String dstBucket, String dstObject) { + requireObjectRead(authorization, srcBucket, srcObject); + requireObjectWrite(authorization, dstBucket, dstObject); + } + + public void rejectDownscopedToken(String authorization) { + lookupDownscopedToken(authorization).ifPresent(token -> { + throw permissionDenied(); + }); + } + + public boolean isBypassed(String authorization) { + return bearerToken(authorization) + .map(token -> !token.startsWith(CredentialTokenService.FLOCI_TOKEN_PREFIX)) + .orElse(true); + } + + private Optional lookupDownscopedToken(String authorization) { + Optional bearerToken = bearerToken(authorization); + if (bearerToken.isEmpty()) { + return Optional.empty(); + } + Optional token = tokenService.lookupBearerToken(bearerToken.get()); + if (token.isEmpty() || token.get().getTokenKind() != StoredCredentialToken.TokenKind.DOWNSCOPED) { + return Optional.empty(); + } + return token; + } + + private static Optional bearerToken(String authorization) { + if (authorization == null || authorization.isBlank() + || !authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return Optional.empty(); + } + String token = authorization.substring(BEARER_PREFIX.length()).trim(); + return token.isEmpty() ? Optional.empty() : Optional.of(token); + } + + private static void requireMatchingRule(StoredCredentialToken token, String bucket, String objectName, + List acceptedPermissions) { + if (token.getGcsRules().stream() + .anyMatch(rule -> matchesObject(rule, bucket, objectName) + && hasAnyPermission(rule, acceptedPermissions))) { + return; + } + throw permissionDenied(); + } + + private static void requireMatchingPrefix(StoredCredentialToken token, String bucket, String prefix, + List acceptedPermissions) { + String normalizedPrefix = normalizePrefix(prefix); + if (token.getGcsRules().stream() + .anyMatch(rule -> bucket.equals(rule.getBucket()) + && normalizedPrefix.startsWith(rule.getObjectPrefix()) + && hasAnyPermission(rule, acceptedPermissions))) { + return; + } + throw permissionDenied(); + } + + private static boolean matchesObject(CredentialAccessBoundaryRule rule, String bucket, String objectName) { + return bucket.equals(rule.getBucket()) + && objectName != null + && objectName.startsWith(rule.getObjectPrefix()); + } + + private static boolean hasAnyPermission(CredentialAccessBoundaryRule rule, List acceptedPermissions) { + return rule.getAvailablePermissions().stream() + .map(permission -> permission.toLowerCase(Locale.ROOT)) + .anyMatch(permission -> acceptedPermissions.stream() + .map(p -> p.toLowerCase(Locale.ROOT)) + .anyMatch(permission::equals)); + } + + private static String normalizePrefix(String prefix) { + return prefix.endsWith("/") ? prefix : prefix + "/"; + } + + private static GcpException permissionDenied() { + return GcpException.permissionDenied("Downscoped token does not allow this GCS operation"); + } +} diff --git a/src/main/java/io/floci/gcp/services/credentials/StoredCredentialToken.java b/src/main/java/io/floci/gcp/services/credentials/StoredCredentialToken.java new file mode 100644 index 0000000..238f629 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/credentials/StoredCredentialToken.java @@ -0,0 +1,84 @@ +package io.floci.gcp.services.credentials; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@RegisterForReflection +public class StoredCredentialToken { + + public enum TokenKind { + IMPERSONATED, + DOWNSCOPED + } + + private String tokenValue; + private TokenKind tokenKind; + private Instant expireTime; + private String sourceToken; + private String principal; + private List gcsRules = new ArrayList<>(); + + public StoredCredentialToken() { + } + + public StoredCredentialToken(String tokenValue, TokenKind tokenKind, Instant expireTime, + String sourceToken, String principal, List gcsRules) { + this.tokenValue = tokenValue; + this.tokenKind = tokenKind; + this.expireTime = expireTime; + this.sourceToken = sourceToken; + this.principal = principal; + this.gcsRules = new ArrayList<>(gcsRules); + } + + public String getTokenValue() { + return tokenValue; + } + + public void setTokenValue(String tokenValue) { + this.tokenValue = tokenValue; + } + + public TokenKind getTokenKind() { + return tokenKind; + } + + public void setTokenKind(TokenKind tokenKind) { + this.tokenKind = tokenKind; + } + + public Instant getExpireTime() { + return expireTime; + } + + public void setExpireTime(Instant expireTime) { + this.expireTime = expireTime; + } + + public String getSourceToken() { + return sourceToken; + } + + public void setSourceToken(String sourceToken) { + this.sourceToken = sourceToken; + } + + public String getPrincipal() { + return principal; + } + + public void setPrincipal(String principal) { + this.principal = principal; + } + + public List getGcsRules() { + return gcsRules; + } + + public void setGcsRules(List gcsRules) { + this.gcsRules = gcsRules == null ? new ArrayList<>() : new ArrayList<>(gcsRules); + } +} diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsBatchController.java b/src/main/java/io/floci/gcp/services/gcs/GcsBatchController.java index c4874f7..5f2ee35 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsBatchController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsBatchController.java @@ -4,8 +4,10 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; import org.jboss.logging.Logger; import java.net.URI; @@ -41,32 +43,34 @@ public GcsBatchController(EmulatorConfig config) { .build(); } - @POST - @Consumes(MediaType.WILDCARD) - @Produces(MediaType.WILDCARD) - public Response batch(@HeaderParam("Content-Type") String contentType, byte[] body) { - String boundary = extractBoundary(contentType); - if (boundary == null) { - return Response.status(400).entity("Missing multipart boundary").build(); - } - - List subRequests = parseMultipart(boundary, body); - LOG.debugf("batch: parsed %d sub-requests", subRequests.size()); - - String responseBoundary = "batch_" + UUID.randomUUID().toString().replace("-", ""); - StringBuilder responseBody = new StringBuilder(); - - for (int i = 0; i < subRequests.size(); i++) { - SubRequest req = subRequests.get(i); - SubResponse resp = dispatch(req); - appendResponsePart(responseBody, responseBoundary, i, req.contentId(), resp); - } - responseBody.append("--").append(responseBoundary).append("--\r\n"); - - return Response.ok(responseBody.toString()) - .type("multipart/mixed; boundary=" + responseBoundary) - .build(); - } + @POST + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.WILDCARD) + public Response batch(@HeaderParam("Content-Type") String contentType, + @HeaderParam("Authorization") String authorization, + @Context UriInfo uriInfo, byte[] body) { + String boundary = extractBoundary(contentType); + if (boundary == null) { + return Response.status(400).entity("Missing multipart boundary").build(); + } + + List subRequests = parseMultipart(boundary, body); + LOG.debugf("batch: parsed %d sub-requests", subRequests.size()); + + String responseBoundary = "batch_" + UUID.randomUUID().toString().replace("-", ""); + StringBuilder responseBody = new StringBuilder(); + + for (int i = 0; i < subRequests.size(); i++) { + SubRequest req = subRequests.get(i); + SubResponse resp = dispatch(req, authorization, requestPort(uriInfo)); + appendResponsePart(responseBody, responseBoundary, i, req.contentId(), resp); + } + responseBody.append("--").append(responseBoundary).append("--\r\n"); + + return Response.ok(responseBody.toString()) + .type("multipart/mixed; boundary=" + responseBoundary) + .build(); + } private String extractBoundary(String contentType) { if (contentType == null) return null; @@ -167,14 +171,21 @@ private SubRequest parseHttpSubRequest(String httpText, String contentId) { } String requestBody = bodyBuilder.toString().strip(); return new SubRequest(method, pathAndQuery, headers, requestBody, contentId); - } - - private SubResponse dispatch(SubRequest req) { - try { - String path = req.path(); - URI uri = path.startsWith("http://") || path.startsWith("https://") - ? rewriteToLocalhost(URI.create(path)) - : URI.create("http://localhost:" + config.port() + path); + } + + private int requestPort(UriInfo uriInfo) { + if (uriInfo != null && uriInfo.getBaseUri() != null && uriInfo.getBaseUri().getPort() > 0) { + return uriInfo.getBaseUri().getPort(); + } + return config.port(); + } + + private SubResponse dispatch(SubRequest req, String outerAuthorization, int port) { + try { + String path = req.path(); + URI uri = path.startsWith("http://") || path.startsWith("https://") + ? rewriteToLocalhost(URI.create(path), port) + : URI.create("http://localhost:" + port + path); HttpRequest.BodyPublisher bodyPublisher = req.body().isEmpty() ? HttpRequest.BodyPublishers.noBody() @@ -195,6 +206,10 @@ private SubResponse dispatch(SubRequest req) { } } }); + if (outerAuthorization != null && req.headers().keySet().stream() + .noneMatch(header -> header.equalsIgnoreCase("Authorization"))) { + builder.header("Authorization", outerAuthorization); + } if (!req.body().isEmpty() && !req.headers().containsKey("Content-Type")) { builder.header("Content-Type", "application/json"); } @@ -209,17 +224,17 @@ private SubResponse dispatch(SubRequest req) { return new SubResponse(500, "{\"error\":{\"code\":500,\"message\":\"Internal batch error\"}}"); } - } - - private URI rewriteToLocalhost(URI original) { - try { - return new URI("http", null, "localhost", config.port(), - original.getRawPath(), original.getRawQuery(), null); - } catch (Exception e) { - return URI.create("http://localhost:" + config.port() + original.getRawPath() - + (original.getRawQuery() != null ? "?" + original.getRawQuery() : "")); - } - } + } + + private URI rewriteToLocalhost(URI original, int port) { + try { + return new URI("http", null, "localhost", port, + original.getRawPath(), original.getRawQuery(), null); + } catch (Exception e) { + return URI.create("http://localhost:" + port + original.getRawPath() + + (original.getRawQuery() != null ? "?" + original.getRawQuery() : "")); + } + } private void appendResponsePart(StringBuilder sb, String boundary, int index, String contentId, SubResponse resp) { diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsBucketController.java b/src/main/java/io/floci/gcp/services/gcs/GcsBucketController.java index 647ba7f..e285b54 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsBucketController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsBucketController.java @@ -4,6 +4,7 @@ import io.floci.gcp.config.EmulatorConfig; import io.floci.gcp.core.common.GcpException; import io.floci.gcp.core.common.PageToken; +import io.floci.gcp.services.credentials.GcsAuthorizationService; import io.floci.gcp.services.gcs.model.GcsBucket; import io.floci.gcp.services.gcs.model.StoredAcl; import io.floci.gcp.services.iam.IamService; @@ -31,14 +32,16 @@ public class GcsBucketController { private final EmulatorConfig config; private final IamService iamService; private final ObjectMapper objectMapper; + private final GcsAuthorizationService authorizationService; @Inject public GcsBucketController(GcsService service, EmulatorConfig config, IamService iamService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, GcsAuthorizationService authorizationService) { this.service = service; this.config = config; this.iamService = iamService; this.objectMapper = objectMapper; + this.authorizationService = authorizationService; } @OPTIONS @@ -56,6 +59,7 @@ public Response options() { @Consumes(MediaType.WILDCARD) public Response createBucket(@QueryParam("project") String project, @Context HttpHeaders headers, byte[] body) { + authorizationService.rejectDownscopedToken(headers.getHeaderString(HttpHeaders.AUTHORIZATION)); Map parsed = parseJsonBody(body); String name = parsed != null ? (String) parsed.get("name") : null; if (name == null || name.isBlank()) { @@ -80,7 +84,9 @@ private Map parseJsonBody(byte[] body) { @GET public Response listBuckets(@QueryParam("project") String project, @QueryParam("maxResults") @DefaultValue("0") int maxResults, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @QueryParam("pageToken") String pageToken) { + authorizationService.rejectDownscopedToken(authorization); List all = service.listBuckets(project); PageToken.Page page = PageToken.paginate(all, maxResults, pageToken); Map response = new LinkedHashMap<>(); @@ -96,14 +102,18 @@ public Response listBuckets(@QueryParam("project") String project, @GET @Path("/{bucket}") - public Response getBucket(@PathParam("bucket") String bucket) { + public Response getBucket(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); return Response.ok(service.getBucket(bucket)).build(); } @PATCH @Path("/{bucket}") @Consumes(MediaType.APPLICATION_JSON) - public Response patchBucket(@PathParam("bucket") String bucket, Map body) { + public Response patchBucket(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { + authorizationService.rejectDownscopedToken(authorization); return Response.ok(service.updateBucket(bucket, body)).build(); } @@ -112,7 +122,9 @@ public Response patchBucket(@PathParam("bucket") String bucket, Map body) { + authorizationService.rejectDownscopedToken(authorization); if ("PATCH".equalsIgnoreCase(methodOverride)) { return Response.ok(service.updateBucket(bucket, body)).build(); } @@ -121,7 +133,9 @@ public Response postBucketMethodOverride(@PathParam("bucket") String bucket, @DELETE @Path("/{bucket}") - public Response deleteBucket(@PathParam("bucket") String bucket) { + public Response deleteBucket(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); service.deleteBucket(bucket); return Response.noContent().build(); } @@ -129,13 +143,17 @@ public Response deleteBucket(@PathParam("bucket") String bucket) { @POST @Path("/{bucket}/lockRetentionPolicy") public Response lockRetentionPolicy(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @QueryParam("ifMetagenerationMatch") Long ifMetagenerationMatch) { + authorizationService.rejectDownscopedToken(authorization); return Response.ok(service.lockRetentionPolicy(bucket, ifMetagenerationMatch)).build(); } @GET @Path("/{bucket}/storageLayout") - public Response getStorageLayout(@PathParam("bucket") String bucket) { + public Response getStorageLayout(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); GcsBucket b = service.getBucket(bucket); String location = b.getLocation() != null ? b.getLocation() : "US"; Map response = new LinkedHashMap<>(); @@ -158,7 +176,9 @@ private static String locationType(String location) { @GET @Path("/{bucket}/iam") - public Response getBucketIamPolicy(@PathParam("bucket") String bucket) { + public Response getBucketIamPolicy(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); service.getBucket(bucket); StoredPolicy policy = iamService.getPolicy("buckets/" + bucket); return Response.ok(bucketIamResponse(bucket, policy)).build(); @@ -167,7 +187,9 @@ public Response getBucketIamPolicy(@PathParam("bucket") String bucket) { @PUT @Path("/{bucket}/iam") @Consumes(MediaType.APPLICATION_JSON) - public Response setBucketIamPolicy(@PathParam("bucket") String bucket, Map body) { + public Response setBucketIamPolicy(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { + authorizationService.rejectDownscopedToken(authorization); service.getBucket(bucket); StoredPolicy policy = parsePolicy(body); iamService.setPolicy("buckets/" + bucket, policy); @@ -178,7 +200,9 @@ public Response setBucketIamPolicy(@PathParam("bucket") String bucket, Map body) { + authorizationService.rejectDownscopedToken(authorization); service.getBucket(bucket); @SuppressWarnings("unchecked") List requested = body != null ? (List) body.get("permissions") : List.of(); @@ -190,7 +214,9 @@ public Response testBucketIamPermissions(@PathParam("bucket") String bucket, @GET @Path("/{bucket}/acl") - public Response listBucketAcls(@PathParam("bucket") String bucket) { + public Response listBucketAcls(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); List items = service.listBucketAcls(bucket); return Response.ok(Map.of("kind", "storage#bucketAccessControls", "items", items)).build(); } @@ -198,7 +224,9 @@ public Response listBucketAcls(@PathParam("bucket") String bucket) { @POST @Path("/{bucket}/acl") @Consumes(MediaType.APPLICATION_JSON) - public Response insertBucketAcl(@PathParam("bucket") String bucket, Map body) { + public Response insertBucketAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { + authorizationService.rejectDownscopedToken(authorization); String entity = body != null ? (String) body.get("entity") : null; String role = body != null ? (String) body.get("role") : "READER"; StoredAcl acl = service.upsertBucketAcl(bucket, entity, role); @@ -208,7 +236,9 @@ public Response insertBucketAcl(@PathParam("bucket") String bucket, Map body) { + authorizationService.rejectDownscopedToken(authorization); String role = body != null ? (String) body.get("role") : "READER"; StoredAcl acl = service.upsertBucketAcl(bucket, decode(entity), role); return Response.ok(acl).build(); @@ -225,7 +257,9 @@ public Response updateBucketAcl(@PathParam("bucket") String bucket, @DELETE @Path("/{bucket}/acl/{entity}") public Response deleteBucketAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("entity") String entity) { + authorizationService.rejectDownscopedToken(authorization); service.deleteBucketAcl(bucket, decode(entity)); return Response.noContent().build(); } @@ -234,7 +268,9 @@ public Response deleteBucketAcl(@PathParam("bucket") String bucket, @GET @Path("/{bucket}/defaultObjectAcl") - public Response listDefaultAcls(@PathParam("bucket") String bucket) { + public Response listDefaultAcls(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); List items = service.listDefaultAcls(bucket); return Response.ok(Map.of("kind", "storage#objectAccessControls", "items", items)).build(); } @@ -242,7 +278,9 @@ public Response listDefaultAcls(@PathParam("bucket") String bucket) { @POST @Path("/{bucket}/defaultObjectAcl") @Consumes(MediaType.APPLICATION_JSON) - public Response insertDefaultAcl(@PathParam("bucket") String bucket, Map body) { + public Response insertDefaultAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { + authorizationService.rejectDownscopedToken(authorization); String entity = body != null ? (String) body.get("entity") : null; String role = body != null ? (String) body.get("role") : "READER"; StoredAcl acl = service.upsertDefaultAcl(bucket, entity, role); @@ -252,7 +290,9 @@ public Response insertDefaultAcl(@PathParam("bucket") String bucket, Map body) { + authorizationService.rejectDownscopedToken(authorization); String role = body != null ? (String) body.get("role") : "READER"; StoredAcl acl = service.upsertDefaultAcl(bucket, decode(entity), role); return Response.ok(acl).build(); @@ -269,7 +311,9 @@ public Response updateDefaultAcl(@PathParam("bucket") String bucket, @DELETE @Path("/{bucket}/defaultObjectAcl/{entity}") public Response deleteDefaultAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("entity") String entity) { + authorizationService.rejectDownscopedToken(authorization); service.deleteDefaultAcl(bucket, decode(entity)); return Response.noContent().build(); } diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsDownloadController.java b/src/main/java/io/floci/gcp/services/gcs/GcsDownloadController.java index 739d83a..f2f28cb 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsDownloadController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsDownloadController.java @@ -1,6 +1,7 @@ package io.floci.gcp.services.gcs; import io.floci.gcp.services.gcs.model.GcsObjectMeta; +import io.floci.gcp.services.credentials.GcsAuthorizationService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -8,6 +9,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import java.net.URLDecoder; @@ -18,10 +20,12 @@ public class GcsDownloadController { private final GcsService service; + private final GcsAuthorizationService authorizationService; @Inject - public GcsDownloadController(GcsService service) { + public GcsDownloadController(GcsService service, GcsAuthorizationService authorizationService) { this.service = service; + this.authorizationService = authorizationService; } @GET @@ -31,8 +35,10 @@ public Response download( @PathParam("object") String objectPath, @QueryParam("generation") String generation, @HeaderParam("x-goog-encryption-key-sha256") String customerEncryptionKeySha256, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @HeaderParam("Range") String rangeHeader) { String objectName = URLDecoder.decode(objectPath, StandardCharsets.UTF_8); + authorizationService.requireObjectRead(authorization, bucket, objectName); GcsCustomerEncryption customerEncryption = GcsCustomerEncryption.fromKeySha256(customerEncryptionKeySha256); if (generation != null) { byte[] data = service.getObjectData(bucket, objectName, generation, customerEncryption); diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsNotificationController.java b/src/main/java/io/floci/gcp/services/gcs/GcsNotificationController.java index cb725d3..31fdbcb 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsNotificationController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsNotificationController.java @@ -1,10 +1,12 @@ package io.floci.gcp.services.gcs; +import io.floci.gcp.services.credentials.GcsAuthorizationService; import io.floci.gcp.services.gcs.model.StoredNotification; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import java.util.List; @@ -16,20 +18,26 @@ public class GcsNotificationController { private final GcsService service; + private final GcsAuthorizationService authorizationService; @Inject - public GcsNotificationController(GcsService service) { + public GcsNotificationController(GcsService service, GcsAuthorizationService authorizationService) { this.service = service; + this.authorizationService = authorizationService; } @POST @Consumes(MediaType.APPLICATION_JSON) - public Response createNotification(@PathParam("bucket") String bucket, Map body) { + public Response createNotification(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { + authorizationService.rejectDownscopedToken(authorization); return Response.ok(service.createNotification(bucket, body)).build(); } @GET - public Response listNotifications(@PathParam("bucket") String bucket) { + public Response listNotifications(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization) { + authorizationService.rejectDownscopedToken(authorization); List items = service.listNotifications(bucket); return Response.ok(Map.of("kind", "storage#notifications", "items", items)).build(); } @@ -37,14 +45,18 @@ public Response listNotifications(@PathParam("bucket") String bucket) { @GET @Path("/{notification}") public Response getNotification(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("notification") String notificationId) { + authorizationService.rejectDownscopedToken(authorization); return Response.ok(service.getNotification(bucket, notificationId)).build(); } @DELETE @Path("/{notification}") public Response deleteNotification(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("notification") String notificationId) { + authorizationService.rejectDownscopedToken(authorization); service.deleteNotification(bucket, notificationId); return Response.noContent().build(); } diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsObjectController.java b/src/main/java/io/floci/gcp/services/gcs/GcsObjectController.java index 353f591..d0f7a06 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsObjectController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsObjectController.java @@ -3,6 +3,7 @@ import io.floci.gcp.config.EmulatorConfig; import io.floci.gcp.core.common.GcpException; import io.floci.gcp.core.common.PageToken; +import io.floci.gcp.services.credentials.GcsAuthorizationService; import io.floci.gcp.services.gcs.model.GcsObjectMeta; import io.floci.gcp.services.gcs.model.StoredAcl; import jakarta.enterprise.context.ApplicationScoped; @@ -30,11 +31,14 @@ public class GcsObjectController { private final GcsService service; private final EmulatorConfig config; + private final GcsAuthorizationService authorizationService; @Inject - public GcsObjectController(GcsService service, EmulatorConfig config) { + public GcsObjectController(GcsService service, EmulatorConfig config, + GcsAuthorizationService authorizationService) { this.service = service; this.config = config; + this.authorizationService = authorizationService; } @OPTIONS @@ -49,8 +53,10 @@ public Response listObjects(@PathParam("bucket") String bucket, @QueryParam("pageToken") String pageToken, @QueryParam("prefix") String prefix, @QueryParam("delimiter") String delimiter, - @QueryParam("startOffset") String startOffset, + @QueryParam("startOffset") String startOffset, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @QueryParam("versions") @DefaultValue("false") boolean includeVersions) { + authorizationService.requireObjectList(authorization, bucket, prefix); List all = includeVersions ? service.listObjectVersions(bucket, prefix) : service.listObjects(bucket); @@ -94,7 +100,9 @@ public Response listObjects(@PathParam("bucket") String bucket, @GET @Path("/{object: .+}/acl") public Response listObjectAcls(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("object") String objectPath) { + authorizationService.rejectDownscopedToken(authorization); String objectName = decode(objectPath); List items = service.listObjectAcls(bucket, objectName); return Response.ok(Map.of("kind", "storage#objectAccessControls", "items", items)).build(); @@ -104,7 +112,9 @@ public Response listObjectAcls(@PathParam("bucket") String bucket, @Path("/{object: .+}/acl") @Consumes(MediaType.APPLICATION_JSON) public Response insertObjectAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("object") String objectPath, Map body) { + authorizationService.rejectDownscopedToken(authorization); String objectName = decode(objectPath); String entity = body != null ? (String) body.get("entity") : null; String role = body != null ? (String) body.get("role") : "READER"; @@ -115,8 +125,10 @@ public Response insertObjectAcl(@PathParam("bucket") String bucket, @GET @Path("/{object: .+}/acl/{entity}") public Response getObjectAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("object") String objectPath, @PathParam("entity") String entity) { + authorizationService.rejectDownscopedToken(authorization); String objectName = decode(objectPath); return Response.ok(service.getObjectAcl(bucket, objectName, decode(entity))).build(); } @@ -125,8 +137,10 @@ public Response getObjectAcl(@PathParam("bucket") String bucket, @Path("/{object: .+}/acl/{entity}") @Consumes(MediaType.APPLICATION_JSON) public Response updateObjectAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("object") String objectPath, @PathParam("entity") String entity, Map body) { + authorizationService.rejectDownscopedToken(authorization); String objectName = decode(objectPath); String role = body != null ? (String) body.get("role") : "READER"; StoredAcl acl = service.upsertObjectAcl(bucket, objectName, decode(entity), role); @@ -136,8 +150,10 @@ public Response updateObjectAcl(@PathParam("bucket") String bucket, @DELETE @Path("/{object: .+}/acl/{entity}") public Response deleteObjectAcl(@PathParam("bucket") String bucket, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @PathParam("object") String objectPath, @PathParam("entity") String entity) { + authorizationService.rejectDownscopedToken(authorization); String objectName = decode(objectPath); service.deleteObjectAcl(bucket, objectName, decode(entity)); return Response.noContent().build(); @@ -150,8 +166,10 @@ public Response getObject(@PathParam("bucket") String bucket, @QueryParam("alt") String alt, @QueryParam("generation") String generation, @HeaderParam("x-goog-encryption-key-sha256") String customerEncryptionKeySha256, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @HeaderParam("Range") String rangeHeader) { String objectName = decode(objectPath); + authorizationService.requireObjectRead(authorization, bucket, objectName); GcsCustomerEncryption customerEncryption = GcsCustomerEncryption.fromKeySha256(customerEncryptionKeySha256); if (generation != null) { if ("media".equals(alt)) { @@ -178,8 +196,10 @@ public Response patchObject(@PathParam("bucket") String bucket, @QueryParam("ifGenerationNotMatch") Long ifGenerationNotMatch, @QueryParam("ifMetagenerationMatch") Long ifMetagenerationMatch, @QueryParam("ifMetagenerationNotMatch") Long ifMetagenerationNotMatch, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { String objectName = decode(objectPath); + authorizationService.requireObjectWrite(authorization, bucket, objectName); service.checkPreconditions(bucket, objectName, ifGenerationMatch, ifGenerationNotMatch, ifMetagenerationMatch, ifMetagenerationNotMatch); return Response.ok(service.patchObject(bucket, objectName, body)).build(); @@ -194,8 +214,10 @@ public Response updateObject(@PathParam("bucket") String bucket, @QueryParam("ifGenerationNotMatch") Long ifGenerationNotMatch, @QueryParam("ifMetagenerationMatch") Long ifMetagenerationMatch, @QueryParam("ifMetagenerationNotMatch") Long ifMetagenerationNotMatch, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { String objectName = decode(objectPath); + authorizationService.requireObjectWrite(authorization, bucket, objectName); service.checkPreconditions(bucket, objectName, ifGenerationMatch, ifGenerationNotMatch, ifMetagenerationMatch, ifMetagenerationNotMatch); return Response.ok(service.patchObject(bucket, objectName, body)).build(); @@ -211,9 +233,11 @@ public Response postObjectMethodOverride(@PathParam("bucket") String bucket, @QueryParam("ifGenerationNotMatch") Long ifGenerationNotMatch, @QueryParam("ifMetagenerationMatch") Long ifMetagenerationMatch, @QueryParam("ifMetagenerationNotMatch") Long ifMetagenerationNotMatch, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, Map body) { if ("PATCH".equalsIgnoreCase(methodOverride)) { String objectName = decode(objectPath); + authorizationService.requireObjectWrite(authorization, bucket, objectName); service.checkPreconditions(bucket, objectName, ifGenerationMatch, ifGenerationNotMatch, ifMetagenerationMatch, ifMetagenerationNotMatch); return Response.ok(service.patchObject(bucket, objectName, body)).build(); @@ -225,8 +249,10 @@ public Response postObjectMethodOverride(@PathParam("bucket") String bucket, @Path("/{object: .+}") public Response deleteObject(@PathParam("bucket") String bucket, @PathParam("object") String objectPath, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @QueryParam("generation") String generation) { String objectName = decode(objectPath); + authorizationService.requireObjectDelete(authorization, bucket, objectName); if (generation != null) { service.deleteObjectVersion(bucket, objectName, generation); return Response.noContent().build(); @@ -244,6 +270,7 @@ public Response composeObject(@PathParam("bucket") String bucket, @PathParam("destObject") String destObjectPath, @Context HttpHeaders headers, Map body) { String destObject = decode(destObjectPath); + String authorization = headers.getHeaderString(HttpHeaders.AUTHORIZATION); @SuppressWarnings("unchecked") List> sourceObjects = body != null ? (List>) body.get("sourceObjects") : List.of(); @@ -253,6 +280,10 @@ public Response composeObject(@PathParam("bucket") String bucket, String contentType = destReq != null ? (String) destReq.get("contentType") : null; List sourceNames = sourceObjects == null ? List.of() : sourceObjects.stream().map(s -> (String) s.get("name")).toList(); + for (String sourceName : sourceNames) { + authorizationService.requireObjectRead(authorization, bucket, sourceName); + } + authorizationService.requireObjectWrite(authorization, bucket, destObject); GcsObjectMeta meta = service.composeObject(bucket, destObject, sourceNames, contentType, requestBaseUrl(headers)); return Response.ok(meta).build(); @@ -267,6 +298,8 @@ public Response copyObject(@PathParam("bucket") String srcBucket, @Context HttpHeaders headers) { String srcObject = decode(srcObjectPath); String dstObject = decode(dstObjectPath); + authorizationService.requireSourceReadAndDestinationWrite( + headers.getHeaderString(HttpHeaders.AUTHORIZATION), srcBucket, srcObject, dstBucket, dstObject); GcsObjectMeta meta = service.copyObject(srcBucket, srcObject, dstBucket, dstObject, requestBaseUrl(headers)); return Response.ok(meta).build(); @@ -281,6 +314,8 @@ public Response rewriteObject(@PathParam("bucket") String srcBucket, @Context HttpHeaders headers) { String srcObject = decode(srcObjectPath); String dstObject = decode(dstObjectPath); + authorizationService.requireSourceReadAndDestinationWrite( + headers.getHeaderString(HttpHeaders.AUTHORIZATION), srcBucket, srcObject, dstBucket, dstObject); GcsObjectMeta meta = service.copyObject(srcBucket, srcObject, dstBucket, dstObject, requestBaseUrl(headers)); Map response = new LinkedHashMap<>(); diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsService.java b/src/main/java/io/floci/gcp/services/gcs/GcsService.java index 15f9483..ecbaf04 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsService.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsService.java @@ -669,6 +669,15 @@ public String startResumableUpload(String bucket, String objectName, String cont return uploadId; } + public ResumableUpload getResumableUpload(String uploadId) { + ResumableUpload upload = resumableUploads.get(uploadId); + if (upload == null) { + LOG.warnf("getResumableUpload failed: upload not found uploadId=%s", uploadId); + throw GcpException.notFound("Resumable upload not found: " + uploadId); + } + return upload; + } + public GcsObjectMeta completeResumableUpload(String uploadId, byte[] data, String baseUrl) { LOG.debugf("completeResumableUpload uploadId=%s size=%d", uploadId, data.length); ResumableUpload upload = resumableUploads.remove(uploadId); diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsUploadController.java b/src/main/java/io/floci/gcp/services/gcs/GcsUploadController.java index eb1fcfa..c6c5bbe 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsUploadController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsUploadController.java @@ -3,7 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.floci.gcp.config.EmulatorConfig; import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.services.credentials.GcsAuthorizationService; import io.floci.gcp.services.gcs.model.GcsObjectMeta; +import io.floci.gcp.services.gcs.model.ResumableUpload; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.*; @@ -25,12 +27,15 @@ public class GcsUploadController { private final GcsService service; private final EmulatorConfig config; private final ObjectMapper objectMapper; + private final GcsAuthorizationService authorizationService; @Inject - public GcsUploadController(GcsService service, EmulatorConfig config, ObjectMapper objectMapper) { + public GcsUploadController(GcsService service, EmulatorConfig config, ObjectMapper objectMapper, + GcsAuthorizationService authorizationService) { this.service = service; this.config = config; this.objectMapper = objectMapper; + this.authorizationService = authorizationService; } @POST @@ -75,6 +80,9 @@ public Response resumablePut( if (uploadId == null) { throw GcpException.invalidArgument("missing upload_id query parameter"); } + ResumableUpload upload = service.getResumableUpload(uploadId); + authorizationService.requireObjectWrite( + headers.getHeaderString(HttpHeaders.AUTHORIZATION), upload.bucket(), upload.objectName()); String contentRange = headers.getHeaderString("Content-Range"); if (contentRange != null && !contentRange.isBlank()) { ContentRange range = parseContentRange(contentRange, body.length); @@ -172,6 +180,7 @@ private Response handleMultipart(String bucket, String nameParam, HttpHeaders he if (objectContentType == null) { objectContentType = extractPartHeader(rawParts[1], "content-type"); } + authorizationService.requireObjectWrite(headers.getHeaderString(HttpHeaders.AUTHORIZATION), bucket, objectName); preconditions.check(service, bucket, objectName); byte[] dataBytes = extractPartBody(rawParts[1]).getBytes(ISO); GcsObjectMeta meta = service.putObject(bucket, objectName, objectContentType, dataBytes, @@ -205,6 +214,7 @@ private Response handleStartResumable(String bucket, String nameParam, HttpHeade contentType = "application/octet-stream"; } + authorizationService.requireObjectWrite(headers.getHeaderString(HttpHeaders.AUTHORIZATION), bucket, name); preconditions.check(service, bucket, name); String uploadId = service.startResumableUpload(bucket, name, contentType, GcsCustomerEncryption.fromHeaders(headers)); @@ -217,6 +227,7 @@ private Response handleStartResumable(String bucket, String nameParam, HttpHeade private Response handleMedia(String bucket, String name, HttpHeaders headers, byte[] body, Preconditions preconditions) { String contentType = headers.getHeaderString(HttpHeaders.CONTENT_TYPE); + authorizationService.requireObjectWrite(headers.getHeaderString(HttpHeaders.AUTHORIZATION), bucket, name); preconditions.check(service, bucket, name); GcsObjectMeta meta = service.putObject(bucket, name, contentType, body, GcsCustomerEncryption.fromHeaders(headers), requestBaseUrl(headers)); diff --git a/src/main/java/io/floci/gcp/services/gcs/GcsXmlDownloadController.java b/src/main/java/io/floci/gcp/services/gcs/GcsXmlDownloadController.java index 8521a77..f1faee9 100644 --- a/src/main/java/io/floci/gcp/services/gcs/GcsXmlDownloadController.java +++ b/src/main/java/io/floci/gcp/services/gcs/GcsXmlDownloadController.java @@ -1,6 +1,7 @@ package io.floci.gcp.services.gcs; import io.floci.gcp.config.EmulatorConfig; +import io.floci.gcp.services.credentials.GcsAuthorizationService; import io.floci.gcp.services.gcs.model.GcsObjectMeta; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -24,11 +25,14 @@ public class GcsXmlDownloadController { private final GcsService service; private final EmulatorConfig config; + private final GcsAuthorizationService authorizationService; @Inject - public GcsXmlDownloadController(GcsService service, EmulatorConfig config) { + public GcsXmlDownloadController(GcsService service, EmulatorConfig config, + GcsAuthorizationService authorizationService) { this.service = service; this.config = config; + this.authorizationService = authorizationService; } @OPTIONS @@ -50,9 +54,11 @@ public Response download( @Context UriInfo uriInfo, @QueryParam("generation") String generation, @HeaderParam("x-goog-encryption-key-sha256") String customerEncryptionKeySha256, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, @HeaderParam("Range") String rangeHeader) { GcsSignedUrl.checkNotExpired(uriInfo); String objectName = URLDecoder.decode(objectPath, StandardCharsets.UTF_8); + authorizationService.requireObjectRead(authorization, bucket, objectName); GcsCustomerEncryption customerEncryption = GcsCustomerEncryption.fromKeySha256(customerEncryptionKeySha256); if (generation != null) { byte[] data = service.getObjectData(bucket, objectName, generation, customerEncryption); @@ -76,6 +82,7 @@ public Response upload( byte[] body) { GcsSignedUrl.checkNotExpired(uriInfo); String objectName = URLDecoder.decode(objectPath, StandardCharsets.UTF_8); + authorizationService.requireObjectWrite(headers.getHeaderString(HttpHeaders.AUTHORIZATION), bucket, objectName); String contentType = headers.getHeaderString(HttpHeaders.CONTENT_TYPE); String host = headers.getHeaderString("Host"); String baseUrl = host != null ? "http://" + host : config.baseUrl(); diff --git a/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsController.java b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsController.java new file mode 100644 index 0000000..d941ce1 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsController.java @@ -0,0 +1,45 @@ +package io.floci.gcp.services.iamcredentials; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Path("/v1/projects/-/serviceAccounts") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class IamCredentialsController { + + private final IamCredentialsService service; + + @Inject + public IamCredentialsController(IamCredentialsService service) { + this.service = service; + } + + @POST + @Path("/{serviceAccount}:generateAccessToken") + public Response generateAccessToken(@PathParam("serviceAccount") String serviceAccount, + Map body) { + List scopes = body != null && body.get("scope") instanceof List scopeList + ? scopeList + : null; + IamCredentialsService.GeneratedAccessToken token = + service.generateAccessToken(serviceAccount, scopes, body != null ? body.get("lifetime") : null); + + Map response = new LinkedHashMap<>(); + response.put("accessToken", token.accessToken()); + response.put("expireTime", token.expireTime().toString()); + return Response.ok(response).build(); + } +} diff --git a/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsService.java b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsService.java new file mode 100644 index 0000000..8ae9977 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/iamcredentials/IamCredentialsService.java @@ -0,0 +1,110 @@ +package io.floci.gcp.services.iamcredentials; + +import io.floci.gcp.config.EmulatorConfig; +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.common.ServiceDescriptor; +import io.floci.gcp.core.common.ServiceProtocol; +import io.floci.gcp.core.common.ServiceRegistry; +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +@ApplicationScoped +public class IamCredentialsService { + + static final String DEVSTORAGE_READ_WRITE_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write"; + static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + static final String TOKEN_PREFIX = CredentialTokenService.IMPERSONATED_TOKEN_PREFIX; + static final long DEFAULT_LIFETIME_SECONDS = 3600; + static final long MAX_LIFETIME_SECONDS = 3600; + private static final Set SUPPORTED_SCOPES = Set.of(DEVSTORAGE_READ_WRITE_SCOPE, CLOUD_PLATFORM_SCOPE); + + private final ServiceRegistry serviceRegistry; + private final EmulatorConfig config; + private final CredentialTokenService tokenService; + private final Clock clock; + + @Inject + public IamCredentialsService(ServiceRegistry serviceRegistry, EmulatorConfig config, + CredentialTokenService tokenService) { + this(serviceRegistry, config, tokenService, Clock.systemUTC()); + } + + IamCredentialsService(CredentialTokenService tokenService, Clock clock) { + this(null, null, tokenService, clock); + } + + private IamCredentialsService(ServiceRegistry serviceRegistry, EmulatorConfig config, + CredentialTokenService tokenService, Clock clock) { + this.serviceRegistry = serviceRegistry; + this.config = config; + this.tokenService = tokenService; + this.clock = clock; + } + + void onStart(@Observes StartupEvent ev) { + serviceRegistry.register(ServiceDescriptor.builder("iamcredentials") + .enabled(config.services().iamcredentials().enabled()) + .storageKey("iamcredentials") + .protocol(ServiceProtocol.REST) + .resourceClasses(IamCredentialsController.class) + .build()); + } + + public GeneratedAccessToken generateAccessToken(String serviceAccount, List scopes, Object lifetime) { + if (serviceAccount == null || serviceAccount.isBlank()) { + throw GcpException.invalidArgument("service account is required"); + } + validateScopes(scopes); + + long lifetimeSeconds = parseLifetimeSeconds(lifetime); + Instant expireTime = clock.instant().plusSeconds(lifetimeSeconds); + StoredCredentialToken token = tokenService.mintImpersonatedToken(serviceAccount, expireTime); + return new GeneratedAccessToken(token.getTokenValue(), expireTime); + } + + private static void validateScopes(List scopes) { + if (scopes == null || scopes.isEmpty()) { + throw GcpException.invalidArgument("scope is required"); + } + boolean supported = scopes.stream() + .anyMatch(SUPPORTED_SCOPES::contains); + if (!supported) { + throw GcpException.invalidArgument("unsupported scope"); + } + } + + private static long parseLifetimeSeconds(Object lifetime) { + if (lifetime == null) { + return DEFAULT_LIFETIME_SECONDS; + } + if (!(lifetime instanceof String value)) { + throw GcpException.invalidArgument("lifetime must be a duration string"); + } + if (!value.endsWith("s") || value.length() == 1) { + throw GcpException.invalidArgument("lifetime must be a duration string ending in seconds"); + } + + long requestedSeconds; + try { + requestedSeconds = Long.parseLong(value.substring(0, value.length() - 1)); + } catch (NumberFormatException e) { + throw GcpException.invalidArgument("lifetime must be a duration string ending in seconds"); + } + + if (requestedSeconds <= 0) { + throw GcpException.invalidArgument("lifetime must be positive"); + } + return Math.min(requestedSeconds, MAX_LIFETIME_SECONDS); + } + + public record GeneratedAccessToken(String accessToken, Instant expireTime) {} +} diff --git a/src/main/java/io/floci/gcp/services/sts/StsController.java b/src/main/java/io/floci/gcp/services/sts/StsController.java new file mode 100644 index 0000000..e679e03 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/sts/StsController.java @@ -0,0 +1,42 @@ +package io.floci.gcp.services.sts; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Path("/v1") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class StsController { + + @Inject + StsService service; + + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response token(@FormParam("grant_type") String grantType, + @FormParam("subject_token_type") String subjectTokenType, + @FormParam("subject_token") String subjectToken, + @FormParam("requested_token_type") String requestedTokenType, + @FormParam("options") String options) { + try { + return Response.ok(service.exchangeToken( + grantType, subjectTokenType, subjectToken, requestedTokenType, options)).build(); + } catch (StsException e) { + Map error = new LinkedHashMap<>(); + error.put("error", e.error()); + error.put("error_description", e.getMessage()); + return Response.status(400).type(MediaType.APPLICATION_JSON).entity(error).build(); + } + } +} diff --git a/src/main/java/io/floci/gcp/services/sts/StsException.java b/src/main/java/io/floci/gcp/services/sts/StsException.java new file mode 100644 index 0000000..b00fe40 --- /dev/null +++ b/src/main/java/io/floci/gcp/services/sts/StsException.java @@ -0,0 +1,15 @@ +package io.floci.gcp.services.sts; + +class StsException extends RuntimeException { + + private final String error; + + StsException(String error, String message) { + super(message); + this.error = error; + } + + String error() { + return error; + } +} diff --git a/src/main/java/io/floci/gcp/services/sts/StsService.java b/src/main/java/io/floci/gcp/services/sts/StsService.java new file mode 100644 index 0000000..07c58bf --- /dev/null +++ b/src/main/java/io/floci/gcp/services/sts/StsService.java @@ -0,0 +1,96 @@ +package io.floci.gcp.services.sts; + +import io.floci.gcp.config.EmulatorConfig; +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.common.ServiceDescriptor; +import io.floci.gcp.core.common.ServiceProtocol; +import io.floci.gcp.core.common.ServiceRegistry; +import io.floci.gcp.services.credentials.CredentialAccessBoundaryParser; +import io.floci.gcp.services.credentials.CredentialAccessBoundaryRule; +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class StsService { + + static final String TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; + static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + static final String BEARER_TOKEN_TYPE = "Bearer"; + + private final ServiceRegistry serviceRegistry; + private final EmulatorConfig config; + private final CredentialAccessBoundaryParser cabParser; + private final CredentialTokenService tokenService; + + @Inject + public StsService(ServiceRegistry serviceRegistry, EmulatorConfig config, + CredentialAccessBoundaryParser cabParser, CredentialTokenService tokenService) { + this.serviceRegistry = serviceRegistry; + this.config = config; + this.cabParser = cabParser; + this.tokenService = tokenService; + } + + StsService(CredentialAccessBoundaryParser cabParser, CredentialTokenService tokenService) { + this(null, null, cabParser, tokenService); + } + + void onStart(@Observes StartupEvent ev) { + serviceRegistry.register(ServiceDescriptor.builder("sts") + .enabled(config.services().sts().enabled()) + .storageKey("credential-tokens") + .protocol(ServiceProtocol.REST) + .resourceClasses(StsController.class) + .build()); + } + + public Map exchangeToken(String grantType, String subjectTokenType, + String subjectToken, String requestedTokenType, String options) { + requireEquals("grant_type", grantType, TOKEN_EXCHANGE_GRANT_TYPE); + requireEquals("subject_token_type", subjectTokenType, ACCESS_TOKEN_TYPE); + requireEquals("requested_token_type", requestedTokenType, ACCESS_TOKEN_TYPE); + if (subjectToken == null || subjectToken.isBlank()) { + throw invalidRequest("subject_token is required"); + } + if (options == null || options.isBlank()) { + throw invalidRequest("options is required"); + } + + List rules; + try { + rules = cabParser.parse(options); + } catch (GcpException e) { + throw invalidGrant(e.getMessage()); + } + + StoredCredentialToken token = tokenService.mintDownscopedToken(subjectToken, rules); + Map response = new LinkedHashMap<>(); + response.put("access_token", token.getTokenValue()); + response.put("issued_token_type", ACCESS_TOKEN_TYPE); + response.put("token_type", BEARER_TOKEN_TYPE); + response.put("expires_in", CredentialTokenService.DEFAULT_LIFETIME_SECONDS); + return response; + } + + private static void requireEquals(String field, String actual, String expected) { + if (!expected.equals(actual)) { + throw invalidRequest(field + " must be " + expected); + } + } + + private static StsException invalidRequest(String message) { + return new StsException("invalid_request", message); + } + + private static StsException invalidGrant(String message) { + return new StsException("invalid_grant", message); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5751f51..61f87ca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,6 +55,10 @@ floci-gcp: enabled: true iam: enabled: true + iamcredentials: + enabled: true + sts: + enabled: true secretmanager: enabled: true logging: diff --git a/src/test/java/io/floci/gcp/core/common/GcpExceptionMapperTest.java b/src/test/java/io/floci/gcp/core/common/GcpExceptionMapperTest.java index 2d8db26..3498de5 100644 --- a/src/test/java/io/floci/gcp/core/common/GcpExceptionMapperTest.java +++ b/src/test/java/io/floci/gcp/core/common/GcpExceptionMapperTest.java @@ -84,6 +84,8 @@ void reasonIsDerivedFromStatus() { GcpExceptionMapper.ErrorDetail.of(409, "x", "ALREADY_EXISTS").errors().get(0).reason()); assertEquals("conditionNotMet", GcpExceptionMapper.ErrorDetail.of(412, "x", "CONDITION_NOT_MET").errors().get(0).reason()); + assertEquals("authError", + GcpExceptionMapper.ErrorDetail.of(401, "x", "UNAUTHENTICATED").errors().get(0).reason()); assertEquals("forbidden", GcpExceptionMapper.ErrorDetail.of(403, "x", "PERMISSION_DENIED").errors().get(0).reason()); assertEquals("backendError", diff --git a/src/test/java/io/floci/gcp/core/common/GcpExceptionTest.java b/src/test/java/io/floci/gcp/core/common/GcpExceptionTest.java index 1650487..673c1ac 100644 --- a/src/test/java/io/floci/gcp/core/common/GcpExceptionTest.java +++ b/src/test/java/io/floci/gcp/core/common/GcpExceptionTest.java @@ -48,6 +48,14 @@ void permissionDenied() { assertEquals(Status.Code.PERMISSION_DENIED, ex.getGrpcCode()); } + @Test + void unauthenticated() { + GcpException ex = GcpException.unauthenticated("bad token"); + assertEquals(401, ex.getHttpStatus()); + assertEquals("UNAUTHENTICATED", ex.getGcpStatus()); + assertEquals(Status.Code.UNAUTHENTICATED, ex.getGrpcCode()); + } + @Test void resourceExhausted() { GcpException ex = GcpException.resourceExhausted("quota exceeded"); diff --git a/src/test/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParserTest.java b/src/test/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParserTest.java new file mode 100644 index 0000000..acbd04a --- /dev/null +++ b/src/test/java/io/floci/gcp/services/credentials/CredentialAccessBoundaryParserTest.java @@ -0,0 +1,147 @@ +package io.floci.gcp.services.credentials; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.floci.gcp.core.common.GcpException; +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; + +class CredentialAccessBoundaryParserTest { + + private final CredentialAccessBoundaryParser parser = new CredentialAccessBoundaryParser(new ObjectMapper()); + + @Test + void parsesReadRuleWithLegacyObjectReader() { + List rules = parser.parse(cab( + "bucket", + CredentialAccessBoundaryParser.LEGACY_OBJECT_READER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/data/')")); + + assertEquals(1, rules.size()); + assertEquals("bucket", rules.getFirst().getBucket()); + assertEquals("data/", rules.getFirst().getObjectPrefix()); + assertEquals(List.of(CredentialAccessBoundaryParser.LEGACY_OBJECT_READER), + rules.getFirst().getAvailablePermissions()); + } + + @Test + void parsesListRuleWithObjectViewer() { + List rules = parser.parse(cab( + "bucket", + CredentialAccessBoundaryParser.OBJECT_VIEWER, + "api.getAttribute('storage.googleapis.com/objectListPrefix', '').startsWith('data/')")); + + assertEquals("data/", rules.getFirst().getObjectPrefix()); + assertEquals(List.of(CredentialAccessBoundaryParser.OBJECT_VIEWER), + rules.getFirst().getAvailablePermissions()); + } + + @Test + void parsesWriteRuleWithLegacyBucketWriter() { + List rules = parser.parse(cab( + "bucket", + CredentialAccessBoundaryParser.LEGACY_BUCKET_WRITER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/out')")); + + assertEquals("out/", rules.getFirst().getObjectPrefix()); + } + + @Test + void parsesOrExpressionWithSamePrefix() { + List rules = parser.parse(cab( + "bucket", + CredentialAccessBoundaryParser.OBJECT_VIEWER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/data/')" + + " || api.getAttribute('storage.googleapis.com/objectListPrefix', '').startsWith('data/')")); + + assertEquals("data/", rules.getFirst().getObjectPrefix()); + } + + @Test + void preservesPrefixBoundaries() { + List rules = parser.parse(cab( + "bucket", + CredentialAccessBoundaryParser.LEGACY_OBJECT_READER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/data')")); + + assertEquals("data/", rules.getFirst().getObjectPrefix()); + } + + @Test + void decodesEscapedStrings() { + List rules = parser.parse(cab( + "bucket", + CredentialAccessBoundaryParser.LEGACY_OBJECT_READER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/dir\\\\quoted\\'/')")); + + assertEquals("dir\\quoted'/", rules.getFirst().getObjectPrefix()); + } + + @Test + void rejectsUnsupportedResource() { + assertInvalidGrant(cab( + "bucket", + CredentialAccessBoundaryParser.LEGACY_OBJECT_READER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/data/')") + .replace("storage.googleapis.com", "pubsub.googleapis.com")); + } + + @Test + void rejectsUnsupportedPermission() { + assertInvalidGrant(cab( + "bucket", + "inRole:roles/storage.admin", + "resource.name.startsWith('projects/_/buckets/bucket/objects/data/')")); + } + + @Test + void rejectsUnsupportedExpression() { + assertInvalidGrant(cab( + "bucket", + CredentialAccessBoundaryParser.LEGACY_OBJECT_READER, + "resource.name == 'projects/_/buckets/bucket/objects/data/file'")); + } + + @Test + void rejectsMalformedJson() { + assertInvalidGrant("{not-json"); + } + + @Test + void rejectsExpressionsWithDifferentPrefixes() { + assertInvalidGrant(cab( + "bucket", + CredentialAccessBoundaryParser.OBJECT_VIEWER, + "resource.name.startsWith('projects/_/buckets/bucket/objects/data/')" + + " || api.getAttribute('storage.googleapis.com/objectListPrefix', '').startsWith('other/')")); + } + + private static void assertInvalidGrant(String options) { + GcpException ex = assertThrows(GcpException.class, + () -> new CredentialAccessBoundaryParser(new ObjectMapper()).parse(options)); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + assertEquals("invalid_grant", ex.getReason()); + } + + static String cab(String bucket, String permission, String expression) { + return """ + { + "accessBoundary": { + "accessBoundaryRules": [ + { + "availableResource": "//storage.googleapis.com/projects/_/buckets/%s", + "availablePermissions": ["%s"], + "availabilityCondition": { + "expression": "%s" + } + } + ] + } + } + """.formatted(bucket, permission, expression.replace("\\", "\\\\").replace("\"", "\\\"")); + } +} diff --git a/src/test/java/io/floci/gcp/services/credentials/CredentialTokenServiceTest.java b/src/test/java/io/floci/gcp/services/credentials/CredentialTokenServiceTest.java new file mode 100644 index 0000000..8700cff --- /dev/null +++ b/src/test/java/io/floci/gcp/services/credentials/CredentialTokenServiceTest.java @@ -0,0 +1,92 @@ +package io.floci.gcp.services.credentials; + +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.storage.InMemoryStorage; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CredentialTokenServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-15T12:00:00Z"); + private final InMemoryStorage store = new InMemoryStorage<>(); + private final CredentialTokenService service = + new CredentialTokenService(store, Clock.fixed(NOW, ZoneOffset.UTC)); + + @Test + void storesAndRetrievesDownscopedToken() { + StoredCredentialToken token = service.mintDownscopedToken("source-token", rules()); + + Optional resolved = service.lookupBearerToken(token.getTokenValue()); + + assertTrue(resolved.isPresent()); + assertEquals(StoredCredentialToken.TokenKind.DOWNSCOPED, resolved.get().getTokenKind()); + assertEquals(NOW.plusSeconds(CredentialTokenService.DEFAULT_LIFETIME_SECONDS), + resolved.get().getExpireTime()); + assertNull(resolved.get().getSourceToken()); + assertEquals("bucket", resolved.get().getGcsRules().getFirst().getBucket()); + } + + @Test + void storesAndRetrievesImpersonatedTokenWithoutAccessRules() { + StoredCredentialToken token = service.mintImpersonatedToken( + "test@test-project.iam.gserviceaccount.com", NOW.plusSeconds(1200)); + + Optional resolved = service.lookupBearerToken(token.getTokenValue()); + + assertTrue(resolved.isPresent()); + assertEquals(StoredCredentialToken.TokenKind.IMPERSONATED, resolved.get().getTokenKind()); + assertEquals(NOW.plusSeconds(1200), resolved.get().getExpireTime()); + assertEquals("test@test-project.iam.gserviceaccount.com", resolved.get().getPrincipal()); + assertTrue(resolved.get().getGcsRules().isEmpty()); + } + + @Test + void expiredTokenIsRejectedAndRemoved() { + StoredCredentialToken expired = new StoredCredentialToken( + CredentialTokenService.DOWNSCOPED_TOKEN_PREFIX + "expired", + StoredCredentialToken.TokenKind.DOWNSCOPED, + NOW.minusSeconds(1), + "source-token", + null, + rules()); + store.put(expired.getTokenValue(), expired); + + GcpException ex = assertThrows(GcpException.class, + () -> service.lookupBearerToken(expired.getTokenValue())); + + assertEquals("UNAUTHENTICATED", ex.getGcpStatus()); + assertFalse(store.get(expired.getTokenValue()).isPresent()); + } + + @Test + void unknownFlociTokenIsRejected() { + GcpException ex = assertThrows(GcpException.class, + () -> service.lookupBearerToken(CredentialTokenService.DOWNSCOPED_TOKEN_PREFIX + "missing")); + + assertEquals("UNAUTHENTICATED", ex.getGcpStatus()); + } + + @Test + void nonFlociTokenReturnsEmptyLookupForLaterBypassContract() { + assertTrue(service.lookupBearerToken("external-token").isEmpty()); + assertTrue(service.lookupBearerToken(null).isEmpty()); + } + + private static List rules() { + return List.of(new CredentialAccessBoundaryRule( + "bucket", + "data/", + List.of(CredentialAccessBoundaryParser.LEGACY_OBJECT_READER))); + } +} diff --git a/src/test/java/io/floci/gcp/services/gcs/GcsAuthorizationTest.java b/src/test/java/io/floci/gcp/services/gcs/GcsAuthorizationTest.java new file mode 100644 index 0000000..2c21c2a --- /dev/null +++ b/src/test/java/io/floci/gcp/services/gcs/GcsAuthorizationTest.java @@ -0,0 +1,139 @@ +package io.floci.gcp.services.gcs; + +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.storage.InMemoryStorage; +import io.floci.gcp.services.credentials.CredentialAccessBoundaryRule; +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.floci.gcp.services.credentials.GcsAuthorizationService; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GcsAuthorizationTest { + + private static final Instant NOW = Instant.parse("2026-07-15T12:00:00Z"); + private static final String READER = "inRole:roles/storage.legacyObjectReader"; + private static final String VIEWER = "inRole:roles/storage.objectViewer"; + private static final String WRITER = "inRole:roles/storage.legacyBucketWriter"; + + private final InMemoryStorage store = new InMemoryStorage<>(); + private final CredentialTokenService tokenService = + new CredentialTokenService(store, Clock.fixed(NOW, ZoneOffset.UTC)); + private final GcsAuthorizationService authorizationService = new GcsAuthorizationService(tokenService); + + @Test + void missingAndStaticBearerTokensBypassAuthorization() { + assertTrue(authorizationService.isBypassed(null)); + assertTrue(authorizationService.isBypassed("Bearer external-token")); + + assertDoesNotThrow(() -> authorizationService.requireObjectRead(null, "bucket", "outside/file.txt")); + assertDoesNotThrow(() -> authorizationService.requireObjectWrite( + "Bearer external-token", "bucket", "outside/file.txt")); + } + + @Test + void storedImpersonatedTokenBypassesDownscopedCabChecks() { + String token = tokenService.mintImpersonatedToken( + "test@test-project.iam.gserviceaccount.com", NOW.plusSeconds(1200)).getTokenValue(); + + assertDoesNotThrow(() -> authorizationService.requireObjectWrite( + bearer(token), "bucket", "outside/file.txt")); + } + + @Test + void unknownFlociTokenIsUnauthenticated() { + GcpException ex = assertThrows(GcpException.class, + () -> authorizationService.requireObjectRead( + "Bearer floci-gcp-downscoped-missing", "bucket", "allowed/file.txt")); + + assertEquals("UNAUTHENTICATED", ex.getGcpStatus()); + } + + @Test + void expiredFlociTokenIsUnauthenticated() { + StoredCredentialToken expired = new StoredCredentialToken( + CredentialTokenService.DOWNSCOPED_TOKEN_PREFIX + "expired", + StoredCredentialToken.TokenKind.DOWNSCOPED, + NOW.minusSeconds(1), + "source-token", + null, + List.of(rule("bucket", "allowed/", READER))); + store.put(expired.getTokenValue(), expired); + + GcpException ex = assertThrows(GcpException.class, + () -> authorizationService.requireObjectRead( + "Bearer " + expired.getTokenValue(), "bucket", "allowed/file.txt")); + + assertEquals("UNAUTHENTICATED", ex.getGcpStatus()); + assertTrue(store.get(expired.getTokenValue()).isEmpty()); + } + + @Test + void allowedPrefixAndPermissionsSucceed() { + String token = mint("bucket", "allowed/", READER, VIEWER, WRITER); + + assertDoesNotThrow(() -> authorizationService.requireObjectRead( + bearer(token), "bucket", "allowed/file.txt")); + assertDoesNotThrow(() -> authorizationService.requireObjectList( + bearer(token), "bucket", "allowed/nested/")); + assertDoesNotThrow(() -> authorizationService.requireObjectWrite( + bearer(token), "bucket", "allowed/file.txt")); + assertDoesNotThrow(() -> authorizationService.requireObjectDelete( + bearer(token), "bucket", "allowed/file.txt")); + } + + @Test + void siblingPrefixAndNoPrefixListAreForbidden() { + String token = mint("bucket", "allowed/", READER, VIEWER, WRITER); + + assertPermissionDenied(() -> authorizationService.requireObjectRead( + bearer(token), "bucket", "allowed_sibling/file.txt")); + assertPermissionDenied(() -> authorizationService.requireObjectWrite( + bearer(token), "bucket", "allowed_sibling/file.txt")); + assertPermissionDenied(() -> authorizationService.requireObjectList( + bearer(token), "bucket", "allowed_sibling/")); + assertPermissionDenied(() -> authorizationService.requireObjectList( + bearer(token), "bucket", null)); + } + + @Test + void adminSurfaceRejectsDownscopedToken() { + String token = mint("bucket", "allowed/", READER, VIEWER, WRITER); + + assertPermissionDenied(() -> authorizationService.rejectDownscopedToken(bearer(token))); + assertDoesNotThrow(() -> authorizationService.rejectDownscopedToken("Bearer external-token")); + } + + private String mint(String bucket, String prefix, String... permissions) { + return tokenService.mintDownscopedToken("source-token", + List.of(new CredentialAccessBoundaryRule(bucket, prefix, List.of(permissions)))) + .getTokenValue(); + } + + private static CredentialAccessBoundaryRule rule(String bucket, String prefix, String... permissions) { + return new CredentialAccessBoundaryRule(bucket, prefix, List.of(permissions)); + } + + private static String bearer(String token) { + return "Bearer " + token; + } + + private static void assertPermissionDenied(ThrowingRunnable runnable) { + GcpException ex = assertThrows(GcpException.class, runnable::run); + assertEquals("PERMISSION_DENIED", ex.getGcpStatus()); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run(); + } +} diff --git a/src/test/java/io/floci/gcp/services/gcs/GcsScopedTokenRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/gcs/GcsScopedTokenRestIntegrationTest.java new file mode 100644 index 0000000..df09408 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/gcs/GcsScopedTokenRestIntegrationTest.java @@ -0,0 +1,279 @@ +package io.floci.gcp.services.gcs; + +import io.floci.gcp.services.credentials.CredentialAccessBoundaryRule; +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +@QuarkusTest +class GcsScopedTokenRestIntegrationTest { + + private static final String READER = "inRole:roles/storage.legacyObjectReader"; + private static final String VIEWER = "inRole:roles/storage.objectViewer"; + private static final String WRITER = "inRole:roles/storage.legacyBucketWriter"; + + @Inject + CredentialTokenService tokenService; + + private String bucket; + + @BeforeEach + void setUp() { + tokenService.clear(); + bucket = "scoped-" + System.nanoTime(); + createBucket(bucket); + } + + @Test + void missingAndStaticBearerTokensPreserveExistingBypassBehavior() { + upload(null, "bypass/missing.txt", "missing").statusCode(200); + upload("Bearer static-token", "bypass/static.txt", "static").statusCode(200); + + given() + .header("Authorization", "Bearer static-token") + .when().get("/storage/v1/b/{bucket}/o/{object}?alt=media", bucket, "bypass/static.txt") + .then() + .statusCode(200) + .body(equalTo("static")); + } + + @Test + void storedImpersonatedBearerTokenPreservesExistingBypassBehavior() { + String authorization = bearer(tokenService.mintImpersonatedToken( + "test@test-project.iam.gserviceaccount.com", java.time.Instant.now().plusSeconds(1200)) + .getTokenValue()); + + upload(authorization, "impersonated/outside.txt", "impersonated").statusCode(200); + + given() + .header("Authorization", authorization) + .when().get("/storage/v1/b/{bucket}/o/{object}?alt=media", bucket, "impersonated/outside.txt") + .then() + .statusCode(200) + .body(equalTo("impersonated")); + } + + @Test + void unknownFlociTokenIsUnauthenticated() { + given() + .header("Authorization", "Bearer floci-gcp-downscoped-missing") + .when().get("/storage/v1/b/{bucket}/o/{object}?alt=media", bucket, "allowed/file.txt") + .then() + .statusCode(401) + .body("error.status", equalTo("UNAUTHENTICATED")); + } + + @Test + void downscopedTokenAllowsOnlyMatchingPrefixOperations() { + String authorization = bearer(mint("allowed/", READER, VIEWER, WRITER)); + + upload(authorization, "allowed/file.txt", "allowed").statusCode(200); + + given() + .header("Authorization", authorization) + .when().get("/storage/v1/b/{bucket}/o/{object}?alt=media", bucket, "allowed/file.txt") + .then() + .statusCode(200) + .body(equalTo("allowed")); + + given() + .header("Authorization", authorization) + .queryParam("prefix", "allowed/") + .when().get("/storage/v1/b/{bucket}/o", bucket) + .then() + .statusCode(200) + .body("items[0].name", equalTo("allowed/file.txt")); + + given() + .header("Authorization", authorization) + .when().delete("/storage/v1/b/{bucket}/o/{object}", bucket, "allowed/file.txt") + .then() + .statusCode(204); + } + + @Test + void siblingPrefixAndNoPrefixListAreForbidden() { + upload(null, "allowed_sibling/file.txt", "sibling").statusCode(200); + String authorization = bearer(mint("allowed/", READER, VIEWER, WRITER)); + + given() + .header("Authorization", authorization) + .when().get("/storage/v1/b/{bucket}/o/{object}?alt=media", bucket, "allowed_sibling/file.txt") + .then() + .statusCode(403) + .body("error.status", equalTo("PERMISSION_DENIED")); + + upload(authorization, "allowed_sibling/write.txt", "denied") + .statusCode(403) + .body("error.status", equalTo("PERMISSION_DENIED")); + + given() + .header("Authorization", authorization) + .queryParam("prefix", "allowed_sibling/") + .when().get("/storage/v1/b/{bucket}/o", bucket) + .then() + .statusCode(403); + + given() + .header("Authorization", authorization) + .when().get("/storage/v1/b/{bucket}/o", bucket) + .then() + .statusCode(403); + } + + @Test + void composeCopyAndRewriteRequireSourceReadAndDestinationWrite() { + upload(null, "allowed/source.txt", "source").statusCode(200); + upload(null, "allowed_sibling/source.txt", "source").statusCode(200); + String authorization = bearer(mint("allowed/", READER, VIEWER, WRITER)); + + given() + .header("Authorization", authorization) + .contentType("application/json") + .body(Map.of("sourceObjects", List.of(Map.of("name", "allowed_sibling/source.txt")))) + .when().post("/storage/v1/b/{bucket}/o/{object}/compose", bucket, "allowed/composed.txt") + .then() + .statusCode(403); + + given() + .header("Authorization", authorization) + .when().post("/storage/v1/b/{bucket}/o/{source}/copyTo/b/{destBucket}/o/{dest}", + bucket, "allowed/source.txt", bucket, "allowed_sibling/copied.txt") + .then() + .statusCode(403); + + given() + .header("Authorization", authorization) + .when().post("/storage/v1/b/{bucket}/o/{source}/rewriteTo/b/{destBucket}/o/{dest}", + bucket, "allowed_sibling/source.txt", bucket, "allowed/rewritten.txt") + .then() + .statusCode(403); + } + + @Test + void resumableUploadRequiresCurrentTokenScopeForCompletion() { + String location = given() + .contentType("application/json") + .queryParam("uploadType", "resumable") + .queryParam("name", "allowed/resumable.txt") + .body("{}") + .when().post("/upload/storage/v1/b/{bucket}/o", bucket) + .then() + .statusCode(200) + .header("Location", containsString("upload_id=")) + .extract().header("Location"); + + String uploadId = location.substring(location.indexOf("upload_id=") + "upload_id=".length()); + String otherAuthorization = bearer(mint("other/", READER, VIEWER, WRITER)); + + given() + .header("Authorization", otherAuthorization) + .contentType("text/plain") + .queryParam("upload_id", uploadId) + .body("denied") + .when().put("/upload/storage/v1/b/{bucket}/o", bucket) + .then() + .statusCode(403); + } + + @Test + void downscopedTokenCannotUseBucketAdminSurfaces() { + String authorization = bearer(mint("allowed/", READER, VIEWER, WRITER)); + + given() + .header("Authorization", authorization) + .when().get("/storage/v1/b/{bucket}", bucket) + .then() + .statusCode(403); + + given() + .header("Authorization", authorization) + .when().get("/storage/v1/b/{bucket}/notificationConfigs", bucket) + .then() + .statusCode(403); + } + + @Test + void batchRequestsEnforceOuterAndEmbeddedFlociTokens() { + upload(null, "allowed_sibling/file.txt", "sibling").statusCode(200); + String authorization = bearer(mint("allowed/", READER, VIEWER, WRITER)); + + given() + .header("Authorization", authorization) + .contentType("multipart/mixed; boundary=batch_test") + .body(batchRequest("/storage/v1/b/" + bucket + "/o/allowed_sibling/file.txt?alt=media", null) + .getBytes(StandardCharsets.UTF_8)) + .when().post("/batch/storage/v1") + .then() + .statusCode(200) + .body(startsWith("--batch_")) + .body(containsString("HTTP/1.1 403 Forbidden")); + + given() + .contentType("multipart/mixed; boundary=batch_test") + .body(batchRequest("https://storage.googleapis.com/storage/v1/b/" + bucket + + "/o/allowed_sibling/file.txt?alt=media", authorization) + .getBytes(StandardCharsets.UTF_8)) + .when().post("/batch/storage/v1") + .then() + .statusCode(200) + .body(containsString("HTTP/1.1 403 Forbidden")); + } + + private void createBucket(String bucketName) { + given() + .contentType("application/json") + .queryParam("project", "test-project") + .body(Map.of("name", bucketName)) + .when().post("/storage/v1/b") + .then() + .statusCode(200); + } + + private io.restassured.response.ValidatableResponse upload( + String authorization, String objectName, String content) { + var request = given() + .contentType("text/plain") + .queryParam("uploadType", "media") + .queryParam("name", objectName) + .body(content.getBytes(StandardCharsets.UTF_8)); + if (authorization != null) { + request.header("Authorization", authorization); + } + return request.when().post("/upload/storage/v1/b/{bucket}/o", bucket).then(); + } + + private String mint(String prefix, String... permissions) { + return tokenService.mintDownscopedToken("source-token", + List.of(new CredentialAccessBoundaryRule(bucket, prefix, List.of(permissions)))) + .getTokenValue(); + } + + private static String bearer(String token) { + return "Bearer " + token; + } + + private static String batchRequest(String path, String authorization) { + String authHeader = authorization == null ? "" : "Authorization: " + authorization + "\r\n"; + return """ + --batch_test\r + Content-Type: application/http\r + Content-ID: \r + \r + GET %s HTTP/1.1\r + %s\r + --batch_test--\r + """.formatted(path, authHeader); + } +} diff --git a/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsDisabledRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsDisabledRestIntegrationTest.java new file mode 100644 index 0000000..ff86a39 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsDisabledRestIntegrationTest.java @@ -0,0 +1,37 @@ +package io.floci.gcp.services.iamcredentials; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +@QuarkusTest +@TestProfile(IamCredentialsDisabledRestIntegrationTest.DisabledIamCredentialsProfile.class) +class IamCredentialsDisabledRestIntegrationTest { + + @Test + void disabledIamCredentialsReturnsUnavailableWrapper() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of("scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE))) + .when().post("/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken") + .then() + .statusCode(503) + .body("error.status", equalTo("UNAVAILABLE")) + .body("error.message", equalTo("Service iamcredentials is not enabled.")); + } + + public static class DisabledIamCredentialsProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-gcp.services.iamcredentials.enabled", "false"); + } + } +} diff --git a/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsRestIntegrationTest.java new file mode 100644 index 0000000..e832aba --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsRestIntegrationTest.java @@ -0,0 +1,145 @@ +package io.floci.gcp.services.iamcredentials; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class IamCredentialsRestIntegrationTest { + + private static final String PATH = + "/v1/projects/-/serviceAccounts/test@test-project.iam.gserviceaccount.com:generateAccessToken"; + private static final Map VALID_BODY = Map.of( + "delegates", List.of(), + "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "lifetime", "3600s"); + + @Test + void generateAccessTokenReturnsGoogleCompatibleJson() { + String expireTime = given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(VALID_BODY) + .when().post(PATH) + .then() + .statusCode(200) + .body("accessToken", startsWith(IamCredentialsService.TOKEN_PREFIX)) + .body("expireTime", notNullValue()) + .extract().path("expireTime"); + + assertTrue(Instant.parse(expireTime).isAfter(Instant.now().minusSeconds(1))); + } + + @Test + void specificRouteDoesNotFallThroughToIamCatchAll() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of("scope", List.of("https://www.googleapis.com/auth/userinfo.email"))) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")) + .body("error.message", equalTo("unsupported scope")); + } + + @Test + void missingScopeReturnsGcpStyleError() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of("lifetime", "60s")) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")) + .body("error.errors[0].reason", equalTo("invalid")); + } + + @Test + void emptyScopeReturnsGcpStyleError() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of("scope", List.of(), "lifetime", "60s")) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")); + } + + @Test + void unsupportedScopeReturnsGcpStyleError() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of("scope", List.of("https://www.googleapis.com/auth/userinfo.email"))) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")); + } + + @Test + void cloudPlatformScopeReturnsAccessToken() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of("scope", List.of(IamCredentialsService.CLOUD_PLATFORM_SCOPE))) + .when().post(PATH) + .then() + .statusCode(200) + .body("accessToken", startsWith(IamCredentialsService.TOKEN_PREFIX)); + } + + @Test + void malformedLifetimeReturnsGcpStyleError() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of( + "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "lifetime", "not-a-duration")) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")); + } + + @Test + void zeroLifetimeReturnsGcpStyleError() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of( + "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "lifetime", "0s")) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")); + } + + @Test + void negativeLifetimeReturnsGcpStyleError() { + given() + .urlEncodingEnabled(false) + .contentType("application/json") + .body(Map.of( + "scope", List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "lifetime", "-1s")) + .when().post(PATH) + .then() + .statusCode(400) + .body("error.status", equalTo("INVALID_ARGUMENT")); + } +} diff --git a/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsServiceTest.java b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsServiceTest.java new file mode 100644 index 0000000..7d44a4f --- /dev/null +++ b/src/test/java/io/floci/gcp/services/iamcredentials/IamCredentialsServiceTest.java @@ -0,0 +1,155 @@ +package io.floci.gcp.services.iamcredentials; + +import io.floci.gcp.core.common.GcpException; +import io.floci.gcp.core.storage.InMemoryStorage; +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IamCredentialsServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-15T12:00:00Z"); + private static final String SERVICE_ACCOUNT = "test@test-project.iam.gserviceaccount.com"; + + private final InMemoryStorage tokenStore = new InMemoryStorage<>(); + private final CredentialTokenService tokenService = + new CredentialTokenService(tokenStore, Clock.fixed(NOW, ZoneOffset.UTC)); + private final IamCredentialsService service = + new IamCredentialsService(tokenService, Clock.fixed(NOW, ZoneOffset.UTC)); + + @Test + void generatesTokenAndExpiryForNormalRequest() { + IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "1200s"); + + assertTrue(token.accessToken().startsWith(IamCredentialsService.TOKEN_PREFIX)); + assertEquals(NOW.plusSeconds(1200), token.expireTime()); + Optional stored = tokenService.lookupBearerToken(token.accessToken()); + assertTrue(stored.isPresent()); + assertEquals(StoredCredentialToken.TokenKind.IMPERSONATED, stored.get().getTokenKind()); + assertEquals(SERVICE_ACCOUNT, stored.get().getPrincipal()); + } + + @Test + void defaultsLifetimeWhenAbsent() { + IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + null); + + assertEquals(NOW.plusSeconds(IamCredentialsService.DEFAULT_LIFETIME_SECONDS), token.expireTime()); + } + + @Test + void capsLifetimeAtOneHour() { + IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "7200s"); + + assertEquals(NOW.plusSeconds(IamCredentialsService.MAX_LIFETIME_SECONDS), token.expireTime()); + } + + @Test + void acceptsArbitraryServiceAccountWithoutPreCreation() { + IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken( + "not-created@example.iam.gserviceaccount.com", + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "60s"); + + assertTrue(token.accessToken().startsWith(IamCredentialsService.TOKEN_PREFIX)); + } + + @Test + void acceptsCloudPlatformScope() { + IamCredentialsService.GeneratedAccessToken token = service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.CLOUD_PLATFORM_SCOPE), + "60s"); + + assertTrue(token.accessToken().startsWith(IamCredentialsService.TOKEN_PREFIX)); + } + + @Test + void rejectsBlankServiceAccount() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken( + " ", + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "60s")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } + + @Test + void rejectsMissingScope() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken(SERVICE_ACCOUNT, null, "60s")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } + + @Test + void rejectsEmptyScope() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken(SERVICE_ACCOUNT, List.of(), "60s")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } + + @Test + void rejectsUnsupportedScope() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken( + SERVICE_ACCOUNT, + List.of("https://www.googleapis.com/auth/userinfo.email"), + "60s")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } + + @Test + void rejectsMalformedLifetime() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "one-hour")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } + + @Test + void rejectsZeroLifetime() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "0s")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } + + @Test + void rejectsNegativeLifetime() { + GcpException ex = assertThrows(GcpException.class, + () -> service.generateAccessToken( + SERVICE_ACCOUNT, + List.of(IamCredentialsService.DEVSTORAGE_READ_WRITE_SCOPE), + "-1s")); + + assertEquals("INVALID_ARGUMENT", ex.getGcpStatus()); + } +} diff --git a/src/test/java/io/floci/gcp/services/sts/StsDisabledRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/sts/StsDisabledRestIntegrationTest.java new file mode 100644 index 0000000..7272d22 --- /dev/null +++ b/src/test/java/io/floci/gcp/services/sts/StsDisabledRestIntegrationTest.java @@ -0,0 +1,39 @@ +package io.floci.gcp.services.sts; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +@QuarkusTest +@TestProfile(StsDisabledRestIntegrationTest.DisabledStsProfile.class) +class StsDisabledRestIntegrationTest { + + @Test + void disabledStsReturnsUnavailableWrapper() { + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", StsService.TOKEN_EXCHANGE_GRANT_TYPE) + .formParam("subject_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("subject_token", "source-token") + .formParam("requested_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("options", StsServiceTest.cab()) + .when().post("/v1/token") + .then() + .statusCode(503) + .body("error.status", equalTo("UNAVAILABLE")) + .body("error.message", equalTo("Service sts is not enabled.")); + } + + public static class DisabledStsProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-gcp.services.sts.enabled", "false"); + } + } +} diff --git a/src/test/java/io/floci/gcp/services/sts/StsRestIntegrationTest.java b/src/test/java/io/floci/gcp/services/sts/StsRestIntegrationTest.java new file mode 100644 index 0000000..2b5bc0e --- /dev/null +++ b/src/test/java/io/floci/gcp/services/sts/StsRestIntegrationTest.java @@ -0,0 +1,76 @@ +package io.floci.gcp.services.sts; + +import io.floci.gcp.services.credentials.CredentialTokenService; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +@QuarkusTest +class StsRestIntegrationTest { + + @Test + void validFormExchangeReturnsRequiredFields() { + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", StsService.TOKEN_EXCHANGE_GRANT_TYPE) + .formParam("subject_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("subject_token", "source-token") + .formParam("requested_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("options", StsServiceTest.cab()) + .when().post("/v1/token") + .then() + .statusCode(200) + .body("access_token", startsWith(CredentialTokenService.DOWNSCOPED_TOKEN_PREFIX)) + .body("issued_token_type", equalTo(StsService.ACCESS_TOKEN_TYPE)) + .body("token_type", equalTo(StsService.BEARER_TOKEN_TYPE)) + .body("expires_in", equalTo((int) CredentialTokenService.DEFAULT_LIFETIME_SECONDS)); + } + + @Test + void missingOptionsReturnsOauthError() { + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", StsService.TOKEN_EXCHANGE_GRANT_TYPE) + .formParam("subject_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("subject_token", "source-token") + .formParam("requested_token_type", StsService.ACCESS_TOKEN_TYPE) + .when().post("/v1/token") + .then() + .statusCode(400) + .body("error", equalTo("invalid_request")); + } + + @Test + void unsupportedCabReturnsInvalidGrant() { + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", StsService.TOKEN_EXCHANGE_GRANT_TYPE) + .formParam("subject_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("subject_token", "source-token") + .formParam("requested_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("options", StsServiceTest.cab().replace( + "inRole:roles/storage.legacyObjectReader", "inRole:roles/storage.admin")) + .when().post("/v1/token") + .then() + .statusCode(400) + .body("error", equalTo("invalid_grant")); + } + + @Test + void wrongGrantTypeReturnsInvalidRequest() { + given() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", "bad") + .formParam("subject_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("subject_token", "source-token") + .formParam("requested_token_type", StsService.ACCESS_TOKEN_TYPE) + .formParam("options", StsServiceTest.cab()) + .when().post("/v1/token") + .then() + .statusCode(400) + .body("error", equalTo("invalid_request")); + } +} diff --git a/src/test/java/io/floci/gcp/services/sts/StsServiceTest.java b/src/test/java/io/floci/gcp/services/sts/StsServiceTest.java new file mode 100644 index 0000000..370919a --- /dev/null +++ b/src/test/java/io/floci/gcp/services/sts/StsServiceTest.java @@ -0,0 +1,85 @@ +package io.floci.gcp.services.sts; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.floci.gcp.core.storage.InMemoryStorage; +import io.floci.gcp.services.credentials.CredentialAccessBoundaryParser; +import io.floci.gcp.services.credentials.StoredCredentialToken; +import io.floci.gcp.services.credentials.CredentialTokenService; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StsServiceTest { + + private final InMemoryStorage store = new InMemoryStorage<>(); + private final CredentialTokenService tokenService = + new CredentialTokenService(store, Clock.fixed(Instant.parse("2026-07-15T12:00:00Z"), ZoneOffset.UTC)); + private final StsService service = new StsService( + new CredentialAccessBoundaryParser(new ObjectMapper()), + tokenService); + + @Test + void exchangesSupportedCabForStoredDownscopedToken() { + Map response = validExchange(); + + String accessToken = (String) response.get("access_token"); + assertTrue(accessToken.startsWith(CredentialTokenService.DOWNSCOPED_TOKEN_PREFIX)); + assertEquals(StsService.ACCESS_TOKEN_TYPE, response.get("issued_token_type")); + assertEquals(StsService.BEARER_TOKEN_TYPE, response.get("token_type")); + assertEquals(CredentialTokenService.DEFAULT_LIFETIME_SECONDS, response.get("expires_in")); + assertTrue(store.get(accessToken).isPresent()); + } + + @Test + void rejectsWrongGrantTypeAsInvalidRequest() { + StsException ex = assertThrows(StsException.class, + () -> service.exchangeToken("bad", StsService.ACCESS_TOKEN_TYPE, "source-token", + StsService.ACCESS_TOKEN_TYPE, cab())); + + assertEquals("invalid_request", ex.error()); + } + + @Test + void rejectsUnsupportedCabAsInvalidGrant() { + StsException ex = assertThrows(StsException.class, + () -> service.exchangeToken(StsService.TOKEN_EXCHANGE_GRANT_TYPE, StsService.ACCESS_TOKEN_TYPE, + "source-token", StsService.ACCESS_TOKEN_TYPE, cab().replace( + "inRole:roles/storage.legacyObjectReader", "inRole:roles/storage.admin"))); + + assertEquals("invalid_grant", ex.error()); + } + + private Map validExchange() { + return service.exchangeToken( + StsService.TOKEN_EXCHANGE_GRANT_TYPE, + StsService.ACCESS_TOKEN_TYPE, + "source-token", + StsService.ACCESS_TOKEN_TYPE, + cab()); + } + + static String cab() { + return """ + { + "accessBoundary": { + "accessBoundaryRules": [ + { + "availableResource": "//storage.googleapis.com/projects/_/buckets/bucket", + "availablePermissions": ["%s"], + "availabilityCondition": { + "expression": "resource.name.startsWith('projects/_/buckets/bucket/objects/data/')" + } + } + ] + } + } + """.formatted("inRole:roles/storage.legacyObjectReader"); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e8a1463..79bc9d3 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -11,6 +11,10 @@ floci-gcp: storage: mode: memory services: + iamcredentials: + enabled: true + sts: + enabled: true datastore: enabled: true kafka: