Skip to content
Open
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
4 changes: 4 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,10 @@
<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.firebase</groupId>
<artifactId>firebase-admin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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;
Expand Down Expand Up @@ -215,6 +217,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,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());
}
}
}
7 changes: 7 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,8 @@ interface ServicesConfig {

IamServiceConfig iam();

IamCredentialsServiceConfig iamcredentials();

SecretManagerServiceConfig secretmanager();

LoggingServiceConfig logging();
Expand Down Expand Up @@ -186,6 +188,11 @@ interface IamServiceConfig {
boolean enabled();
}

interface IamCredentialsServiceConfig {
@WithDefault("true")
boolean enabled();
}

interface SecretManagerServiceConfig {
@WithDefault("true")
boolean enabled();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> response = new LinkedHashMap<>();
response.put("accessToken", token.accessToken());
response.put("expireTime", token.expireTime().toString());
return Response.ok(response).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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.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;
import java.util.UUID;

@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 = "floci-gcp-impersonated-";
static final long DEFAULT_LIFETIME_SECONDS = 3600;
static final long MAX_LIFETIME_SECONDS = 3600;
private static final Set<String> SUPPORTED_SCOPES = Set.of(DEVSTORAGE_READ_WRITE_SCOPE, CLOUD_PLATFORM_SCOPE);

private final ServiceRegistry serviceRegistry;
private final EmulatorConfig config;
private final Clock clock;

@Inject
public IamCredentialsService(ServiceRegistry serviceRegistry, EmulatorConfig config) {
this(serviceRegistry, config, Clock.systemUTC());
}

IamCredentialsService(Clock clock) {
this(null, null, clock);
}

private IamCredentialsService(ServiceRegistry serviceRegistry, EmulatorConfig config, Clock clock) {
this.serviceRegistry = serviceRegistry;
this.config = config;
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);
return new GeneratedAccessToken(TOKEN_PREFIX + UUID.randomUUID(), 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");
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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) {}
}
2 changes: 2 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ floci-gcp:
enabled: true
iam:
enabled: true
iamcredentials:
enabled: true
secretmanager:
enabled: true
logging:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> getConfigOverrides() {
return Map.of("floci-gcp.services.iamcredentials.enabled", "false");
}
}
}
Loading