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
20 changes: 20 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,32 @@ Run `tw credentials add <provider> -h` to view the required fields for your prov

Seqera requires credentials to access your cloud compute environments. See the [compute environment page][compute-envs] for your cloud provider for more information.

AWS credentials support two modes: **keys** (default) and **role**.

**Keys mode** — uses an AWS access key and secret key:

```console
$ tw credentials add aws --name=my_aws_creds --access-key=<aws access key> --secret-key=<aws secret key>

New AWS credentials 'my_aws_creds (1sxCxvxfx8xnxdxGxQxqxH)' added at user workspace
```

Keys mode also supports an optional IAM role ARN for cross-account access, with an optional platform-managed External ID:

```bash
tw credentials add aws --name=my_aws_creds --access-key=<aws access key> --secret-key=<aws secret key> --assume-role-arn=<role ARN> --generate-external-id
```

**Role mode** — uses IAM role assumption without static credentials. An External ID is automatically generated by the platform:

```console
$ tw credentials add aws --name=my_aws_role_creds --mode=role --assume-role-arn=<role ARN>

New AWS credentials 'my_aws_role_creds (2sxCxvxfx8xnxdxGxQxqxH)' added at user workspace
```

> **Note**: In role mode, `--access-key` and `--secret-key` cannot be used. The `--assume-role-arn` option is required.

#### Git credentials

Seqera requires access credentials to interact with pipeline Git repositories. See [Git integration][git-integration] for more information.
Expand Down
2 changes: 1 addition & 1 deletion VERSION-API
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
1.109.0
1.114.0
// Only first line of this file is read
// This version should be bumped to the minimum version where dependent API changes were introduced
// But never higher then the current Platform API Version deployed in Cloud Production: https://cloud.seqera.io/api/service-info
41 changes: 38 additions & 3 deletions conf/reflect-config.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ classgraphVersion = "4.8.180"
commonsCompressVersion = "1.28.0"
commonsIoVersion = "2.21.0"
graalvmNativeVersion = "0.10.6"
jacksonDatabindNullableVersion = "0.2.8"
jakartaAnnotationVersion = "3.0.0"
javaxAnnotationVersion = "1.3.2"
jerseyVersion = "2.47"
Expand All @@ -13,13 +14,14 @@ mockserverVersion = "5.15.0"
picocliVersion = "4.6.3"
shadowVersion = "9.3.1"
slf4jVersion = "2.0.17"
towerJavaSdkVersion = "1.107.0"
towerJavaSdkVersion = "1.114.0"
xzVersion = "1.10"

[libraries]
classgraph = { group = "io.github.classgraph", name = "classgraph", version.ref = "classgraphVersion" }
commonsCompress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompressVersion" }
commonsIo = { group = "commons-io", name = "commons-io", version.ref = "commonsIoVersion" }
jacksonDatabindNullable = { group = "org.openapitools", name = "jackson-databind-nullable", version.ref = "jacksonDatabindNullableVersion" }
jakartaAnnotation = { group = "jakarta.annotation", name = "jakarta.annotation-api", version.ref = "jakartaAnnotationVersion" }
javaxAnnotation = { group = "javax.annotation", name = "javax.annotation-api", version.ref = "javaxAnnotationVersion" }
jerseyClient = { group = "org.glassfish.jersey.core", name = "jersey-client", version.ref = "jerseyVersion" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ protected Response exec() throws ApiException, IOException {

if (overwrite) tryDeleteCredentials(name, wspId);

CreateCredentialsResponse resp = credentialsApi().createCredentials(new CreateCredentialsRequest().credentials(specs), wspId);
CreateCredentialsResponse resp = credentialsApi().createCredentials(new CreateCredentialsRequest().credentials(specs), wspId, getProvider().useExternalId());

return new CredentialsAdded(getProvider().type().name(), resp.getCredentialsId(), name, workspaceRef(wspId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.seqera.tower.cli.commands.credentials.providers;

import io.seqera.tower.model.AwsCredentialsMode;
import io.seqera.tower.model.AwsSecurityKeys;
import io.seqera.tower.model.Credentials.ProviderEnum;
import picocli.CommandLine.ArgGroup;
Expand All @@ -25,16 +26,30 @@ public class AwsProvider extends AbstractProvider<AwsSecurityKeys> {

@ArgGroup(exclusive = false)
public Keys keys;

@Option(names = {"-r", "--assume-role-arn"}, description = "IAM role ARN to assume for accessing AWS resources. Allows cross-account access or privilege elevation. Must be a fully qualified ARN (e.g., arn:aws:iam::123456789012:role/RoleName).")
String assumeRoleArn;

@Option(names = {"--mode"}, description = "AWS credential mode: 'keys' (access key + secret key) or 'role' (IAM role only). Default: keys.")
String mode;

@Option(names = {"--generate-external-id"}, description = "Generate a platform-managed External ID for the credential (used with IAM role ARN).", defaultValue = "false")
boolean generateExternalId;

public AwsProvider() {
super(ProviderEnum.AWS);
}

@Override
public AwsSecurityKeys securityKeys() {
validate();

AwsSecurityKeys result = new AwsSecurityKeys();

if (getMode() != null) {
result.mode(getMode());
}

if (keys != null) {
result.accessKey(keys.accessKey).secretKey(keys.secretKey);
}
Expand All @@ -46,6 +61,46 @@ public AwsSecurityKeys securityKeys() {
return result;
}

@Override
public Boolean useExternalId() {
AwsCredentialsMode mode = getMode();
if (mode == AwsCredentialsMode.role) {
return true;
}
if (generateExternalId && assumeRoleArn != null) {
return true;
}
return null;
}

private AwsCredentialsMode getMode() {
if (mode == null) {
return null;
}
return switch (mode.toLowerCase()) {
case "keys" -> AwsCredentialsMode.keys;
case "role" -> AwsCredentialsMode.role;
default -> throw new IllegalArgumentException(String.format("Invalid AWS credential mode '%s'. Allowed values: 'keys', 'role'.", mode));
};
}

private void validate() {
AwsCredentialsMode mode = getMode();

if (mode == AwsCredentialsMode.role) {
if (keys != null && (keys.accessKey != null || keys.secretKey != null)) {
throw new IllegalArgumentException("Options '--access-key' and '--secret-key' cannot be used with '--mode=role'. Role mode uses IAM role assumption without static credentials.");
}
if (assumeRoleArn == null) {
throw new IllegalArgumentException("Option '--assume-role-arn' is required when using '--mode=role'.");
}
}

if (generateExternalId && mode != AwsCredentialsMode.role && assumeRoleArn == null) {
throw new IllegalArgumentException("Option '--generate-external-id' requires '--assume-role-arn' to be specified.");
}
}

public static class Keys {

@Option(names = {"-a", "--access-key"}, description = "AWS access key identifier. Part of AWS IAM credentials used for programmatic access to AWS services.")
Expand All @@ -54,4 +109,4 @@ public static class Keys {
@Option(names = {"-s", "--secret-key"}, description = "AWS secret access key. Part of AWS IAM credentials used for programmatic access to AWS services. Keep this value secure.")
String secretKey;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ public interface CredentialsProvider {
ProviderEnum type();

SecurityKeys securityKeys() throws IOException, ApiException;

default Boolean useExternalId() {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ protected Response update(Credentials creds, Long wspId) throws ApiException, IO
.provider(getProvider().type())
.id(creds.getId());

credentialsApi().updateCredentials(creds.getId(), new UpdateCredentialsRequest().credentials(specs), wspId);
credentialsApi().updateCredentials(creds.getId(), new UpdateCredentialsRequest().credentials(specs), wspId, getProvider().useExternalId());

return new CredentialsUpdated(getProvider().type().name(), name, workspaceRef(wspId));
}
Expand Down
4 changes: 2 additions & 2 deletions src/test/java/io/seqera/tower/cli/InfoCmdTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ void testInfo(OutputType format, MockServerClient mock) throws IOException {
Map<String, String> opts = new HashMap<>();
opts.put("cliVersion", getCliVersion() );
opts.put("cliApiVersion", getCliApiVersion());
opts.put("towerApiVersion", "1.109.0");
opts.put("towerApiVersion", "1.114.0");
opts.put("towerVersion", "22.3.0-torricelli");
opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort());
opts.put("userName", "jordi");
Expand Down Expand Up @@ -86,7 +86,7 @@ void testInfoStatusTokenFail(MockServerClient mock) throws IOException {
Map<String, String> opts = new HashMap<>();
opts.put("cliVersion", getCliVersion() );
opts.put("cliApiVersion", getCliApiVersion());
opts.put("towerApiVersion", "1.109.0");
opts.put("towerApiVersion", "1.114.0");
opts.put("towerVersion", "22.3.0-torricelli");
opts.put("towerApiEndpoint", "http://localhost:"+mock.getPort());
opts.put("userName", null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockserver.matchers.Times.exactly;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
Expand Down Expand Up @@ -80,6 +81,89 @@ void testAdd(OutputType format, MockServerClient mock) {

}

@ParameterizedTest
@EnumSource(OutputType.class)
void testAddWithExplicitKeysMode(OutputType format, MockServerClient mock) {

mock.when(
request()
.withMethod("POST")
.withPath("/credentials")
.withBody(json("{\"credentials\":{\"keys\":{\"mode\":\"keys\",\"accessKey\":\"access_key\",\"secretKey\":\"secret_key\"},\"name\":\"aws-keys\",\"provider\":\"aws\"}}")),
exactly(1)
).respond(
response().withStatusCode(200).withBody("{\"credentialsId\":\"2cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON)
);

ExecOut out = exec(format, mock, "credentials", "add", "aws", "-n", "aws-keys", "--mode=keys", "-a", "access_key", "-s", "secret_key");
assertOutput(format, out, new CredentialsAdded("AWS", "2cz5A8cuBkB5iJliCwJCFU", "aws-keys", USER_WORKSPACE_NAME));
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testAddWithRoleMode(OutputType format, MockServerClient mock) {

mock.when(
request()
.withMethod("POST")
.withPath("/credentials")
.withQueryStringParameter("useExternalId", "true")
.withBody(json("{\"credentials\":{\"keys\":{\"mode\":\"role\",\"assumeRoleArn\":\"arn:aws:iam::123456789012:role/MyRole\"},\"name\":\"aws-role\",\"provider\":\"aws\"}}")),
exactly(1)
).respond(
response().withStatusCode(200).withBody("{\"credentialsId\":\"3cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON)
);

ExecOut out = exec(format, mock, "credentials", "add", "aws", "-n", "aws-role", "--mode=role", "-r", "arn:aws:iam::123456789012:role/MyRole");
assertOutput(format, out, new CredentialsAdded("AWS", "3cz5A8cuBkB5iJliCwJCFU", "aws-role", USER_WORKSPACE_NAME));
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testAddKeysModeWithGenerateExternalId(OutputType format, MockServerClient mock) {

mock.when(
request()
.withMethod("POST")
.withPath("/credentials")
.withQueryStringParameter("useExternalId", "true")
.withBody(json("{\"credentials\":{\"keys\":{\"accessKey\":\"access_key\",\"secretKey\":\"secret_key\",\"assumeRoleArn\":\"arn_role\"},\"name\":\"aws-ext\",\"provider\":\"aws\"}}")),
exactly(1)
).respond(
response().withStatusCode(200).withBody("{\"credentialsId\":\"4cz5A8cuBkB5iJliCwJCFU\"}").withContentType(MediaType.APPLICATION_JSON)
);

ExecOut out = exec(format, mock, "credentials", "add", "aws", "-n", "aws-ext", "-a", "access_key", "-s", "secret_key", "-r", "arn_role", "--generate-external-id");
assertOutput(format, out, new CredentialsAdded("AWS", "4cz5A8cuBkB5iJliCwJCFU", "aws-ext", USER_WORKSPACE_NAME));
}

@Test
void testAddRoleModeRejectsAccessKeys(MockServerClient mock) {

ExecOut out = exec(mock, "credentials", "add", "aws", "-n", "aws-role-bad", "--mode=role", "-a", "access_key", "-s", "secret_key", "-r", "arn_role");

assertTrue(out.stdErr.contains("'--access-key' and '--secret-key' cannot be used with '--mode=role'"), "Expected error about access keys not allowed in role mode, got: " + out.stdErr);
assertEquals(1, out.exitCode);
}

@Test
void testAddRoleModeRequiresAssumeRoleArn(MockServerClient mock) {

ExecOut out = exec(mock, "credentials", "add", "aws", "-n", "aws-role-bad", "--mode=role");

assertTrue(out.stdErr.contains("'--assume-role-arn' is required when using '--mode=role'"), "Expected error about missing assume-role-arn, got: " + out.stdErr);
assertEquals(1, out.exitCode);
}

@Test
void testAddGenerateExternalIdRequiresAssumeRoleArn(MockServerClient mock) {

ExecOut out = exec(mock, "credentials", "add", "aws", "-n", "aws-ext-bad", "-a", "access_key", "-s", "secret_key", "--generate-external-id");

assertTrue(out.stdErr.contains("'--generate-external-id' requires '--assume-role-arn'"), "Expected error about missing assume-role-arn for generate-external-id, got: " + out.stdErr);
assertEquals(1, out.exitCode);
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testUpdate(OutputType format, MockServerClient mock) {
Expand All @@ -104,6 +188,55 @@ void testUpdate(OutputType format, MockServerClient mock) {
assertOutput(format, out, new CredentialsUpdated("AWS", "aws", USER_WORKSPACE_NAME));
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testUpdateWithRoleMode(OutputType format, MockServerClient mock) {

mock.when(
request().withMethod("GET").withPath("/credentials/kfKx9xRgzpIIZrbCMOcU4"), exactly(1)
).respond(
response().withStatusCode(200).withBody("{\"credentials\":{\"id\":\"kfKx9xRgzpIIZrbCMOcU4\",\"name\":\"aws\",\"description\":null,\"discriminator\":\"aws\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-06T15:16:52Z\",\"dateCreated\":\"2021-09-03T13:23:37Z\",\"lastUpdated\":\"2021-09-03T13:23:37Z\"}}").withContentType(MediaType.APPLICATION_JSON)
);

mock.when(
request()
.withMethod("PUT")
.withPath("/credentials/kfKx9xRgzpIIZrbCMOcU4")
.withQueryStringParameter("useExternalId", "true")
.withBody(json("{\"credentials\":{\"keys\":{\"mode\":\"role\",\"assumeRoleArn\":\"arn:aws:iam::123456789012:role/NewRole\"},\"id\":\"kfKx9xRgzpIIZrbCMOcU4\",\"name\":\"aws\",\"provider\":\"aws\"}}"))
.withContentType(MediaType.APPLICATION_JSON)
).respond(
response().withStatusCode(204)
);

ExecOut out = exec(format, mock, "credentials", "update", "aws", "-i", "kfKx9xRgzpIIZrbCMOcU4", "--mode=role", "-r", "arn:aws:iam::123456789012:role/NewRole");
assertOutput(format, out, new CredentialsUpdated("AWS", "aws", USER_WORKSPACE_NAME));
}

@ParameterizedTest
@EnumSource(OutputType.class)
void testUpdateWithKeysMode(OutputType format, MockServerClient mock) {

mock.when(
request().withMethod("GET").withPath("/credentials/kfKx9xRgzpIIZrbCMOcU4"), exactly(1)
).respond(
response().withStatusCode(200).withBody("{\"credentials\":{\"id\":\"kfKx9xRgzpIIZrbCMOcU4\",\"name\":\"aws\",\"description\":null,\"discriminator\":\"aws\",\"baseUrl\":null,\"category\":null,\"deleted\":null,\"lastUsed\":\"2021-09-06T15:16:52Z\",\"dateCreated\":\"2021-09-03T13:23:37Z\",\"lastUpdated\":\"2021-09-03T13:23:37Z\"}}").withContentType(MediaType.APPLICATION_JSON)
);

mock.when(
request()
.withMethod("PUT")
.withPath("/credentials/kfKx9xRgzpIIZrbCMOcU4")
.withBody(json("{\"credentials\":{\"keys\":{\"mode\":\"keys\",\"accessKey\":\"new_key\",\"secretKey\":\"new_secret\"},\"id\":\"kfKx9xRgzpIIZrbCMOcU4\",\"name\":\"aws\",\"provider\":\"aws\"}}"))
.withContentType(MediaType.APPLICATION_JSON)
).respond(
response().withStatusCode(204)
);

ExecOut out = exec(format, mock, "credentials", "update", "aws", "-i", "kfKx9xRgzpIIZrbCMOcU4", "--mode=keys", "-a", "new_key", "-s", "new_secret");
assertOutput(format, out, new CredentialsUpdated("AWS", "aws", USER_WORKSPACE_NAME));
}

@Test
void testUpdateNotFound(MockServerClient mock) {

Expand Down Expand Up @@ -135,4 +268,4 @@ void testInvalidAuth(MockServerClient mock) {
assertEquals(1, out.exitCode);
}

}
}
2 changes: 1 addition & 1 deletion src/test/resources/runcmd/info/service-info.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"serviceInfo": {
"version": "22.3.0-torricelli",
"apiVersion": "1.109.0",
"apiVersion": "1.114.0",
"commitId": "3f04bfd4",
"authTypes": [
"github",
Expand Down