Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
72 changes: 66 additions & 6 deletions admin-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This CLI program is used to configure a Katta Server including its S3 storage ba

- AWS S3 accessed using static access keys
- AWS S3 accessed using AWS Security Token Service (STS) issuing temporary access keys from OIDC access token obtained by user from Keycloak identity provider.
- Generic S3-compatible provider accessed using static access credentials.
- MinIO accessed using Security Token Service (STS) with OIDC.

### Setup AWS using OIDC Provider and Security Token Service (STS) with `setup` command

Expand Down Expand Up @@ -39,23 +41,81 @@ Requires [Setup AWS using OIDC Provider and Security Token Service (STS)](#setup
katta storageprofile aws sts \
--hubUrl <hub-url> \
--awsAccountId <aws-account-id> \
--region <aws-region> \
--authUrl <auth-url> \
--tokenUrl <token-url> \
--region <aws-region>
```

**Required Options:**

- `--hubUrl`: Hub URL
- `--hubUrl`: Hub URL. Example: `https://hub.default.katta.cloud/`. Keycloak auth and token endpoints are fetched automatically from `<hub-url>/api/config`.
- `--awsAccountId`: AWS Account ID. A 12-digit number, such as 012345678901, that uniquely identifies an AWS account.
- `--region`: Bucket region. Example: `eu-west-1`
- `--authUrl`: Keycloak URL. Example: `https://keycloak.default.katta.cloud/kc/realms/cryptomator/protocol/openid-connect/auth`
- `--tokenUrl`: Keycloak URL. Example: `https://keycloak.default.katta.cloud/kc/realms/cryptomator/protocol/openid-connect/token`

**Additional Options:**

- `--roleNamePrefix`: Prefix used for IAM role names. Defaults to `katta-`.
- `--bucketPrefix`: Prefix used when creating buckets for this storage profile. Defaults to `katta-`.
- `--authUrl`: Keycloak auth endpoint URL. Overrides the value fetched from `--hubUrl`.
- `--tokenUrl`: Keycloak token endpoint URL. Overrides the value fetched from `--hubUrl`.

Comment thread
dkocher marked this conversation as resolved.
### Configure storage profile for a generic S3-compatible provider using `storageprofile` command

Uploads a storage profile to Katta Server for use with any S3-compatible storage provider using static access credentials.
Unlike STS-based profiles, no temporary credentials are issued; the server uses static access key credentials directly.

```bash
katta storageprofile s3 static \
--hubUrl <hub-url> \
--endpointUrl <s3-endpoint-url> \
--region <region>
```

**Required Options:**

- `--hubUrl`: Hub URL. Example: `https://hub.default.katta.cloud/`
- `--endpointUrl`: S3 endpoint URL. Example: `https://s3.example.com` or `https://s3.example.com:9000`
- `--region`: Default bucket region. Example: `us-east-1`

**Additional Options:**

- `--bucketPrefix`: Prefix used when creating buckets for this storage profile. Defaults to `katta-`.
- `--regions`: Additional bucket regions. Example: `--regions us-east-1 --regions us-west-2`
- `--name`: Display name for the storage profile.
- `--uuid`: UUID for the storage profile (auto-generated if omitted).

### Configure storage profile for MinIO using `storageprofile` command

Uploads a storage profile to Katta Server for use with MinIO STS. Requires MinIO STS setup with an OIDC provider.

Unlike AWS, MinIO does not support role chaining, so the same role ARN is used for both bucket creation and hub access.
Comment thread
dkocher marked this conversation as resolved.
MinIO uses the `${jwt:client_id}` policy variable to scope bucket access per vault.

See also: [MinIO setup documentation](https://github.com/shift7-ch/katta-docs/blob/main/SETUP_KATTA_SERVER.md#minio).

```bash
katta storageprofile minio sts \
--hubUrl <hub-url> \
--endpointUrl <minio-endpoint-url> \
--region <region> \
--stsRoleCreateBucketClient <role-arn> \
--stsRoleCreateBucketHub <role-arn> \
--stsRoleAccessBucket <role-arn>
Comment thread
dkocher marked this conversation as resolved.
```

**Required Options:**

- `--hubUrl`: Hub URL. Example: `https://hub.default.katta.cloud/`
- `--endpointUrl`: MinIO endpoint URL (S3 API). Example: `https://minio.example.com` or `https://minio.example.com:9000`
- `--region`: Default bucket region. Example: `us-east-1`
- `--stsRoleCreateBucketClient`: MinIO role ARN for bucket creation by the Cryptomator client (from `mc idp openid ls` for the `cryptomator` client).
- `--stsRoleCreateBucketHub`: MinIO role ARN for bucket creation by Cryptomator Hub (from `mc idp openid ls` for the `cryptomatorhub` client).
- `--stsRoleAccessBucket`: MinIO role ARN for bucket access (from `mc idp openid ls` for the `cryptomatorvaults` client).

**Additional Options:**

- `--bucketPrefix`: Prefix used when creating buckets for this storage profile. Defaults to `katta-`.
- `--regions`: Additional bucket regions. Example: `--regions us-east-1 --regions us-west-2`
- `--name`: Display name for the storage profile.
- `--uuid`: UUID for the storage profile (auto-generated if omitted).

### Generate shell completion script with `completion` command

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

import org.apache.commons.lang3.StringUtils;

import java.awt.*;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -17,16 +19,19 @@

public class AbstractAuthorizationCode {

@CommandLine.Option(names = {"--tokenUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/realms/cryptomator/protocol/openid-connect/token\"", required = false)
@CommandLine.Option(names = {"--hubUrl"}, description = "Hub URL. Example: \"https://hub.default.katta.cloud/\"", required = false)
protected String hubUrl;

Comment thread
dkocher marked this conversation as resolved.
@CommandLine.Option(names = {"--tokenUrl"}, description = "Keycloak token endpoint URL. Fetched from --hubUrl if not provided.", required = false)
protected String tokenUrl;

@CommandLine.Option(names = {"--authUrl"}, description = "Keycloak realm URL with scheme. Example: \"https://testing.katta.cloud/realms/cryptomator/protocol/openid-connect/auth\"", required = false)
@CommandLine.Option(names = {"--authUrl"}, description = "Keycloak auth endpoint URL. Fetched from --hubUrl if not provided.", required = false)
protected String authUrl;

@CommandLine.Option(names = {"--clientId"}, description = "Client ID to authorize with. Example: \"cryptomator\"", required = false, defaultValue = "cryptomator")
protected 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)
@CommandLine.Option(names = {"--accessToken"}, description = "The access token. If not provided, --hubUrl (or --tokenUrl and --authUrl) and --clientId need to be provided. Requires admin role in the hub.", required = false)
protected String accessToken;
Comment thread
dkocher marked this conversation as resolved.

public AbstractAuthorizationCode() {
Expand All @@ -41,13 +46,38 @@ public AbstractAuthorizationCode(final String tokenUrl, final String authUrl, fi

protected String login() throws IOException, InterruptedException {
if(null == accessToken) {
if(StringUtils.isEmpty(tokenUrl) || StringUtils.isEmpty(authUrl) || StringUtils.isEmpty(clientId)) {
throw new IllegalArgumentException("If --accessToken is not provided, you must specify --tokenUrl, --authUrl and --clientId.");
if(StringUtils.isEmpty(tokenUrl) || StringUtils.isEmpty(authUrl)) {
if(StringUtils.isEmpty(hubUrl)) {
throw new IllegalArgumentException("If --accessToken is not provided, you must specify --hubUrl (or --tokenUrl and --authUrl).");
}
var request = HttpRequest.newBuilder()
.uri(URI.create(hubUrl + "/api/config"))
.GET()
.build();
try (HttpClient client = HttpClient.newHttpClient()) {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if(response.statusCode() != 200) {
throw new IOException("Failed to fetch config from %s/api/config. HTTP status: %d".formatted(hubUrl, response.statusCode()));
}
var rootNode = new ObjectMapper().reader().readTree(response.body());
this.tokenUrl = rootNode.get("keycloakTokenEndpoint").asText();
this.authUrl = rootNode.get("keycloakAuthEndpoint").asText();
}
Comment thread
dkocher marked this conversation as resolved.
Comment thread
dkocher marked this conversation as resolved.
Comment thread
dkocher marked this conversation as resolved.
Comment thread
dkocher marked this conversation as resolved.
}
var authResponse = TinyOAuth2.client(clientId)
.withTokenEndpoint(URI.create(tokenUrl))
.authorizationCodeGrant(URI.create(authUrl))
.authorize(HttpClient.newHttpClient(), uri -> System.out.println("Please login on " + uri));
.authorize(HttpClient.newHttpClient(), uri -> {
System.out.println("Please login on " + uri);
if(Desktop.isDesktopSupported()) {
try {
Desktop.getDesktop().browse(uri);
}
catch(IOException e) {
// Ignore
}
}
});
return extractAccessToken(authResponse);
}
else {
Expand All @@ -57,15 +87,15 @@ protected String login() throws IOException, InterruptedException {

private String extractAccessToken(HttpResponse<String> response) throws IOException {
var statusCode = response.statusCode();
if (statusCode != 200) {
if(statusCode != 200) {
throw new IOException("""
Failed to retrieve access token. HTTP status: %d, body:
%s
""".formatted(statusCode, response.body()));
}
var rootNode = new ObjectMapper().reader().readTree(response.body());
var accessTokenNode = rootNode.get("access_token");
if (accessTokenNode == null || accessTokenNode.isNull()) {
if(accessTokenNode == null || accessTokenNode.isNull()) {
throw new IOException("""
Failed to parse access token from response body:
%s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@
import cloud.katta.cli.commands.AbstractAuthorizationCode;
import cloud.katta.client.ApiClient;
import cloud.katta.client.ApiException;
import cloud.katta.client.JSON;
import cloud.katta.client.api.StorageProfileResourceApi;
import cloud.katta.client.model.StorageProfileDto;
import picocli.CommandLine;

public abstract class AbstractStorageProfile extends AbstractAuthorizationCode implements Callable<Void> {
@CommandLine.Option(names = {"--hubUrl"}, description = "Hub URL. Example: \"https://hub.default.katta.cloud/\"", required = true)
protected String hubUrl;

@CommandLine.Option(names = {"--uuid"}, description = "The uuid.", required = false)
protected String uuid;

Expand All @@ -29,6 +28,9 @@ public abstract class AbstractStorageProfile extends AbstractAuthorizationCode i
@CommandLine.Option(names = {"--regions"}, description = "Bucket regions, e.g. \"--regions eu-west-1 --regions eu-west-2 --regions eu-west-3\".", required = false)
protected List<String> regions;

@CommandLine.Option(names = {"--debug"}, description = "Print HTTP request and response headers.", defaultValue = "false")
protected boolean debug;

public AbstractStorageProfile() {
}

Expand All @@ -45,9 +47,11 @@ public Void call() throws Exception {
final ApiClient apiClient = new ApiClient();
apiClient.setBasePath(hubUrl);
Comment thread
dkocher marked this conversation as resolved.
apiClient.addDefaultHeader("Authorization", "Bearer %s".formatted(this.login()));
this.call(new StorageProfileResourceApi(apiClient));
apiClient.setDebugging(debug);
final StorageProfileDto response = this.call(new StorageProfileResourceApi(apiClient));
System.out.println(new JSON().getContext(null).writeValueAsString(response));
return null;
}

protected abstract void call(final StorageProfileResourceApi storageProfileResourceApi) throws ApiException;
protected abstract StorageProfileDto call(final StorageProfileResourceApi storageProfileResourceApi) throws ApiException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@
description = "Archive (deactivate) an existing storage profile.",
mixinStandardHelpOptions = true)
public class ArchiveStorageProfile extends AbstractAuthorizationCode implements Callable<Void> {
@CommandLine.Option(names = {"--hubUrl"}, description = "Hub URL. Example: \"https://hub.testing.katta.cloud\"", required = true)
String hubUrl;

@CommandLine.Option(names = {"--uuid"}, description = "The uuid.", required = true)
@CommandLine.Option(names = {"--uuid"}, description = "Storage Profile.", required = true)
Comment thread
dkocher marked this conversation as resolved.
String uuid;
Comment thread
dkocher marked this conversation as resolved.
Comment thread
dkocher marked this conversation as resolved.

public ArchiveStorageProfile() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
package cloud.katta.cli.commands.hub.storageprofile;

import cloud.katta.cli.commands.hub.storageprofile.aws.AWS;
import cloud.katta.cli.commands.hub.storageprofile.minio.MinIO;
import cloud.katta.cli.commands.hub.storageprofile.s3.S3;
import picocli.CommandLine;

@CommandLine.Command(name = "storageprofile", subcommands = {
ArchiveStorageProfile.class,
AWS.class,
MinIO.class,
S3.class,
CommandLine.HelpCommand.class
},
description = "Configure Storage Location", mixinStandardHelpOptions = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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.StorageProfileS3STSDto;
import picocli.CommandLine;

Expand Down Expand Up @@ -51,7 +52,7 @@ public AWSSTSStorageProfile(final String hubUrl, final String uuid, final String
}

@Override
protected void call(final StorageProfileResourceApi storageProfileResourceApi) throws ApiException {
protected StorageProfileDto call(final StorageProfileResourceApi storageProfileResourceApi) throws ApiException {
final UUID uuid = UUID.fromString(null == this.uuid ? UUID.randomUUID().toString() : this.uuid);
storageProfileResourceApi.apiStorageprofileS3stsPost(new StorageProfileS3STSDto()
.id(uuid)
Expand Down Expand Up @@ -82,7 +83,7 @@ protected void call(final StorageProfileResourceApi storageProfileResourceApi) t
.stsRoleAccessBucketAssumeRoleTaggedSession(String.format("arn:aws:iam::%s:role/%s%s%s", awsAccountId, roleNamePrefix, ACCESS_BUCKET_ROLE_NAME_INFIX, ASSUME_ROLE_TAGGED_SESSION_ROLE_SUFFIX))
.stsSessionTag(REQUEST_TAG)
);
System.out.println(storageProfileResourceApi.apiStorageprofileProfileIdGet(uuid));
return storageProfileResourceApi.apiStorageprofileProfileIdGet(uuid);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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 picocli.CommandLine;

Expand Down Expand Up @@ -40,31 +41,32 @@ public AWSStaticStorageProfile(final String hubUrl, final String uuid, final Str
}

@Override
protected void call(final StorageProfileResourceApi storageProfileResourceApi) throws ApiException {
protected StorageProfileDto call(final StorageProfileResourceApi storageProfileResourceApi) throws ApiException {
final UUID uuid = UUID.fromString(null == this.uuid ? UUID.randomUUID().toString() : this.uuid);
storageProfileResourceApi.apiStorageprofileS3staticPost(new StorageProfileS3StaticDto()
.id(uuid)
.name(null == name ? this.toString() : 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)
.bucketPrefix(bucketPrefix)
.bucketEncryption(S3SERVERSIDEENCRYPTION.NONE)
.bucketVersioning(true)
.bucketAcceleration(null)

.region(region)
.regions(null == regions ? List.of(region) : regions)
.bucketPrefix(bucketPrefix)
// TODO bad design smell? not all S3 providers might have STS to create static bucket?

// Workaround https://github.com/shift7-ch/katta-server/issues/124
.stsRoleCreateBucketClient("")
.stsRoleCreateBucketHub("")
);
System.out.println(storageProfileResourceApi.apiStorageprofileProfileIdGet(uuid));
return storageProfileResourceApi.apiStorageprofileProfileIdGet(uuid);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 shift7 GmbH. All rights reserved.
*/

package cloud.katta.cli.commands.hub.storageprofile.minio;

import picocli.CommandLine;

@CommandLine.Command(name = "minio", subcommands = {
MinIOSTSStorageProfile.class,
CommandLine.HelpCommand.class
},
description = "Setup MinIO Storage Provider Integration", mixinStandardHelpOptions = true)
public class MinIO {
}
Loading
Loading