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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions compatibility-tests/sdk-test-java/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-service-usage</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-iamcredentials</artifactId>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
</dependency>
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

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;
import com.google.cloud.firestore.Firestore;
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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
14 changes: 14 additions & 0 deletions src/main/java/io/floci/gcp/config/EmulatorConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ interface ServicesConfig {

IamServiceConfig iam();

IamCredentialsServiceConfig iamcredentials();

StsServiceConfig sts();

SecretManagerServiceConfig secretmanager();

LoggingServiceConfig logging();
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/io/floci/gcp/core/common/GcpException.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading