diff --git a/admin-cli/pom.xml b/admin-cli/pom.xml new file mode 100644 index 00000000..92d1beb5 --- /dev/null +++ b/admin-cli/pom.xml @@ -0,0 +1,164 @@ + + + 4.0.0 + + cloud.katta + katta-clientlib + 1.0.0-SNAPSHOT + + admin-cli + + + 21 + 21 + UTF-8 + 4.7.6 + + + + + cloud.katta + katta-clientlib-hub + ${project.version} + + + cloud.katta + katta-clientlib-hub + ${project.version} + tests + test-jar + test + + + ch.cyberduck + core + test-jar + test + + + ch.cyberduck + test + pom + test + + + io.github.coffeelibs + tiny-oauth2-client + 0.8.1 + + + commons-codec + commons-codec + 1.18.0 + + + software.amazon.awssdk + iam + 2.31.27 + compile + + + software.amazon.awssdk + iam-policy-builder + 2.31.27 + compile + + + org.json + json + 20250107 + compile + + + org.apache.commons + commons-compress + 1.27.0 + compile + + + info.picocli + picocli + ${picocli.version} + + + io.rest-assured + rest-assured + 5.5.0 + + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + compile + + + org.testcontainers + testcontainers + test + + + io.minio + minio-admin + 8.5.17 + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + unpack + process-sources + + unpack + + + + + cloud.katta + katta-clientlib-hub + ${project.version} + tests + jar + false + ${project.build.directory}/test-classes + **/* + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-bytecode-version + + enforce + + + true + + + + + + org.codehaus.mojo + extra-enforcer-rules + 1.10.0 + + + + + + diff --git a/admin-cli/src/main/java/cloud/katta/cli/KattaSetupCli.java b/admin-cli/src/main/java/cloud/katta/cli/KattaSetupCli.java new file mode 100644 index 00000000..5547e3ef --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/KattaSetupCli.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli; + +import cloud.katta.cli.commands.hub.StorageProfileArchive; +import cloud.katta.cli.commands.hub.StorageProfileAwsStaticSetup; +import cloud.katta.cli.commands.hub.StorageProfileAwsStsSetup; +import cloud.katta.cli.commands.login.AuthorizationCode; +import cloud.katta.cli.commands.storage.AwsStsSetup; +import cloud.katta.cli.commands.storage.MinioStsSetup; +import picocli.CommandLine; + +@CommandLine.Command(name = "katta-admin-cli", + mixinStandardHelpOptions = true, + subcommands = { + // storage + AwsStsSetup.class, + MinioStsSetup.class, + // hub + StorageProfileAwsStsSetup.class, StorageProfileAwsStaticSetup.class, + StorageProfileArchive.class, + // misc. + AuthorizationCode.class, + CommandLine.HelpCommand.class, + }) +public class KattaSetupCli { + + public static void main(String... args) { + var app = new KattaSetupCli(); + int exitCode = new CommandLine(app) + .setPosixClusteredShortOptionsAllowed(false) + .execute(args); + System.exit(exitCode); + } +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/AbstractAuthorizationCode.java b/admin-cli/src/main/java/cloud/katta/cli/commands/AbstractAuthorizationCode.java new file mode 100644 index 00000000..d71a81c5 --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/AbstractAuthorizationCode.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands; + +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.coffeelibs.tinyoauth2client.TinyOAuth2; +import picocli.CommandLine; + +public class AbstractAuthorizationCode { + + @CommandLine.Option(names = {"--tokenUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/token\"", required = false) + String tokenUrl; + + @CommandLine.Option(names = {"--authUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/auth\"", required = false) + String authUrl; + + @CommandLine.Option(names = {"--clientId"}, description = "Keycloak realm URL with scheme. Example: \"cryptomator\"", required = false) + String clientId; + + @CommandLine.Option(names = {"--accessToken"}, description = "The access token. If not provided, --tokenUrl, --authUrl and --clientId need to be provided. Requires admin role in the hub.", required = false) + String accessToken; + + protected String login() throws IOException, InterruptedException { + if(StringUtils.isEmpty(accessToken)) { + var authResponse = TinyOAuth2.client(clientId) + .withTokenEndpoint(URI.create(tokenUrl)) + .authorizationCodeGrant(URI.create(authUrl)) + .authorize(HttpClient.newHttpClient(), uri -> { + System.out.println("Please login on " + uri); + }); + return extractAccessToken(authResponse); + } + else { + return accessToken; + } + } + + private static String extractAccessToken(HttpResponse response) throws JsonProcessingException { + var statusCode = response.statusCode(); + if(statusCode != 200) { + System.err.println(""" + Request was responded with code %d and body:\n%s\n""".formatted(statusCode, response.body())); + return null; + } + return new ObjectMapper().reader().readTree(response.body()).get("access_token").asText(); + } +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/hub/AbstractStorageProfile.java b/admin-cli/src/main/java/cloud/katta/cli/commands/hub/AbstractStorageProfile.java new file mode 100644 index 00000000..e50187ae --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/hub/AbstractStorageProfile.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import java.util.UUID; +import java.util.concurrent.Callable; + +import cloud.katta.cli.commands.AbstractAuthorizationCode; +import cloud.katta.client.ApiClient; +import cloud.katta.client.ApiException; +import picocli.CommandLine; + +public abstract class AbstractStorageProfile extends AbstractAuthorizationCode implements Callable { + @CommandLine.Option(names = {"--hubUrl"}, description = "Hub URL. Example: \"https://testing.katta.cloud/tamarind\"", required = true) + String hubUrl; + + @CommandLine.Option(names = {"--uuid"}, description = "The uuid.", required = true) + String uuid; + + @CommandLine.Option(names = {"--name"}, description = "The name.", required = true) + String name; + + @Override + public Void call() throws Exception { + final String accessToken = login(); + call(hubUrl, accessToken, uuid, name); + return null; + } + + protected void call(final String hubUrl, final String accessToken, final String uuid, final String name) throws ApiException { + final ApiClient apiClient = new ApiClient(); + apiClient.setBasePath(hubUrl); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + call(UUID.fromString(uuid), name, apiClient); + } + + protected abstract void call(final UUID uuid, final String name, final ApiClient apiClient) throws ApiException; +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/hub/StorageProfileArchive.java b/admin-cli/src/main/java/cloud/katta/cli/commands/hub/StorageProfileArchive.java new file mode 100644 index 00000000..22e5273b --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/hub/StorageProfileArchive.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import java.util.UUID; + +import cloud.katta.client.ApiClient; +import cloud.katta.client.ApiException; +import cloud.katta.client.api.StorageProfileResourceApi; +import picocli.CommandLine; + +/** + * Archives a storage profile using /api/storageprofile. + *

+ * Requires admin role in Katta Server. + *

+ * See also create-vaults role to create vaults for an existing AWS S3 bucket. + *

+ * See also: katta docs. + */ +@CommandLine.Command(name = "storageProfileAWSStatic", + description = "Upload storage profile for AWS Static.", + mixinStandardHelpOptions = true) +public class StorageProfileAwsStaticSetup extends AbstractStorageProfile { + + @CommandLine.Option(names = {"--region"}, description = "Bucket region, e.g. \"eu-west-1\".", required = true) + String region; + + @CommandLine.Option(names = {"--regions"}, description = "Bucket regions, e.g. \"--regions eu-west-1 --regions eu-west-2 --regions eu-west-3\"].", required = true) + List regions; + + @Override + protected void call(final UUID uuid, final String name, final ApiClient apiClient) throws ApiException { + final StorageProfileResourceApi storageProfileResourceApi = new StorageProfileResourceApi(apiClient); + + call(uuid, name, storageProfileResourceApi); + } + + protected void call(final UUID uuid, final String name, final StorageProfileResourceApi storageProfileResourceApi) throws ApiException { + storageProfileResourceApi.apiStorageprofileS3staticPost(new StorageProfileS3StaticDto() + .id(uuid) + .name(name) + .protocol(Protocol.S3_STATIC) + .archived(false) + + // -- (1) bucket creation, template upload and client profile + .scheme("https") + .port(443) + .storageClass(S3STORAGECLASSES.STANDARD) + .withPathStyleAccessEnabled(false) + + // -- (2) bucket creation only (only relevant for Desktop client) + // TODO extract option with default + .bucketEncryption(S3SERVERSIDEENCRYPTION.NONE) + .bucketVersioning(true) + .region(region) + .regions(regions) + // TODO extract option with default + .bucketPrefix("katta-") + // TODO bad design smell? not all S3 providers might have STS to create static bucket? + .stsRoleCreateBucketClient("") + .stsRoleCreateBucketHub("") + ); + System.out.println(storageProfileResourceApi.apiStorageprofileProfileIdGet(uuid)); + } +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetup.java b/admin-cli/src/main/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetup.java new file mode 100644 index 00000000..2186bdcb --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetup.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import cloud.katta.client.ApiClient; +import cloud.katta.client.ApiException; +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.Protocol; +import cloud.katta.client.model.S3SERVERSIDEENCRYPTION; +import cloud.katta.client.model.S3STORAGECLASSES; +import cloud.katta.client.model.StorageProfileS3STSDto; +import picocli.CommandLine; + +import java.util.List; +import java.util.UUID; + +/** + * Uploads a storage profile to Katta Server for use with AWS STS. Requires AWS STS setup. + *

+ * The storage profile then allows users with create-vaults role to create vaults and their corresponding S3 bucket seamlessly. + *

+ * See also: katta docs. + */ +@CommandLine.Command(name = "storageProfileAWSSTS", + description = "Upload storage profile for AWS STS.", + mixinStandardHelpOptions = true) +public class StorageProfileAwsStsSetup extends AbstractStorageProfile { + + @CommandLine.Option(names = {"--rolePrefix"}, description = "ARN Role Prefix. Example: \"arn:aws:iam::XXXXXXX:role/testing.katta.cloud-kc-realms-tamarind-\"", required = true) + String rolePrefix; + + @CommandLine.Option(names = {"--bucketPrefix"}, description = "Bucket prefix.", required = false, defaultValue = "katta-") + String bucketPrefix; + + @CommandLine.Option(names = {"--region"}, description = "Bucket region, e.g. \"eu-west-1\".", required = true) + String region; + + @CommandLine.Option(names = {"--regions"}, description = "Bucket regions, e.g. [\"eu-west-1\",\"eu-west-2\",\"eu-west-3\"].", required = true) + List regions; + + @Override + protected void call(final UUID uuid, final String name, final ApiClient apiClient) throws ApiException { + final StorageProfileResourceApi storageProfileResourceApi = new StorageProfileResourceApi(apiClient); + + call(uuid, name, storageProfileResourceApi); + } + + protected void call(final UUID uuid, final String name, final StorageProfileResourceApi storageProfileResourceApi) throws ApiException { + storageProfileResourceApi.apiStorageprofileS3stsPost(new StorageProfileS3STSDto() + .id(uuid) + .name(name) + .protocol(Protocol.S3_STS) + .archived(false) + + // -- (1) bucket creation, template upload and client profile + .scheme("https") + .port(443) + .storageClass(S3STORAGECLASSES.STANDARD) + .withPathStyleAccessEnabled(false) + + // -- (2) bucket creation only (only relevant for Desktop client) + .bucketPrefix(bucketPrefix) + .region(region) + .regions(regions) + // TODO extract option with default + .bucketEncryption(S3SERVERSIDEENCRYPTION.NONE) + .bucketVersioning(true) + + // arn:aws:iam::XXXXXXX:role/testing.katta.cloud-kc-realms-tamarind-createbucket + .stsRoleCreateBucketClient(String.format("%s-createbucket", rolePrefix)) + .stsRoleCreateBucketHub(String.format("%s-createbucket", rolePrefix)) + .bucketEncryption(S3SERVERSIDEENCRYPTION.NONE) + // TODO https://github.com/shift7-ch/katta-clientlib/issues/190 naming + // arn:aws:iam::XXXXXXX:role/testing.katta.cloud-kc-realms-tamarind-sts-chain-01 + .stsRoleAccessBucketAssumeRoleWithWebIdentity(String.format("%s-sts-chain-01", rolePrefix)) + // arn:aws:iam::XXXXXXX:role/testing.katta.cloud-kc-realms-tamarind-sts-chain-02 + .stsRoleAccessBucketAssumeRoleTaggedSession(String.format("%s-sts-chain-02", rolePrefix)) + ); + System.out.println(storageProfileResourceApi.apiStorageprofileProfileIdGet(uuid)); + } +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/login/AuthorizationCode.java b/admin-cli/src/main/java/cloud/katta/cli/commands/login/AuthorizationCode.java new file mode 100644 index 00000000..44d48863 --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/login/AuthorizationCode.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.login; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.util.concurrent.Callable; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.coffeelibs.tinyoauth2client.TinyOAuth2; +import picocli.CommandLine; + + +/** + * Based on hub-cli. + */ +@CommandLine.Command(name = "authorizationCode", description = "Get token using authorization code flow.", mixinStandardHelpOptions = true) +public class AuthorizationCode implements Callable { + + @CommandLine.Option(names = {"--tokenUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/token\"", required = true) + String tokenUrl; + + @CommandLine.Option(names = {"--authUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/auth\"", required = true) + String authUrl; + + @CommandLine.Option(names = {"--clientId"}, description = "Keycloak realm URL with scheme. Example: \"cryptomator\"", required = true) + String clientId; + + + @Override + public Void call() throws Exception { + var authResponse = TinyOAuth2.client(clientId) + .withTokenEndpoint(URI.create(tokenUrl)) + .authorizationCodeGrant(URI.create(authUrl)) + .authorize(HttpClient.newHttpClient(), uri -> { + System.out.println("Please login on " + uri); + }); + System.out.println(authResponse); + printAccessToken(authResponse); + return null; + } + + private static int printAccessToken(HttpResponse response) throws JsonProcessingException { + var statusCode = response.statusCode(); + if(statusCode != 200) { + System.err.println(""" + Request was responded with code %d and body:\n%s\n""".formatted(statusCode, response.body())); + return statusCode; + } + var token = new ObjectMapper().reader().readTree(response.body()).get("access_token").asText(); + System.out.println(token); + return 0; + } +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/storage/AwsStsSetup.java b/admin-cli/src/main/java/cloud/katta/cli/commands/storage/AwsStsSetup.java new file mode 100644 index 00000000..fa8a22bf --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/storage/AwsStsSetup.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.storage; + +import cloud.katta.cli.KattaSetupCli; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.io.IOUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import picocli.CommandLine; +import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.iam.IamClient; +import software.amazon.awssdk.services.iam.model.*; + +import javax.net.ssl.HttpsURLConnection; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.Charset; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * Sets up AWS for Katta in STS mode: + *

+ *

+ * See also: Katta Docs. + */ +@CommandLine.Command(name = "awsSetup", description = "Setup/update OIDC provider and roles for STS in AWS.", mixinStandardHelpOptions = true) +public class AwsStsSetup implements Callable { + + @CommandLine.Option(names = {"--realmUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/kc/realms/tamarind\"", required = true) + String realmUrl; + + @CommandLine.Option(names = {"--profileName"}, description = "AWS profile to load AWS credentials from. See ~/.aws/credentials.", required = true) + String profileName; + + @CommandLine.Option(names = {"--bucketPrefix"}, description = "Bucket Prefix for STS vaults.", required = false, defaultValue = "katta-") + String bucketPrefix; + + @CommandLine.Option(names = {"--maxSessionDuration"}, description = "Bucket Prefix for STS vaults.", required = false) + Integer maxSessionDuration; + + @CommandLine.Option(names = {"--clientId"}, description = "ClientIds for the OIDC provider.", required = true) + List clientId; + + @Override + public Void call() throws Exception { + // remove trailing slash + realmUrl = realmUrl.replaceAll("/$", ""); + + final String arnPostfix = realmUrl.replace("https://", ""); + final String arnPostfixSanitized = arnPostfix.replace("/", "-"); + + final URL url = new URI(realmUrl).toURL(); + + final String sha = getThumbprint(url); + System.out.println(sha); + + try (final IamClient iam = IamClient.builder() + .region(Region.AWS_GLOBAL) + .credentialsProvider(ProfileCredentialsProvider.create(profileName)) + .build()) { + final ListOpenIdConnectProvidersResponse existingOpenIdConnectProviders = iam.listOpenIDConnectProviders(); + System.out.println(existingOpenIdConnectProviders); + + + final Optional existingOIDCProvider = existingOpenIdConnectProviders.openIDConnectProviderList().stream().filter(idp -> idp.arn().endsWith(arnPostfix)).findFirst(); + // + // aws iam create-open-id-connect-provider --url https://testing.hub.cryptomator.org/kc/realms/cipherduck --client-id-list cryptomator cryptomatorhub --thumbprint-list BE21B29075BF9F3265353F8B85208A8981DAEC2A + // + final String oidcProviderArn; + if(existingOIDCProvider.isEmpty()) { + final CreateOpenIdConnectProviderResponse openIDConnectProvider = iam.createOpenIDConnectProvider(CreateOpenIdConnectProviderRequest.builder() + .url(realmUrl) + .clientIDList(clientId) + .thumbprintList(sha) + .build()); + oidcProviderArn = openIDConnectProvider.openIDConnectProviderArn(); + System.out.println(oidcProviderArn); + } + else { + oidcProviderArn = existingOIDCProvider.get().arn(); + iam.updateOpenIDConnectProviderThumbprint(UpdateOpenIdConnectProviderThumbprintRequest.builder() + .openIDConnectProviderArn(oidcProviderArn) + .thumbprintList(sha).build()); + + } + System.out.println(oidcProviderArn); + final String arnPrefix = oidcProviderArn.replace(":oidc-provider" + "/" + arnPostfix, ""); + + // + // aws iam create-role --role-name cipherduck-createbucket --assume-role-policy-document file://src/main/resources/cipherduck/setup/aws_stscreatebuckettrustpolicy.json + // aws iam put-role-policy --role-name cipherduck-createbucket --policy-name cipherduck-createbucket --policy-document file://src/main/resources/cipherduck/setup/aws_stscreatebucketpermissionpolicy.json + // + final String awsSTSCreateBucketRoleName = String.format("%s-createbucket", arnPostfixSanitized); + final JSONObject awsSTSCreateBuckeTrustPolicyTemplate = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/aws_sts/createbuckettrustpolicy.json"), Charset.defaultCharset())); + final JSONObject awsSTSCreateBuckePermissionPolicyTemplate = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/aws_sts/createbucketpermissionpolicy.json"), Charset.defaultCharset())); + injectFederated(awsSTSCreateBuckeTrustPolicyTemplate, oidcProviderArn); + injectBucketPrefixIntoResources(awsSTSCreateBuckePermissionPolicyTemplate, bucketPrefix); + uploadAssumeRolePolicyAndPermissionPolicy(iam, awsSTSCreateBucketRoleName, awsSTSCreateBuckeTrustPolicyTemplate, awsSTSCreateBuckePermissionPolicyTemplate, maxSessionDuration); + + // + // aws iam create-role --role-name cipherduck_chain_01 --assume-role-policy-document file://src/main/resources/cipherduck/setup/aws_stscipherduck_chain_01_trustpolicy.json + // aws iam put-role-policy --role-name cipherduck_chain_01 --policy-name cipherduck_chain_01 --policy-document file://src/main/resources/cipherduck/setup/aws_stscipherduck_chain_01_permissionpolicy.json + // + final String awsSTSChain01RoleName = String.format("%s-sts-chain-01", arnPostfixSanitized); + final String awsSTSChain02RoleName = String.format("%s-sts-chain-02", arnPostfixSanitized); + final JSONObject awsSTSChain01RoleNameTrustPolicyTemplate = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/aws_sts/cipherduck_chain_01_trustpolicy.json"), Charset.defaultCharset())); + final JSONObject awsSTSChain01RoleNamePermissionPolicyTemplate = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/aws_sts/cipherduck_chain_01_permissionpolicy.json"), Charset.defaultCharset())); + injectFederated(awsSTSChain01RoleNameTrustPolicyTemplate, oidcProviderArn); + awsSTSChain01RoleNamePermissionPolicyTemplate.getJSONArray("Statement").getJSONObject(0).put("Resource", arnPrefix + ":role/" + awsSTSChain02RoleName); + uploadAssumeRolePolicyAndPermissionPolicy(iam, awsSTSChain01RoleName, awsSTSChain01RoleNameTrustPolicyTemplate, awsSTSChain01RoleNamePermissionPolicyTemplate, maxSessionDuration); + + // + // sleep 10; + // + Thread.sleep(10000); + + // + // aws iam create-role --role-name cipherduck_chain_02 --assume-role-policy-document file://src/main/resources/cipherduck/setup/aws_stscipherduck_chain_02_trustpolicy.json + // aws iam put-role-policy --role-name cipherduck_chain_02 --policy-name cipherduck_chain_02 --policy-document file://src/main/resources/cipherduck/setup/aws_stscipherduck_chain_02_permissionpolicy.json + // + final JSONObject awsSTSChain02RoleNameTrustPolicyTemplate = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/aws_sts/cipherduck_chain_02_trustpolicy.json"), Charset.defaultCharset())); + final JSONObject awsSTSChain02RoleNamePermissionPolicyTemplate = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/aws_sts/cipherduck_chain_02_permissionpolicy.json"), Charset.defaultCharset())); + final GetRoleResponse role = iam.getRole(GetRoleRequest.builder().roleName(awsSTSChain01RoleName).build()); + awsSTSChain02RoleNameTrustPolicyTemplate.getJSONArray("Statement").getJSONObject(0).getJSONObject("Principal").put("AWS", Collections.singletonList(role.role().arn())); + injectBucketPrefixIntoResources(awsSTSChain02RoleNamePermissionPolicyTemplate, bucketPrefix); + uploadAssumeRolePolicyAndPermissionPolicy(iam, awsSTSChain02RoleName, awsSTSChain02RoleNameTrustPolicyTemplate, awsSTSChain02RoleNamePermissionPolicyTemplate, maxSessionDuration); + } + return null; + } + + private static void injectBucketPrefixIntoResources(final JSONObject policy, final String bucketPrefix) { + final JSONArray statements = policy.getJSONArray("Statement"); + for(int i = 0; i < statements.length(); i++) { + final JSONArray resources = statements.getJSONObject(i).getJSONArray("Resource"); + for(int j = 0; j < resources.length(); j++) { + resources.put(j, resources.getString(j).replace("cipherduck", bucketPrefix)); + } + } + } + + private static void injectFederated(final JSONObject policy, final String oidcProviderArn) { + policy.getJSONArray("Statement").getJSONObject(0).getJSONObject("Principal").put("Federated", Collections.singletonList(oidcProviderArn)); + } + + private static void uploadAssumeRolePolicyAndPermissionPolicy(final IamClient iam, final String roleName, final JSONObject trustPolicyDocument, final JSONObject permissionPolicyDocument, final Integer maxSessionDuration) { + System.out.println(trustPolicyDocument); + System.out.println(permissionPolicyDocument); + try { + final GetRoleResponse role = iam.getRole(GetRoleRequest.builder().roleName(roleName).build()); + System.out.println(role); + iam.updateAssumeRolePolicy(UpdateAssumeRolePolicyRequest.builder() + .roleName(roleName) + .policyDocument(trustPolicyDocument.toString()) + .build()); + + } + catch(NoSuchEntityException e) { + iam.createRole(CreateRoleRequest.builder() + .roleName(roleName) + .assumeRolePolicyDocument(trustPolicyDocument.toString()) + .maxSessionDuration(maxSessionDuration) + .build() + ); + } + iam.putRolePolicy(PutRolePolicyRequest.builder() + .roleName(roleName) + .policyName(roleName) + .policyDocument(permissionPolicyDocument.toString()) + .build()); + } + + private static String getThumbprint(final URL url) throws IOException, CertificateEncodingException { + // openssl s_client -servername testing.hub.cryptomator.org -showcerts -connect testing.hub.cryptomator.org:443 > testing.hub.cryptomator.org.crt + // + // vi testing.hub.cryptomator.org.crt ... + // (remove the irrelevant parts from the chain) + // + // cat testing.hub.cryptomator.org.crt + // -----BEGIN CERTIFICATE----- + // MIIGBDCCBOygAwIBAgISA1CGKN3OkGJihg/qGhz2fl3fMA0GCSqGSIb3DQEBCwUA + // MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD + // EwJSMzAeFw0yMzExMTIxMzAyMTdaFw0yNDAyMTAxMzAyMTZaMCYxJDAiBgNVBAMT + // G3Rlc3RpbmcuaHViLmNyeXB0b21hdG9yLm9yZzCCAiIwDQYJKoZIhvcNAQEBBQAD + // ggIPADCCAgoCggIBALWWmJr7lckOPCysl8p8FywJ2BwfCfdqMqTeb7KdOa3Zd9kb + // rb0dYUAs6cs4XKIxSBzKTDJAZiE5d2/iXUgHIBS8hDjG8U40EFaKDTc/JugOSovs + // HB6FQTi4YCMNfm3oMBiREMXYQTEKErBFfECbtGw8mTua2suT6Uc7lwj91qbPO6BN + // TROk0Az1NcifYOz8lMZhelg0WXEa10YfalaKGtjh4srMBv0rT85PpXaJXaNp58Ls + // 4Psf/YlPjGJOhevnyAuqZouUD9sz7gZX8WvQ87y9uTXpDoarySh/0nppYLPZTDty + // sI3LeVwwrf4ir5jObVgjkH1CdS8kj/ueKLLW0BBqSX/9oji9o1zFJlBeRcWbeW08 + // SD3+7292cy+zpNo3Y7xEFxGs0SVlJjTRk4cf6edkVq5QzTPqIF9FSn6tgXC6OTJi + // ISHnLGvkuSOzCieADPwjlYJiix3duK+0rpeN3xH3/NnyvPnncbWr/KLwwGE/tsHx + // orv1XLXkV0nmD9MDvE1gqRd7m7n3PwXEojz2Ih37i4bowFx2jYy6acAyY0KJSWwE + // 3Rl2BRvOqXY1AOZC2MKOp7mb3hbryr8pzUPb0j4p3iOmOG9MgUQydKLyE97W1Ucd + // PRQMHdoG+EKnDeaauKdZ/3Lj0jMJ1CKlmYOB5qShHv1XCR5uimouioQkoJTFAgMB + // AAGjggIeMIICGjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG + // CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFHkBSFhuApvRJvGqRHZg + // 5t183UMCMB8GA1UdIwQYMBaAFBQusxe3WFbLrlAJQOYfr52LFMLGMFUGCCsGAQUF + // BwEBBEkwRzAhBggrBgEFBQcwAYYVaHR0cDovL3IzLm8ubGVuY3Iub3JnMCIGCCsG + // AQUFBzAChhZodHRwOi8vcjMuaS5sZW5jci5vcmcvMCYGA1UdEQQfMB2CG3Rlc3Rp + // bmcuaHViLmNyeXB0b21hdG9yLm9yZzATBgNVHSAEDDAKMAgGBmeBDAECATCCAQUG + // CisGAQQB1nkCBAIEgfYEgfMA8QB2ADtTd3U+LbmAToswWwb+QDtn2E/D9Me9AA0t + // cm/h+tQXAAABi8PXIB0AAAQDAEcwRQIhAPOlsQr63JOSMbTFWOM746oA7i4HQ+hl + // p7M3pRpG4HYQAiBKqLSDsx1FdI18Fax3k7zkCgsY8x96ZAQvVUfdch0xoAB3AO7N + // 0GTV2xrOxVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABi8PXIBwAAAQDAEgwRgIh + // AOZskIE18A5sTthKz6w3wMvIocbaoj3UCTCIAXWVJJNzAiEAmMWS709vLq/WOPG0 + // 5hb6lBPn6NRnjizJaNEnj/ts71EwDQYJKoZIhvcNAQELBQADggEBADiSgsGpOKqZ + // 0kzeIS9x7vJlc3I0lnScB9JjxJyLoZFs//T4SNWE18zFxnzVspWRnwu4NTmuGURv + // 6RWJ8RAznYwjZCnVDdQREUSX7wahzGdz+3GalRaIYngkvwHOhT+aGLbrKRjz+Pfh + // 13qMStwjlfA6iSofHqVeQFCf48itgeVjNbpdZKEOLwdiV+JMwpT4n/i0nfVwWkaG + // RcEWn8S4gfSq1iZ/LAhWdyB0QJ4EcCO6mx02wABxbQibPc5FM8Q64j37TizHniVu + // hs+X7qFNDF/jvbob3sL09e0BLjiZWxVasAHiAAaZONTRV0N5YYV56F5br/vnegic + // u3AvSS5HW70= + // -----END CERTIFICATE----- + // + // + // openssl x509 -in testing.hub.cryptomator.org.crt -fingerprint -sha1 -noout | sed -e 's/://g' | sed -e 's/[Ss][Hh][Aa]1 [Ff]ingerprint=//' + // BE21B29075BF9F3265353F8B85208A8981DAEC2A + // + // aws iam create-open-id-connect-provider --url https://testing.hub.cryptomator.org/kc/realms/cipherduck --client-id-list cryptomator cryptomatorhub --thumbprint-list BE21B29075BF9F3265353F8B85208A8981DAEC2A + // { + // "OpenIDConnectProviderArn": "arn:aws:iam::930717317329:oidc-provider/testing.hub.cryptomator.org/kc/realms/cipherduck1" + // } + // + // aws iam list-open-id-connect-providers + // + // aws iam get-open-id-connect-provider --open-id-connect-provider-arn arn:aws:iam::930717317329:oidc-provider/testing.hub.cryptomator.org/kc/realms/cipherduck + // { + // "Url": "testing.hub.cryptomator.org/kc/realms/cipherduck", + // "ClientIDList": [ + // "cryptomatorhub", + // "cryptomator" + // ], + // "ThumbprintList": [ + // "a053375bfe84e8b748782c7cee15827a6af5a405" + // ], + // "CreateDate": "2023-11-13T13:51:32.729000+00:00", + // "Tags": [] + // } + HttpURLConnection.setFollowRedirects(false); + final Certificate[] chain = getCertificates(url); + + // TODO is it always first cert? + return DigestUtils.sha1Hex(chain[0].getEncoded()); + } + + private static Certificate[] getCertificates(final URL url) throws IOException { + final HttpsURLConnection c = (HttpsURLConnection) url.openConnection(); + c.setRequestMethod("HEAD"); // GET + c.setDoOutput(false); + c.setAllowUserInteraction(false); + c.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:45.0) Gecko/20100101 Firefox/45.0 URLInspect/1.0"); + c.setRequestProperty("Connection", "close"); + + c.connect(); // throws SSL handshake exception + + // retrieve TLS info before reading response (which closes connection?) + return c.getServerCertificates(); + } +} diff --git a/admin-cli/src/main/java/cloud/katta/cli/commands/storage/MinioStsSetup.java b/admin-cli/src/main/java/cloud/katta/cli/commands/storage/MinioStsSetup.java new file mode 100644 index 00000000..53f550be --- /dev/null +++ b/admin-cli/src/main/java/cloud/katta/cli/commands/storage/MinioStsSetup.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.storage; + +import org.apache.commons.io.IOUtils; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.net.URI; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; + +import cloud.katta.cli.KattaSetupCli; +import io.minio.admin.MinioAdminClient; +import picocli.CommandLine; +import software.amazon.awssdk.policybuilder.iam.IamEffect; +import software.amazon.awssdk.policybuilder.iam.IamPolicy; +import software.amazon.awssdk.policybuilder.iam.IamPolicyWriter; + +/** + * Sets up MinIO for Katta in STS mode: + *

+ *

+ * See also: Katta Docs. + */ +@CommandLine.Command(name = "minioStsSetup", description = "Setup/update OIDC provider and roles for STS in MinIO.", mixinStandardHelpOptions = true) +public class MinioStsSetup implements Callable { + @CommandLine.Option(names = {"--endpointUrl"}, description = "MinIO URL. Example: \"http://localhost:9000\"", required = true) + String endpointUrl; + + @CommandLine.Option(names = {"--hubUrl"}, description = "Hub URL. Example: \"https://testing.katta.cloud/tamarind\"", required = true) + String hubUrl; + + @CommandLine.Option(names = {"--profileName"}, description = "AWS profile to load AWS credentials from. See ~/.aws/credentials.", required = false) + String profileName; + + @CommandLine.Option(names = {"--accessKey"}, description = "Access Key for administering MinIO if no profile is used.", required = false) + String accessKey; + + @CommandLine.Option(names = {"--secretKey"}, description = "Secret Key for administering MinIO if no profile is used.", required = false) + String secretKey; + + @CommandLine.Option(names = {"--bucketPrefix"}, description = "Bucket Prefix for STS vaults.", required = false, defaultValue = "katta-") + String bucketPrefix; + + @CommandLine.Option(names = {"--maxSessionDuration"}, description = "Bucket Prefix for STS vaults.", required = false) + Integer maxSessionDuration; + + @CommandLine.Option(names = {"--createbucketPolicyName"}, description = "Policy name for accessing Katta STS buckets. Defaults to {bucketPrefix}createbucketPolicy.") + String createbucketPolicyName; + + @CommandLine.Option(names = {"--accessbucketPolicyName"}, description = "Policy name for accessing Katta STS buckets. Defaults to {bucketPrefix}accessbucketpolicy.") + String accessbucketPolicyName; + + @Override + public Void call() throws Exception { + if(createbucketPolicyName == null) { + createbucketPolicyName = String.format("%screatebucketpolicy", bucketPrefix); + } + if(accessbucketPolicyName == null) { + accessbucketPolicyName = String.format("%saccessbucketpolicy", bucketPrefix); + } + + final MinioAdminClient minioAdminClient = new MinioAdminClient.Builder() + .credentials(accessKey, secretKey) + .endpoint(endpointUrl).build(); + + // /mc admin policy create myminio cipherduckcreatebucket /setup/minio_sts/createbucketpolicy.json + { + final IamPolicy miniocreatebucketpolicy = IamPolicy.builder() + .addStatement(b -> b + .effect(IamEffect.ALLOW) + .addAction("s3:CreateBucket") + .addAction("s3:GetBucketPolicy") + .addAction("s3:PutBucketVersioning") + .addAction("s3:GetBucketVersioning") + .addResource(String.format("arn:aws:s3:::%s*", bucketPrefix))) + .addStatement(b -> b + .effect(IamEffect.ALLOW) + .addAction("s3:PutObject") + .addResource(String.format("arn:aws:s3:::%s*/*/", bucketPrefix)) + .addResource(String.format("arn:aws:s3:::%s*/*.uvf", bucketPrefix))) + .build(); + minioAdminClient.addCannedPolicy(createbucketPolicyName, miniocreatebucketpolicy.toJson(IamPolicyWriter.builder() + .prettyPrint(true) + .build())); + System.out.println(minioAdminClient.listCannedPolicies().get(createbucketPolicyName)); + } + // /mc admin policy create myminio cipherduckaccessbucket /setup/minio_sts/accessbucketpolicy.json + { + final JSONObject minioaccessbucketpolicy = new JSONObject(IOUtils.toString(KattaSetupCli.class.getResourceAsStream("/setup/local/minio_sts/accessbucketpolicy.json"), Charset.defaultCharset())); + final JSONArray statements = minioaccessbucketpolicy.getJSONArray("Statement"); + for(int i = 0; i < statements.length(); i++) { + final List list = statements.getJSONObject(i).getJSONArray("Resource").toList().stream().map(Objects::toString).map(s -> s.replace("katta", bucketPrefix)).toList(); + statements.getJSONObject(i).put("Resource", list); + } + minioAdminClient.addCannedPolicy(accessbucketPolicyName, minioaccessbucketpolicy.toString()); + System.out.println(minioAdminClient.listCannedPolicies().get(accessbucketPolicyName)); + } + + final String json = IOUtils.toString(URI.create(hubUrl + "/api/config"), Charset.forName("UTF-8")); + final JSONObject apiConfig = new JSONObject(json); + final String wellKnown = String.format("%s/realms/%s/.well-known/openid-configuration", apiConfig.getString("keycloakUrl"), apiConfig.getString("keycloakRealm")); + + String keycloakClientIdCryptomator = apiConfig.getString("keycloakClientIdCryptomator"); + String keycloakClientIdHub = apiConfig.getString("keycloakClientIdHub"); + String keycloakClientIdCryptomatorVaults = apiConfig.getString("keycloakClientIdCryptomatorVaults"); + System.out.println(String.format(""" + # The MinIO Client API is incomplete (https://github.com/minio/minio/issues/16151). + # Please execute the following commands on the command line. + # Further info: https://github.com/shift7-ch/katta-docs/blob/main/SETUP_KATTA_SERVER.md#minio + + mc alias set myminio %s %s %s + + mc idp openid add myminio %s \\ + config_url="%s" \\ + client_id="%s" \\ + client_secret="ignore-me" \\ + role_policy="%s" + mc idp openid add myminio %s \\ + config_url="%s" \\ + client_id="%s" \\ + client_secret="ignore-me" \\ + role_policy="%s" \s + mc idp openid add myminio %s \\ + config_url="%s" \\ + client_id="%s" \\ + client_secret="ignore-me" \\ + role_policy="%s" \s + mc admin service restart myminio + """, endpointUrl, accessKey, secretKey, + keycloakClientIdCryptomator, wellKnown, keycloakClientIdCryptomator, createbucketPolicyName, + keycloakClientIdHub, wellKnown, keycloakClientIdHub, accessbucketPolicyName, + keycloakClientIdCryptomatorVaults, wellKnown, keycloakClientIdCryptomatorVaults, accessbucketPolicyName)); + return null; + } +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileArchiveIT.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileArchiveIT.java new file mode 100644 index 00000000..71bbdca7 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileArchiveIT.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; + +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.Protocol; +import cloud.katta.client.model.S3SERVERSIDEENCRYPTION; +import cloud.katta.client.model.S3STORAGECLASSES; +import cloud.katta.client.model.StorageProfileDto; +import cloud.katta.client.model.StorageProfileS3StaticDto; +import cloud.katta.model.StorageProfileDtoWrapper; +import cloud.katta.testcontainers.AbtractAdminCliIT; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StorageProfileArchiveIT extends AbtractAdminCliIT { + @Test + public void testStorageProfileArchive() throws Exception { + final String storageProfileId = "732D43FA-3716-46C4-B931-66EA5405EF1C".toLowerCase(); + final StorageProfileResourceApi storageProfileResourceApi = new StorageProfileResourceApi(apiClient); + { + storageProfileResourceApi.apiStorageprofileS3staticPost(new StorageProfileS3StaticDto() + .id(UUID.fromString(storageProfileId)) + .name("S3 static") + .protocol(Protocol.S3_STATIC) + .archived(false) + .storageClass(S3STORAGECLASSES.STANDARD) + .region("eu-west-1") + .regions(Arrays.asList("eu-west-1")) + .bucketPrefix("katta-test") + .stsRoleCreateBucketClient("") + .stsRoleCreateBucketHub("") + .bucketVersioning(true) + .bucketEncryption(S3SERVERSIDEENCRYPTION.NONE) + ); + + final Optional profile = storageProfileResourceApi.apiStorageprofileGet(null).stream().filter(p -> StorageProfileDtoWrapper.coerce(p).getId().toString().toLowerCase().equals(storageProfileId)) + .map(StorageProfileDto::getActualInstance).map(StorageProfileS3StaticDto.class::cast) + .findFirst(); + assertTrue(profile.isPresent()); + assertFalse(profile.get().getArchived()); + } + new StorageProfileArchive().call("http://localhost:8280", accessToken, storageProfileId, "S3 static"); + { + final Optional profile = storageProfileResourceApi.apiStorageprofileGet(null).stream().filter(p -> StorageProfileDtoWrapper.coerce(p).getId().toString().toLowerCase().equals(storageProfileId)).map(StorageProfileDto::getActualInstance).map(StorageProfileS3StaticDto.class::cast).findFirst(); + assertTrue(profile.isPresent()); + assertTrue(profile.get().getArchived()); + } + } +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileArchiveTest.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileArchiveTest.java new file mode 100644 index 00000000..2dc7086d --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileArchiveTest.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.UUID; + +import cloud.katta.client.ApiException; +import cloud.katta.client.api.StorageProfileResourceApi; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; + +class StorageProfileArchiveTest { + + @Test + public void testCall() throws ApiException { + final StorageProfileResourceApi proxyMock = Mockito.mock(StorageProfileResourceApi.class); + final UUID vaultId = UUID.randomUUID(); + new StorageProfileArchive().call(vaultId, proxyMock); + Mockito.verify(proxyMock, times(1)).apiStorageprofileProfileIdPut(vaultId, true); + Mockito.verify(proxyMock, times(1)).apiStorageprofileProfileIdPut(any(), any()); + } + +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStaticSetupIT.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStaticSetupIT.java new file mode 100644 index 00000000..8cbf7c66 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStaticSetupIT.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; + +import cloud.katta.cli.KattaSetupCli; +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.Protocol; +import cloud.katta.client.model.S3SERVERSIDEENCRYPTION; +import cloud.katta.client.model.S3STORAGECLASSES; +import cloud.katta.client.model.StorageProfileDto; +import cloud.katta.client.model.StorageProfileS3StaticDto; +import cloud.katta.testcontainers.AbtractAdminCliIT; +import picocli.CommandLine; + +import static org.junit.jupiter.api.Assertions.*; + +class StorageProfileAwsStaticSetupIT extends AbtractAdminCliIT { + + @Test + public void testStorageProfileAwsStaticSetup() throws Exception { + final UUID storageProfileId = UUID.randomUUID(); + int rc = new CommandLine(new KattaSetupCli()).execute( + "storageProfileAWSStatic", + "--hubUrl", "http://localhost:8280", + "--accessToken", accessToken, + "--uuid", storageProfileId.toString(), + "--name", "AWS S3 Static", + "--region", "eu-west-1", + "--regions", "eu-west-1", + "--regions", "eu-west-2", + "--regions", "eu-west-3" + ); + assertEquals(0, rc); + final StorageProfileResourceApi storageProfileResourceApi = new StorageProfileResourceApi(apiClient); + Optional profile = storageProfileResourceApi.apiStorageprofileGet(null).stream() + .filter(p -> p.getActualInstance() instanceof StorageProfileS3StaticDto) + .filter(p -> p.getStorageProfileS3StaticDto().getId().equals(storageProfileId)).findFirst(); + assertTrue(profile.isPresent()); + final StorageProfileS3StaticDto dto = profile.get().getStorageProfileS3StaticDto(); + assertEquals("AWS S3 Static", dto.getName()); + assertEquals(Protocol.S3_STATIC, dto.getProtocol()); + assertFalse(dto.getArchived()); + assertEquals("https", dto.getScheme()); + assertNull(dto.getHostname()); + assertEquals(443, dto.getPort()); + assertFalse(dto.getWithPathStyleAccessEnabled()); + assertEquals(S3STORAGECLASSES.STANDARD, dto.getStorageClass()); + assertEquals("eu-west-1", dto.getRegion()); + assertEquals(Arrays.asList("eu-west-1", "eu-west-2", "eu-west-3"), dto.getRegions()); + assertEquals("katta-", dto.getBucketPrefix()); + assertEquals("", dto.getStsRoleCreateBucketClient()); + assertEquals("", dto.getStsRoleCreateBucketHub()); + assertNull(dto.getStsEndpoint()); + assertTrue(dto.getBucketVersioning()); + assertNull(dto.getBucketAcceleration()); + assertEquals(S3SERVERSIDEENCRYPTION.NONE, dto.getBucketEncryption()); + } +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStaticSetupTest.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStaticSetupTest.java new file mode 100644 index 00000000..3086b659 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStaticSetupTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.UUID; + +import cloud.katta.client.ApiException; +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.Protocol; +import cloud.katta.client.model.S3SERVERSIDEENCRYPTION; +import cloud.katta.client.model.S3STORAGECLASSES; +import cloud.katta.client.model.StorageProfileS3StaticDto; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; + +class StorageProfileAwsStaticSetupTest { + @Test + public void testCall() throws ApiException { + final StorageProfileResourceApi api = Mockito.mock(StorageProfileResourceApi.class); + final UUID vaultId = UUID.randomUUID(); + final StorageProfileAwsStaticSetup cli = new StorageProfileAwsStaticSetup(); + + cli.region = "us-east-1"; + cli.regions = List.of(); + cli.call(vaultId, "AWS S3 static", api); + + + final StorageProfileS3StaticDto dto = new StorageProfileS3StaticDto(); + dto.setId(vaultId); + dto.setName("AWS S3 static"); + dto.setRegion("us-east-1"); + dto.setProtocol(Protocol.S3_STATIC); + dto.setArchived(false); + dto.setScheme("https"); + dto.setPort(443); + dto.setWithPathStyleAccessEnabled(false); + dto.setBucketPrefix("katta-"); + dto.setStorageClass(S3STORAGECLASSES.STANDARD); + dto.setBucketEncryption(S3SERVERSIDEENCRYPTION.NONE); + dto.stsRoleCreateBucketClient(""); + dto.stsRoleCreateBucketHub(""); + Mockito.verify(api, times(1)).apiStorageprofileS3staticPost(dto); + Mockito.verify(api, times(1)).apiStorageprofileS3staticPost(any()); + } +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetupIT.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetupIT.java new file mode 100644 index 00000000..3400e918 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetupIT.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import cloud.katta.cli.KattaSetupCli; +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.*; +import cloud.katta.testcontainers.AbtractAdminCliIT; +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import java.util.Arrays; +import java.util.Optional; +import java.util.UUID; + +class StorageProfileAwsStsSetupIT extends AbtractAdminCliIT { + + @Test + public void testStorageProfileAwsStsSetup() throws Exception { + final UUID storageProfileId = UUID.randomUUID(); + int rc = new CommandLine(new KattaSetupCli()).execute( + "storageProfileAWSSTS", + "--hubUrl", "http://localhost:8280", + "--accessToken", accessToken, + "--uuid", storageProfileId.toString(), + "--name", "AWS S3 STS", + "--rolePrefix", "arn:aws:iam::linguine:role/farfalle", + "--region", "eu-west-1", + "--regions", "eu-west-1", + "--regions", "eu-west-2", + "--regions", "eu-west-3" + ); + assertEquals(0, rc); + final StorageProfileResourceApi storageProfileResourceApi = new StorageProfileResourceApi(apiClient); + Optional profile = storageProfileResourceApi.apiStorageprofileGet(null).stream() + .filter(p -> p.getActualInstance() instanceof StorageProfileS3STSDto) + .filter(p -> p.getStorageProfileS3STSDto().getId().equals(storageProfileId)).findFirst(); + assertTrue(profile.isPresent()); + final StorageProfileS3STSDto dto = profile.get().getStorageProfileS3STSDto(); + assertEquals("AWS S3 STS", dto.getName()); + assertEquals(Protocol.S3_STS, dto.getProtocol()); + assertFalse(dto.getArchived()); + assertEquals("https", dto.getScheme()); + assertNull(dto.getHostname()); + assertEquals(443, dto.getPort()); + assertFalse(dto.getWithPathStyleAccessEnabled()); + assertEquals(S3STORAGECLASSES.STANDARD, dto.getStorageClass()); + assertEquals("eu-west-1", dto.getRegion()); + assertEquals(Arrays.asList("eu-west-1", "eu-west-2", "eu-west-3"), dto.getRegions()); + assertEquals("katta-", dto.getBucketPrefix()); + assertEquals("arn:aws:iam::linguine:role/farfalle-createbucket", dto.getStsRoleCreateBucketClient()); + assertEquals("arn:aws:iam::linguine:role/farfalle-createbucket", dto.getStsRoleCreateBucketHub()); + assertNull(dto.getStsEndpoint()); + assertTrue(dto.getBucketVersioning()); + assertNull(dto.getBucketAcceleration()); + assertEquals(S3SERVERSIDEENCRYPTION.NONE, dto.getBucketEncryption()); + assertEquals("arn:aws:iam::linguine:role/farfalle-sts-chain-01", dto.getStsRoleAccessBucketAssumeRoleWithWebIdentity()); + assertEquals("arn:aws:iam::linguine:role/farfalle-sts-chain-02", dto.getStsRoleAccessBucketAssumeRoleTaggedSession()); + assertNull(dto.getStsDurationSeconds()); + } +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetupTest.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetupTest.java new file mode 100644 index 00000000..6a884429 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/StorageProfileAwsStsSetupTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.UUID; + +import cloud.katta.client.ApiException; +import cloud.katta.client.api.StorageProfileResourceApi; +import cloud.katta.client.model.Protocol; +import cloud.katta.client.model.S3SERVERSIDEENCRYPTION; +import cloud.katta.client.model.S3STORAGECLASSES; +import cloud.katta.client.model.StorageProfileS3STSDto; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; + +class StorageProfileAwsStsSetupTest { + @Test + public void testCall() throws ApiException { + final StorageProfileResourceApi api = Mockito.mock(StorageProfileResourceApi.class); + final UUID vaultId = UUID.randomUUID(); + final StorageProfileAwsStsSetup cli = new StorageProfileAwsStsSetup(); + cli.bucketPrefix = "fancy-"; + cli.rolePrefix = "arn:aws:iam::1234:role/testing.katta.cloud-kc-realms-pepper"; + cli.region = "eu-west-1"; + cli.regions = Arrays.asList("eu-west-1", "eu-west-2", "eu-west-3"); + cli.call(vaultId, "AWS S3 STS", api); + + final StorageProfileS3STSDto dto = new StorageProfileS3STSDto(); + dto.setId(vaultId); + dto.setName("AWS S3 STS"); + dto.setProtocol(Protocol.S3_STS); + dto.setArchived(false); + dto.setScheme("https"); + dto.setPort(443); + dto.setWithPathStyleAccessEnabled(false); + dto.setStorageClass(S3STORAGECLASSES.STANDARD); + dto.setRegion("eu-west-1"); + dto.setRegions(Arrays.asList("eu-west-1", "eu-west-2", "eu-west-3")); + dto.bucketPrefix("fancy-"); + dto.stsRoleCreateBucketClient("arn:aws:iam::1234:role/testing.katta.cloud-kc-realms-pepper-createbucket"); + dto.stsRoleCreateBucketHub("arn:aws:iam::1234:role/testing.katta.cloud-kc-realms-pepper-createbucket"); + dto.setBucketEncryption(S3SERVERSIDEENCRYPTION.NONE); + dto.stsRoleAccessBucketAssumeRoleWithWebIdentity("arn:aws:iam::1234:role/testing.katta.cloud-kc-realms-pepper-sts-chain-01"); + dto.stsRoleAccessBucketAssumeRoleTaggedSession("arn:aws:iam::1234:role/testing.katta.cloud-kc-realms-pepper-sts-chain-02"); + Mockito.verify(api, times(1)).apiStorageprofileS3stsPost(dto); + Mockito.verify(api, times(1)).apiStorageprofileS3stsPost(any()); + } +} diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/hub/Tamarind.java b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/Tamarind.java new file mode 100644 index 00000000..f2b6f531 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/hub/Tamarind.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.hub; + +import cloud.katta.cli.KattaSetupCli; +import picocli.CommandLine; + +import java.util.UUID; + +/** + * Tamarind example. + */ +public class Tamarind { + + public static void main(String[] args) { + if(false) { + new CommandLine(new KattaSetupCli()).execute( + "storageProfileArchive", + "--tokenUrl", "https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/token", + "--authUrl", "https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/auth", + "--clientId", "cryptomator", + "--hubUrl", "https://testing.katta.cloud/tamarind/", + "--uuid", "d7f8aa61-7b07-423c-89e5-fff8b8c2a56e", + // TODO --name should not be required + "--name", "ignore-me" + ); + } + + if(true) { + final UUID storageProfileId = UUID.randomUUID(); + new CommandLine(new KattaSetupCli()).execute( + "storageProfileAWSSTS", + "--tokenUrl", "https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/token", + "--authUrl", "https://testing.katta.cloud/kc/realms/tamarind/protocol/openid-connect/auth", + "--clientId", "cryptomator", + "--hubUrl", "https://testing.katta.cloud/tamarind/", + "--uuid", storageProfileId.toString(), + "--name", "AWS S3 STS", + "--bucketPrefix", "katta-test-", + "--rolePrefix", "arn:aws:iam::430118840017:role/testing.katta.cloud-kc-realms-tamarind" + ); + } + } +} + diff --git a/admin-cli/src/test/java/cloud/katta/cli/commands/storage/MinioStsSetupIT.java b/admin-cli/src/test/java/cloud/katta/cli/commands/storage/MinioStsSetupIT.java new file mode 100644 index 00000000..8e5fc933 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/cli/commands/storage/MinioStsSetupIT.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.cli.commands.storage; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Objects; + +import cloud.katta.cli.KattaSetupCli; +import cloud.katta.testcontainers.AbtractAdminCliIT; +import io.minio.admin.MinioAdminClient; +import picocli.CommandLine; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MinioStsSetupIT extends AbtractAdminCliIT { + + @Test + public void testMinioSetup() throws Exception { + int rc = new CommandLine(new KattaSetupCli()).execute( + "minioStsSetup", + "--endpointUrl", "http://localhost:9100", + "--hubUrl", "http://localhost:8280", + "--accessKey", "minioadmin", + "--secretKey", "minioadmin", + "--bucketPrefix", "fusilli" + ); + assertEquals(0, rc); + + final MinioAdminClient minioAdminClient = new MinioAdminClient.Builder() + .credentials("minioadmin", "minioadmin") + .endpoint("http://localhost:9100").build(); + + final Map cannedPolicies = minioAdminClient.listCannedPolicies(); + { + final JSONObject miniocreatebucketpolicy = new JSONObject(cannedPolicies.get("fusillicreatebucketpolicy")); + final JSONArray statements = miniocreatebucketpolicy.getJSONArray("Statement"); + long count = 0; + for(int i = 0; i < statements.length(); i++) { + count += statements.getJSONObject(i).getJSONArray("Resource").toList().stream().map(Objects::toString).filter(s -> s.contains("fusilli")).count(); + } + assertEquals(3, count); + } + { + final JSONObject minioaccessbucket = new JSONObject(cannedPolicies.get("fusilliaccessbucketpolicy")); + final JSONArray statements = minioaccessbucket.getJSONArray("Statement"); + long count = 0; + for(int i = 0; i < statements.length(); i++) { + count += statements.getJSONObject(i).getJSONArray("Resource").toList().stream().map(Objects::toString).filter(s -> s.contains("fusilli")).count(); + } + assertEquals(2, count); + } + } +} diff --git a/admin-cli/src/test/java/cloud/katta/testcontainers/AbtractAdminCliIT.java b/admin-cli/src/test/java/cloud/katta/testcontainers/AbtractAdminCliIT.java new file mode 100644 index 00000000..bc911606 --- /dev/null +++ b/admin-cli/src/test/java/cloud/katta/testcontainers/AbtractAdminCliIT.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 shift7 GmbH. All rights reserved. + */ + +package cloud.katta.testcontainers; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Collectors; + +import cloud.katta.client.ApiClient; +import cloud.katta.client.ApiException; +import cloud.katta.client.Pair; +import cloud.katta.client.auth.HttpBearerAuth; + +import static io.restassured.RestAssured.given; + +public class AbtractAdminCliIT { + private static final Logger log = LogManager.getLogger(AbtractAdminCliIT.class.getName()); + public static ComposeContainer compose; + + protected String accessToken; + protected ApiClient apiClient; + + @BeforeAll + public static void setupDocker() throws URISyntaxException, IOException { + final String composeFile = "/docker-compose-minio-localhost-hub.yml"; + final String envFile = "/.local.env"; + final String profile = "local"; + final String hubAdminUser = "admin"; + final String hubAdminPassword = "admin"; + final String hubKeycloakSystemClientSecret = "top-secret"; + final Properties props = new Properties(); + props.load(AbtractAdminCliIT.class.getResourceAsStream(envFile)); + final HashMap env = props.entrySet().stream().collect( + Collectors.toMap( + e -> String.valueOf(e.getKey()), + e -> String.valueOf(e.getValue()), + (prev, next) -> next, HashMap::new + )); + env.put("HUB_ADMIN_USER", hubAdminUser); + env.put("HUB_ADMIN_PASSWORD", hubAdminPassword); + env.put("HUB_KEYCLOAK_SYSTEM_CLIENT_SECRET", hubKeycloakSystemClientSecret); + compose = new ComposeContainer( + new File(AbtractAdminCliIT.class.getResource(composeFile).toURI())) + .withLocalCompose(true) + .withPull(true) + .withEnv(env) + .withOptions(profile == null ? "" : String.format("--profile=%s", profile)) + .waitingFor("minio_setup-1", new LogMessageWaitStrategy().withRegEx(".*Completed MinIO Setup.*").withStartupTimeout(Duration.ofMinutes(2))); + compose.start(); + } + + @BeforeEach + protected void setup() throws Exception { + accessToken = given() + .header("Content-Type", "application/x-www-form-urlencoded") + .formParam("client_id", "cryptomator") + .formParam("grant_type", "password") + .formParam("username", "admin") + .formParam("password", "admin") + .when() + .post("http://localhost:8380/realms/cryptomator/protocol/openid-connect/token") + .then() + .statusCode(200) + .extract().path("access_token"); + final HttpBearerAuth auth = new HttpBearerAuth("Bearer"); + auth.setBearerToken(accessToken); + apiClient = new ApiClient() { + protected void updateParamsForAuth(String[] authNames, List queryParams, Map headerParams, + Map cookieParams, String payload, String method, URI uri) throws ApiException { + auth.applyToParams(queryParams, headerParams, cookieParams, payload, method, uri); + } + }; + apiClient.setBasePath("http://localhost:8280"); + } + + @AfterAll + public static void teardownDocker() { + try { + compose.stop(); + } + catch(Exception e) { + log.warn(e); + } + } +} diff --git a/hub/pom.xml b/hub/pom.xml index 6be86547..1d74ef36 100644 --- a/hub/pom.xml +++ b/hub/pom.xml @@ -27,6 +27,18 @@ + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + test-jar + + + + org.openapitools diff --git a/pom.xml b/pom.xml index 0eb59da5..141c178c 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ hub osx + admin-cli