diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java index c648134d5c92..2a72b3c65f00 100644 --- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java +++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java @@ -445,6 +445,10 @@ public final class OzoneConfigKeys { "ozone.security.enabled"; public static final boolean OZONE_SECURITY_ENABLED_DEFAULT = false; + public static final String OZONE_STS_WEB_IDENTITY_ENABLED = + "ozone.sts.web.identity.enabled"; + public static final boolean OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT = false; + public static final String OZONE_HTTP_SECURITY_ENABLED_KEY = "ozone.security.http.kerberos.enabled"; public static final boolean OZONE_HTTP_SECURITY_ENABLED_DEFAULT = false; diff --git a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md new file mode 100644 index 000000000000..d8b96d7e3ec9 --- /dev/null +++ b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md @@ -0,0 +1,297 @@ +--- +title: "Using Ozone STS AssumeRoleWithWebIdentity with Keycloak and Ranger" +date: "2026-05-14" +summary: Exchange Keycloak/OIDC web identity tokens for temporary Ozone S3 credentials through Ozone STS. +weight: 6 +menu: + main: + parent: Security +icon: key +--- + + +Ozone STS can exchange an OIDC web identity token for short-lived S3 +credentials using an AWS-compatible `AssumeRoleWithWebIdentity` request. This +is intended for deployments where workloads already authenticate to Keycloak or +another OIDC provider and need temporary credentials for Ozone S3. + +This feature is disabled by default. + +## Architecture + +The Web Identity flow has three separate responsibilities: + +1. Keycloak authenticates the caller and issues a signed OIDC JWT. +2. Ozone STS validates the JWT and issues temporary S3 credentials. +3. Ranger or the configured Ozone authorizer authorizes role assumption and + subsequent S3 access. + +Keycloak groups and roles are identity attributes only. They are not the final +bucket or object policy engine. Ranger policies or the configured Ozone +authorizer remain the policy decision point (PDP) and source of authorization +decisions. + +The request path is: + +```text +Client or workload + -> Keycloak access token + -> Ozone STS AssumeRoleWithWebIdentity + -> temporary AccessKeyId, SecretAccessKey, SessionToken + -> normal S3 SigV4 request with x-amz-security-token + -> OM STS token validation + -> Ranger or Ozone authorizer +``` + +## What This Adds + +This feature adds `AssumeRoleWithWebIdentity` to the existing Ozone STS +temporary credential model. Ozone STS validates the configured issuer, +audience, JWT signature, expiry, not-before time, issued-at time, username +claim, subject claim, and configured group and role claims. + +The returned credentials use the existing STS session-token validation path for +later S3 operations. S3 clients must sign normal S3 requests with AWS Signature +Version 4 and include the returned session token as `x-amz-security-token`. + +## What This Does Not Add + +This feature does not replace Kerberos daemon authentication, does not add OFS +OIDC login, does not add CLI device-code login, does not make non-secure Ozone +fully secure, and does not use Keycloak Authorization Services as the Ozone +bucket or object policy decision point. + +## Ozone Configuration + +Enable Web Identity support only on clusters where STS and the S3 Gateway are +configured and the authorization provider can authorize STS role assumption and +subsequent S3 access. + +```xml + + ozone.sts.web.identity.enabled + true + + + ozone.sts.web.identity.issuer.uri + https://keycloak.example.com/realms/ozone + + + ozone.sts.web.identity.jwks.uri + https://keycloak.example.com/realms/ozone/protocol/openid-connect/certs + + + ozone.sts.web.identity.audience + ozone + + + ozone.sts.web.identity.username.claim + preferred_username + + + ozone.sts.web.identity.subject.claim + sub + + + ozone.sts.web.identity.groups.claim + groups + + + ozone.sts.web.identity.roles.claim + realm_access.roles + + + ozone.sts.web.identity.require.https + true + + + ozone.sts.web.identity.jwks.refresh.interval + 10m + + + ozone.sts.web.identity.jwks.connect.timeout + 5s + + + ozone.sts.web.identity.jwks.read.timeout + 5s + + + ozone.sts.web.identity.jwks.size.limit + 1MB + +``` + +For local tests only, HTTP issuer and JWKS URLs can be enabled explicitly by +turning off the HTTPS requirement: + +```xml + + ozone.sts.web.identity.require.https + false + +``` + +Production deployments should use HTTPS for both Keycloak and Ozone endpoints. + +JWKS keys are cached. The default refresh interval is 10 minutes. A token with +an unknown `kid` can trigger an earlier JWKS refresh, but repeated unknown +`kid` values are debounced to avoid refresh storms. During Keycloak signing key +rotation, new tokens may fail until Ozone refreshes JWKS or sees the first +unknown `kid` after the debounce window. Operators should publish old and new +keys concurrently for at least the maximum token lifetime plus the JWKS refresh +interval. + +## Keycloak Setup + +A minimal Keycloak setup contains: + +- Realm: `ozone` +- Client: `ozone-sts` +- Audience mapper: include `ozone` in access tokens +- Group membership mapper: include groups in the `groups` claim +- Users and groups used by Ranger policies, for example user `tomato-user` in + group `ozone-tomato` + +The token presented to Ozone STS must contain claims compatible with the Ozone +configuration: + +```json +{ + "iss": "https://keycloak.example.com/realms/ozone", + "aud": "ozone", + "sub": "ce3f0b9b-...", + "preferred_username": "tomato-user", + "groups": ["ozone-tomato"], + "realm_access": { + "roles": ["offline_access"] + } +} +``` + +## Ranger Policy Model + +The authorizer must allow the mapped OIDC identity to perform +`AssumeRoleWithWebIdentity` on the requested role ARN before Ozone issues any +temporary credential. The role ARN is treated as an authorization resource for +this MVP. This patch does not add an IAM role database. + +Production Ranger deployments need a WebIdentity-capable Ozone authorizer. In +this source tree, `IAccessAuthorizer` provides the +`generateAssumeRoleWithWebIdentitySessionPolicy(...)` extension point and its +default implementation fails closed with `NOT_SUPPORTED_OPERATION`. The +`org.apache.ranger.authorization.ozone.authorizer.RangerOzoneAuthorizer` +implementation is supplied by the external Ranger Ozone plugin, not by this +repository. Until that plugin overrides the WebIdentity method, deployments +using it will fail closed and will not issue temporary credentials for +`AssumeRoleWithWebIdentity`. + +The authorizer must also allow the later S3 operations. In a Ranger deployment, +the recommended shape is: + +- STS assumption policy: allow group `ozone-tomato` to assume the configured + role ARN. +- S3 resource policy: allow group `ozone-tomato` to access the intended + volume, bucket, key, or prefix. +- Deny by default for identities and buckets not covered by policy. + +## STS Request + +The client posts a form-encoded request to the Ozone STS endpoint: + +```bash +curl -sS -X POST "https://s3g.example.com:9881/sts" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "Action=AssumeRoleWithWebIdentity" \ + --data-urlencode "RoleArn=arn:aws:iam::123456789012:role/tomato-role" \ + --data-urlencode "RoleSessionName=tomato-session" \ + --data-urlencode "WebIdentityToken=${KEYCLOAK_ACCESS_TOKEN}" \ + --data-urlencode "DurationSeconds=3600" +``` + +The bootstrap request does not use S3 SigV4. The unauthenticated bypass is +limited to `/sts`, only for `Action=AssumeRoleWithWebIdentity`, and only when +`ozone.sts.web.identity.enabled=true`. OM still validates the JWT itself before +issuing credentials. + +## STS Response + +The response follows the AWS STS shape where practical: + +```xml + + + + ... + ... + ... + 2026-05-14T12:00:00Z + + ... + + arn:aws:sts::123456789012:assumed-role/tomato-role/tomato-session + ... + + ozone + https://keycloak.example.com/realms/ozone + + +``` + +## Using Temporary Credentials + +AWS-compatible clients must use all three returned credential fields: + +```bash +export AWS_ACCESS_KEY_ID="returned-access-key" +export AWS_SECRET_ACCESS_KEY="returned-secret-key" +export AWS_SESSION_TOKEN="returned-session-token" +export AWS_DEFAULT_REGION="us-east-1" + +aws --endpoint-url https://s3g.example.com:9878 s3 cp ./file.txt s3://tomato-files/file.txt +aws --endpoint-url https://s3g.example.com:9878 s3 ls s3://tomato-files/ +aws --endpoint-url https://s3g.example.com:9878 s3 cp s3://tomato-files/file.txt ./file.txt +``` + +If `AWS_SESSION_TOKEN` is missing, wrong, expired, or encoded in a +non-canonical form, STS temporary credential validation fails closed. + +## Security Notes + +The raw web identity JWT is validated by OM before Ratis replication and is +stripped before the replicated request is written. The replicated request and +STS token identifier contain sanitized identity and session fields, not the raw +JWT. + +The temporary `SecretAccessKey` and returned session token are protected by the +existing STS token path. The sanitized replicated OM request still carries +temporary credential material through Ratis similarly to the existing +`AssumeRole` implementation, because the credentials are generated before the +state-machine apply path. Operators must protect OM metadata directories, Ratis +logs, snapshots, and backups as sensitive security material. + +Do not log bearer tokens, session tokens, temporary secrets, client secrets, or +Authorization headers. Ozone errors should not include token material. + +Temporary credentials expire. The effective expiration is constrained by the +STS duration limits and the web identity token lifetime. + +Protect Ozone endpoints according to the deployment model. This feature does +not make direct unauthenticated OM, SCM, DataNode, OFS, or internal RPC access +safe. It only adds an OIDC-to-temporary-S3-credentials exchange path for Ozone +STS. diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java index f423979bde2d..a01839f12027 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java @@ -37,6 +37,7 @@ import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.OmVolumeArgs; @@ -767,6 +768,17 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); } + /** + * Process the AssumeRoleWithWebIdentity operation. + */ + public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity( + String roleArn, String roleSessionName, int durationSeconds, + String webIdentityToken, String providerId, String requestId) + throws IOException { + return proxy.assumeRoleWithWebIdentity(roleArn, roleSessionName, + durationSeconds, webIdentityToken, providerId, requestId); + } + /** * Revokes an STS token. * @param sessionToken The STS sessionToken diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java index 1877d6bbed8a..07b890a6cc83 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java @@ -47,6 +47,7 @@ import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -1387,6 +1388,23 @@ void deleteObjectTagging(String volumeName, String bucketName, String keyName) AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds, String awsIamSessionPolicy, String requestId) throws IOException; + /** + * Process the AssumeRoleWithWebIdentity operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name for this operation + * @param durationSeconds The requested token validity in seconds + * @param webIdentityToken The OIDC web identity token + * @param providerId Optional provider id + * @param requestId The requestId from the STS endpoint + * @return response information containing temporary credentials + * @throws IOException if an error occurs during the operation + */ + AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity( + String roleArn, String roleSessionName, int durationSeconds, + String webIdentityToken, String providerId, String requestId) + throws IOException; + /** * Revokes an STS token. * @param sessionToken The STS sessionToken diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java index a9075320dbee..c8b3768e852b 100644 --- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java +++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java @@ -130,6 +130,7 @@ import org.apache.hadoop.ozone.om.OmConfig; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo; import org.apache.hadoop.ozone.om.helpers.BucketLayout; @@ -2787,6 +2788,16 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId); } + @Override + public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity( + String roleArn, String roleSessionName, int durationSeconds, + String webIdentityToken, String providerId, String requestId) + throws IOException { + return ozoneManagerClient.assumeRoleWithWebIdentity(roleArn, + roleSessionName, durationSeconds, webIdentityToken, providerId, + requestId); + } + @Override public void revokeSTSToken(String sessionToken) throws IOException { ozoneManagerClient.revokeSTSToken(sessionToken); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java index 74848c0ff6a1..8caadd428c99 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java @@ -280,6 +280,7 @@ public static boolean isReadOnly(OMRequest omRequest) { case AbortMultiPartUpload: case GetS3Secret: case AssumeRole: + case AssumeRoleWithWebIdentity: case GetDelegationToken: case RenewDelegationToken: case CancelDelegationToken: @@ -440,6 +441,7 @@ public static boolean shouldSendToFollower(OMRequest omRequest) { case PutObjectTagging: case DeleteObjectTagging: case AssumeRole: + case AssumeRoleWithWebIdentity: case RevokeSTSToken: case DeleteRevokedSTSTokens: case ServiceList: // OM leader should have the most up-to-date OM service list info diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java index 5f21abb3cbd6..232f2bda50c1 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java @@ -80,8 +80,8 @@ public AssumeRoleResponse getProtobuf() { @Override public String toString() { - return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "', secretAccessKey='" + secretAccessKey + - "', sessionToken='" + sessionToken + "', expirationEpochSeconds=" + expirationEpochSeconds + + return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "', secretAccessKey=" + + ", sessionToken=, expirationEpochSeconds=" + expirationEpochSeconds + ", assumedRoleId='" + assumedRoleId + "'}"; } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java new file mode 100644 index 000000000000..82ef3079caba --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.helpers; + +import java.util.Objects; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityResponse; + +/** + * Utility class to handle AssumeRoleWithWebIdentityResponse protobuf message. + */ +@Immutable +public class AssumeRoleWithWebIdentityResponseInfo { + + private final String accessKeyId; + private final String secretAccessKey; + private final String sessionToken; + private final long expirationEpochSeconds; + private final String assumedRoleId; + private final String subjectFromWebIdentityToken; + private final String audience; + private final String provider; + + @SuppressWarnings("checkstyle:ParameterNumber") + public AssumeRoleWithWebIdentityResponseInfo(String accessKeyId, + String secretAccessKey, String sessionToken, long expirationEpochSeconds, + String assumedRoleId, String subjectFromWebIdentityToken, String audience, + String provider) { + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.sessionToken = sessionToken; + this.expirationEpochSeconds = expirationEpochSeconds; + this.assumedRoleId = assumedRoleId; + this.subjectFromWebIdentityToken = subjectFromWebIdentityToken; + this.audience = audience; + this.provider = provider; + } + + public String getAccessKeyId() { + return accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public long getExpirationEpochSeconds() { + return expirationEpochSeconds; + } + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public String getSubjectFromWebIdentityToken() { + return subjectFromWebIdentityToken; + } + + public String getAudience() { + return audience; + } + + public String getProvider() { + return provider; + } + + public static AssumeRoleWithWebIdentityResponseInfo fromProtobuf( + AssumeRoleWithWebIdentityResponse response) { + return new AssumeRoleWithWebIdentityResponseInfo( + response.getAccessKeyId(), response.getSecretAccessKey(), + response.getSessionToken(), response.getExpirationEpochSeconds(), + response.getAssumedRoleId(), + response.getSubjectFromWebIdentityToken(), response.getAudience(), + response.hasProvider() ? response.getProvider() : null); + } + + public AssumeRoleWithWebIdentityResponse getProtobuf() { + AssumeRoleWithWebIdentityResponse.Builder builder = + AssumeRoleWithWebIdentityResponse.newBuilder() + .setAccessKeyId(accessKeyId) + .setSecretAccessKey(secretAccessKey) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds) + .setAssumedRoleId(assumedRoleId) + .setSubjectFromWebIdentityToken(subjectFromWebIdentityToken) + .setAudience(audience); + if (provider != null && !provider.isEmpty()) { + builder.setProvider(provider); + } + return builder.build(); + } + + @Override + public String toString() { + return "AssumeRoleWithWebIdentityResponseInfo{" + + "accessKeyId='" + accessKeyId + '\'' + + ", secretAccessKey=" + + ", sessionToken=" + + ", expirationEpochSeconds=" + expirationEpochSeconds + + ", assumedRoleId='" + assumedRoleId + '\'' + + ", subjectFromWebIdentityToken='" + subjectFromWebIdentityToken + '\'' + + ", audience='" + audience + '\'' + + ", provider='" + provider + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + AssumeRoleWithWebIdentityResponseInfo that = + (AssumeRoleWithWebIdentityResponseInfo) o; + return expirationEpochSeconds == that.expirationEpochSeconds + && Objects.equals(accessKeyId, that.accessKeyId) + && Objects.equals(secretAccessKey, that.secretAccessKey) + && Objects.equals(sessionToken, that.sessionToken) + && Objects.equals(assumedRoleId, that.assumedRoleId) + && Objects.equals(subjectFromWebIdentityToken, + that.subjectFromWebIdentityToken) + && Objects.equals(audience, that.audience) + && Objects.equals(provider, that.provider); + } + + @Override + public int hashCode() { + return Objects.hash(accessKeyId, secretAccessKey, sessionToken, + expirationEpochSeconds, assumedRoleId, subjectFromWebIdentityToken, + audience, provider); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java index 5e4dafb925bc..cd80780ac4f7 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java @@ -30,6 +30,7 @@ import org.apache.hadoop.ozone.om.OMConfigKeys; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; @@ -1193,6 +1194,26 @@ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName throw new UnsupportedOperationException("OzoneManager does not require this to be implemented"); } + /** + * Process the AssumeRoleWithWebIdentity operation. + * + * @param roleArn The ARN of the role to assume + * @param roleSessionName The session name for this operation + * @param durationSeconds The requested token validity in seconds + * @param webIdentityToken The OIDC web identity token + * @param providerId Optional provider id + * @param requestId The requestId from the STS endpoint + * @return response information containing temporary credentials + * @throws IOException if an error occurs during the operation + */ + default AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity( + String roleArn, String roleSessionName, int durationSeconds, + String webIdentityToken, String providerId, String requestId) + throws IOException { + throw new UnsupportedOperationException( + "OzoneManager does not require this to be implemented"); + } + /** * Revokes an STS token. * @param sessionToken The STS sessionToken diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java index b42b0e6ddf87..ecf4dbebd2a5 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java @@ -55,6 +55,7 @@ import org.apache.hadoop.ozone.OzoneAcl; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo; import org.apache.hadoop.ozone.om.helpers.DBUpdates; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; @@ -2685,6 +2686,34 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, handleError(submitRequest(omRequest)).getAssumeRoleResponse()); } + @Override + public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity( + String roleArn, String roleSessionName, int durationSeconds, + String webIdentityToken, String providerId, String requestId) + throws IOException { + final OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest.Builder + request = + OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest + .newBuilder() + .setRoleArn(roleArn) + .setRoleSessionName(roleSessionName) + .setDurationSeconds(durationSeconds) + .setWebIdentityToken(webIdentityToken) + .setRequestId(requestId); + if (providerId != null && !providerId.isEmpty()) { + request.setProviderId(providerId); + } + + final OMRequest omRequest = + createOMRequest(Type.AssumeRoleWithWebIdentity) + .setAssumeRoleWithWebIdentityRequest(request) + .build(); + + return AssumeRoleWithWebIdentityResponseInfo.fromProtobuf( + handleError(submitRequest(omRequest)) + .getAssumeRoleWithWebIdentityResponse()); + } + @Override public void revokeSTSToken(String sessionToken) throws IOException { final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request = diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java new file mode 100644 index 000000000000..cda41199be9e --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.acl; + +import java.net.InetAddress; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import net.jcip.annotations.Immutable; +import org.apache.commons.lang3.StringUtils; + +/** + * Represents an STS AssumeRoleWithWebIdentity request that has already been + * authenticated by validating the web identity token. + * + * The web identity token itself must not be stored in this object. OM is the + * authoritative validator for the token and passes only normalized identity + * attributes to the authorizer. + */ +@Immutable +public final class AssumeRoleWithWebIdentityRequest { + + public static final String ACTION = "AssumeRoleWithWebIdentity"; + + private final String host; + private final InetAddress ip; + private final String user; + private final Set groups; + private final Set roles; + private final String roleArn; + private final String roleSessionName; + private final String issuer; + private final String subject; + private final String audience; + private final String providerId; + private final Set grants; + + private AssumeRoleWithWebIdentityRequest(Builder builder) { + this.host = builder.host; + this.ip = builder.ip; + this.user = requireNonBlank(builder.user, "user"); + this.groups = immutableSet(builder.groups); + this.roles = immutableSet(builder.roles); + this.roleArn = requireNonBlank(builder.roleArn, "roleArn"); + this.roleSessionName = + requireNonBlank(builder.roleSessionName, "roleSessionName"); + this.issuer = requireNonBlank(builder.issuer, "issuer"); + this.subject = requireNonBlank(builder.subject, "subject"); + this.audience = requireNonBlank(builder.audience, "audience"); + this.providerId = builder.providerId; + this.grants = builder.grants; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getAction() { + return ACTION; + } + + public String getHost() { + return host; + } + + public InetAddress getIp() { + return ip; + } + + public String getUser() { + return user; + } + + public Set getGroups() { + return groups; + } + + public Set getRoles() { + return roles; + } + + public String getRoleArn() { + return roleArn; + } + + public String getRoleSessionName() { + return roleSessionName; + } + + public String getIssuer() { + return issuer; + } + + public String getSubject() { + return subject; + } + + public String getAudience() { + return audience; + } + + public String getProviderId() { + return providerId; + } + + public Set getGrants() { + return grants; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + + final AssumeRoleWithWebIdentityRequest that = + (AssumeRoleWithWebIdentityRequest) o; + return Objects.equals(host, that.host) + && Objects.equals(ip, that.ip) + && Objects.equals(user, that.user) + && Objects.equals(groups, that.groups) + && Objects.equals(roles, that.roles) + && Objects.equals(roleArn, that.roleArn) + && Objects.equals(roleSessionName, that.roleSessionName) + && Objects.equals(issuer, that.issuer) + && Objects.equals(subject, that.subject) + && Objects.equals(audience, that.audience) + && Objects.equals(providerId, that.providerId) + && Objects.equals(grants, that.grants); + } + + @Override + public int hashCode() { + return Objects.hash(host, ip, user, groups, roles, roleArn, + roleSessionName, issuer, subject, audience, providerId, grants); + } + + @Override + public String toString() { + return "AssumeRoleWithWebIdentityRequest{" + + "host='" + host + '\'' + + ", ip=" + ip + + ", user='" + user + '\'' + + ", groups=" + groups + + ", roles=" + roles + + ", roleArn='" + roleArn + '\'' + + ", roleSessionName='" + roleSessionName + '\'' + + ", issuer='" + issuer + '\'' + + ", subject='" + subject + '\'' + + ", audience='" + audience + '\'' + + ", providerId='" + providerId + '\'' + + ", grants=" + grants + + '}'; + } + + private static Set immutableSet(Set values) { + if (values == null) { + return Collections.emptySet(); + } + return Collections.unmodifiableSet(new LinkedHashSet<>(values)); + } + + private static String requireNonBlank(String value, String name) { + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return value; + } + + /** + * Builder for {@link AssumeRoleWithWebIdentityRequest}. + */ + public static final class Builder { + private String host; + private InetAddress ip; + private String user; + private Set groups; + private Set roles; + private String roleArn; + private String roleSessionName; + private String issuer; + private String subject; + private String audience; + private String providerId; + private Set grants; + + private Builder() { + } + + public Builder setHost(String value) { + this.host = value; + return this; + } + + public Builder setIp(InetAddress value) { + this.ip = value; + return this; + } + + public Builder setUser(String value) { + this.user = value; + return this; + } + + public Builder setGroups(Set value) { + this.groups = value; + return this; + } + + public Builder setRoles(Set value) { + this.roles = value; + return this; + } + + public Builder setRoleArn(String value) { + this.roleArn = value; + return this; + } + + public Builder setRoleSessionName(String value) { + this.roleSessionName = value; + return this; + } + + public Builder setIssuer(String value) { + this.issuer = value; + return this; + } + + public Builder setSubject(String value) { + this.subject = value; + return this; + } + + public Builder setAudience(String value) { + this.audience = value; + return this; + } + + public Builder setProviderId(String value) { + this.providerId = value; + return this; + } + + public Builder setGrants(Set value) { + this.grants = value; + return this; + } + + public AssumeRoleWithWebIdentityRequest build() { + return new AssumeRoleWithWebIdentityRequest(this); + } + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java index 8a07bab606b0..569f6b41bb2b 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java @@ -69,6 +69,31 @@ default String generateAssumeRoleSessionPolicy(AssumeRoleRequest assumeRoleReque throw new OMException("The generateAssumeRoleSessionPolicy call is not supported", NOT_SUPPORTED_OPERATION); } + /** + * Attempts to authorize an STS AssumeRoleWithWebIdentity request after OM has + * validated the web identity token and mapped it to an Ozone identity. + * + *

Implementations must treat Keycloak/OIDC groups and roles as identity + * attributes only. The final role-assumption authorization decision and the + * returned session policy must come from this authorizer.

+ * + *

Deployments using an external Ranger Ozone plugin need a companion + * plugin implementation of this method. Without it, this default method + * fails closed with {@link OMException.ResultCodes#NOT_SUPPORTED_OPERATION} + * and Ozone STS will not issue WebIdentity temporary credentials.

+ * + * @param request the web identity role assumption request shape + * @return a String representing the permissions granted according to + * the authorizer. + * @throws OMException if the caller is not authorized or the operation is not + * supported. + */ + default String generateAssumeRoleWithWebIdentitySessionPolicy( + AssumeRoleWithWebIdentityRequest request) throws OMException { + throw new OMException("The generateAssumeRoleWithWebIdentitySessionPolicy" + + " call is not supported", NOT_SUPPORTED_OPERATION); + } + /** * @return true for Ozone-native authorizer */ diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java index 38c74dc1f261..8cb556f5bc76 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java @@ -18,8 +18,10 @@ package org.apache.hadoop.ozone.om.helpers; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse; import org.junit.jupiter.api.Test; @@ -186,12 +188,14 @@ public void testToString() { ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID); final String toString = response.toString(); - final String expectedString = "AssumeRoleResponseInfo{" + "accessKeyId='" + ACCESS_KEY_ID + - "', secretAccessKey='" + SECRET_ACCESS_KEY + "', sessionToken='" + SESSION_TOKEN + - "', expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + ", assumedRoleId='" + ASSUMED_ROLE_ID + "'}"; assertNotNull(toString); - assertEquals(expectedString, toString); + assertTrue(toString.contains("accessKeyId='" + ACCESS_KEY_ID + "'")); + assertTrue(toString.contains("secretAccessKey=")); + assertTrue(toString.contains("sessionToken=")); + assertTrue(toString.contains("expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS)); + assertTrue(toString.contains("assumedRoleId='" + ASSUMED_ROLE_ID + "'")); + assertFalse(toString.contains(SECRET_ACCESS_KEY)); + assertFalse(toString.contains(SESSION_TOKEN)); } } - diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java new file mode 100644 index 000000000000..f4a40d25b8fb --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.acl; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_DENIED; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.InetAddress; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link AssumeRoleWithWebIdentityRequest}. + */ +public class TestAssumeRoleWithWebIdentityRequest { + + private static final String ROLE_ARN = + "arn:aws:iam::123456789012:role/tomato"; + + @Test + public void testConstructorAndGetters() throws Exception { + final Set groups = set("ozone-tomato"); + final Set roles = set("role:writer"); + final InetAddress ip = InetAddress.getByName("127.0.0.1"); + + final AssumeRoleWithWebIdentityRequest request = + AssumeRoleWithWebIdentityRequest.newBuilder() + .setHost("s3g.example.com") + .setIp(ip) + .setUser("tomato-user") + .setGroups(groups) + .setRoles(roles) + .setRoleArn(ROLE_ARN) + .setRoleSessionName("tomato-session") + .setIssuer("https://keycloak.example.com/realms/ozone") + .setSubject("subject-1") + .setAudience("ozone") + .setProviderId("keycloak") + .build(); + + groups.add("mutated-group"); + roles.add("mutated-role"); + + assertEquals(AssumeRoleWithWebIdentityRequest.ACTION, + request.getAction()); + assertEquals("s3g.example.com", request.getHost()); + assertEquals(ip, request.getIp()); + assertEquals("tomato-user", request.getUser()); + assertEquals(set("ozone-tomato"), request.getGroups()); + assertEquals(set("role:writer"), request.getRoles()); + assertEquals(ROLE_ARN, request.getRoleArn()); + assertEquals("tomato-session", request.getRoleSessionName()); + assertEquals("https://keycloak.example.com/realms/ozone", + request.getIssuer()); + assertEquals("subject-1", request.getSubject()); + assertEquals("ozone", request.getAudience()); + assertEquals("keycloak", request.getProviderId()); + assertEquals(null, request.getGrants()); + } + + @Test + public void testRequiredIdentityFieldsFailClosed() { + assertThrows(IllegalArgumentException.class, + () -> requestWithUser(" ")); + assertThrows(IllegalArgumentException.class, + () -> AssumeRoleWithWebIdentityRequest.newBuilder() + .setUser("tomato-user") + .setGroups(set("ozone-tomato")) + .setRoles(set()) + .setRoleArn(" ") + .setRoleSessionName("tomato-session") + .setIssuer("issuer") + .setSubject("subject") + .setAudience("audience") + .build()); + } + + @Test + public void testAuthorizerRequestShapeAndAllow() throws Exception { + final AssumeRoleWithWebIdentityRequest request = requestWithUser( + "tomato-user"); + final FakeAuthorizer authorizer = FakeAuthorizer.allowing("session-policy"); + + final String policy = + authorizer.generateAssumeRoleWithWebIdentitySessionPolicy(request); + + assertEquals("session-policy", policy); + assertEquals(request, authorizer.getLastRequest()); + assertEquals("tomato-user", authorizer.getLastRequest().getUser()); + assertEquals(set("ozone-tomato"), authorizer.getLastRequest().getGroups()); + assertEquals(AssumeRoleWithWebIdentityRequest.ACTION, + authorizer.getLastRequest().getAction()); + assertEquals(ROLE_ARN, authorizer.getLastRequest().getRoleArn()); + assertEquals("issuer", authorizer.getLastRequest().getIssuer()); + assertEquals("subject", authorizer.getLastRequest().getSubject()); + assertEquals("audience", authorizer.getLastRequest().getAudience()); + assertEquals("session", authorizer.getLastRequest().getRoleSessionName()); + assertEquals("provider", authorizer.getLastRequest().getProviderId()); + } + + @Test + public void testAuthorizerDenyByDefault() { + final FakeAuthorizer authorizer = new FakeAuthorizer(false, null); + + final OMException ex = assertThrows(OMException.class, + () -> authorizer.generateAssumeRoleWithWebIdentitySessionPolicy( + requestWithUser("tomato-user"))); + + assertEquals(ACCESS_DENIED, ex.getResult()); + } + + @Test + public void testDefaultAuthorizerMethodIsNotSupported() { + final IAccessAuthorizer authorizer = new IAccessAuthorizer() { + @Override + public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) { + return false; + } + }; + + final OMException ex = assertThrows(OMException.class, + () -> authorizer.generateAssumeRoleWithWebIdentitySessionPolicy( + requestWithUser("tomato-user"))); + + assertEquals(NOT_SUPPORTED_OPERATION, ex.getResult()); + } + + @Test + public void testRequestDoesNotContainTokenMaterial() { + final String token = "eyJhbGciOiJSUzI1NiJ9.sensitive.signature"; + + assertThat(requestWithUser("tomato-user").toString()) + .doesNotContain(token) + .doesNotContain("WebIdentityToken"); + } + + private static AssumeRoleWithWebIdentityRequest requestWithUser( + String user) { + return AssumeRoleWithWebIdentityRequest.newBuilder() + .setHost("host") + .setUser(user) + .setGroups(set("ozone-tomato")) + .setRoles(set("role:writer")) + .setRoleArn(ROLE_ARN) + .setRoleSessionName("session") + .setIssuer("issuer") + .setSubject("subject") + .setAudience("audience") + .setProviderId("provider") + .build(); + } + + private static Set set(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } + + private static final class FakeAuthorizer implements IAccessAuthorizer { + + private final boolean allow; + private final String sessionPolicy; + private AssumeRoleWithWebIdentityRequest lastRequest; + + private FakeAuthorizer(boolean allow, String sessionPolicy) { + this.allow = allow; + this.sessionPolicy = sessionPolicy; + } + + private static FakeAuthorizer allowing(String sessionPolicy) { + return new FakeAuthorizer(true, sessionPolicy); + } + + @Override + public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) { + return false; + } + + @Override + public String generateAssumeRoleWithWebIdentitySessionPolicy( + AssumeRoleWithWebIdentityRequest request) throws OMException { + lastRequest = request; + if (!allow) { + throw new OMException("Denied", ACCESS_DENIED); + } + return sessionPolicy; + } + + private AssumeRoleWithWebIdentityRequest getLastRequest() { + return lastRequest; + } + } +} diff --git a/hadoop-ozone/integration-test-s3/pom.xml b/hadoop-ozone/integration-test-s3/pom.xml index 8ee8d5794576..b6244430a6c2 100644 --- a/hadoop-ozone/integration-test-s3/pom.xml +++ b/hadoop-ozone/integration-test-s3/pom.xml @@ -41,6 +41,11 @@ guava test + + com.nimbusds + nimbus-jose-jwt + test + commons-io commons-io @@ -112,6 +117,11 @@ test-jar test + + org.apache.ozone + ozone-manager + test + org.apache.ozone ozone-mini-cluster @@ -157,6 +167,11 @@ slf4j-api test + + org.testcontainers + testcontainers + test + software.amazon.awssdk apache-client diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java new file mode 100644 index 000000000000..9ecadea55865 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java @@ -0,0 +1,430 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.awssdk; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.hadoop.hdds.security.SecurityConfig.OZONE_TEST_AUTHORIZATION_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_AUTHORIZER_CLASS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_KEY; +import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY; +import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_AUDIENCE; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_JWKS_URI; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; +import static org.apache.ozone.test.GenericTestUtils.PortAllocator.localhostWithFreePort; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import javax.crypto.KeyGenerator; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient; +import org.apache.hadoop.ozone.MiniOzoneCluster; +import org.apache.hadoop.ozone.client.OzoneClient; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.s3.S3GatewayService; +import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.acl.IOzoneObj; +import org.apache.hadoop.ozone.security.acl.OzoneObj; +import org.apache.hadoop.ozone.security.acl.RequestContext; +import org.apache.ozone.test.ClusterForTests; +import org.junit.jupiter.api.AfterAll; +import org.w3c.dom.Document; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Shared mini-cluster harness for WebIdentity STS bootstrap tests. + */ +abstract class AbstractAssumeRoleWithWebIdentityS3Test + extends ClusterForTests { + + protected static final String AUDIENCE = "ozone"; + protected static final String ALLOWED_BUCKET = "tomato-files"; + protected static final String DENIED_BUCKET = "denied-files"; + protected static final String ROLE_ARN = + "arn:aws:iam::123456789012:role/tomato-role"; + protected static final String SESSION_NAME = "tomato-session"; + protected static final String PROVIDER_ID = "keycloak"; + + private S3GatewayService s3GatewayService; + + protected abstract String issuerUri(); + + protected abstract String jwksUri(); + + @Override + protected OzoneConfiguration createOzoneConfig() { + OzoneConfiguration conf = super.createOzoneConfig(); + conf.setBoolean(OZONE_TEST_AUTHORIZATION_ENABLED, true); + conf.setBoolean(OZONE_ACL_ENABLED, true); + conf.set(OZONE_ACL_AUTHORIZER_CLASS, + WebIdentityTestAuthorizer.class.getName()); + conf.setBoolean(OZONE_S3G_STS_HTTP_ENABLED_KEY, true); + conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true); + conf.set(OZONE_STS_WEB_IDENTITY_ISSUER_URI, issuerUri()); + conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE); + conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwksUri()); + conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false); + conf.set(OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY, "RATIS"); + conf.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, "ONE"); + conf.set(OZONE_S3G_STS_HTTP_ADDRESS_KEY, localhostWithFreePort()); + conf.set(OZONE_S3G_STS_HTTPS_ADDRESS_KEY, localhostWithFreePort()); + return conf; + } + + @Override + protected MiniOzoneCluster createCluster() throws Exception { + OzoneManager.setTestSecureOmFlag(true); + WebIdentityTestAuthorizer.reset(); + s3GatewayService = new S3GatewayService(); + return newClusterBuilder() + .setNumDatanodes(1) + .setSecretKeyClient(new InMemorySecretKeyClient()) + .addService(s3GatewayService) + .build(); + } + + @Override + protected void onClusterReady() throws Exception { + try (OzoneClient client = + Objects.requireNonNull(getCluster().newClient())) { + client.getObjectStore().createS3Bucket(ALLOWED_BUCKET); + client.getObjectStore().createS3Bucket(DENIED_BUCKET); + } + } + + @AfterAll + void resetTestSecurityFlag() { + OzoneManager.setTestSecureOmFlag(false); + } + + protected StsCredentials assumeRoleWithWebIdentity(String token, + String expectedSubject) throws Exception { + HttpResponse response = postSts(token); + assertEquals(200, response.getCode(), response.getBody()); + Document document = parseXml(response.getBody()); + StsCredentials credentials = new StsCredentials( + xmlText(document, "AccessKeyId"), + xmlText(document, "SecretAccessKey"), + xmlText(document, "SessionToken")); + assertThat(credentials.getAccessKeyId()).startsWith("ASIA"); + assertThat(credentials.getSecretAccessKey()).isNotBlank(); + assertThat(credentials.getSessionToken()).isNotBlank(); + if (expectedSubject != null) { + assertThat(xmlText(document, "SubjectFromWebIdentityToken")) + .isEqualTo(expectedSubject); + } + assertThat(xmlText(document, "Audience")).isEqualTo(AUDIENCE); + assertThat(xmlText(document, "Provider")).isEqualTo(PROVIDER_ID); + assertThat(xmlText(document, "AssumedRoleId")).contains(SESSION_NAME); + return credentials; + } + + protected void assertTemporaryCredentialsAuthorizeS3Operations( + StsCredentials credentials) { + try (S3Client s3 = Objects.requireNonNull(s3Client(credentials))) { + String key = "allowed.txt"; + s3.putObject(PutObjectRequest.builder() + .bucket(ALLOWED_BUCKET) + .key(key) + .build(), + RequestBody.fromBytes("web identity data".getBytes(UTF_8))); + + ResponseBytes object = + s3.getObjectAsBytes(b -> b.bucket(ALLOWED_BUCKET).key(key)); + assertEquals("web identity data", object.asUtf8String()); + + assertThat(s3.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)).contents()) + .anyMatch(item -> key.equals(item.key())); + + S3Exception denied = assertThrows(S3Exception.class, () -> + s3.putObject(PutObjectRequest.builder() + .bucket(DENIED_BUCKET) + .key("denied.txt") + .build(), + RequestBody.fromBytes("denied".getBytes(UTF_8)))); + assertThat(denied.statusCode()).isEqualTo(403); + } + } + + protected void assertNormalS3RequestWithoutSigV4IsDenied() + throws Exception { + URL url = URI.create("http://" + + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY) + + "/" + ALLOWED_BUCKET).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + assertThat(connection.getResponseCode()).isEqualTo(403); + } + + protected void assertStsFails(String token, int expectedStatus) + throws Exception { + HttpResponse response = postSts(token); + assertEquals(expectedStatus, response.getCode(), response.getBody()); + if (token != null) { + assertThat(response.getBody()).doesNotContain(token); + } + } + + protected HttpResponse postSts(String token) throws IOException { + URL url = URI.create("http://" + + s3GatewayService.getConf().get(OZONE_S3G_STS_HTTP_ADDRESS_KEY) + + "/sts").toURL(); + String body = "Action=AssumeRoleWithWebIdentity" + + "&Version=2011-06-15" + + "&RoleArn=" + encode(ROLE_ARN) + + "&RoleSessionName=" + encode(SESSION_NAME) + + "&ProviderId=" + encode(PROVIDER_ID) + + "&DurationSeconds=900"; + if (token != null) { + body += "&WebIdentityToken=" + encode(token); + } + + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", + "application/x-www-form-urlencoded"); + try (OutputStream output = connection.getOutputStream()) { + output.write(body.getBytes(UTF_8)); + } + + int code = connection.getResponseCode(); + String responseBody; + if (code >= 400) { + responseBody = IOUtils.toString(connection.getErrorStream(), UTF_8); + } else { + responseBody = IOUtils.toString(connection.getInputStream(), UTF_8); + } + return new HttpResponse(code, responseBody); + } + + protected S3Client s3Client(StsCredentials credentials) { + return s3Client(credentials.getAccessKeyId(), credentials.getSecretAccessKey(), + credentials.getSessionToken()); + } + + protected S3Client s3Client(String accessKeyId, String secretAccessKey, + String sessionToken) { + StaticCredentialsProvider credentialsProvider; + if (sessionToken == null) { + credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, secretAccessKey)); + } else { + credentialsProvider = StaticCredentialsProvider.create( + AwsSessionCredentials.create(accessKeyId, secretAccessKey, + sessionToken)); + } + return S3Client.builder() + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://" + + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY))) + .credentialsProvider(credentialsProvider) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .forcePathStyle(true) + .build(); + } + + protected AssumeRoleWithWebIdentityRequest lastAssumeRoleRequest() { + return WebIdentityTestAuthorizer.lastAssumeRoleRequest; + } + + protected int accessChecksWithSessionPolicy() { + return WebIdentityTestAuthorizer.ACCESS_CHECKS_WITH_SESSION_POLICY.get(); + } + + private static String encode(String value) throws IOException { + return URLEncoder.encode(value, UTF_8.name()); + } + + private static Document parseXml(String xml) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder() + .parse(IOUtils.toInputStream(xml, UTF_8)); + } + + private static String xmlText(Document document, String localName) { + return document.getElementsByTagNameNS("*", localName) + .item(0).getTextContent(); + } + + protected static final class HttpResponse { + private final int code; + private final String body; + + private HttpResponse(int code, String body) { + this.code = code; + this.body = body; + } + + int getCode() { + return code; + } + + String getBody() { + return body; + } + } + + protected static final class StsCredentials { + private final String accessKeyId; + private final String secretAccessKey; + private final String sessionToken; + + private StsCredentials(String accessKeyId, String secretAccessKey, + String sessionToken) { + this.accessKeyId = accessKeyId; + this.secretAccessKey = secretAccessKey; + this.sessionToken = sessionToken; + } + + String getAccessKeyId() { + return accessKeyId; + } + + String getSecretAccessKey() { + return secretAccessKey; + } + + String getSessionToken() { + return sessionToken; + } + } + + public static final class WebIdentityTestAuthorizer + implements IAccessAuthorizer { + private static final AtomicInteger ACCESS_CHECKS_WITH_SESSION_POLICY = + new AtomicInteger(); + private static volatile AssumeRoleWithWebIdentityRequest + lastAssumeRoleRequest; + + public static void reset() { + ACCESS_CHECKS_WITH_SESSION_POLICY.set(0); + lastAssumeRoleRequest = null; + } + + @Override + public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) { + if (context.getSessionPolicy() == null) { + return true; + } + ACCESS_CHECKS_WITH_SESSION_POLICY.incrementAndGet(); + if (!(ozoneObject instanceof OzoneObj)) { + return false; + } + OzoneObj obj = (OzoneObj) ozoneObject; + return obj.getBucketName() == null + || ALLOWED_BUCKET.equals(obj.getBucketName()); + } + + @Override + public String generateAssumeRoleWithWebIdentitySessionPolicy( + AssumeRoleWithWebIdentityRequest request) throws OMException { + recordAssumeRoleRequest(request); + if (!"tomato-user".equals(request.getUser()) + || !request.getGroups().contains("ozone-tomato") + || !ROLE_ARN.equals(request.getRoleArn()) + || !PROVIDER_ID.equals(request.getProviderId()) + || StringUtils.isBlank(request.getSubject())) { + throw new OMException("WebIdentity role assumption denied", + OMException.ResultCodes.ACCESS_DENIED); + } + return "{" + + "\"Version\":\"2012-10-17\"," + + "\"Statement\":[{" + + "\"Effect\":\"Allow\"," + + "\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:ListBucket\"]," + + "\"Resource\":[\"arn:aws:s3:::" + ALLOWED_BUCKET + + "\",\"arn:aws:s3:::" + ALLOWED_BUCKET + "/*\"]" + + "}]}"; + } + + private static void recordAssumeRoleRequest( + AssumeRoleWithWebIdentityRequest request) { + lastAssumeRoleRequest = request; + } + } + + private static final class InMemorySecretKeyClient + implements SecretKeyClient { + private final Map keys = new LinkedHashMap<>(); + private final ManagedSecretKey current; + + private InMemorySecretKeyClient() { + this.current = newKey(); + keys.put(current.getId(), current); + } + + @Override + public ManagedSecretKey getCurrentSecretKey() { + return current; + } + + @Override + public ManagedSecretKey getSecretKey(UUID id) { + return keys.get(id); + } + + private static ManagedSecretKey newKey() { + try { + KeyGenerator generator = KeyGenerator.getInstance("HmacSHA256"); + return new ManagedSecretKey(UUID.randomUUID(), Instant.now(), + Instant.now().plus(Duration.ofHours(4)), generator.generateKey()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("HmacSHA256 is unavailable", e); + } + } + } +} diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java new file mode 100644 index 000000000000..06dcbf449494 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java @@ -0,0 +1,240 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.awssdk; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import com.nimbusds.jwt.SignedJWT; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * End-to-end coverage for the WebIdentity STS bootstrap path using a generated + * JWT and local JWKS file. + */ +class TestAssumeRoleWithWebIdentityEndToEnd + extends AbstractAssumeRoleWithWebIdentityS3Test { + + private static final String ISSUER = "http://keycloak.test/realms/ozone"; + + private final TestJwtIssuer jwtIssuer = new TestJwtIssuer(); + + @Override + protected String issuerUri() { + return ISSUER; + } + + @Override + protected String jwksUri() { + return jwtIssuer.jwksUri(); + } + + @Test + void webIdentityTemporaryCredentialsAuthorizeS3Operations() + throws Exception { + StsCredentials credentials = assumeRoleWithWebIdentity( + jwtIssuer.token("tomato-user", "subject-tomato", + Collections.singletonList("ozone-tomato"), AUDIENCE, + Instant.now().plus(Duration.ofHours(1))), "subject-tomato"); + + AssumeRoleWithWebIdentityRequest request = lastAssumeRoleRequest(); + assertThat(request).isNotNull(); + assertEquals("tomato-user", request.getUser()); + assertThat(request.getGroups()).containsExactly("ozone-tomato"); + assertEquals(ROLE_ARN, request.getRoleArn()); + assertEquals(SESSION_NAME, request.getRoleSessionName()); + assertEquals(ISSUER, request.getIssuer()); + assertEquals("subject-tomato", request.getSubject()); + assertEquals(AUDIENCE, request.getAudience()); + assertEquals(PROVIDER_ID, request.getProviderId()); + + assertTemporaryCredentialsAuthorizeS3Operations(credentials); + assertThat(accessChecksWithSessionPolicy()).isPositive(); + } + + @Test + void invalidWebIdentityTokensFailBeforeCredentialsAreIssued() + throws Exception { + HttpResponse missingToken = postSts(null); + assertThat(missingToken.getCode()).isGreaterThanOrEqualTo(400); + + assertStsFails(jwtIssuer.token("tomato-user", "subject-tomato", + Collections.singletonList("ozone-tomato"), "wrong-audience", + Instant.now().plus(Duration.ofHours(1))), 403); + + assertStsFails(jwtIssuer.token("tomato-user", "subject-tomato", + Collections.singletonList("ozone-tomato"), AUDIENCE, + Instant.now().minus(Duration.ofMinutes(5))), 403); + + assertStsFails(jwtIssuer.algNoneToken(), 403); + + assertStsFails(jwtIssuer.tamperedGroupsToken(), 403); + + assertStsFails(jwtIssuer.token("denied-user", "subject-denied", + Collections.singletonList("ozone-denied"), AUDIENCE, + Instant.now().plus(Duration.ofHours(1))), 403); + } + + @Test + void normalS3RequestWithoutSigV4IsStillDenied() throws Exception { + assertNormalS3RequestWithoutSigV4IsDenied(); + } + + @Test + void temporaryCredentialsRequireCorrectSecretAndSessionToken() + throws Exception { + StsCredentials credentials = assumeRoleWithWebIdentity( + jwtIssuer.token("tomato-user", "subject-tomato", + Collections.singletonList("ozone-tomato"), AUDIENCE, + Instant.now().plus(Duration.ofHours(1))), "subject-tomato"); + + try (S3Client missingSessionToken = + s3Client(credentials.getAccessKeyId(), + credentials.getSecretAccessKey(), + null)) { + assertThrows(S3Exception.class, () -> + missingSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); + } + + try (S3Client wrongSessionToken = + s3Client(credentials.getAccessKeyId(), + credentials.getSecretAccessKey(), + credentials.getSessionToken() + "wrong")) { + assertThrows(S3Exception.class, () -> + wrongSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); + } + + try (S3Client wrongSecret = + s3Client(credentials.getAccessKeyId(), + credentials.getSecretAccessKey() + "wrong", + credentials.getSessionToken())) { + assertThrows(S3Exception.class, () -> + wrongSecret.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); + } + } + + private static final class TestJwtIssuer { + private final RSAKey key; + private final Path jwksFile; + + private TestJwtIssuer() { + try { + this.key = rsaKey("kid-primary"); + this.jwksFile = Files.createTempFile( + "ozone-webidentity-jwks", ".json"); + JWKSet jwkSet = new JWKSet(Collections.singletonList( + key.toPublicJWK())); + Files.write(jwksFile, jwkSet.toString().getBytes(UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("Failed to create test JWKS", e); + } + } + + private String jwksUri() { + return jwksFile.toUri().toString(); + } + + private String token(String username, String subject, + Iterable groups, String audience, Instant expiresAt) + throws Exception { + return signedToken(key, key.getKeyID(), username, subject, groups, + audience, expiresAt); + } + + private String tamperedGroupsToken() throws Exception { + String token = token("tomato-user", "subject-tomato", + Collections.singletonList("ozone-tomato"), AUDIENCE, + Instant.now().plus(Duration.ofHours(1))); + String[] parts = token.split("\\."); + String payload = new String(java.util.Base64.getUrlDecoder() + .decode(parts[1]), UTF_8); + parts[1] = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.replace("ozone-tomato", "ozone-admins") + .getBytes(UTF_8)); + return String.join(".", parts); + } + + private String algNoneToken() { + return new PlainJWT(claims("tomato-user", "subject-tomato", + Collections.singletonList("ozone-tomato"), AUDIENCE, + Instant.now().plus(Duration.ofHours(1))).build()).serialize(); + } + + private static String signedToken(RSAKey signerKey, String keyId, + String username, String subject, Iterable groups, + String audience, Instant expiresAt) throws Exception { + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(keyId).build(), + claims(username, subject, groups, audience, expiresAt).build()); + jwt.sign(new RSASSASigner(signerKey.toRSAPrivateKey())); + return jwt.serialize(); + } + + private static JWTClaimsSet.Builder claims(String username, String subject, + Iterable groups, String audience, Instant expiresAt) { + Map realmAccess = new LinkedHashMap<>(); + realmAccess.put("roles", Arrays.asList("writer", "read")); + Instant now = Instant.now(); + return new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(subject) + .audience(Collections.singletonList(audience)) + .issueTime(Date.from(now.minusSeconds(30))) + .expirationTime(Date.from(expiresAt)) + .claim("preferred_username", username) + .claim("groups", groups) + .claim("realm_access", realmAccess); + } + + private static RSAKey rsaKey(String keyId) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .keyID(keyId) + .algorithm(JWSAlgorithm.RS256) + .build(); + } + } +} diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java new file mode 100644 index 000000000000..e970a3ce8124 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3.awssdk; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.util.Base64; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.io.IOUtils; +import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +/** + * Integration coverage for a real Keycloak-issued JWT with Ozone STS + * AssumeRoleWithWebIdentity. + */ +class TestAssumeRoleWithWebIdentityKeycloakIT + extends AbstractAssumeRoleWithWebIdentityS3Test { + + private static final DockerImageName KEYCLOAK_IMAGE = + DockerImageName.parse("quay.io/keycloak/keycloak:26.0.7"); + private static final Pattern ACCESS_TOKEN_PATTERN = + Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\""); + private static GenericContainer keycloak; + + @Override + protected String issuerUri() { + return keycloakIssuerUri(); + } + + @Override + protected String jwksUri() { + return keycloakIssuerUri() + "/protocol/openid-connect/certs"; + } + + @AfterAll + static synchronized void stopKeycloak() { + GenericContainer container = keycloak; + keycloak = null; + if (container != null) { + container.stop(); + } + } + + @Test + void keycloakTokenCanAssumeRoleAndUseTemporaryS3Credentials() + throws Exception { + String token = fetchToken("ozone-sts", "tomato-user", "tomato-password"); + + StsCredentials credentials = + assumeRoleWithWebIdentity(token, keycloakSubject(token)); + + AssumeRoleWithWebIdentityRequest request = lastAssumeRoleRequest(); + assertThat(request).isNotNull(); + assertThat(request.getUser()).isEqualTo("tomato-user"); + assertThat(request.getGroups()).contains("ozone-tomato"); + assertThat(request.getIssuer()).isEqualTo(keycloakIssuerUri()); + assertThat(request.getAudience()).isEqualTo(AUDIENCE); + assertThat(request.getRoleArn()).isEqualTo(ROLE_ARN); + assertThat(request.getRoleSessionName()).isEqualTo(SESSION_NAME); + assertThat(request.getProviderId()).isEqualTo(PROVIDER_ID); + + assertTemporaryCredentialsAuthorizeS3Operations(credentials); + assertThat(accessChecksWithSessionPolicy()).isPositive(); + } + + @Test + void deniedKeycloakUserCannotAssumeRole() throws Exception { + String token = fetchToken("ozone-sts", "denied-user", "denied-password"); + + assertStsFails(token, 403); + } + + @Test + void wrongAudienceKeycloakTokenFails() throws Exception { + String token = fetchToken("wrong-audience-client", "tomato-user", + "tomato-password"); + + assertStsFails(token, 403); + } + + @Test + void tamperedKeycloakTokenFails() throws Exception { + String token = fetchToken("ozone-sts", "tomato-user", "tomato-password"); + + assertStsFails(rewriteJwtPayload(token, "ozone-tomato", "ozone-admins"), + 403); + } + + @Test + void missingWebIdentityTokenFails() throws Exception { + HttpResponse response = postSts(null); + + assertThat(response.getCode()).isGreaterThanOrEqualTo(400); + } + + private static synchronized String keycloakIssuerUri() { + GenericContainer container = keycloak(); + return "http://" + container.getHost() + ":" + + container.getMappedPort(8080) + "/realms/ozone-test"; + } + + private static synchronized GenericContainer keycloak() { + if (keycloak == null) { + keycloak = new GenericContainer<>(KEYCLOAK_IMAGE) + .withExposedPorts(8080) + .withEnv("KEYCLOAK_ADMIN", "admin") + .withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin") + .withEnv("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") + .withEnv("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") + .withCopyFileToContainer( + MountableFile.forClasspathResource( + "keycloak/ozone-test-realm.json"), + "/opt/keycloak/data/import/ozone-test-realm.json") + .withCommand("start-dev", "--import-realm", + "--hostname-strict=false") + .waitingFor(Wait.forHttp( + "/realms/ozone-test/.well-known/openid-configuration") + .forStatusCode(200)); + keycloak.start(); + } + return keycloak; + } + + private static String fetchToken(String clientId, String username, + String password) throws IOException { + URL url = URI.create(keycloakIssuerUri() + + "/protocol/openid-connect/token").toURL(); + String body = "grant_type=password" + + "&client_id=" + encode(clientId) + + "&username=" + encode(username) + + "&password=" + encode(password); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", + "application/x-www-form-urlencoded"); + try (OutputStream output = connection.getOutputStream()) { + output.write(body.getBytes(UTF_8)); + } + int code = connection.getResponseCode(); + String response = code >= 400 + ? IOUtils.toString(connection.getErrorStream(), UTF_8) + : IOUtils.toString(connection.getInputStream(), UTF_8); + assertThat(code).as("Keycloak token endpoint response code") + .isEqualTo(200); + Matcher matcher = ACCESS_TOKEN_PATTERN.matcher(response); + assertThat(matcher.find()).as("Keycloak access token is present") + .isTrue(); + return matcher.group(1); + } + + private static String keycloakSubject(String jwt) { + return jsonValue(jwtPayload(jwt), "sub"); + } + + private static String rewriteJwtPayload(String jwt, String from, String to) { + String[] parts = jwt.split("\\."); + assertThat(parts).hasSize(3); + String payload = jwtPayload(jwt); + assertThat(payload).contains(from); + parts[1] = Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.replace(from, to).getBytes(UTF_8)); + return String.join(".", parts); + } + + private static String jwtPayload(String jwt) { + String[] parts = jwt.split("\\."); + assertThat(parts).hasSize(3); + return new String(Base64.getUrlDecoder().decode(parts[1]), UTF_8); + } + + private static String jsonValue(String json, String field) { + Pattern pattern = Pattern.compile("\"" + Pattern.quote(field) + + "\"\\s*:\\s*\"([^\"]+)\""); + Matcher matcher = pattern.matcher(json); + assertThat(matcher.find()).as("JWT claim %s is present", field).isTrue(); + return matcher.group(1); + } + + private static String encode(String value) throws IOException { + return URLEncoder.encode(value, UTF_8.name()); + } +} diff --git a/hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json b/hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json new file mode 100644 index 000000000000..5c8f4204187f --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json @@ -0,0 +1,137 @@ +{ + "realm": "ozone-test", + "enabled": true, + "accessTokenLifespan": 3600, + "groups": [ + { + "name": "ozone-tomato" + }, + { + "name": "ozone-denied" + } + ], + "clients": [ + { + "clientId": "ozone-sts", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "protocolMappers": [ + { + "name": "audience-ozone", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "ozone", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "claim.name": "groups", + "full.path": "false", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "clientId": "wrong-audience-client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "protocolMappers": [ + { + "name": "audience-not-ozone", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "not-ozone", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "claim.name": "groups", + "full.path": "false", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "tomato-user", + "enabled": true, + "email": "tomato-user@example.com", + "emailVerified": true, + "firstName": "Tomato", + "lastName": "User", + "requiredActions": [], + "groups": [ + "/ozone-tomato" + ], + "credentials": [ + { + "type": "password", + "value": "tomato-password", + "temporary": false + } + ] + }, + { + "username": "denied-user", + "enabled": true, + "email": "denied-user@example.com", + "emailVerified": true, + "firstName": "Denied", + "lastName": "User", + "requiredActions": [], + "groups": [ + "/ozone-denied" + ], + "credentials": [ + { + "type": "password", + "value": "denied-password", + "temporary": false + } + ] + } + ] +} diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 9bb0d801ee7b..9d4aa22f9b67 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -159,6 +159,7 @@ enum Type { AssumeRole = 143; RevokeSTSToken = 144; DeleteRevokedSTSTokens = 145; + AssumeRoleWithWebIdentity = 146; } enum SafeMode { @@ -313,6 +314,8 @@ message OMRequest { optional RevokeSTSTokenRequest revokeSTSTokenRequest = 145; optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 146; optional UpdateAssumeRoleRequest updateAssumeRoleRequest = 147; + optional AssumeRoleWithWebIdentityRequest assumeRoleWithWebIdentityRequest = 148; + optional UpdateAssumeRoleWithWebIdentityRequest updateAssumeRoleWithWebIdentityRequest = 149; } message OMResponse { @@ -449,6 +452,7 @@ message OMResponse { optional AssumeRoleResponse assumeRoleResponse = 143; optional RevokeSTSTokenResponse revokeSTSTokenResponse = 144; optional DeleteRevokedSTSTokensResponse deleteRevokedSTSTokensResponse = 145; + optional AssumeRoleWithWebIdentityResponse assumeRoleWithWebIdentityResponse = 146; } enum Status { @@ -1537,6 +1541,20 @@ message OMTokenProto { optional string originalAccessKeyId = 18; optional string secretAccessKey = 19; optional string sessionPolicy = 20; + enum STSAuthType { + ASSUME_ROLE = 1; + WEB_IDENTITY = 2; + }; + optional STSAuthType stsAuthType = 21 [default = ASSUME_ROLE]; + optional string effectiveUser = 22; + optional string issuer = 23; + optional string subject = 24; + optional string audience = 25; + repeated string groups = 26; + repeated string roles = 27; + optional string roleSessionName = 28; + optional string providerId = 29; + optional string tokenFingerprint = 30; } message SecretKeyProto { @@ -2434,6 +2452,53 @@ message UpdateAssumeRoleRequest { required string roleId = 8; } +message AssumeRoleWithWebIdentityRequest { + optional string roleArn = 1; + optional string roleSessionName = 2; + optional int32 durationSeconds = 3 [default = 3600]; + optional string webIdentityToken = 4; + optional string providerId = 5; + optional string requestId = 6; +} + +message AssumeRoleWithWebIdentityResponse { + optional string accessKeyId = 1; + optional string secretAccessKey = 2; + optional string sessionToken = 3; + optional uint64 expirationEpochSeconds = 4; + optional string assumedRoleId = 5; + optional string subjectFromWebIdentityToken = 6; + optional string audience = 7; + optional string provider = 8; +} + +/** + This request is created by the OM leader after validating the incoming + WebIdentityToken. It is the only WebIdentity request form that may be + replicated through Ratis; it must never contain the raw JWT. +*/ +message UpdateAssumeRoleWithWebIdentityRequest { + optional string roleArn = 1; + optional string roleSessionName = 2; + optional int32 durationSeconds = 3; + optional string providerId = 4; + optional string requestId = 5; + optional string tempAccessKeyId = 6; + optional string secretAccessKey = 7; + optional string roleId = 8; + optional string effectiveUser = 9; + optional string subject = 10; + optional string issuer = 11; + optional string audience = 12; + repeated string groups = 13; + repeated string roles = 14; + optional uint64 webIdentityTokenExpiresAt = 15; + optional uint64 authenticatedAt = 16; + optional string tokenFingerprint = 17; + optional string sessionPolicy = 18; + optional uint64 credentialExpirationEpochSeconds = 19; +} + message RevokeSTSTokenRequest { required string sessionToken = 1; } diff --git a/hadoop-ozone/ozone-manager/pom.xml b/hadoop-ozone/ozone-manager/pom.xml index 2dd6dfdc10db..e4de0086620f 100644 --- a/hadoop-ozone/ozone-manager/pom.xml +++ b/hadoop-ozone/ozone-manager/pom.xml @@ -46,6 +46,10 @@ com.google.protobuf protobuf-java + + com.nimbusds + nimbus-jose-jwt + commons-codec commons-codec diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java index f07c4494619a..ccb318fb6b58 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java @@ -85,6 +85,7 @@ public enum OMAction implements AuditAction { // STS Actions S3_ASSUME_ROLE, + S3_ASSUME_ROLE_WITH_WEB_IDENTITY, REVOKE_STS_TOKEN, CREATE_TENANT, diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java index d1f27c6132ce..d2334199d7ab 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java @@ -899,6 +899,15 @@ public static String getS3AuthEffectiveAccessId() throws OMException { throw new OMException( "OMClientRequest has session token but no token identifier in OzoneManager", INVALID_REQUEST); } + if (stsTokenIdentifier.isWebIdentity()) { + final String effectiveUser = stsTokenIdentifier.getEffectiveUser(); + if (effectiveUser != null && !effectiveUser.isEmpty()) { + return effectiveUser; + } + throw new OMException( + "Invalid STS Token format - could not find effectiveUser", + INVALID_REQUEST); + } final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId(); if (originalAccessKeyId != null && !originalAccessKeyId.isEmpty()) { return originalAccessKeyId; diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java index 2c17d2335475..a381a5b79fe6 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java @@ -96,6 +96,8 @@ private static void init() { CMD_AUDIT_ACTION_MAP.put(Type.PutObjectTagging, OMAction.PUT_OBJECT_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.DeleteObjectTagging, OMAction.DELETE_OBJECT_TAGGING); CMD_AUDIT_ACTION_MAP.put(Type.AssumeRole, OMAction.S3_ASSUME_ROLE); + CMD_AUDIT_ACTION_MAP.put(Type.AssumeRoleWithWebIdentity, + OMAction.S3_ASSUME_ROLE_WITH_WEB_IDENTITY); CMD_AUDIT_ACTION_MAP.put(Type.RevokeSTSToken, OMAction.REVOKE_STS_TOKEN); } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java index d405654585c8..685dc51bc032 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java @@ -68,6 +68,7 @@ import org.apache.hadoop.ozone.om.request.s3.multipart.S3ExpiredMultipartUploadsAbortRequest; import org.apache.hadoop.ozone.om.request.s3.security.OMSetSecretRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest; +import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleWithWebIdentityRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3DeleteRevokedSTSTokensRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3GetSecretRequest; import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSTSTokenRequest; @@ -203,6 +204,9 @@ public static OMClientRequest createClientRequest(OMRequest omRequest, case AssumeRole: ozoneManager.checkS3STSEnabled(); return new S3AssumeRoleRequest(omRequest, CLOCK); + case AssumeRoleWithWebIdentity: + ozoneManager.checkS3STSEnabled(); + return new S3AssumeRoleWithWebIdentityRequest(omRequest, CLOCK); case RevokeSTSToken: return new S3RevokeSTSTokenRequest(omRequest); case DeleteRevokedSTSTokens: diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java index a87f2de54dde..ecde295dda28 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java @@ -101,12 +101,9 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { // HA mode therefore will have identical audit logs with the same tempAccessKeyId. // Generate temporary AWS credentials using cryptographically strong SecureRandom - final String tempAccessKeyId = STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars( - CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, STS_ACCESS_KEY_ID_LENGTH); - final String secretAccessKey = generateSecureRandomStringUsingChars( - CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH); - final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( - CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, STS_ROLE_ID_LENGTH); + final String tempAccessKeyId = generateTempAccessKeyId(); + final String secretAccessKey = generateSecretAccessKey(); + final String roleId = generateRoleId(); // Build UpdateAssumeRoleRequest with leader-generated credentials final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder = @@ -302,4 +299,22 @@ static String generateSecureRandomStringUsingChars(String chars, int charsLength } return sb.toString(); } + + static String generateTempAccessKeyId() { + return STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars( + CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, + STS_ACCESS_KEY_ID_LENGTH); + } + + static String generateSecretAccessKey() { + return generateSecureRandomStringUsingChars( + CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, + STS_SECRET_ACCESS_KEY_LENGTH); + } + + static String generateRoleId() { + return ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars( + CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, + STS_ROLE_ID_LENGTH); + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java new file mode 100644 index 000000000000..8e6afa68f81d --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,417 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_DENIED; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_JWKS_URI; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import java.io.IOException; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import org.apache.hadoop.ipc_.ProtobufRpcEngine; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.audit.OMAction; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; +import org.apache.hadoop.ozone.om.helpers.S3STSUtils; +import org.apache.hadoop.ozone.om.request.OMClientRequest; +import org.apache.hadoop.ozone.om.request.util.OmResponseUtil; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleWithWebIdentityResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleWithWebIdentityRequest; +import org.apache.hadoop.ozone.security.oidc.AuthCredentials; +import org.apache.hadoop.ozone.security.oidc.CachingJwksProvider; +import org.apache.hadoop.ozone.security.oidc.OidcAuthenticationException; +import org.apache.hadoop.ozone.security.oidc.OidcConfig; +import org.apache.hadoop.ozone.security.oidc.OidcJwtIdentityProvider; +import org.apache.hadoop.ozone.security.oidc.OzoneIdentity; +import org.apache.hadoop.ozone.security.oidc.OzoneIdentityProvider; +import org.apache.hadoop.ozone.security.oidc.UrlJwksFetcher; + +/** + * Handles STS AssumeRoleWithWebIdentity requests. + * + * Raw WebIdentityToken is accepted only in the external OM RPC request. The + * leader validates it in {@link #preExecute(OzoneManager)} and returns a + * sanitized OMRequest for Ratis replication. + */ +public class S3AssumeRoleWithWebIdentityRequest extends OMClientRequest { + + private final Clock clock; + private final OzoneIdentityProvider testIdentityProvider; + + public S3AssumeRoleWithWebIdentityRequest(OMRequest omRequest, Clock clock) { + this(omRequest, clock, null); + } + + @VisibleForTesting + S3AssumeRoleWithWebIdentityRequest(OMRequest omRequest, Clock clock, + OzoneIdentityProvider identityProvider) { + super(omRequest); + this.clock = clock; + this.testIdentityProvider = identityProvider; + } + + @Override + public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { + final AssumeRoleWithWebIdentityRequest request = + getOmRequest().getAssumeRoleWithWebIdentityRequest(); + final OidcConfig oidcConfig; + try { + oidcConfig = OidcConfig.from(ozoneManager.getConfiguration()); + } catch (IllegalArgumentException e) { + throw new OMException("Invalid STS WebIdentity OIDC configuration", + e, INVALID_REQUEST); + } + if (!oidcConfig.isEnabled()) { + throw new OMException("STS WebIdentity is not enabled. Please set " + + "ozone.sts.web.identity.enabled to true and restart all OMs.", + FEATURE_NOT_ENABLED); + } + + validateExternalRequest(request); + final int requestedDuration = + S3STSUtils.validateDuration(request.getDurationSeconds()); + S3STSUtils.validateRoleSessionName(request.getRoleSessionName()); + AwsRoleArnValidator.validateAndExtractRoleNameFromArn( + request.getRoleArn()); + + final OzoneIdentity identity = authenticate(oidcConfig, + request.getWebIdentityToken()); + final Instant issuedAt = clock.instant(); + final int effectiveDuration = + clampDurationToTokenLifetime(requestedDuration, identity, issuedAt); + final long credentialExpirationEpochSeconds = + issuedAt.plusSeconds(effectiveDuration).getEpochSecond(); + final String tokenFingerprint = + sha256Hex(request.getWebIdentityToken()); + + final String sessionPolicy = generateSessionPolicy(ozoneManager, + identity, request, oidcConfig.getAudience()); + if (Strings.isNullOrEmpty(sessionPolicy)) { + throw new OMException("AssumeRoleWithWebIdentity was denied because " + + "the authorizer returned no session policy", ACCESS_DENIED); + } + + final UpdateAssumeRoleWithWebIdentityRequest updateRequest = + UpdateAssumeRoleWithWebIdentityRequest.newBuilder() + .setRoleArn(request.getRoleArn()) + .setRoleSessionName(request.getRoleSessionName()) + .setDurationSeconds(effectiveDuration) + .setProviderId(request.hasProviderId() + ? request.getProviderId() : "") + .setRequestId(request.getRequestId()) + .setTempAccessKeyId(S3AssumeRoleRequest.generateTempAccessKeyId()) + .setSecretAccessKey(S3AssumeRoleRequest.generateSecretAccessKey()) + .setRoleId(S3AssumeRoleRequest.generateRoleId()) + .setEffectiveUser(identity.getUsername()) + .setSubject(identity.getSubject()) + .setIssuer(identity.getIssuer()) + .setAudience(oidcConfig.getAudience()) + .addAllGroups(identity.getGroups()) + .addAllRoles(identity.getRoles()) + .setWebIdentityTokenExpiresAt( + identity.getExpiresAt().toEpochMilli()) + .setAuthenticatedAt(identity.getAuthenticatedAt().toEpochMilli()) + .setTokenFingerprint(tokenFingerprint) + .setSessionPolicy(sessionPolicy) + .setCredentialExpirationEpochSeconds( + credentialExpirationEpochSeconds) + .build(); + + final OMRequest.Builder omRequest = OMRequest.newBuilder() + .setUserInfo(getUserInfo().toBuilder() + .setUserName(identity.getUsername())) + .setCmdType(getOmRequest().getCmdType()) + .setClientId(getOmRequest().getClientId()) + .setUpdateAssumeRoleWithWebIdentityRequest(updateRequest); + + if (getOmRequest().hasTraceID()) { + omRequest.setTraceID(getOmRequest().getTraceID()); + } + + return omRequest.build(); + } + + @Override + public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, + ExecutionContext context) { + final OMRequest omRequest = getOmRequest(); + final UpdateAssumeRoleWithWebIdentityRequest updateRequest = + omRequest.getUpdateAssumeRoleWithWebIdentityRequest(); + + final Map auditMap = new HashMap<>(); + final AuditLogger auditLogger = ozoneManager.getAuditLogger(); + addAuditParams(auditMap, updateRequest); + + Exception exception = null; + OMClientResponse omClientResponse; + try { + validateUpdateRequest(updateRequest); + S3STSUtils.validateDuration(updateRequest.getDurationSeconds()); + S3STSUtils.validateRoleSessionName(updateRequest.getRoleSessionName()); + AwsRoleArnValidator.validateAndExtractRoleNameFromArn( + updateRequest.getRoleArn()); + + final String sessionToken = ozoneManager.getSTSTokenSecretManager() + .createWebIdentitySTSTokenString( + updateRequest.getTempAccessKeyId(), + updateRequest.getRoleArn(), + Instant.ofEpochSecond( + updateRequest.getCredentialExpirationEpochSeconds()), + updateRequest.getSecretAccessKey(), + updateRequest.getSessionPolicy(), + updateRequest.getEffectiveUser(), + updateRequest.getIssuer(), + updateRequest.getSubject(), + updateRequest.getAudience(), + new LinkedHashSet<>(updateRequest.getGroupsList()), + new LinkedHashSet<>(updateRequest.getRolesList()), + updateRequest.getRoleSessionName(), + updateRequest.getProviderId(), + updateRequest.getTokenFingerprint()); + + final String assumedRoleId = updateRequest.getRoleId() + + ":" + updateRequest.getRoleSessionName(); + final long expirationEpochSeconds = + updateRequest.getCredentialExpirationEpochSeconds(); + + final AssumeRoleWithWebIdentityResponse.Builder responseBuilder = + AssumeRoleWithWebIdentityResponse.newBuilder() + .setAccessKeyId(updateRequest.getTempAccessKeyId()) + .setSecretAccessKey(updateRequest.getSecretAccessKey()) + .setSessionToken(sessionToken) + .setExpirationEpochSeconds(expirationEpochSeconds) + .setAssumedRoleId(assumedRoleId) + .setSubjectFromWebIdentityToken(updateRequest.getSubject()) + .setAudience(updateRequest.getAudience()); + if (!Strings.isNullOrEmpty(updateRequest.getProviderId())) { + responseBuilder.setProvider(updateRequest.getProviderId()); + } + + omClientResponse = new S3AssumeRoleWithWebIdentityResponse( + OmResponseUtil.getOMResponseBuilder(omRequest) + .setAssumeRoleWithWebIdentityResponse(responseBuilder.build()) + .build()); + } catch (OMException e) { + exception = e; + omClientResponse = new S3AssumeRoleWithWebIdentityResponse( + createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), + e)); + } catch (IOException e) { + final OMException omException = new OMException( + "Failed to generate STS WebIdentity token", e, INTERNAL_ERROR); + exception = omException; + omClientResponse = new S3AssumeRoleWithWebIdentityResponse( + createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), + omException)); + } + + markForAudit(auditLogger, buildAuditMessage( + OMAction.S3_ASSUME_ROLE_WITH_WEB_IDENTITY, auditMap, exception, + omRequest.getUserInfo())); + return omClientResponse; + } + + private OzoneIdentity authenticate(OidcConfig oidcConfig, + String webIdentityToken) throws OMException { + try { + return identityProvider(oidcConfig) + .authenticate(AuthCredentials.bearerToken(webIdentityToken)); + } catch (OidcAuthenticationException | IllegalArgumentException e) { + throw new OMException("Invalid WebIdentityToken", e, INVALID_TOKEN); + } + } + + private OzoneIdentityProvider identityProvider(OidcConfig oidcConfig) + throws OMException { + if (testIdentityProvider != null) { + return testIdentityProvider; + } + if (Strings.isNullOrEmpty(oidcConfig.getJwksUri())) { + throw new OMException(OZONE_STS_WEB_IDENTITY_JWKS_URI + + " must be configured when STS WebIdentity is enabled", + INVALID_REQUEST); + } + try { + URL jwksUrl = new URL(oidcConfig.getJwksUri()); + return new OidcJwtIdentityProvider(oidcConfig, + new CachingJwksProvider(new UrlJwksFetcher(jwksUrl, + oidcConfig.getJwksConnectTimeout(), + oidcConfig.getJwksReadTimeout(), + oidcConfig.getJwksSizeLimit()), + oidcConfig.getJwksRefreshInterval())); + } catch (MalformedURLException e) { + throw new OMException(OZONE_STS_WEB_IDENTITY_JWKS_URI + + " is not a valid URL", e, INVALID_REQUEST); + } + } + + private int clampDurationToTokenLifetime(int requestedDuration, + OzoneIdentity identity, Instant now) throws OMException { + long jwtRemainingSeconds = + Duration.between(now, identity.getExpiresAt()).getSeconds(); + if (jwtRemainingSeconds < S3STSUtils.MIN_DURATION_SECONDS) { + throw new OMException("WebIdentityToken expires before the minimum STS " + + "credential duration can be issued", TOKEN_EXPIRED); + } + return (int) Math.min(requestedDuration, Math.min(jwtRemainingSeconds, + S3STSUtils.MAX_DURATION_SECONDS)); + } + + private String generateSessionPolicy(OzoneManager ozoneManager, + OzoneIdentity identity, AssumeRoleWithWebIdentityRequest request, + String audience) throws OMException { + InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp(); + if (remoteIp == null) { + remoteIp = ozoneManager.getOmRpcServerAddr().getAddress(); + } + final String hostName = remoteIp != null ? remoteIp.getHostName() + : ozoneManager.getOmRpcServerAddr().getHostName(); + + return ozoneManager.getAccessAuthorizer() + .generateAssumeRoleWithWebIdentitySessionPolicy( + org.apache.hadoop.ozone.security.acl + .AssumeRoleWithWebIdentityRequest.newBuilder() + .setHost(hostName) + .setIp(remoteIp) + .setUser(identity.getUsername()) + .setGroups(identity.getGroups()) + .setRoles(identity.getRoles()) + .setRoleArn(request.getRoleArn()) + .setRoleSessionName(request.getRoleSessionName()) + .setIssuer(identity.getIssuer()) + .setSubject(identity.getSubject()) + .setAudience(audience) + .setProviderId(request.hasProviderId() + ? request.getProviderId() : null) + .build()); + } + + private static void validateExternalRequest( + AssumeRoleWithWebIdentityRequest request) throws OMException { + requirePresentNonBlank(request.hasRoleArn(), request.getRoleArn(), + "roleArn"); + requirePresentNonBlank(request.hasRoleSessionName(), + request.getRoleSessionName(), "roleSessionName"); + requirePresentNonBlank(request.hasWebIdentityToken(), + request.getWebIdentityToken(), "webIdentityToken"); + requirePresentNonBlank(request.hasRequestId(), request.getRequestId(), + "requestId"); + } + + private static void validateUpdateRequest( + UpdateAssumeRoleWithWebIdentityRequest request) throws OMException { + requirePresentNonBlank(request.hasRoleArn(), request.getRoleArn(), + "roleArn"); + requirePresentNonBlank(request.hasRoleSessionName(), + request.getRoleSessionName(), "roleSessionName"); + requirePresent(request.hasDurationSeconds(), "durationSeconds"); + requirePresentNonBlank(request.hasRequestId(), request.getRequestId(), + "requestId"); + requirePresentNonBlank(request.hasTempAccessKeyId(), + request.getTempAccessKeyId(), "tempAccessKeyId"); + requirePresentNonBlank(request.hasSecretAccessKey(), + request.getSecretAccessKey(), "secretAccessKey"); + requirePresentNonBlank(request.hasRoleId(), request.getRoleId(), + "roleId"); + requirePresentNonBlank(request.hasEffectiveUser(), + request.getEffectiveUser(), "effectiveUser"); + requirePresentNonBlank(request.hasSubject(), request.getSubject(), + "subject"); + requirePresentNonBlank(request.hasIssuer(), request.getIssuer(), + "issuer"); + requirePresentNonBlank(request.hasAudience(), request.getAudience(), + "audience"); + requirePresent(request.hasWebIdentityTokenExpiresAt(), + "webIdentityTokenExpiresAt"); + requirePresent(request.hasAuthenticatedAt(), "authenticatedAt"); + requirePresentNonBlank(request.hasTokenFingerprint(), + request.getTokenFingerprint(), "tokenFingerprint"); + requirePresentNonBlank(request.hasSessionPolicy(), + request.getSessionPolicy(), "sessionPolicy"); + requirePresent(request.hasCredentialExpirationEpochSeconds(), + "credentialExpirationEpochSeconds"); + } + + private static void requirePresent(boolean present, String field) + throws OMException { + if (!present) { + throw new OMException("Missing WebIdentity " + field, INVALID_REQUEST); + } + } + + private static void requirePresentNonBlank(boolean present, String value, + String field) throws OMException { + requirePresent(present, field); + if (value.trim().isEmpty()) { + throw new OMException("Missing WebIdentity " + field, INVALID_REQUEST); + } + } + + private static void addAuditParams(Map auditMap, + UpdateAssumeRoleWithWebIdentityRequest request) { + auditMap.put("action", "AssumeRoleWithWebIdentity"); + auditMap.put("roleArn", request.getRoleArn()); + auditMap.put("roleSessionName", request.getRoleSessionName()); + auditMap.put("duration", String.valueOf(request.getDurationSeconds())); + auditMap.put("providerId", request.getProviderId()); + auditMap.put("effectiveUser", request.getEffectiveUser()); + auditMap.put("issuer", request.getIssuer()); + auditMap.put("subject", request.getSubject()); + auditMap.put("audience", request.getAudience()); + auditMap.put("tokenFingerprint", request.getTokenFingerprint()); + auditMap.put("requestId", request.getRequestId()); + } + + private static String sha256Hex(String value) throws OMException { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte b : digest) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException e) { + throw new OMException("SHA-256 digest is unavailable", e, + INTERNAL_ERROR); + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java index 94c2f8d50831..7f7d8c06b515 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java @@ -112,7 +112,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry( new CacheKey<>(sessionToken), CacheValue.get(context.getIndex(), CLOCK.millis())); - LOG.info("Marked STS session token '{}' as revoked.", sessionToken); + LOG.info("Marked STS session token as revoked."); return omClientResponse; } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java new file mode 100644 index 000000000000..92d6d510a0fe --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.response.s3.security; + +import java.io.IOException; +import org.apache.hadoop.hdds.utils.db.BatchOperation; +import org.apache.hadoop.ozone.om.OMMetadataManager; +import org.apache.hadoop.ozone.om.response.CleanupTableInfo; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; + +/** + * Response for AssumeRoleWithWebIdentity request. + * This is a stateless operation that doesn't modify any database tables. + */ +@CleanupTableInfo() +public class S3AssumeRoleWithWebIdentityResponse extends OMClientResponse { + + public S3AssumeRoleWithWebIdentityResponse(OMResponse omResponse) { + super(omResponse); + } + + @Override + public void addToDBBatch(OMMetadataManager omMetadataManager, + BatchOperation batchOperation) throws IOException { + // No database changes for assume role with web identity. + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java index 923bf9d9a78f..9846074bc53a 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java @@ -75,12 +75,14 @@ public static void validateS3Credential(OMRequest omRequest, // Ensure the token is not revoked if (isRevokedStsToken(token, ozoneManager)) { - LOG.info("Session token has been revoked: {}, {}", stsTokenIdentifier.getTempAccessKeyId(), token); + LOG.info("Session token has been revoked for tempAccessKeyId: {}", + stsTokenIdentifier.getTempAccessKeyId()); throw new OMException("STS token has been revoked", REVOKED_TOKEN); } // Ensure the principal that created the STS token (originalAccessKeyId) has not been revoked - if (isOriginalAccessKeyIdRevoked(stsTokenIdentifier, ozoneManager)) { + if (!stsTokenIdentifier.isWebIdentity() && + isOriginalAccessKeyIdRevoked(stsTokenIdentifier, ozoneManager)) { LOG.info("OriginalAccessKeyId for session token has been revoked: {}, {}", stsTokenIdentifier.getOriginalAccessKeyId(), stsTokenIdentifier.getTempAccessKeyId()); throw new OMException("STS token no longer valid: OriginalAccessKeyId principal revoked", REVOKED_TOKEN); @@ -151,8 +153,8 @@ private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIden s3Authentication.getStringToSign(), s3Authentication.getSignature(), secretAccessKey)) { return; } - throw new OMException( - "STS token validation failed for token: " + omRequest.getS3Authentication().getSessionToken(), INVALID_TOKEN); + throw new OMException("STS token signature validation failed", + INVALID_TOKEN); } /** diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java index c414708cebee..90f660724e15 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java @@ -154,6 +154,10 @@ private static Token decodeTokenFromString(String encodedTok final Token token = new Token<>(); try { token.decodeFromUrlString(encodedToken); + if (!encodedToken.equals(token.encodeToUrlString())) { + throw new SecretManager.InvalidToken( + "Failed to decode STS token string: non-canonical token encoding"); + } return token; } catch (IOException e) { throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e); @@ -172,12 +176,29 @@ static void ensureEssentialFieldsArePresentInToken(STSTokenIdentifier stsTokenId if (StringUtils.isEmpty(stsTokenIdentifier.getRoleArn())) { throw new SecretManager.InvalidToken("Invalid STS token - roleArn is null/empty"); } - if (StringUtils.isEmpty(stsTokenIdentifier.getOriginalAccessKeyId())) { - throw new SecretManager.InvalidToken("Invalid STS token - originalAccessKeyId is null/empty"); + if (stsTokenIdentifier.isWebIdentity()) { + if (StringUtils.isEmpty(stsTokenIdentifier.getEffectiveUser())) { + throw new SecretManager.InvalidToken( + "Invalid STS token - effectiveUser is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getIssuer())) { + throw new SecretManager.InvalidToken( + "Invalid STS token - issuer is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getSubject())) { + throw new SecretManager.InvalidToken( + "Invalid STS token - subject is null/empty"); + } + if (StringUtils.isEmpty(stsTokenIdentifier.getAudience())) { + throw new SecretManager.InvalidToken( + "Invalid STS token - audience is null/empty"); + } + } else if (StringUtils.isEmpty(stsTokenIdentifier.getOriginalAccessKeyId())) { + throw new SecretManager.InvalidToken( + "Invalid STS token - originalAccessKeyId is null/empty"); } if (StringUtils.isEmpty(stsTokenIdentifier.getSecretAccessKey())) { throw new SecretManager.InvalidToken("Invalid STS token - secretAccessKey is null/empty"); } } } - diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java index 1ba4b7186f2c..596e743616ac 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java @@ -25,7 +25,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.Objects; +import java.util.Set; import java.util.UUID; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; @@ -41,17 +44,35 @@ public class STSTokenIdentifier extends ShortLivedTokenIdentifier { public static final Text KIND_NAME = new Text("STSToken"); + // Service name for STS tokens + public static final String STS_SERVICE = "STS"; + // STS-specific fields + private AuthType authType = AuthType.ASSUME_ROLE; private String roleArn; private String originalAccessKeyId; private String secretAccessKey; private String sessionPolicy; + private String effectiveUser; + private String issuer; + private String subject; + private String audience; + private Set groups = Collections.emptySet(); + private Set roles = Collections.emptySet(); + private String roleSessionName; + private String providerId; + private String tokenFingerprint; // Encryption key derived from ManagedSecretKey for this token private transient byte[] encryptionKey; - // Service name for STS tokens - public static final String STS_SERVICE = "STS"; + /** + * Identifies the authentication path that produced an STS token. + */ + public enum AuthType { + ASSUME_ROLE, + WEB_IDENTITY + } /** * Create an empty STS token identifier. @@ -75,6 +96,7 @@ public STSTokenIdentifier() { public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, String roleArn, Instant expiry, String secretAccessKey, String sessionPolicy, byte[] encryptionKey) { super(tempAccessKeyId, expiry); + this.authType = AuthType.ASSUME_ROLE; this.originalAccessKeyId = originalAccessKeyId; this.roleArn = roleArn; this.secretAccessKey = secretAccessKey; @@ -82,6 +104,32 @@ public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, St this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null; } + /** + * Create a new WebIdentity-backed STS token identifier. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + public STSTokenIdentifier(String tempAccessKeyId, String roleArn, + Instant expiry, String secretAccessKey, String sessionPolicy, + String effectiveUser, String issuer, String subject, String audience, + Set groups, Set roles, String roleSessionName, + String providerId, String tokenFingerprint, byte[] encryptionKey) { + super(tempAccessKeyId, expiry); + this.authType = AuthType.WEB_IDENTITY; + this.roleArn = roleArn; + this.secretAccessKey = secretAccessKey; + this.sessionPolicy = sessionPolicy; + this.effectiveUser = effectiveUser; + this.issuer = issuer; + this.subject = subject; + this.audience = audience; + this.groups = immutableSet(groups); + this.roles = immutableSet(roles); + this.roleSessionName = roleSessionName; + this.providerId = providerId; + this.tokenFingerprint = tokenFingerprint; + this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null; + } + @Override public Text getKind() { return KIND_NAME; @@ -126,10 +174,25 @@ public OMTokenProto toProtoBuf() { .setMaxDate(getExpiry().toEpochMilli()) .setOwner(getOwnerId() != null ? getOwnerId() : "") .setAccessKeyId(getOwnerId() != null ? getOwnerId() : "") - .setOriginalAccessKeyId(originalAccessKeyId != null ? originalAccessKeyId : "") - .setRoleArn(roleArn != null ? roleArn : "") - .setSecretAccessKey(secretAccessKey != null ? encryptSensitiveField(secretAccessKey) : "") - .setSessionPolicy(sessionPolicy != null ? sessionPolicy : ""); + .setStsAuthType(toProtoAuthType(authType)); + + setIfNotEmpty(builder::setOriginalAccessKeyId, originalAccessKeyId); + setIfNotEmpty(builder::setRoleArn, roleArn); + if (secretAccessKey != null) { + builder.setSecretAccessKey(encryptSensitiveField(secretAccessKey)); + } + if (sessionPolicy != null) { + builder.setSessionPolicy(sessionPolicy); + } + setIfNotEmpty(builder::setEffectiveUser, effectiveUser); + setIfNotEmpty(builder::setIssuer, issuer); + setIfNotEmpty(builder::setSubject, subject); + setIfNotEmpty(builder::setAudience, audience); + builder.addAllGroups(groups); + builder.addAllRoles(roles); + setIfNotEmpty(builder::setRoleSessionName, roleSessionName); + setIfNotEmpty(builder::setProviderId, providerId); + setIfNotEmpty(builder::setTokenFingerprint, tokenFingerprint); return builder.build(); } @@ -145,12 +208,13 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { setOwnerId(token.getOwner()); setExpiry(Instant.ofEpochMilli(token.getMaxDate())); + this.authType = fromProtoAuthType(token.getStsAuthType()); if (token.hasOriginalAccessKeyId()) { - this.originalAccessKeyId = token.getOriginalAccessKeyId(); + this.originalAccessKeyId = emptyToNull(token.getOriginalAccessKeyId()); } if (token.hasRoleArn()) { - this.roleArn = token.getRoleArn(); + this.roleArn = emptyToNull(token.getRoleArn()); } if (token.hasSecretKeyId()) { try { @@ -168,6 +232,31 @@ public void fromProtoBuf(OMTokenProto token) throws IOException { if (token.hasSessionPolicy()) { this.sessionPolicy = token.getSessionPolicy(); + } else { + this.sessionPolicy = ""; + } + if (token.hasEffectiveUser()) { + this.effectiveUser = emptyToNull(token.getEffectiveUser()); + } + if (token.hasIssuer()) { + this.issuer = emptyToNull(token.getIssuer()); + } + if (token.hasSubject()) { + this.subject = emptyToNull(token.getSubject()); + } + if (token.hasAudience()) { + this.audience = emptyToNull(token.getAudience()); + } + this.groups = immutableSet(token.getGroupsList()); + this.roles = immutableSet(token.getRolesList()); + if (token.hasRoleSessionName()) { + this.roleSessionName = emptyToNull(token.getRoleSessionName()); + } + if (token.hasProviderId()) { + this.providerId = emptyToNull(token.getProviderId()); + } + if (token.hasTokenFingerprint()) { + this.tokenFingerprint = emptyToNull(token.getTokenFingerprint()); } } @@ -222,6 +311,14 @@ public String getRoleArn() { return roleArn; } + public AuthType getAuthType() { + return authType; + } + + public boolean isWebIdentity() { + return authType == AuthType.WEB_IDENTITY; + } + public String getSecretAccessKey() { return secretAccessKey; } @@ -230,6 +327,42 @@ public String getOriginalAccessKeyId() { return originalAccessKeyId; } + public String getEffectiveUser() { + return effectiveUser; + } + + public String getIssuer() { + return issuer; + } + + public String getSubject() { + return subject; + } + + public String getAudience() { + return audience; + } + + public Set getGroups() { + return groups; + } + + public Set getRoles() { + return roles; + } + + public String getRoleSessionName() { + return roleSessionName; + } + + public String getProviderId() { + return providerId; + } + + public String getTokenFingerprint() { + return tokenFingerprint; + } + /** * Get the temporary access key ID (same as owner). */ @@ -263,23 +396,79 @@ public boolean equals(Object o) { } final STSTokenIdentifier that = (STSTokenIdentifier) o; - return Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) && + return authType == that.authType && + Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) && Objects.equals(originalAccessKeyId, that.originalAccessKeyId) && - Objects.equals(sessionPolicy, that.sessionPolicy); + Objects.equals(sessionPolicy, that.sessionPolicy) && + Objects.equals(effectiveUser, that.effectiveUser) && + Objects.equals(issuer, that.issuer) && + Objects.equals(subject, that.subject) && + Objects.equals(audience, that.audience) && + Objects.equals(groups, that.groups) && + Objects.equals(roles, that.roles) && + Objects.equals(roleSessionName, that.roleSessionName) && + Objects.equals(providerId, that.providerId) && + Objects.equals(tokenFingerprint, that.tokenFingerprint); } @Override public int hashCode() { return Objects.hash( - super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy); + super.hashCode(), authType, roleArn, secretAccessKey, originalAccessKeyId, + sessionPolicy, effectiveUser, issuer, subject, audience, groups, roles, + roleSessionName, providerId, tokenFingerprint); } @Override public String toString() { // Intentionally left off secretAccessKey return "STSTokenIdentifier{" + "tempAccessKeyId='" + getOwnerId() + "'" + + ", authType=" + authType + ", originalAccessKeyId='" + originalAccessKeyId + "', roleArn='" + roleArn + "'" + + ", effectiveUser='" + effectiveUser + "', issuer='" + issuer + "'" + + ", subject='" + subject + "', audience='" + audience + "'" + + ", groups=" + groups + ", roles=" + roles + + ", roleSessionName='" + roleSessionName + "', providerId='" + providerId + "'" + + ", tokenFingerprint='" + tokenFingerprint + "'" + ", expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + "'" + ", sessionPolicy='" + sessionPolicy + "'}"; } + + private static OMTokenProto.STSAuthType toProtoAuthType(AuthType value) { + return value == AuthType.WEB_IDENTITY + ? OMTokenProto.STSAuthType.WEB_IDENTITY + : OMTokenProto.STSAuthType.ASSUME_ROLE; + } + + private static AuthType fromProtoAuthType(OMTokenProto.STSAuthType value) { + return value == OMTokenProto.STSAuthType.WEB_IDENTITY + ? AuthType.WEB_IDENTITY : AuthType.ASSUME_ROLE; + } + + private static Set immutableSet(Iterable values) { + if (values == null) { + return Collections.emptySet(); + } + LinkedHashSet set = new LinkedHashSet<>(); + for (String value : values) { + if (value != null && !value.isEmpty()) { + set.add(value); + } + } + return Collections.unmodifiableSet(set); + } + + private static void setIfNotEmpty(StringSetter setter, String value) { + if (value != null && !value.isEmpty()) { + setter.set(value); + } + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + + private interface StringSetter { + void set(String value); + } } diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java index f72b1892de85..d321851a76fb 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.time.Clock; import java.time.Instant; +import java.util.Set; import org.apache.hadoop.hdds.annotation.InterfaceAudience; import org.apache.hadoop.hdds.annotation.InterfaceStability; import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; @@ -39,7 +40,7 @@ public class STSTokenSecretManager extends ShortLivedTokenSecretManager token = generateToken(identifier); return token.encodeToUrlString(); } -} + /** + * Create a WebIdentity-backed STS token and return it as an encoded string. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + public String createWebIdentitySTSTokenString(String tempAccessKeyId, + String roleArn, int durationSeconds, String secretAccessKey, + String sessionPolicy, String effectiveUser, String issuer, String subject, + String audience, Set groups, Set roles, + String roleSessionName, String providerId, String tokenFingerprint, + Clock clock) throws IOException { + final Instant expiration = clock.instant().plusSeconds(durationSeconds); + return createWebIdentitySTSTokenString(tempAccessKeyId, roleArn, expiration, + secretAccessKey, sessionPolicy, effectiveUser, issuer, subject, + audience, groups, roles, roleSessionName, providerId, + tokenFingerprint); + } + + /** + * Create a WebIdentity-backed STS token with a precomputed expiration. + * + * This is used by OM Ratis apply/replay paths where the leader has already + * validated the WebIdentityToken and replicated only sanitized deterministic + * session data. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + public String createWebIdentitySTSTokenString(String tempAccessKeyId, + String roleArn, Instant expiration, String secretAccessKey, + String sessionPolicy, String effectiveUser, String issuer, String subject, + String audience, Set groups, Set roles, + String roleSessionName, String providerId, String tokenFingerprint) + throws IOException { + final ManagedSecretKey currentSecretKey = + secretKeyClient.getCurrentSecretKey(); + final byte[] encryptionKey = + currentSecretKey.getSecretKey().getEncoded(); + + final STSTokenIdentifier identifier = new STSTokenIdentifier( + tempAccessKeyId, roleArn, expiration, secretAccessKey, sessionPolicy, + effectiveUser, issuer, subject, audience, groups, roles, + roleSessionName, providerId, tokenFingerprint, encryptionKey); + final Token token = generateToken(identifier); + return token.encodeToUrlString(); + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java new file mode 100644 index 000000000000..2b4ca3b06b37 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import org.apache.commons.lang3.StringUtils; + +/** + * Authentication material accepted by Ozone identity providers. + */ +public final class AuthCredentials { + + private final String bearerToken; + + private AuthCredentials(String bearerToken) { + this.bearerToken = bearerToken; + } + + public static AuthCredentials bearerToken(String token) { + if (StringUtils.isBlank(token)) { + throw new IllegalArgumentException("Bearer token must not be empty"); + } + return new AuthCredentials(token); + } + + public String getBearerToken() { + return bearerToken; + } + + @Override + public String toString() { + return "AuthCredentials{bearerToken=}"; + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java new file mode 100644 index 000000000000..625337c5d623 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import java.io.IOException; +import java.text.ParseException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.StringUtils; + +/** + * Thread-safe JWKS cache with refresh-on-unknown-kid semantics. + */ +public final class CachingJwksProvider implements JwksProvider { + + private static final Duration DEFAULT_UNKNOWN_KID_REFRESH_DEBOUNCE = + Duration.ofSeconds(5); + + private final JwksFetcher fetcher; + private final Duration refreshInterval; + private final Duration unknownKidRefreshDebounce; + private final Clock clock; + private volatile JWKSet jwkSet; + private volatile Instant loadedAt = Instant.EPOCH; + private volatile Instant lastUnknownKidRefreshAt = Instant.EPOCH; + + public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) { + this(fetcher, refreshInterval, Clock.systemUTC()); + } + + CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval, + Clock clock) { + this(fetcher, refreshInterval, DEFAULT_UNKNOWN_KID_REFRESH_DEBOUNCE, + clock); + } + + CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval, + Duration unknownKidRefreshDebounce, Clock clock) { + if (fetcher == null) { + throw new IllegalArgumentException("JWKS fetcher must not be null"); + } + if (refreshInterval == null || refreshInterval.isNegative()) { + throw new IllegalArgumentException( + "JWKS refresh interval must not be negative"); + } + if (unknownKidRefreshDebounce == null + || unknownKidRefreshDebounce.isNegative()) { + throw new IllegalArgumentException( + "Unknown kid refresh debounce must not be negative"); + } + this.fetcher = fetcher; + this.refreshInterval = refreshInterval; + this.unknownKidRefreshDebounce = unknownKidRefreshDebounce; + this.clock = clock; + } + + @Override + public List getKeys(String keyId) throws OidcAuthenticationException { + refreshIfNeeded(false); + List keys = findKeys(jwkSet, keyId); + if (keys.isEmpty() && StringUtils.isNotBlank(keyId)) { + refreshForUnknownKidIfNeeded(); + keys = findKeys(jwkSet, keyId); + } + return keys; + } + + private void refreshIfNeeded(boolean force) + throws OidcAuthenticationException { + Instant now = clock.instant(); + JWKSet snapshot = jwkSet; + if (!force && snapshot != null + && now.isBefore(loadedAt.plus(refreshInterval))) { + return; + } + + synchronized (this) { + now = clock.instant(); + snapshot = jwkSet; + if (!force && snapshot != null + && now.isBefore(loadedAt.plus(refreshInterval))) { + return; + } + try { + jwkSet = fetcher.fetch(); + loadedAt = now; + } catch (IOException | ParseException e) { + throw new OidcAuthenticationException( + "Unable to refresh OIDC JWKS", e); + } + } + } + + private void refreshForUnknownKidIfNeeded() + throws OidcAuthenticationException { + Instant now = clock.instant(); + if (now.isBefore(lastUnknownKidRefreshAt.plus( + unknownKidRefreshDebounce))) { + return; + } + + synchronized (this) { + now = clock.instant(); + if (now.isBefore(lastUnknownKidRefreshAt.plus( + unknownKidRefreshDebounce))) { + return; + } + try { + jwkSet = fetcher.fetch(); + loadedAt = now; + lastUnknownKidRefreshAt = now; + } catch (IOException | ParseException e) { + throw new OidcAuthenticationException( + "Unable to refresh OIDC JWKS", e); + } + } + } + + private static List findKeys(JWKSet set, String keyId) { + if (set == null) { + return Collections.emptyList(); + } + if (StringUtils.isBlank(keyId)) { + return Collections.unmodifiableList(new ArrayList<>(set.getKeys())); + } + JWK key = set.getKeyByKeyId(keyId); + if (key == null) { + return Collections.emptyList(); + } + return Collections.singletonList(key); + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java new file mode 100644 index 000000000000..8139076161a2 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import com.nimbusds.jose.jwk.JWKSet; +import java.io.IOException; +import java.text.ParseException; + +/** + * Loads a JSON Web Key Set from an external source. + */ +@FunctionalInterface +public interface JwksFetcher { + + JWKSet fetch() throws IOException, ParseException; +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java new file mode 100644 index 000000000000..4323138c58f0 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import com.nimbusds.jose.jwk.JWK; +import java.util.List; + +/** + * Supplies candidate verification keys for a signed OIDC token. + */ +public interface JwksProvider { + + List getKeys(String keyId) throws OidcAuthenticationException; +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java new file mode 100644 index 000000000000..faf19282c229 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import java.io.IOException; + +/** + * Checked exception used when OIDC authentication fails closed. + */ +public class OidcAuthenticationException extends IOException { + + public OidcAuthenticationException(String message) { + super(message); + } + + public OidcAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java new file mode 100644 index 000000000000..3aa1fc44b631 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java @@ -0,0 +1,461 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import static org.apache.hadoop.hdds.conf.ConfigTag.OM; +import static org.apache.hadoop.hdds.conf.ConfigTag.SECURITY; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hdds.conf.Config; +import org.apache.hadoop.hdds.conf.ConfigGroup; +import org.apache.hadoop.hdds.conf.ConfigType; +import org.apache.hadoop.hdds.conf.ConfigurationSource; +import org.apache.hadoop.hdds.conf.PostConstruct; +import org.apache.hadoop.hdds.conf.StorageUnit; + +/** + * Configuration for the experimental OIDC identity provider. + */ +@ConfigGroup(prefix = OidcConfig.PREFIX) +public final class OidcConfig { + + static final String PREFIX = "ozone.sts.web.identity"; + + public static final String OZONE_STS_WEB_IDENTITY_ENABLED = + PREFIX + ".enabled"; + public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI = + PREFIX + ".issuer.uri"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI = + PREFIX + ".jwks.uri"; + public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE = + PREFIX + ".audience"; + public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM = + PREFIX + ".username.claim"; + public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM = + PREFIX + ".subject.claim"; + public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM = + PREFIX + ".groups.claim"; + public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM = + PREFIX + ".roles.claim"; + public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW = + PREFIX + ".clock.skew"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL = + PREFIX + ".jwks.refresh.interval"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT = + PREFIX + ".jwks.connect.timeout"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT = + PREFIX + ".jwks.read.timeout"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT = + PREFIX + ".jwks.size.limit"; + public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS = + PREFIX + ".require.https"; + + private static final String ENABLED_DEFAULT = "false"; + private static final String ISSUER_URI_DEFAULT = ""; + private static final String JWKS_URI_DEFAULT = ""; + private static final String AUDIENCE_DEFAULT = ""; + private static final String USERNAME_CLAIM_DEFAULT = "preferred_username"; + private static final String SUBJECT_CLAIM_DEFAULT = "sub"; + private static final String GROUPS_CLAIM_DEFAULT = "groups"; + private static final String ROLES_CLAIM_DEFAULT = "realm_access.roles"; + private static final String CLOCK_SKEW_DEFAULT = "60s"; + private static final String JWKS_REFRESH_INTERVAL_DEFAULT = "10m"; + private static final String JWKS_CONNECT_TIMEOUT_DEFAULT = "5s"; + private static final String JWKS_READ_TIMEOUT_DEFAULT = "5s"; + private static final String JWKS_SIZE_LIMIT_DEFAULT = "1MB"; + private static final String REQUIRE_HTTPS_DEFAULT = "true"; + + @Config(key = OZONE_STS_WEB_IDENTITY_ENABLED, + defaultValue = ENABLED_DEFAULT, + type = ConfigType.BOOLEAN, + description = "Enables experimental Ozone STS " + + "AssumeRoleWithWebIdentity support.", + tags = {OM, SECURITY}) + private boolean enabled; + + @Config(key = OZONE_STS_WEB_IDENTITY_ISSUER_URI, + defaultValue = ISSUER_URI_DEFAULT, + type = ConfigType.STRING, + description = "Expected OIDC issuer URI. Required when " + + "AssumeRoleWithWebIdentity support is enabled.", + tags = {OM, SECURITY}) + private String issuerUri; + + @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_URI, + defaultValue = JWKS_URI_DEFAULT, + type = ConfigType.STRING, + description = "Optional JWKS endpoint URI. If unset, OM resolves JWKS " + + "from the issuer discovery metadata.", + tags = {OM, SECURITY}) + private String jwksUri; + + @Config(key = OZONE_STS_WEB_IDENTITY_AUDIENCE, + defaultValue = AUDIENCE_DEFAULT, + type = ConfigType.STRING, + description = "Expected OIDC audience value for WebIdentity tokens.", + tags = {OM, SECURITY}) + private String audience; + + @Config(key = OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM, + defaultValue = USERNAME_CLAIM_DEFAULT, + type = ConfigType.STRING, + description = "OIDC claim used as the Ozone user name.", + tags = {OM, SECURITY}) + private String usernameClaim = USERNAME_CLAIM_DEFAULT; + + @Config(key = OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM, + defaultValue = SUBJECT_CLAIM_DEFAULT, + type = ConfigType.STRING, + description = "OIDC claim used as the stable token subject.", + tags = {OM, SECURITY}) + private String subjectClaim = SUBJECT_CLAIM_DEFAULT; + + @Config(key = OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM, + defaultValue = GROUPS_CLAIM_DEFAULT, + type = ConfigType.STRING, + description = "OIDC claim used to import group attributes.", + tags = {OM, SECURITY}) + private String groupsClaim = GROUPS_CLAIM_DEFAULT; + + @Config(key = OZONE_STS_WEB_IDENTITY_ROLES_CLAIM, + defaultValue = ROLES_CLAIM_DEFAULT, + type = ConfigType.STRING, + description = "OIDC claim path used to import role attributes.", + tags = {OM, SECURITY}) + private String rolesClaim = ROLES_CLAIM_DEFAULT; + + @Config(key = OZONE_STS_WEB_IDENTITY_CLOCK_SKEW, + defaultValue = CLOCK_SKEW_DEFAULT, + type = ConfigType.TIME, + timeUnit = TimeUnit.MILLISECONDS, + description = "Allowed clock skew while validating OIDC temporal " + + "claims.", + tags = {OM, SECURITY}) + private Duration clockSkew = Duration.ofSeconds(60); + + @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL, + defaultValue = JWKS_REFRESH_INTERVAL_DEFAULT, + type = ConfigType.TIME, + timeUnit = TimeUnit.MILLISECONDS, + description = "Interval after which cached OIDC JWKS keys are " + + "refreshed.", + tags = {OM, SECURITY}) + private Duration jwksRefreshInterval = Duration.ofMinutes(10); + + @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT, + defaultValue = JWKS_CONNECT_TIMEOUT_DEFAULT, + type = ConfigType.TIME, + timeUnit = TimeUnit.MILLISECONDS, + description = "Connect timeout for loading OIDC JWKS keys.", + tags = {OM, SECURITY}) + private Duration jwksConnectTimeout = Duration.ofSeconds(5); + + @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT, + defaultValue = JWKS_READ_TIMEOUT_DEFAULT, + type = ConfigType.TIME, + timeUnit = TimeUnit.MILLISECONDS, + description = "Read timeout for loading OIDC JWKS keys.", + tags = {OM, SECURITY}) + private Duration jwksReadTimeout = Duration.ofSeconds(5); + + @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT, + defaultValue = JWKS_SIZE_LIMIT_DEFAULT, + type = ConfigType.SIZE, + sizeUnit = StorageUnit.BYTES, + description = "Maximum accepted OIDC JWKS response size.", + tags = {OM, SECURITY}) + private int jwksSizeLimit = 1024 * 1024; + + @Config(key = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, + defaultValue = REQUIRE_HTTPS_DEFAULT, + type = ConfigType.BOOLEAN, + description = "Requires HTTPS issuer and JWKS URIs for WebIdentity " + + "token validation. Tests and local development may set this to " + + "false explicitly.", + tags = {OM, SECURITY}) + private boolean requireHttps = true; + + public OidcConfig() { + } + + private OidcConfig(Builder builder) { + this.enabled = builder.enabled; + this.issuerUri = builder.issuerUri; + this.jwksUri = builder.jwksUri; + this.audience = builder.audience; + this.usernameClaim = builder.usernameClaim; + this.subjectClaim = builder.subjectClaim; + this.groupsClaim = builder.groupsClaim; + this.rolesClaim = builder.rolesClaim; + this.clockSkew = builder.clockSkew; + this.jwksRefreshInterval = builder.jwksRefreshInterval; + this.jwksConnectTimeout = builder.jwksConnectTimeout; + this.jwksReadTimeout = builder.jwksReadTimeout; + this.jwksSizeLimit = builder.jwksSizeLimit; + this.requireHttps = builder.requireHttps; + normalizeAndValidate(); + } + + public static OidcConfig from(ConfigurationSource conf) { + return conf.getObject(OidcConfig.class); + } + + public static Builder newBuilder() { + return new Builder(); + } + + public boolean isEnabled() { + return enabled; + } + + public String getIssuerUri() { + return issuerUri; + } + + public String getJwksUri() { + return jwksUri; + } + + public String getAudience() { + return audience; + } + + public String getUsernameClaim() { + return usernameClaim; + } + + public String getSubjectClaim() { + return subjectClaim; + } + + public String getGroupsClaim() { + return groupsClaim; + } + + public String getRolesClaim() { + return rolesClaim; + } + + public Duration getClockSkew() { + return clockSkew; + } + + public Duration getJwksRefreshInterval() { + return jwksRefreshInterval; + } + + public Duration getJwksConnectTimeout() { + return jwksConnectTimeout; + } + + public Duration getJwksReadTimeout() { + return jwksReadTimeout; + } + + public int getJwksSizeLimit() { + return jwksSizeLimit; + } + + public boolean isRequireHttps() { + return requireHttps; + } + + @PostConstruct + public void normalizeAndValidate() { + issuerUri = StringUtils.trimToEmpty(issuerUri); + jwksUri = StringUtils.trimToEmpty(jwksUri); + audience = StringUtils.trimToEmpty(audience); + usernameClaim = requireNonBlank(usernameClaim, + OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM); + subjectClaim = requireNonBlank(subjectClaim, + OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM); + groupsClaim = requireNonBlank(groupsClaim, + OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM); + rolesClaim = requireNonBlank(rolesClaim, + OZONE_STS_WEB_IDENTITY_ROLES_CLAIM); + clockSkew = requireNonNegative(clockSkew, + OZONE_STS_WEB_IDENTITY_CLOCK_SKEW); + jwksRefreshInterval = requireNonNegative(jwksRefreshInterval, + OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL); + jwksConnectTimeout = requirePositive(jwksConnectTimeout, + OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT); + jwksReadTimeout = requirePositive(jwksReadTimeout, + OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT); + jwksSizeLimit = requirePositive(jwksSizeLimit, + OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT); + + if (enabled) { + validateForProvider(); + } + } + + void validateForProvider() { + requireNonBlank(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI); + requireNonBlank(audience, OZONE_STS_WEB_IDENTITY_AUDIENCE); + + if (requireHttps) { + requireHttpsUri(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI); + if (StringUtils.isNotEmpty(jwksUri)) { + requireHttpsUri(jwksUri, OZONE_STS_WEB_IDENTITY_JWKS_URI); + } + } + } + + private static Duration requireNonNegative(Duration value, String key) { + if (value == null || value.isNegative()) { + throw new IllegalArgumentException(key + " must not be negative"); + } + return value; + } + + private static Duration requirePositive(Duration value, String key) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(key + " must be positive"); + } + return value; + } + + private static int requirePositive(int value, String key) { + if (value <= 0) { + throw new IllegalArgumentException(key + " must be positive"); + } + return value; + } + + private static String requireNonBlank(String value, String key) { + String trimmed = StringUtils.trimToEmpty(value); + if (StringUtils.isBlank(trimmed)) { + throw new IllegalArgumentException(key + " must not be empty"); + } + return trimmed; + } + + private static void requireHttpsUri(String value, String key) { + URI uri; + try { + uri = new URI(value); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(key + " is not a valid URI", e); + } + if (!"https".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException(key + " must use https"); + } + } + + /** + * Builder for {@link OidcConfig}. + */ + public static final class Builder { + + private boolean enabled; + private String issuerUri = ISSUER_URI_DEFAULT; + private String jwksUri = JWKS_URI_DEFAULT; + private String audience = AUDIENCE_DEFAULT; + private String usernameClaim = USERNAME_CLAIM_DEFAULT; + private String subjectClaim = SUBJECT_CLAIM_DEFAULT; + private String groupsClaim = GROUPS_CLAIM_DEFAULT; + private String rolesClaim = ROLES_CLAIM_DEFAULT; + private Duration clockSkew = Duration.ofSeconds(60); + private Duration jwksRefreshInterval = Duration.ofMinutes(10); + private Duration jwksConnectTimeout = Duration.ofSeconds(5); + private Duration jwksReadTimeout = Duration.ofSeconds(5); + private int jwksSizeLimit = 1024 * 1024; + private boolean requireHttps = true; + + private Builder() { + } + + public Builder setEnabled(boolean value) { + this.enabled = value; + return this; + } + + public Builder setIssuerUri(String value) { + this.issuerUri = value; + return this; + } + + public Builder setJwksUri(String value) { + this.jwksUri = value; + return this; + } + + public Builder setAudience(String value) { + this.audience = value; + return this; + } + + public Builder setUsernameClaim(String value) { + this.usernameClaim = value; + return this; + } + + public Builder setSubjectClaim(String value) { + this.subjectClaim = value; + return this; + } + + public Builder setGroupsClaim(String value) { + this.groupsClaim = value; + return this; + } + + public Builder setRolesClaim(String value) { + this.rolesClaim = value; + return this; + } + + public Builder setClockSkew(Duration value) { + this.clockSkew = value; + return this; + } + + public Builder setJwksRefreshInterval(Duration value) { + this.jwksRefreshInterval = value; + return this; + } + + public Builder setJwksConnectTimeout(Duration value) { + this.jwksConnectTimeout = value; + return this; + } + + public Builder setJwksReadTimeout(Duration value) { + this.jwksReadTimeout = value; + return this; + } + + public Builder setJwksSizeLimit(int value) { + this.jwksSizeLimit = value; + return this; + } + + public Builder setRequireHttps(boolean value) { + this.requireHttps = value; + return this; + } + + public OidcConfig build() { + return new OidcConfig(this); + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java new file mode 100644 index 000000000000..590cbb97ea08 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java @@ -0,0 +1,307 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.SignedJWT; +import java.lang.reflect.Array; +import java.text.ParseException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +/** + * OIDC identity provider backed by locally validated signed JWTs. + */ +public final class OidcJwtIdentityProvider implements OzoneIdentityProvider { + + private final OidcConfig config; + private final JwksProvider jwksProvider; + private final Clock clock; + + public OidcJwtIdentityProvider(OidcConfig config, + JwksProvider jwksProvider) { + this(config, jwksProvider, Clock.systemUTC()); + } + + OidcJwtIdentityProvider(OidcConfig config, JwksProvider jwksProvider, + Clock clock) { + if (config == null) { + throw new IllegalArgumentException("OIDC config must not be null"); + } + if (jwksProvider == null) { + throw new IllegalArgumentException("JWKS provider must not be null"); + } + config.validateForProvider(); + this.config = config; + this.jwksProvider = jwksProvider; + this.clock = clock; + } + + @Override + public OzoneIdentity authenticate(AuthCredentials credentials) + throws OidcAuthenticationException { + if (credentials == null || credentials.getBearerToken() == null + || StringUtils.isBlank(credentials.getBearerToken())) { + throw new OidcAuthenticationException("Missing OIDC bearer token"); + } + + SignedJWT jwt = parse(credentials.getBearerToken()); + verifySignature(jwt); + JWTClaimsSet claims = claims(jwt); + return toIdentity(claims); + } + + private SignedJWT parse(String token) throws OidcAuthenticationException { + try { + JWT jwt = JWTParser.parse(token); + if (!(jwt instanceof SignedJWT)) { + throw new OidcAuthenticationException("OIDC token must be signed"); + } + return (SignedJWT) jwt; + } catch (ParseException e) { + throw new OidcAuthenticationException("Unable to parse OIDC token"); + } + } + + private void verifySignature(SignedJWT jwt) + throws OidcAuthenticationException { + JWSHeader header = jwt.getHeader(); + JWSAlgorithm algorithm = header.getAlgorithm(); + if (algorithm == null || "none".equalsIgnoreCase(algorithm.getName())) { + throw new OidcAuthenticationException( + "OIDC token must use a signed algorithm"); + } + if (!JWSAlgorithm.Family.RSA.contains(algorithm)) { + throw new OidcAuthenticationException( + "Unsupported OIDC token signing algorithm"); + } + + List keys = jwksProvider.getKeys(header.getKeyID()); + if (keys.isEmpty()) { + throw new OidcAuthenticationException("OIDC signing key is unknown"); + } + for (JWK key : keys) { + if (verifyWithKey(jwt, algorithm, key)) { + return; + } + } + throw new OidcAuthenticationException( + "OIDC token signature does not match"); + } + + private boolean verifyWithKey(SignedJWT jwt, JWSAlgorithm algorithm, + JWK key) throws OidcAuthenticationException { + if (!(key instanceof RSAKey)) { + return false; + } + if (key.getAlgorithm() != null + && !algorithm.getName().equals(key.getAlgorithm().getName())) { + return false; + } + + try { + JWSVerifier verifier = + new RSASSAVerifier(((RSAKey) key).toRSAPublicKey()); + return jwt.verify(verifier); + } catch (JOSEException e) { + throw new OidcAuthenticationException( + "Unable to verify OIDC token signature", e); + } + } + + private JWTClaimsSet claims(SignedJWT jwt) + throws OidcAuthenticationException { + try { + return jwt.getJWTClaimsSet(); + } catch (ParseException e) { + throw new OidcAuthenticationException( + "Unable to parse OIDC token claims"); + } + } + + private OzoneIdentity toIdentity(JWTClaimsSet claims) + throws OidcAuthenticationException { + Instant now = clock.instant(); + Instant expiresAt = validateExpiration(claims, now); + validateNotBefore(claims, now); + Instant authenticatedAt = validateIssueTime(claims, now); + validateIssuer(claims); + validateAudience(claims); + + String username = extractStringClaim(claims, config.getUsernameClaim()); + String subject = extractStringClaim(claims, config.getSubjectClaim()); + Set groups = extractStringSet(claims, config.getGroupsClaim()); + Set roles = extractStringSet(claims, config.getRolesClaim()); + + return OzoneIdentity.newBuilder() + .setUsername(username) + .setSubject(subject) + .setIssuer(claims.getIssuer()) + .setGroups(groups) + .setRoles(roles) + .setAuthMethod(OzoneIdentity.AUTH_METHOD_OIDC) + .setAuthenticatedAt(authenticatedAt) + .setExpiresAt(expiresAt) + .setRawClaims(claims.getClaims()) + .build(); + } + + private Instant validateExpiration(JWTClaimsSet claims, Instant now) + throws OidcAuthenticationException { + Date expiration = claims.getExpirationTime(); + if (expiration == null) { + throw new OidcAuthenticationException( + "OIDC token does not contain expiration"); + } + Instant expiresAt = expiration.toInstant(); + if (expiresAt.plus(config.getClockSkew()).isBefore(now)) { + throw new OidcAuthenticationException("OIDC token is expired"); + } + return expiresAt; + } + + private void validateNotBefore(JWTClaimsSet claims, Instant now) + throws OidcAuthenticationException { + Date notBefore = claims.getNotBeforeTime(); + if (notBefore != null) { + Duration clockSkew = config.getClockSkew(); + if (notBefore.toInstant().minus(clockSkew).isAfter(now)) { + throw new OidcAuthenticationException( + "OIDC token is not valid yet"); + } + } + } + + private Instant validateIssueTime(JWTClaimsSet claims, Instant now) + throws OidcAuthenticationException { + Date issueTime = claims.getIssueTime(); + if (issueTime == null) { + throw new OidcAuthenticationException( + "OIDC token does not contain issue time"); + } + Instant issuedAt = issueTime.toInstant(); + if (issuedAt.minus(config.getClockSkew()).isAfter(now)) { + throw new OidcAuthenticationException( + "OIDC token issue time is in the future"); + } + return issuedAt; + } + + private void validateIssuer(JWTClaimsSet claims) + throws OidcAuthenticationException { + if (!config.getIssuerUri().equals(claims.getIssuer())) { + throw new OidcAuthenticationException( + "OIDC token issuer does not match"); + } + } + + private void validateAudience(JWTClaimsSet claims) + throws OidcAuthenticationException { + List audiences = claims.getAudience(); + if (audiences == null || !audiences.contains(config.getAudience())) { + throw new OidcAuthenticationException( + "OIDC token audience does not match"); + } + } + + private String extractStringClaim(JWTClaimsSet claims, String claimPath) + throws OidcAuthenticationException { + Object value = claimValue(claims, claimPath); + if (!(value instanceof String) || StringUtils.isBlank((String) value)) { + throw new OidcAuthenticationException( + "OIDC token is missing required claim"); + } + return ((String) value).trim(); + } + + private Set extractStringSet(JWTClaimsSet claims, String claimPath) + throws OidcAuthenticationException { + Object value = claimValue(claims, claimPath); + if (value == null) { + return Collections.emptySet(); + } + + Set result = new LinkedHashSet<>(); + if (value instanceof String) { + addStringValue(result, (String) value); + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + addStringValue(result, item); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + addStringValue(result, Array.get(value, i)); + } + } else { + throw new OidcAuthenticationException( + "OIDC token claim has unsupported type"); + } + return Collections.unmodifiableSet(result); + } + + private void addStringValue(Set values, Object value) + throws OidcAuthenticationException { + if (!(value instanceof String)) { + throw new OidcAuthenticationException( + "OIDC token claim contains non-string value"); + } + String trimmed = ((String) value).trim(); + if (!trimmed.isEmpty()) { + values.add(trimmed); + } + } + + private Object claimValue(JWTClaimsSet claims, String claimPath) { + Object directValue = claims.getClaim(claimPath); + if (directValue != null) { + return directValue; + } + + String[] parts = claimPath.split("\\."); + Object current = claims.getClaim(parts[0]); + for (int i = 1; i < parts.length; i++) { + if (!(current instanceof Map)) { + return null; + } + current = ((Map) current).get(parts[i]); + if (current == null) { + return null; + } + } + return current; + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java new file mode 100644 index 000000000000..bb39dc51265d --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +/** + * Normalized identity produced by an external authentication provider. + */ +public final class OzoneIdentity { + + public static final String AUTH_METHOD_OIDC = "oidc"; + + private final String username; + private final String subject; + private final String issuer; + private final Set groups; + private final Set roles; + private final String authMethod; + private final Instant authenticatedAt; + private final Instant expiresAt; + private final Map rawClaims; + + private OzoneIdentity(Builder builder) { + this.username = requireNonBlank(builder.username, "username"); + this.subject = requireNonBlank(builder.subject, "subject"); + this.issuer = requireNonBlank(builder.issuer, "issuer"); + this.groups = immutableSet(builder.groups); + this.roles = immutableSet(builder.roles); + this.authMethod = requireNonBlank(builder.authMethod, "authMethod"); + this.authenticatedAt = requireNonNull( + builder.authenticatedAt, "authenticatedAt"); + this.expiresAt = requireNonNull(builder.expiresAt, "expiresAt"); + this.rawClaims = Collections.unmodifiableMap( + new LinkedHashMap<>(builder.rawClaims)); + } + + public String getUsername() { + return username; + } + + public String getSubject() { + return subject; + } + + public String getIssuer() { + return issuer; + } + + public Set getGroups() { + return groups; + } + + public Set getRoles() { + return roles; + } + + public String getAuthMethod() { + return authMethod; + } + + public Instant getAuthenticatedAt() { + return authenticatedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public Map getRawClaims() { + return rawClaims; + } + + @Override + public String toString() { + return "OzoneIdentity{" + + "username='" + username + '\'' + + ", subject='" + subject + '\'' + + ", issuer='" + issuer + '\'' + + ", groups=" + groups + + ", roles=" + roles + + ", authMethod='" + authMethod + '\'' + + ", authenticatedAt=" + authenticatedAt + + ", expiresAt=" + expiresAt + + '}'; + } + + public static Builder newBuilder() { + return new Builder(); + } + + private static Set immutableSet(Set values) { + return Collections.unmodifiableSet(new LinkedHashSet<>(values)); + } + + private static String requireNonBlank(String value, String name) { + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return value; + } + + private static T requireNonNull(T value, String name) { + if (value == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + return value; + } + + /** + * Builder for {@link OzoneIdentity}. + */ + public static final class Builder { + + private String username; + private String subject; + private String issuer; + private Set groups = new LinkedHashSet<>(); + private Set roles = new LinkedHashSet<>(); + private String authMethod = AUTH_METHOD_OIDC; + private Instant authenticatedAt; + private Instant expiresAt; + private Map rawClaims = new LinkedHashMap<>(); + + private Builder() { + } + + public Builder setUsername(String value) { + this.username = value; + return this; + } + + public Builder setSubject(String value) { + this.subject = value; + return this; + } + + public Builder setIssuer(String value) { + this.issuer = value; + return this; + } + + public Builder setGroups(Set value) { + this.groups = new LinkedHashSet<>(value); + return this; + } + + public Builder setRoles(Set value) { + this.roles = new LinkedHashSet<>(value); + return this; + } + + public Builder setAuthMethod(String value) { + this.authMethod = value; + return this; + } + + public Builder setAuthenticatedAt(Instant value) { + this.authenticatedAt = value; + return this; + } + + public Builder setExpiresAt(Instant value) { + this.expiresAt = value; + return this; + } + + public Builder setRawClaims(Map value) { + this.rawClaims = new LinkedHashMap<>(value); + return this; + } + + public OzoneIdentity build() { + return new OzoneIdentity(this); + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java new file mode 100644 index 000000000000..550e41a15c46 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +/** + * Converts external authentication credentials into an Ozone identity. + */ +public interface OzoneIdentityProvider { + + OzoneIdentity authenticate(AuthCredentials credentials) + throws OidcAuthenticationException; +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java new file mode 100644 index 000000000000..483ccf21342e --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import com.nimbusds.jose.jwk.JWKSet; +import java.io.IOException; +import java.net.URL; +import java.text.ParseException; +import java.time.Duration; + +/** + * JWKS fetcher backed by a URL. + */ +public final class UrlJwksFetcher implements JwksFetcher { + + private final URL url; + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + private final int sizeLimitBytes; + + public UrlJwksFetcher(URL url) { + this(url, Duration.ofSeconds(5), Duration.ofSeconds(5), 1024 * 1024); + } + + public UrlJwksFetcher(URL url, Duration connectTimeout, + Duration readTimeout, int sizeLimitBytes) { + if (url == null) { + throw new IllegalArgumentException("JWKS URL must not be null"); + } + this.url = url; + this.connectTimeoutMillis = toMillisInt(connectTimeout, + "JWKS connect timeout"); + this.readTimeoutMillis = toMillisInt(readTimeout, "JWKS read timeout"); + if (sizeLimitBytes <= 0) { + throw new IllegalArgumentException( + "JWKS size limit must be positive"); + } + this.sizeLimitBytes = sizeLimitBytes; + } + + @Override + public JWKSet fetch() throws IOException, ParseException { + return JWKSet.load(url, connectTimeoutMillis, readTimeoutMillis, + sizeLimitBytes); + } + + private static int toMillisInt(Duration value, String name) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + long millis = value.toMillis(); + if (millis <= 0 || millis > Integer.MAX_VALUE) { + throw new IllegalArgumentException(name + + " must be between 1ms and " + Integer.MAX_VALUE + "ms"); + } + return (int) millis; + } +} diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java new file mode 100644 index 000000000000..fb89e48eb780 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * OIDC identity validation support for Ozone STS web identity flows. + */ +package org.apache.hadoop.ozone.security.oidc; diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java new file mode 100644 index 000000000000..8563e73daca7 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,440 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.om.request.s3.security; + +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_AUDIENCE; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_JWKS_URI; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey; +import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient; +import org.apache.hadoop.ozone.audit.AuditLogger; +import org.apache.hadoop.ozone.om.OzoneManager; +import org.apache.hadoop.ozone.om.exceptions.OMException; +import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.response.OMClientResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type; +import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleWithWebIdentityRequest; +import org.apache.hadoop.ozone.security.STSTokenSecretManager; +import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer; +import org.apache.hadoop.ozone.security.oidc.AuthCredentials; +import org.apache.hadoop.ozone.security.oidc.OidcAuthenticationException; +import org.apache.hadoop.ozone.security.oidc.OzoneIdentity; +import org.apache.hadoop.ozone.security.oidc.OzoneIdentityProvider; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.ozone.test.TestClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for OM-authoritative AssumeRoleWithWebIdentity. + */ +public class TestS3AssumeRoleWithWebIdentityRequest { + + private static final String ROLE_ARN = + "arn:aws:iam::123456789012:role/tomato"; + private static final String ROLE_SESSION_NAME = "tomato-session"; + private static final String PROVIDER_ID = "keycloak"; + private static final String REQUEST_ID = UUID.randomUUID().toString(); + private static final String RAW_JWT = + "eyJhbGciOiJSUzI1NiJ9.sensitive-web-identity-token.signature"; + private static final String SESSION_POLICY = "session-policy"; + private static final String ISSUER = + "https://keycloak.example.com/realms/ozone"; + private static final String AUDIENCE = "ozone"; + private static final String SUBJECT = "subject-123"; + private static final String USER = "tomato-user"; + private static final TestClock CLOCK = + new TestClock(Instant.ofEpochSecond(1764819000), ZoneOffset.UTC); + + private OzoneManager ozoneManager; + private OzoneConfiguration configuration; + private IAccessAuthorizer accessAuthorizer; + private ExecutionContext context; + + @BeforeEach + public void setup() throws IOException { + ozoneManager = mock(OzoneManager.class); + configuration = new OzoneConfiguration(); + configuration.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true); + configuration.set(OZONE_STS_WEB_IDENTITY_ISSUER_URI, ISSUER); + configuration.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE); + when(ozoneManager.getConfiguration()).thenReturn(configuration); + when(ozoneManager.getOmRpcServerAddr()).thenReturn( + new InetSocketAddress("localhost", 9876)); + when(ozoneManager.getAuditLogger()).thenReturn(mock(AuditLogger.class)); + + accessAuthorizer = mock(IAccessAuthorizer.class); + when(accessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy( + any(org.apache.hadoop.ozone.security.acl + .AssumeRoleWithWebIdentityRequest.class))) + .thenReturn(SESSION_POLICY); + when(ozoneManager.getAccessAuthorizer()).thenReturn(accessAuthorizer); + + final STSTokenSecretManager stsTokenSecretManager = + createTokenSecretManager(); + when(ozoneManager.getSTSTokenSecretManager()).thenReturn( + stsTokenSecretManager); + context = ExecutionContext.of(1L, null); + } + + @Test + public void testPreExecuteValidatesJwtAndStripsRawToken() throws Exception { + final CapturingIdentityProvider identityProvider = + new CapturingIdentityProvider(identity(3600)); + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + identityProvider); + + final OMRequest preExecuted = request.preExecute(ozoneManager); + + assertThat(identityProvider.getCapturedToken()).isEqualTo(RAW_JWT); + assertThat(preExecuted.hasAssumeRoleWithWebIdentityRequest()).isFalse(); + assertThat(preExecuted.hasUpdateAssumeRoleWithWebIdentityRequest()) + .isTrue(); + assertThat(preExecuted.toString()).doesNotContain(RAW_JWT); + assertThat(new String(preExecuted.toByteArray(), StandardCharsets.ISO_8859_1)) + .doesNotContain(RAW_JWT); + + final UpdateAssumeRoleWithWebIdentityRequest update = + preExecuted.getUpdateAssumeRoleWithWebIdentityRequest(); + assertThat(update.getRoleArn()).isEqualTo(ROLE_ARN); + assertThat(update.getRoleSessionName()).isEqualTo(ROLE_SESSION_NAME); + assertThat(update.getDurationSeconds()).isEqualTo(3600); + assertThat(update.getProviderId()).isEqualTo(PROVIDER_ID); + assertThat(update.getRequestId()).isEqualTo(REQUEST_ID); + assertThat(update.getEffectiveUser()).isEqualTo(USER); + assertThat(update.getSubject()).isEqualTo(SUBJECT); + assertThat(update.getIssuer()).isEqualTo(ISSUER); + assertThat(update.getAudience()).isEqualTo(AUDIENCE); + assertThat(update.getGroupsList()).containsExactly("ozone-tomato"); + assertThat(update.getRolesList()).containsExactly("role:writer"); + assertThat(update.getSessionPolicy()).isEqualTo(SESSION_POLICY); + assertThat(update.getTempAccessKeyId()).startsWith("ASIA"); + assertThat(update.getSecretAccessKey()).hasSize(40); + assertThat(update.getTokenFingerprint()).hasSize(64); + assertThat(update.getCredentialExpirationEpochSeconds()) + .isEqualTo(CLOCK.instant().plusSeconds(3600).getEpochSecond()); + } + + @Test + public void testAuthorizerReceivesMappedIdentityAndContext() + throws Exception { + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + new CapturingIdentityProvider(identity(3600))); + + request.preExecute(ozoneManager); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(org.apache.hadoop.ozone.security.acl + .AssumeRoleWithWebIdentityRequest.class); + verify(accessAuthorizer) + .generateAssumeRoleWithWebIdentitySessionPolicy(captor.capture()); + + final org.apache.hadoop.ozone.security.acl + .AssumeRoleWithWebIdentityRequest captured = captor.getValue(); + assertThat(captured.getUser()).isEqualTo(USER); + assertThat(captured.getGroups()).containsExactly("ozone-tomato"); + assertThat(captured.getRoles()).containsExactly("role:writer"); + assertThat(captured.getAction()).isEqualTo( + org.apache.hadoop.ozone.security.acl + .AssumeRoleWithWebIdentityRequest.ACTION); + assertThat(captured.getRoleArn()).isEqualTo(ROLE_ARN); + assertThat(captured.getRoleSessionName()).isEqualTo(ROLE_SESSION_NAME); + assertThat(captured.getIssuer()).isEqualTo(ISSUER); + assertThat(captured.getSubject()).isEqualTo(SUBJECT); + assertThat(captured.getAudience()).isEqualTo(AUDIENCE); + assertThat(captured.getProviderId()).isEqualTo(PROVIDER_ID); + } + + @Test + public void testValidateAndUpdateCacheUsesOnlySanitizedRequest() + throws Exception { + final CapturingIdentityProvider identityProvider = + new CapturingIdentityProvider(identity(3600)); + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + identityProvider); + final OMRequest preExecuted = request.preExecute(ozoneManager); + + final OzoneIdentityProvider replayProvider = + credentials -> { + throw new AssertionError("OIDC provider must not be called during " + + "Ratis apply/replay"); + }; + final S3AssumeRoleWithWebIdentityRequest replayRequest = + new S3AssumeRoleWithWebIdentityRequest(preExecuted, + new TestClock(CLOCK.instant().plusSeconds(86400), ZoneOffset.UTC), + replayProvider); + + final OMClientResponse clientResponse = + replayRequest.validateAndUpdateCache(ozoneManager, context); + final OMResponse omResponse = clientResponse.getOMResponse(); + + assertThat(omResponse.getStatus()).isEqualTo(Status.OK); + assertThat(omResponse.hasAssumeRoleWithWebIdentityResponse()).isTrue(); + final AssumeRoleWithWebIdentityResponse response = + omResponse.getAssumeRoleWithWebIdentityResponse(); + assertThat(response.getAccessKeyId()).startsWith("ASIA"); + assertThat(response.getSecretAccessKey()).hasSize(40); + assertThat(response.getSessionToken()).isNotBlank(); + assertThat(response.getExpirationEpochSeconds()) + .isEqualTo(CLOCK.instant().plusSeconds(3600).getEpochSecond()); + assertThat(response.getAssumedRoleId()).contains(":" + ROLE_SESSION_NAME); + assertThat(response.getSubjectFromWebIdentityToken()).isEqualTo(SUBJECT); + assertThat(response.getAudience()).isEqualTo(AUDIENCE); + assertThat(response.getProvider()).isEqualTo(PROVIDER_ID); + + verify(accessAuthorizer) + .generateAssumeRoleWithWebIdentitySessionPolicy(any()); + verifyNoMoreInteractions(accessAuthorizer); + } + + @Test + public void testDurationIsClampedToWebIdentityTokenLifetime() + throws Exception { + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + new CapturingIdentityProvider(identity(1200))); + + final OMRequest preExecuted = request.preExecute(ozoneManager); + + assertThat(preExecuted.getUpdateAssumeRoleWithWebIdentityRequest() + .getDurationSeconds()).isEqualTo(1200); + assertThat(preExecuted.getUpdateAssumeRoleWithWebIdentityRequest() + .getCredentialExpirationEpochSeconds()) + .isEqualTo(CLOCK.instant().plusSeconds(1200).getEpochSecond()); + } + + @Test + public void testTokenExpiringBeforeMinimumDurationFailsClosed() { + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + new CapturingIdentityProvider(identity(899))); + + assertThatThrownBy(() -> request.preExecute(ozoneManager)) + .isInstanceOf(OMException.class) + .satisfies(e -> assertThat(((OMException) e).getResult()) + .isEqualTo(TOKEN_EXPIRED)) + .hasMessageContaining("expires before the minimum STS credential"); + } + + @Test + public void testDisabledFeatureFailsBeforeTokenValidation() { + configuration.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, false); + final CapturingIdentityProvider identityProvider = + new CapturingIdentityProvider(identity(3600)); + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + identityProvider); + + assertThatThrownBy(() -> request.preExecute(ozoneManager)) + .isInstanceOf(OMException.class) + .satisfies(e -> assertThat(((OMException) e).getResult()) + .isEqualTo(FEATURE_NOT_ENABLED)); + assertThat(identityProvider.getCapturedToken()).isNull(); + } + + @Test + public void testMissingExternalRequiredFieldFailsClosed() { + final CapturingIdentityProvider identityProvider = + new CapturingIdentityProvider(identity(3600)); + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest( + externalRequest(requestBuilder(3600, RAW_JWT) + .clearWebIdentityToken() + .build()), CLOCK, identityProvider); + + assertThatThrownBy(() -> request.preExecute(ozoneManager)) + .isInstanceOf(OMException.class) + .satisfies(e -> assertThat(((OMException) e).getResult()) + .isEqualTo(INVALID_REQUEST)) + .hasMessageContaining("webIdentityToken"); + assertThat(identityProvider.getCapturedToken()).isNull(); + } + + @Test + public void testMissingSanitizedRequiredFieldFailsClosed() + throws Exception { + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK, + new CapturingIdentityProvider(identity(3600))); + final OMRequest preExecuted = request.preExecute(ozoneManager); + final UpdateAssumeRoleWithWebIdentityRequest malformed = + preExecuted.getUpdateAssumeRoleWithWebIdentityRequest().toBuilder() + .clearSessionPolicy() + .build(); + final OMRequest malformedOmRequest = preExecuted.toBuilder() + .setUpdateAssumeRoleWithWebIdentityRequest(malformed) + .build(); + + final OMClientResponse clientResponse = + new S3AssumeRoleWithWebIdentityRequest(malformedOmRequest, CLOCK) + .validateAndUpdateCache(ozoneManager, context); + + assertThat(clientResponse.getOMResponse().getStatus()) + .isEqualTo(Status.INVALID_REQUEST); + } + + @Test + public void testExceptionCauseChainDoesNotExposeTokenMaterial() { + final String sensitiveToken = "raw.jwt.SecretAccessKey.SessionToken." + + "AuthorizationHeader"; + configuration.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, + "https://keycloak.example.com/realms/ozone/certs"); + final S3AssumeRoleWithWebIdentityRequest request = + new S3AssumeRoleWithWebIdentityRequest( + externalRequest(3600, sensitiveToken), CLOCK); + + assertThatThrownBy(() -> request.preExecute(ozoneManager)) + .isInstanceOf(OMException.class) + .satisfies(e -> assertThrowableChainDoesNotContain(e, + sensitiveToken, "SecretAccessKey", "SessionToken", + "AuthorizationHeader")); + } + + private static OMRequest externalRequest(int durationSeconds) { + return externalRequest(durationSeconds, RAW_JWT); + } + + private static OMRequest externalRequest(int durationSeconds, + String webIdentityToken) { + return externalRequest(requestBuilder(durationSeconds, webIdentityToken) + .build()); + } + + private static OMRequest externalRequest( + org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos + .AssumeRoleWithWebIdentityRequest request) { + return OMRequest.newBuilder() + .setCmdType(Type.AssumeRoleWithWebIdentity) + .setClientId("client-1") + .setAssumeRoleWithWebIdentityRequest(request) + .build(); + } + + private static org.apache.hadoop.ozone.protocol.proto + .OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest.Builder + requestBuilder(int durationSeconds, String webIdentityToken) { + return org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos + .AssumeRoleWithWebIdentityRequest.newBuilder() + .setRoleArn(ROLE_ARN) + .setRoleSessionName(ROLE_SESSION_NAME) + .setDurationSeconds(durationSeconds) + .setProviderId(PROVIDER_ID) + .setRequestId(REQUEST_ID) + .setWebIdentityToken(webIdentityToken); + } + + private static void assertThrowableChainDoesNotContain(Throwable throwable, + String... sensitiveValues) { + Throwable current = throwable; + while (current != null) { + String text = String.valueOf(current); + for (String sensitiveValue : sensitiveValues) { + assertThat(text).doesNotContain(sensitiveValue); + } + current = current.getCause(); + } + } + + private static OzoneIdentity identity(long expiresInSeconds) { + return OzoneIdentity.newBuilder() + .setUsername(USER) + .setSubject(SUBJECT) + .setIssuer(ISSUER) + .setGroups(set("ozone-tomato")) + .setRoles(set("role:writer")) + .setAuthenticatedAt(CLOCK.instant()) + .setExpiresAt(CLOCK.instant().plusSeconds(expiresInSeconds)) + .build(); + } + + private static Set set(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } + + private static STSTokenSecretManager createTokenSecretManager() + throws IOException { + final SecretKeySignerClient secretKeyClient = + mock(SecretKeySignerClient.class); + final ManagedSecretKey managedSecretKey = mock(ManagedSecretKey.class); + final SecretKey secretKey = new SecretKeySpec( + "testSecretKey".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + final UUID secretKeyId = UUID.randomUUID(); + + when(secretKeyClient.getCurrentSecretKey()).thenReturn(managedSecretKey); + when(managedSecretKey.getSecretKey()).thenReturn(secretKey); + when(managedSecretKey.getId()).thenReturn(secretKeyId); + when(managedSecretKey.sign(any(TokenIdentifier.class))).thenReturn( + "signature".getBytes(StandardCharsets.UTF_8)); + return new STSTokenSecretManager(secretKeyClient); + } + + private static final class CapturingIdentityProvider + implements OzoneIdentityProvider { + + private final OzoneIdentity identity; + private String capturedToken; + + private CapturingIdentityProvider(OzoneIdentity identity) { + this.identity = identity; + } + + @Override + public OzoneIdentity authenticate(AuthCredentials credentials) + throws OidcAuthenticationException { + capturedToken = credentials.getBearerToken(); + return identity; + } + + private String getCapturedToken() { + return capturedToken; + } + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java index 6d424a66602d..8ec66e2cc5a0 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java @@ -63,6 +63,7 @@ import org.apache.hadoop.ozone.om.response.file.OMFileCreateResponse; import org.apache.hadoop.ozone.om.response.key.OMKeyCreateResponse; import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse; +import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleWithWebIdentityResponse; import org.apache.hadoop.ozone.om.response.util.OMEchoRPCWriteResponse; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateFileRequest; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateKeyRequest; @@ -140,6 +141,7 @@ public void checkAnnotationAndTableName() { subTypes.remove(OMEchoRPCWriteResponse.class); subTypes.remove(DummyOMClientResponse.class); subTypes.remove(S3AssumeRoleResponse.class); + subTypes.remove(S3AssumeRoleWithWebIdentityResponse.class); subTypes.forEach(aClass -> { if (Modifier.isAbstract(aClass.getModifiers())) { assertFalse(aClass.isAnnotationPresent(CleanupTableInfo.class), diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java index c93df8a49009..f7c26df24d99 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java @@ -28,6 +28,9 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; import org.apache.hadoop.hdds.security.exception.SCMSecurityException; @@ -100,6 +103,38 @@ public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException assertThat(expirationEpochMillis).isEqualTo(clock.millis() + (DURATION_SECONDS * 1000)); } + @Test + public void testConstructValidateAndDecryptWebIdentitySTSTokenSuccess() + throws IOException { + final String tokenString = + tokenSecretManager.createWebIdentitySTSTokenString( + TEMP_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, + SESSION_POLICY, "tomato-user", + "https://keycloak.example.com/realms/ozone", "subject-123", + "ozone", set("ozone-tomato"), set("role:writer"), + "tomato-session", "keycloak", "fingerprint", clock); + + final STSTokenIdentifier result = + STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString, + secretKeyClient, clock); + + assertThat(result.isWebIdentity()).isTrue(); + assertThat(result.getOwnerId()).isEqualTo(TEMP_ACCESS_KEY); + assertThat(result.getOriginalAccessKeyId()).isNull(); + assertThat(result.getEffectiveUser()).isEqualTo("tomato-user"); + assertThat(result.getIssuer()).isEqualTo( + "https://keycloak.example.com/realms/ozone"); + assertThat(result.getSubject()).isEqualTo("subject-123"); + assertThat(result.getAudience()).isEqualTo("ozone"); + assertThat(result.getGroups()).containsExactly("ozone-tomato"); + assertThat(result.getRoles()).containsExactly("role:writer"); + assertThat(result.getRoleSessionName()).isEqualTo("tomato-session"); + assertThat(result.getProviderId()).isEqualTo("keycloak"); + assertThat(result.getTokenFingerprint()).isEqualTo("fingerprint"); + assertThat(result.getSecretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + assertThat(result.getSessionPolicy()).isEqualTo(SESSION_POLICY); + } + @Test public void testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws Exception { // Create a valid token with null session policy @@ -365,6 +400,63 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingOriginalAccessKeyId .hasMessage("Invalid STS token - originalAccessKeyId is null/empty"); } + @Test + public void testEnsureEssentialFieldsAllowsWebIdentityWithoutOriginalAccessKeyId() + throws Exception { + final STSTokenIdentifier tokenIdentifier = webIdentityToken( + "tomato-user", "issuer", "subject", "audience"); + + STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier); + } + + @Test + public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingEffectiveUser() { + final STSTokenIdentifier tokenIdentifier = webIdentityToken( + null, "issuer", "subject", "audience"); + + assertThatThrownBy(() -> + STSSecurityUtil.ensureEssentialFieldsArePresentInToken( + tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - effectiveUser is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingIssuer() { + final STSTokenIdentifier tokenIdentifier = webIdentityToken( + "tomato-user", null, "subject", "audience"); + + assertThatThrownBy(() -> + STSSecurityUtil.ensureEssentialFieldsArePresentInToken( + tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - issuer is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingSubject() { + final STSTokenIdentifier tokenIdentifier = webIdentityToken( + "tomato-user", "issuer", null, "audience"); + + assertThatThrownBy(() -> + STSSecurityUtil.ensureEssentialFieldsArePresentInToken( + tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - subject is null/empty"); + } + + @Test + public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingAudience() { + final STSTokenIdentifier tokenIdentifier = webIdentityToken( + "tomato-user", "issuer", "subject", null); + + assertThatThrownBy(() -> + STSSecurityUtil.ensureEssentialFieldsArePresentInToken( + tokenIdentifier)) + .isInstanceOf(SecretManager.InvalidToken.class) + .hasMessage("Invalid STS token - audience is null/empty"); + } + @Test public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() { final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier( @@ -374,4 +466,17 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() { .isInstanceOf(SecretManager.InvalidToken.class) .hasMessage("Invalid STS token - secretAccessKey is null/empty"); } + + private static STSTokenIdentifier webIdentityToken(String effectiveUser, + String issuer, String subject, String audience) { + return new STSTokenIdentifier( + TEMP_ACCESS_KEY, ROLE_ARN, Instant.now(), SECRET_ACCESS_KEY, + SESSION_POLICY, effectiveUser, issuer, subject, audience, + set("ozone-tomato"), set("role:writer"), "session", "provider", + "fingerprint", ENCRYPTION_KEY); + } + + private static Set set(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java index 09a786faaea3..eb74f7380b62 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java @@ -27,6 +27,9 @@ import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto; @@ -74,11 +77,16 @@ public void testProtoBufRoundTrip() throws IOException { assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); // must be encrypted assertThat(proto.getSessionPolicy()).isEqualTo("sessionPolicy"); assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString()); + assertThat(proto.getStsAuthType()).isEqualTo( + OMTokenProto.STSAuthType.ASSUME_ROLE); final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier(); parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); parsedTokenIdentifier.fromProtoBuf(proto); + assertThat(parsedTokenIdentifier.getAuthType()).isEqualTo( + STSTokenIdentifier.AuthType.ASSUME_ROLE); + assertThat(parsedTokenIdentifier.isWebIdentity()).isFalse(); assertThat(parsedTokenIdentifier.getOwnerId()).isEqualTo("tempAccess"); assertThat(parsedTokenIdentifier.getExpiry()).isEqualTo(expiry); assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isEqualTo("origAccess"); @@ -90,6 +98,68 @@ public void testProtoBufRoundTrip() throws IOException { assertThat(parsedTokenIdentifier.hashCode()).isEqualTo(originalTokenIdentifier.hashCode()); } + @Test + public void testWebIdentityProtoBufRoundTrip() throws IOException { + final Instant expiry = + Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.MILLIS); + final STSTokenIdentifier originalTokenIdentifier = + new STSTokenIdentifier( + "tempAccess", "arn:aws:iam::123456789012:role/WebRole", + expiry, "secretKey", "sessionPolicy", "tomato-user", + "https://keycloak.example.com/realms/ozone", "subject-123", + "ozone", set("ozone-tomato"), set("role:writer"), + "tomato-session", "keycloak", "fingerprint", ENCRYPTION_KEY); + final UUID secretKeyId = UUID.randomUUID(); + originalTokenIdentifier.setSecretKeyId(secretKeyId); + + final OMTokenProto proto = originalTokenIdentifier.toProtoBuf(); + assertThat(proto.getStsAuthType()).isEqualTo( + OMTokenProto.STSAuthType.WEB_IDENTITY); + assertThat(proto.getOwner()).isEqualTo("tempAccess"); + assertThat(proto.getOriginalAccessKeyId()).isEmpty(); + assertThat(proto.getRoleArn()).isEqualTo( + "arn:aws:iam::123456789012:role/WebRole"); + assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); + assertThat(proto.getEffectiveUser()).isEqualTo("tomato-user"); + assertThat(proto.getIssuer()).isEqualTo( + "https://keycloak.example.com/realms/ozone"); + assertThat(proto.getSubject()).isEqualTo("subject-123"); + assertThat(proto.getAudience()).isEqualTo("ozone"); + assertThat(proto.getGroupsList()).containsExactly("ozone-tomato"); + assertThat(proto.getRolesList()).containsExactly("role:writer"); + assertThat(proto.getRoleSessionName()).isEqualTo("tomato-session"); + assertThat(proto.getProviderId()).isEqualTo("keycloak"); + assertThat(proto.getTokenFingerprint()).isEqualTo("fingerprint"); + + final STSTokenIdentifier parsedTokenIdentifier = + new STSTokenIdentifier(); + parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY); + parsedTokenIdentifier.fromProtoBuf(proto); + + assertThat(parsedTokenIdentifier.getAuthType()).isEqualTo( + STSTokenIdentifier.AuthType.WEB_IDENTITY); + assertThat(parsedTokenIdentifier.isWebIdentity()).isTrue(); + assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isNull(); + assertThat(parsedTokenIdentifier.getEffectiveUser()) + .isEqualTo("tomato-user"); + assertThat(parsedTokenIdentifier.getIssuer()).isEqualTo( + "https://keycloak.example.com/realms/ozone"); + assertThat(parsedTokenIdentifier.getSubject()).isEqualTo("subject-123"); + assertThat(parsedTokenIdentifier.getAudience()).isEqualTo("ozone"); + assertThat(parsedTokenIdentifier.getGroups()).containsExactly( + "ozone-tomato"); + assertThat(parsedTokenIdentifier.getRoles()).containsExactly( + "role:writer"); + assertThat(parsedTokenIdentifier.getRoleSessionName()) + .isEqualTo("tomato-session"); + assertThat(parsedTokenIdentifier.getProviderId()).isEqualTo("keycloak"); + assertThat(parsedTokenIdentifier.getTokenFingerprint()) + .isEqualTo("fingerprint"); + assertThat(parsedTokenIdentifier.getSecretAccessKey()) + .isEqualTo("secretKey"); + assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier); + } + @Test public void testFromProtoBufInvalidSecretKeyId() { final OMTokenProto invalid = OMTokenProto.newBuilder() @@ -409,11 +479,16 @@ public void testToString() { stsTokenIdentifier.setSecretKeyId(uuid); final String stsTokenIdentifierStr = stsTokenIdentifier.toString(); - final String expectedString = "STSTokenIdentifier{" + "tempAccessKeyId='tempAccessKeyId'" + - ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + ", expiry='" + expiry + - "', secretKeyId='" + uuid + "', sessionPolicy='sessionPolicy'" + '}'; - assertEquals(expectedString, stsTokenIdentifierStr); + assertThat(stsTokenIdentifierStr) + .contains("tempAccessKeyId='tempAccessKeyId'") + .contains("authType=ASSUME_ROLE") + .contains("originalAccessKeyId='originalAccessKeyId'") + .contains("roleArn='roleArn'") + .contains("expiry='" + expiry + "'") + .contains("secretKeyId='" + uuid + "'") + .contains("sessionPolicy='sessionPolicy'") + .doesNotContain("secretAccessKey"); } @Test @@ -449,6 +524,8 @@ public void testEqualsWithDifferentEncryptionKeys() { assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2); assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode()); } -} - + private static Set set(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } +} diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java new file mode 100644 index 000000000000..850c9b3774fc --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java @@ -0,0 +1,434 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.security.oidc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import com.nimbusds.jwt.SignedJWT; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link OidcJwtIdentityProvider}. + */ +public class TestOidcJwtIdentityProvider { + + private static final String ISSUER = + "https://keycloak.example.com/realms/ozone"; + private static final String AUDIENCE = "ozone"; + private static final Instant NOW = Instant.parse("2026-05-13T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + + private RSAKey primaryKey; + private RSAKey rotatedKey; + private RSAKey wrongKey; + + @BeforeEach + public void setUp() throws Exception { + primaryKey = rsaKey("kid-primary"); + rotatedKey = rsaKey("kid-rotated"); + wrongKey = rsaKey("kid-wrong"); + } + + @Test + public void validJwtPasses() throws Exception { + String jwt = token(primaryKey); + OzoneIdentity identity = provider(primaryKey) + .authenticate(AuthCredentials.bearerToken(jwt)); + + assertThat(identity.getUsername()).isEqualTo("tomato-user"); + assertThat(identity.getSubject()).isEqualTo("subject-tomato"); + assertThat(identity.getIssuer()).isEqualTo(ISSUER); + assertThat(identity.getAuthMethod()).isEqualTo("oidc"); + assertThat(identity.getGroups()).containsExactly("ozone-tomato"); + assertThat(identity.getRoles()).containsExactly("writer", "read"); + assertThat(identity.getExpiresAt()).isEqualTo(NOW.plusSeconds(3600)); + assertThat(identity.toString()).doesNotContain(jwt); + } + + @Test + public void expiredJwtFails() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.expirationTime(Date.from(NOW.minusSeconds(120)))); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("expired"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void notBeforeInFutureFails() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.notBeforeTime(Date.from(NOW.plusSeconds(120)))); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("not valid yet"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void issueTimeInFutureFails() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.issueTime(Date.from(NOW.plusSeconds(120)))); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("issue time"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void wrongIssuerFails() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.issuer("https://other.example.com/realms/ozone")); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("issuer"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void wrongAudienceFails() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.audience(Collections.singletonList("other-audience"))); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("audience"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void wrongSignatureFails() throws Exception { + String jwt = token(wrongKey, primaryKey.getKeyID(), builder -> { + }); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("signature"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void manipulatedGroupsClaimFailsSignatureValidation() + throws Exception { + String jwt = token(primaryKey); + String manipulatedJwt = replacePayloadText(jwt, "ozone-tomato", + "ozone-admins"); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey) + .authenticate(AuthCredentials.bearerToken(manipulatedJwt))); + + assertThat(exception).hasMessageContaining("signature"); + assertThat(exception.toString()).doesNotContain(manipulatedJwt); + } + + @Test + public void algNoneFails() { + String jwt = new PlainJWT(baseClaims().build()).serialize(); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("signed"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void unknownKidTriggersJwksRefresh() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + CachingJwksProvider jwksProvider = new CachingJwksProvider(() -> { + if (fetches.incrementAndGet() == 1) { + return jwkSet(primaryKey); + } + return jwkSet(rotatedKey); + }, Duration.ofMinutes(10), CLOCK); + + OzoneIdentity identity = provider(config(), jwksProvider) + .authenticate(AuthCredentials.bearerToken(token(rotatedKey))); + + assertThat(identity.getUsername()).isEqualTo("tomato-user"); + assertThat(fetches).hasValue(2); + } + + @Test + public void unknownKidRefreshIsDebounced() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + CachingJwksProvider jwksProvider = new CachingJwksProvider(() -> { + fetches.incrementAndGet(); + return jwkSet(primaryKey); + }, Duration.ofMinutes(10), Duration.ofSeconds(5), CLOCK); + OidcJwtIdentityProvider provider = provider(config(), jwksProvider); + + assertThrows(OidcAuthenticationException.class, () -> + provider.authenticate(AuthCredentials.bearerToken(token(rotatedKey)))); + assertThrows(OidcAuthenticationException.class, () -> + provider.authenticate(AuthCredentials.bearerToken(token(wrongKey)))); + + assertThat(fetches).hasValue(2); + } + + @Test + public void keyRotationWorks() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + CachingJwksProvider jwksProvider = new CachingJwksProvider(() -> { + if (fetches.incrementAndGet() == 1) { + return jwkSet(primaryKey); + } + return jwkSet(primaryKey, rotatedKey); + }, Duration.ofMinutes(10), CLOCK); + OidcJwtIdentityProvider provider = provider(config(), jwksProvider); + + provider.authenticate(AuthCredentials.bearerToken(token(primaryKey))); + provider.authenticate(AuthCredentials.bearerToken(token(rotatedKey))); + OzoneIdentity identity = + provider.authenticate(AuthCredentials.bearerToken(token(primaryKey))); + + assertThat(identity.getUsername()).isEqualTo("tomato-user"); + assertThat(fetches).hasValue(2); + } + + @Test + public void usernameClaimMappingWorks() throws Exception { + OidcConfig config = configBuilder() + .setUsernameClaim("email") + .build(); + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.claim("email", "tomato@example.com")); + + OzoneIdentity identity = provider(config, jwksProvider(primaryKey)) + .authenticate(AuthCredentials.bearerToken(jwt)); + + assertThat(identity.getUsername()).isEqualTo("tomato@example.com"); + } + + @Test + public void nestedGroupsAndRolesClaimMappingWorks() throws Exception { + OidcConfig config = configBuilder() + .setGroupsClaim("resource_access.ozone.groups") + .setRolesClaim("resource_access.ozone.roles") + .build(); + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> { + Map ozone = new LinkedHashMap<>(); + ozone.put("groups", Arrays.asList("ozone-tomato", "ozone-admins")); + ozone.put("roles", Collections.singletonList("writer")); + builder.claim("resource_access", + Collections.singletonMap("ozone", ozone)); + }); + + OzoneIdentity identity = provider(config, jwksProvider(primaryKey)) + .authenticate(AuthCredentials.bearerToken(jwt)); + + assertThat(identity.getGroups()) + .containsExactly("ozone-tomato", "ozone-admins"); + assertThat(identity.getRoles()).containsExactly("writer"); + } + + @Test + public void missingUsernameFailsClosed() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.claim("preferred_username", null)); + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception).hasMessageContaining("required claim"); + assertThat(exception.toString()).doesNotContain(jwt); + } + + @Test + public void missingGroupsAreMappedToEmptySet() throws Exception { + String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> + builder.claim("groups", null)); + + OzoneIdentity identity = provider(primaryKey) + .authenticate(AuthCredentials.bearerToken(jwt)); + + assertThat(identity.getGroups()).isEmpty(); + } + + @Test + public void jwksFetcherEnforcesSizeLimit() throws Exception { + Path jwksFile = Files.createTempFile("ozone-test-jwks", ".json"); + Files.write(jwksFile, + jwkSet(primaryKey).toString().getBytes(StandardCharsets.UTF_8)); + UrlJwksFetcher fetcher = new UrlJwksFetcher(jwksFile.toUri().toURL(), + Duration.ofSeconds(5), Duration.ofSeconds(5), 8); + + assertThrows(IOException.class, fetcher::fetch); + } + + @Test + public void tokenMaterialIsNotIncludedInParseException() { + String jwt = "sensitive-token-material"; + + OidcAuthenticationException exception = assertThrows( + OidcAuthenticationException.class, + () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt))); + + assertThat(exception.toString()).doesNotContain(jwt); + } + + private OidcJwtIdentityProvider provider(RSAKey key) { + return provider(config(), jwksProvider(key)); + } + + private OidcJwtIdentityProvider provider(OidcConfig config, + JwksProvider jwksProvider) { + return new OidcJwtIdentityProvider(config, jwksProvider, CLOCK); + } + + private static OidcConfig config() { + return configBuilder().build(); + } + + private static OidcConfig.Builder configBuilder() { + return OidcConfig.newBuilder() + .setIssuerUri(ISSUER) + .setAudience(AUDIENCE) + .setClockSkew(Duration.ofSeconds(60)); + } + + private static JwksProvider jwksProvider(RSAKey... keys) { + JWKSet jwkSet = jwkSet(keys); + return keyId -> { + if (StringUtils.isBlank(keyId)) { + return new ArrayList<>(jwkSet.getKeys()); + } + JWK key = jwkSet.getKeyByKeyId(keyId); + return key == null ? Collections.emptyList() + : Collections.singletonList(key); + }; + } + + private static JWKSet jwkSet(RSAKey... keys) { + List publicKeys = new ArrayList<>(); + for (RSAKey key : keys) { + publicKeys.add(key.toPublicJWK()); + } + return new JWKSet(publicKeys); + } + + private static RSAKey rsaKey(String keyId) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .keyID(keyId) + .algorithm(JWSAlgorithm.RS256) + .build(); + } + + private static String token(RSAKey signerKey) throws Exception { + return token(signerKey, signerKey.getKeyID(), builder -> { + }); + } + + private static String token(RSAKey signerKey, String keyId, + ClaimsCustomizer customizer) throws Exception { + JWTClaimsSet.Builder claims = baseClaims(); + customizer.customize(claims); + SignedJWT signedJWT = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(keyId).build(), + claims.build()); + signedJWT.sign(new RSASSASigner(signerKey.toRSAPrivateKey())); + return signedJWT.serialize(); + } + + private static String replacePayloadText(String jwt, String from, + String to) { + String[] parts = jwt.split("\\."); + String payload = new String(Base64.getUrlDecoder().decode(parts[1]), + StandardCharsets.UTF_8); + String manipulatedPayload = payload.replace(from, to); + parts[1] = Base64.getUrlEncoder().withoutPadding().encodeToString( + manipulatedPayload.getBytes(StandardCharsets.UTF_8)); + return String.join(".", parts); + } + + private static JWTClaimsSet.Builder baseClaims() { + Map realmAccess = new LinkedHashMap<>(); + realmAccess.put("roles", Arrays.asList("writer", "read")); + + return new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject("subject-tomato") + .audience(Collections.singletonList(AUDIENCE)) + .issueTime(Date.from(NOW.minusSeconds(30))) + .expirationTime(Date.from(NOW.plusSeconds(3600))) + .claim("preferred_username", "tomato-user") + .claim("groups", Collections.singletonList("ozone-tomato")) + .claim("realm_access", realmAccess); + } + + @FunctionalInterface + private interface ClaimsCustomizer { + void customize(JWTClaimsSet.Builder builder); + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java index 3db260aef11d..34df811bf20b 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java @@ -50,6 +50,8 @@ @Priority(AuthorizationFilter.PRIORITY) public class AuthorizationFilter implements ContainerRequestFilter { public static final int PRIORITY = 50; + public static final String SKIP_AWS_AUTH_PROPERTY = + AuthorizationFilter.class.getName() + ".skipAwsAuth"; private static final Logger LOG = LoggerFactory.getLogger( AuthorizationFilter.class); @@ -63,6 +65,11 @@ public class AuthorizationFilter implements ContainerRequestFilter { @Override public void filter(ContainerRequestContext context) throws IOException { + if (Boolean.TRUE.equals(context.getProperty(SKIP_AWS_AUTH_PROPERTY))) { + LOG.debug("Skipping AWS SigV4 processing for this request"); + return; + } + try { signatureInfo.initialize(signatureProcessor.parseSignature()); if (signatureInfo.getVersion() == Version.V4) { diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java new file mode 100644 index 000000000000..6b844e1f00b6 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlType; + +/** + * JAXB model for AWS-compatible AssumeRoleWithWebIdentity response XML. + */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "AssumeRoleWithWebIdentityResponse", + namespace = "https://sts.amazonaws.com/doc/2011-06-15/") +public class S3AssumeRoleWithWebIdentityResponseXml { + + @XmlElement(name = "AssumeRoleWithWebIdentityResult") + private AssumeRoleWithWebIdentityResult result; + + @XmlElement(name = "ResponseMetadata") + private ResponseMetadata responseMetadata; + + public AssumeRoleWithWebIdentityResult getResult() { + return result; + } + + public void setResult(AssumeRoleWithWebIdentityResult result) { + this.result = result; + } + + public ResponseMetadata getResponseMetadata() { + return responseMetadata; + } + + public void setResponseMetadata(ResponseMetadata responseMetadata) { + this.responseMetadata = responseMetadata; + } + + /** XML model for the AssumeRoleWithWebIdentityResult element. */ + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "AssumeRoleWithWebIdentityResultType", + namespace = "https://sts.amazonaws.com/doc/2011-06-15/") + public static class AssumeRoleWithWebIdentityResult { + @XmlElement(name = "Credentials") + private Credentials credentials; + + @XmlElement(name = "SubjectFromWebIdentityToken") + private String subjectFromWebIdentityToken; + + @XmlElement(name = "AssumedRoleUser") + private AssumedRoleUser assumedRoleUser; + + @XmlElement(name = "Audience") + private String audience; + + @XmlElement(name = "Provider") + private String provider; + + public Credentials getCredentials() { + return credentials; + } + + public void setCredentials(Credentials credentials) { + this.credentials = credentials; + } + + public String getSubjectFromWebIdentityToken() { + return subjectFromWebIdentityToken; + } + + public void setSubjectFromWebIdentityToken(String value) { + this.subjectFromWebIdentityToken = value; + } + + public AssumedRoleUser getAssumedRoleUser() { + return assumedRoleUser; + } + + public void setAssumedRoleUser(AssumedRoleUser assumedRoleUser) { + this.assumedRoleUser = assumedRoleUser; + } + + public String getAudience() { + return audience; + } + + public void setAudience(String audience) { + this.audience = audience; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + } + + /** XML model for temporary STS credentials. */ + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "AssumeRoleWithWebIdentityCredentialsType", + namespace = "https://sts.amazonaws.com/doc/2011-06-15/") + public static class Credentials { + @XmlElement(name = "AccessKeyId") + private String accessKeyId; + + @XmlElement(name = "SecretAccessKey") + private String secretAccessKey; + + @XmlElement(name = "SessionToken") + private String sessionToken; + + @XmlElement(name = "Expiration") + private String expiration; + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + + public String getExpiration() { + return expiration; + } + + public void setExpiration(String expiration) { + this.expiration = expiration; + } + } + + /** XML model for the assumed role user. */ + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "AssumeRoleWithWebIdentityAssumedRoleUserType", + namespace = "https://sts.amazonaws.com/doc/2011-06-15/") + public static class AssumedRoleUser { + @XmlElement(name = "AssumedRoleId") + private String assumedRoleId; + + @XmlElement(name = "Arn") + private String arn; + + public String getAssumedRoleId() { + return assumedRoleId; + } + + public void setAssumedRoleId(String assumedRoleId) { + this.assumedRoleId = assumedRoleId; + } + + public String getArn() { + return arn; + } + + public void setArn(String arn) { + this.arn = arn; + } + } + + /** XML model for response metadata. */ + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "AssumeRoleWithWebIdentityResponseMetadataType", + namespace = "https://sts.amazonaws.com/doc/2011-06-15/") + public static class ResponseMetadata { + @XmlElement(name = "RequestId") + private String requestId; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java index 091f8851fa3f..56af2134f5f1 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java @@ -21,6 +21,8 @@ import static javax.ws.rs.core.Response.Status.FORBIDDEN; import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT; import com.google.common.annotations.VisibleForTesting; import java.io.IOException; @@ -47,8 +49,10 @@ import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; import org.apache.hadoop.ozone.om.helpers.S3STSUtils; +import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OS3Exception; import org.apache.hadoop.ozone.s3.exception.OSTSException; @@ -95,7 +99,8 @@ public class S3STSEndpoint extends S3STSEndpointBase { static { try { - JAXB_CONTEXT = JAXBContext.newInstance(S3AssumeRoleResponseXml.class); + JAXB_CONTEXT = JAXBContext.newInstance(S3AssumeRoleResponseXml.class, + S3AssumeRoleWithWebIdentityResponseXml.class); } catch (JAXBException e) { throw new RuntimeException("Failed to initialize JAXBContext: " + e, e); } @@ -122,15 +127,27 @@ public void setRequestIdentifier(RequestIdentifier requestIdentifier) { */ @GET @Produces(MediaType.APPLICATION_XML) + @SuppressWarnings("checkstyle:ParameterNumber") public Response get( @QueryParam("Action") String action, @QueryParam("RoleArn") String roleArn, @QueryParam("RoleSessionName") String roleSessionName, @QueryParam("DurationSeconds") Integer durationSeconds, @QueryParam("Version") String version, - @QueryParam("Policy") String awsIamSessionPolicy) throws OS3Exception { + @QueryParam("Policy") String awsIamSessionPolicy, + @QueryParam("WebIdentityToken") String webIdentityToken, + @QueryParam("ProviderId") String providerId) throws OS3Exception { - return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, + version, awsIamSessionPolicy, webIdentityToken, providerId); + } + + @VisibleForTesting + public Response get(String action, String roleArn, String roleSessionName, + Integer durationSeconds, String version, String awsIamSessionPolicy) + throws OS3Exception { + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, + version, awsIamSessionPolicy, null, null); } /** @@ -146,19 +163,33 @@ public Response get( */ @POST @Produces(MediaType.APPLICATION_XML) + @SuppressWarnings("checkstyle:ParameterNumber") public Response post( @FormParam("Action") String action, @FormParam("RoleArn") String roleArn, @FormParam("RoleSessionName") String roleSessionName, @FormParam("DurationSeconds") Integer durationSeconds, @FormParam("Version") String version, - @FormParam("Policy") String awsIamSessionPolicy) throws OS3Exception { + @FormParam("Policy") String awsIamSessionPolicy, + @FormParam("WebIdentityToken") String webIdentityToken, + @FormParam("ProviderId") String providerId) throws OS3Exception { - return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy); + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, + version, awsIamSessionPolicy, webIdentityToken, providerId); + } + + @VisibleForTesting + public Response post(String action, String roleArn, String roleSessionName, + Integer durationSeconds, String version, String awsIamSessionPolicy) + throws OS3Exception { + return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, + version, awsIamSessionPolicy, null, null); } + @SuppressWarnings("checkstyle:ParameterNumber") private Response handleSTSRequest(String action, String roleArn, String roleSessionName, - Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception { + Integer durationSeconds, String version, String awsIamSessionPolicy, + String webIdentityToken, String providerId) throws OS3Exception { final String requestId = requestIdentifier.getRequestId(); // NOTE: invalid, missing or unsupported actions are not added to the audit log try { @@ -173,10 +204,12 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess switch (action) { case ASSUME_ROLE_ACTION: return handleAssumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId); + case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: + return handleAssumeRoleWithWebIdentity(roleArn, roleSessionName, + durationSeconds, webIdentityToken, providerId, version, requestId); // These operations are not supported yet case GET_SESSION_TOKEN_ACTION: case ASSUME_ROLE_WITH_SAML_ACTION: - case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: case GET_CALLER_IDENTITY_ACTION: case DECODE_AUTHORIZATION_MESSAGE_ACTION: case GET_ACCESS_KEY_INFO_ACTION: @@ -197,6 +230,115 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess } } + private Response handleAssumeRoleWithWebIdentity(String roleArn, + String roleSessionName, Integer durationSeconds, String webIdentityToken, + String providerId, String version, String requestId) + throws OSTSException { + final String action = "AssumeRoleWithWebIdentity"; + if (!isWebIdentityEnabled()) { + throw new OSTSException( + INVALID_ACTION, "Operation " + action + " is not supported yet.", + NOT_IMPLEMENTED.getStatusCode()); + } + + if (version == null || !version.equals(EXPECTED_VERSION)) { + throw new OSTSException( + INVALID_ACTION, "Could not find operation " + action + + " for version " + + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: " + + EXPECTED_VERSION : version), BAD_REQUEST.getStatusCode()); + } + + final Set validationErrors = new HashSet<>(); + try { + S3STSUtils.validateDuration(durationSeconds); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + try { + AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + try { + S3STSUtils.validateRoleSessionName(roleSessionName); + } catch (OMException e) { + validationErrors.add(e.getMessage()); + } + + if (StringUtils.isBlank(webIdentityToken)) { + validationErrors.add("Value null at 'webIdentityToken' failed to " + + "satisfy constraint: Member must not be null"); + } + + if (!validationErrors.isEmpty()) { + final String validationMessage = validationErrors.size() + + " validation " + + (validationErrors.size() > 1 ? "errors detected: " + : "error detected: ") + + String.join(";", validationErrors); + throw new OSTSException( + VALIDATION_ERROR, validationMessage, BAD_REQUEST.getStatusCode()); + } + + final int duration = durationSeconds == null + ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds; + final String assumedRoleUserArn = + S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); + try { + final AssumeRoleWithWebIdentityResponseInfo responseInfo = getClient() + .getObjectStore() + .assumeRoleWithWebIdentity(roleArn, roleSessionName, duration, + webIdentityToken, providerId, requestId); + final String responseXml = generateAssumeRoleWithWebIdentityResponse( + assumedRoleUserArn, responseInfo, requestId); + return Response.ok(responseXml) + .header("Content-Type", "text/xml") + .build(); + } catch (IOException e) { + LOG.error("Error during AssumeRoleWithWebIdentity processing", e); + if (e instanceof OMException) { + final OMException omException = (OMException) e; + if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED || + omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED || + omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) { + throw new OSTSException( + ACCESS_DENIED, + "User is not authorized to perform: " + + "sts:AssumeRoleWithWebIdentity on resource: " + roleArn, + FORBIDDEN.getStatusCode()); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) { + throw new OSTSException( + INVALID_CLIENT_TOKEN_ID, + "The web identity token included in the request is invalid.", + FORBIDDEN.getStatusCode()); + } + if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION || + omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) { + throw new OSTSException(UNSUPPORTED_OPERATION, + omException.getMessage(), NOT_IMPLEMENTED.getStatusCode()); + } + if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) { + throw new OSTSException(VALIDATION_ERROR, omException.getMessage(), + BAD_REQUEST.getStatusCode()); + } + } + throw new OSTSException( + INTERNAL_FAILURE, "An internal error has occurred.", + INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver"); + } + } + + private boolean isWebIdentityEnabled() { + return OzoneConfigurationHolder.configuration() != null + && OzoneConfigurationHolder.configuration().getBoolean( + OZONE_STS_WEB_IDENTITY_ENABLED, + OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT); + } + private Response handleAssumeRole(String roleArn, String roleSessionName, Integer durationSeconds, String awsIamSessionPolicy, String version, String requestId) throws OSTSException { final String action = "AssumeRole"; @@ -350,5 +492,54 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR throw new IOException("Failed to marshal AssumeRole response", e); } } -} + private String generateAssumeRoleWithWebIdentityResponse( + String assumedRoleUserArn, + AssumeRoleWithWebIdentityResponseInfo responseInfo, String requestId) + throws IOException { + final String expiration = DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochSecond(responseInfo.getExpirationEpochSeconds()) + .atOffset(ZoneOffset.UTC).toInstant()); + + try { + final S3AssumeRoleWithWebIdentityResponseXml response = + new S3AssumeRoleWithWebIdentityResponseXml(); + final S3AssumeRoleWithWebIdentityResponseXml + .AssumeRoleWithWebIdentityResult result = + new S3AssumeRoleWithWebIdentityResponseXml + .AssumeRoleWithWebIdentityResult(); + final S3AssumeRoleWithWebIdentityResponseXml.Credentials credentials = + new S3AssumeRoleWithWebIdentityResponseXml.Credentials(); + credentials.setAccessKeyId(responseInfo.getAccessKeyId()); + credentials.setSecretAccessKey(responseInfo.getSecretAccessKey()); + credentials.setSessionToken(responseInfo.getSessionToken()); + credentials.setExpiration(expiration); + result.setCredentials(credentials); + result.setSubjectFromWebIdentityToken( + responseInfo.getSubjectFromWebIdentityToken()); + result.setAudience(responseInfo.getAudience()); + if (StringUtils.isNotBlank(responseInfo.getProvider())) { + result.setProvider(responseInfo.getProvider()); + } + final S3AssumeRoleWithWebIdentityResponseXml.AssumedRoleUser user = + new S3AssumeRoleWithWebIdentityResponseXml.AssumedRoleUser(); + user.setAssumedRoleId(responseInfo.getAssumedRoleId()); + user.setArn(assumedRoleUserArn); + result.setAssumedRoleUser(user); + response.setResult(result); + final S3AssumeRoleWithWebIdentityResponseXml.ResponseMetadata meta = + new S3AssumeRoleWithWebIdentityResponseXml.ResponseMetadata(); + meta.setRequestId(requestId); + response.setResponseMetadata(meta); + + final Marshaller marshaller = JAXB_CONTEXT.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + final StringWriter stringWriter = new StringWriter(); + marshaller.marshal(response, stringWriter); + return stringWriter.toString(); + } catch (JAXBException e) { + throw new IOException( + "Failed to marshal AssumeRoleWithWebIdentity response", e); + } + } +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java index 027784b0edc9..af3caa877085 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java @@ -32,6 +32,7 @@ import org.apache.hadoop.ozone.client.OzoneClient; import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.om.protocol.S3Auth; +import org.apache.hadoop.ozone.s3.AuthorizationFilter; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; import org.apache.hadoop.ozone.s3.util.AuditUtils; @@ -63,6 +64,11 @@ public void setAuditLogger(AuditLogger auditLogger) { @PostConstruct public void initialization() { + if (context != null && Boolean.TRUE.equals(context.getProperty( + AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY))) { + return; + } + S3Auth s3Auth = new S3Auth(signatureInfo.getStringToSign(), signatureInfo.getSignature(), signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId()); diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java new file mode 100644 index 000000000000..8096542c92b2 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT; + +import java.io.IOException; +import javax.annotation.Priority; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.PreMatching; +import javax.ws.rs.ext.Provider; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.s3.AuthorizationFilter; +import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; + +/** + * Allows unauthenticated bootstrap only for STS WebIdentity requests. + *

+ * This filter is registered only in the STS JAX-RS application. It does not + * validate or trust the JWT; OM remains the authoritative WebIdentity + * validator and temporary credential issuer. + */ +@Provider +@PreMatching +@Priority(AuthorizationFilter.PRIORITY - 1) +public class S3STSWebIdentityAuthBypassFilter + implements ContainerRequestFilter { + + private final OzoneConfiguration ozoneConfiguration; + + /** + * No-arg constructor for Jersey provider instantiation without injection. + * Falls back to OzoneConfigurationHolder and fails closed when no + * configuration is available. + */ + public S3STSWebIdentityAuthBypassFilter() { + this(null); + } + + @Inject + public S3STSWebIdentityAuthBypassFilter(OzoneConfiguration conf) { + this.ozoneConfiguration = conf; + } + + @Override + public void filter(ContainerRequestContext context) throws IOException { + if (isWebIdentityEnabled() + && S3STSWebIdentityRequestParser.isAssumeRoleWithWebIdentity( + context)) { + context.setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY, + Boolean.TRUE); + } + } + + private boolean isWebIdentityEnabled() { + OzoneConfiguration conf = ozoneConfiguration; + if (conf == null) { + conf = OzoneConfigurationHolder.configuration(); + } + return conf != null && conf.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, + OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT); + } + +} diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java new file mode 100644 index 000000000000..2765b308c62e --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Response; + +/** + * Parses the STS action without logging request parameters. + */ +final class S3STSWebIdentityRequestParser { + + static final String ACTION = "Action"; + static final String ASSUME_ROLE_WITH_WEB_IDENTITY = + "AssumeRoleWithWebIdentity"; + static final int MAX_FORM_BODY_BYTES = 64 * 1024; + + private static final int BUFFER_SIZE = 8192; + private static final int HTTP_PAYLOAD_TOO_LARGE = 413; + + private S3STSWebIdentityRequestParser() { + } + + static boolean isAssumeRoleWithWebIdentity(ContainerRequestContext context) + throws IOException { + String action = getAction(context); + return ASSUME_ROLE_WITH_WEB_IDENTITY.equals(action); + } + + private static String getAction(ContainerRequestContext context) + throws IOException { + String method = context.getMethod(); + if (HttpMethod.GET.equalsIgnoreCase(method)) { + return singleAction(context.getUriInfo().getQueryParameters() + .get(ACTION)); + } + if (HttpMethod.POST.equalsIgnoreCase(method)) { + return getFormAction(context); + } + return null; + } + + private static String getFormAction(ContainerRequestContext context) + throws IOException { + InputStream stream = context.getEntityStream(); + if (stream == null) { + return null; + } + + byte[] body = readBoundedFormBody(stream); + context.setEntityStream(new ByteArrayInputStream(body)); + String form = new String(body, StandardCharsets.UTF_8); + String action = null; + for (String pair : form.split("&")) { + int equals = pair.indexOf('='); + if (equals < 0) { + continue; + } + String name = decode(pair.substring(0, equals)); + if (ACTION.equals(name)) { + if (action != null) { + return null; + } + action = decode(pair.substring(equals + 1)); + } + } + return action; + } + + private static String decode(String value) throws IOException { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } + + private static byte[] readBoundedFormBody(InputStream stream) + throws IOException { + ByteArrayOutputStream body = + new ByteArrayOutputStream(Math.min(MAX_FORM_BODY_BYTES, BUFFER_SIZE)); + byte[] buffer = new byte[BUFFER_SIZE]; + int remaining = MAX_FORM_BODY_BYTES; + while (remaining > 0) { + int read = stream.read(buffer, 0, Math.min(buffer.length, remaining)); + if (read == -1) { + return body.toByteArray(); + } + body.write(buffer, 0, read); + remaining -= read; + } + if (stream.read() != -1) { + throw new WebApplicationException( + "STS WebIdentity request body is too large", + Response.status(HTTP_PAYLOAD_TOO_LARGE).build()); + } + return body.toByteArray(); + } + + private static String singleAction(List values) { + return values != null && values.size() == 1 ? values.get(0) : null; + } +} diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java index 5159f6214128..b4c41f51faba 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java @@ -39,6 +39,7 @@ import org.apache.hadoop.ozone.client.protocol.ClientProtocol; import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.om.helpers.DeleteTenantState; import org.apache.hadoop.ozone.om.helpers.ErrorInfo; import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo; @@ -809,6 +810,14 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, return null; } + @Override + public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity( + String roleArn, String roleSessionName, int durationSeconds, + String webIdentityToken, String providerId, String requestId) + throws IOException { + return null; + } + @Override public void revokeSTSToken(String sessionToken) throws IOException { } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java index 5171138710e0..7ea3a3516a58 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java @@ -35,6 +35,7 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; @@ -53,8 +54,10 @@ import javax.ws.rs.core.UriInfo; import org.apache.hadoop.ozone.s3.signature.AWSSignatureProcessor; import org.apache.hadoop.ozone.s3.signature.SignatureInfo; +import org.apache.hadoop.ozone.s3.signature.SignatureProcessor; import org.apache.hadoop.ozone.s3.signature.StringToSignProducer; import org.apache.kerby.util.Hex; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -71,6 +74,21 @@ public class TestAuthorizationFilter { private static final String CURDATE = DATE_FORMATTER.format(LocalDate.now()); + @Test + void skipsAwsAuthWhenExplicitlyRequested() throws Exception { + ContainerRequestContext context = mock(ContainerRequestContext.class); + SignatureProcessor signatureProcessor = mock(SignatureProcessor.class); + SignatureInfo signatureInfo = mock(SignatureInfo.class); + when(context.getProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY)) + .thenReturn(Boolean.TRUE); + + authorizationFilter.setSignatureParser(signatureProcessor); + authorizationFilter.setSignatureInfo(signatureInfo); + authorizationFilter.filter(context); + + verifyNoInteractions(signatureProcessor, signatureInfo); + } + private static StreamtestAuthFilterFailuresInput() { return Stream.of( arguments( diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java index 059f54e0993c..bd92c3669c41 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java @@ -18,6 +18,7 @@ package org.apache.hadoop.ozone.s3sts; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3_ADMINISTRATORS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -50,6 +51,7 @@ import org.apache.hadoop.ozone.client.OzoneClientStub; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo; import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder; import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OSTSException; @@ -77,6 +79,7 @@ public class TestS3STSEndpoint { @BeforeEach public void setup() throws Exception { + OzoneConfigurationHolder.resetConfiguration(); OzoneConfiguration config = new OzoneConfiguration(); config.set(OZONE_S3_ADMINISTRATORS, "test-user"); OzoneConfigurationHolder.setConfiguration(config); @@ -98,6 +101,17 @@ public void setup() throws Exception { "session-token", Instant.now().plusSeconds(3600).getEpochSecond(), "AROA1234567890123456:test-session")); + when(objectStore.assumeRoleWithWebIdentity(anyString(), anyString(), + anyInt(), anyString(), any(), anyString())) + .thenReturn(new AssumeRoleWithWebIdentityResponseInfo( + "ASIAWEBIDENTITY123456", + "webIdentitySecretAccessKey", + "web-identity-session-token", + Instant.now().plusSeconds(3600).getEpochSecond(), + "AROA1234567890123456:test-session", + "subject-123", + "ozone", + "keycloak")); when(clientStub.getObjectStore()).thenReturn(objectStore); endpoint = new S3STSEndpoint(); @@ -360,6 +374,84 @@ public void testStsWhenActionNotImplemented() throws Exception { "Operation GetSessionToken is not supported yet."); } + @Test + public void testStsAssumeRoleWithWebIdentityDisabled() throws Exception { + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN, + ROLE_SESSION_NAME, 3600, "2011-06-15", null, + "sensitive-token-material", "keycloak")); + + assertEquals(501, ex.getHttpCode()); + verifyNoInteractions(auditLogger); + verify(objectStore, never()).assumeRole(anyString(), anyString(), + anyInt(), any(), anyString()); + verify(objectStore, never()).assumeRoleWithWebIdentity( + anyString(), anyString(), anyInt(), anyString(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction", + "Operation AssumeRoleWithWebIdentity is not supported yet."); + } + + @Test + public void testStsAssumeRoleWithWebIdentityMissingToken() + throws Exception { + enableWebIdentity(); + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN, + ROLE_SESSION_NAME, 3600, "2011-06-15", null, null, + "keycloak")); + + assertEquals(400, ex.getHttpCode()); + verifyNoInteractions(auditLogger); + verify(objectStore, never()).assumeRole(anyString(), anyString(), + anyInt(), any(), anyString()); + verify(objectStore, never()).assumeRoleWithWebIdentity( + anyString(), anyString(), anyInt(), anyString(), any(), anyString()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", + "webIdentityToken"); + } + + @Test + public void testStsAssumeRoleWithWebIdentityRoutesWhenEnabled() + throws Exception { + enableWebIdentity(); + + final Response response = endpoint.get("AssumeRoleWithWebIdentity", + ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null, + "sensitive-token-material", "keycloak"); + + assertEquals(200, response.getStatus()); + verifyNoInteractions(auditLogger); + verify(objectStore).assumeRoleWithWebIdentity(ROLE_ARN, + ROLE_SESSION_NAME, 3600, "sensitive-token-material", "keycloak", + REQUEST_ID); + + final Document doc = parseXml((String) response.getEntity()); + final Element root = doc.getDocumentElement(); + assertEquals("AssumeRoleWithWebIdentityResponse", root.getLocalName()); + assertEquals(STS_NS, root.getNamespaceURI()); + assertNotNull(doc.getElementsByTagNameNS( + STS_NS, "AssumeRoleWithWebIdentityResult").item(0)); + assertEquals("ASIAWEBIDENTITY123456", + doc.getElementsByTagName("AccessKeyId").item(0).getTextContent()); + assertEquals("webIdentitySecretAccessKey", + doc.getElementsByTagName("SecretAccessKey").item(0) + .getTextContent()); + assertEquals("web-identity-session-token", + doc.getElementsByTagName("SessionToken").item(0).getTextContent()); + assertEquals("subject-123", + doc.getElementsByTagName("SubjectFromWebIdentityToken").item(0) + .getTextContent()); + assertEquals("ozone", + doc.getElementsByTagName("Audience").item(0).getTextContent()); + assertEquals("keycloak", + doc.getElementsByTagName("Provider").item(0).getTextContent()); + } + @Test public void testStsMissingRoleSessionName() throws Exception { final OSTSException ex = assertThrows(OSTSException.class, () -> @@ -570,6 +662,13 @@ private static Document parseXml(String xml) throws Exception { return documentBuilder.parse(new InputSource(new StringReader(xml))); } + private static void enableWebIdentity() { + OzoneConfigurationHolder.resetConfiguration(); + OzoneConfiguration config = new OzoneConfiguration(); + config.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true); + OzoneConfigurationHolder.setConfiguration(config); + } + private static void assertStsErrorXml(String xml, String expectedNamespace, String expectedType, String expectedCode, String expectedMessageContains) throws Exception { final Document doc = parseXml(xml); diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java new file mode 100644 index 000000000000..bcc22b26e6ab --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java @@ -0,0 +1,220 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hadoop.ozone.s3sts; + +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.UriInfo; +import org.apache.commons.io.IOUtils; +import org.apache.hadoop.hdds.conf.OzoneConfiguration; +import org.apache.hadoop.ozone.s3.AuthorizationFilter; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Tests for the STS-only WebIdentity auth bypass filter. + */ +public class TestS3STSWebIdentityAuthBypassFilter { + + @Test + public void enabledGetWebIdentityRequestSkipsAwsAuth() throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + ContainerRequestContext context = context(HttpMethod.GET, + "AssumeRoleWithWebIdentity", null); + + filter.filter(context); + + verify(context).setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY, + Boolean.TRUE); + } + + @Test + public void disabledWebIdentityRequestDoesNotSkipAwsAuth() + throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(false); + ContainerRequestContext context = context(HttpMethod.GET, + "AssumeRoleWithWebIdentity", null); + + filter.filter(context); + + verify(context, never()).setProperty(anyString(), any()); + } + + @Test + public void otherStsActionDoesNotSkipAwsAuth() throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + ContainerRequestContext context = context(HttpMethod.GET, "AssumeRole", + null); + + filter.filter(context); + + verify(context, never()).setProperty(anyString(), any()); + } + + @Test + public void postWebIdentityRequestSkipsAwsAuthAndRestoresBody() + throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + String body = "Action=AssumeRoleWithWebIdentity" + + "&WebIdentityToken=sensitive-token-material"; + ContainerRequestContext context = context(HttpMethod.POST, null, body); + + filter.filter(context); + + verify(context).setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY, + Boolean.TRUE); + ArgumentCaptor streamCaptor = + ArgumentCaptor.forClass(InputStream.class); + verify(context).setEntityStream(streamCaptor.capture()); + assertEquals(body, read(streamCaptor.getValue())); + } + + @Test + public void postWebIdentityRequestAtBodyLimitSkipsAwsAuthAndRestoresBody() + throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + String body = formBody(S3STSWebIdentityRequestParser.MAX_FORM_BODY_BYTES); + ContainerRequestContext context = context(HttpMethod.POST, null, body); + + filter.filter(context); + + verify(context).setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY, + Boolean.TRUE); + ArgumentCaptor streamCaptor = + ArgumentCaptor.forClass(InputStream.class); + verify(context).setEntityStream(streamCaptor.capture()); + assertEquals(body, read(streamCaptor.getValue())); + } + + @Test + public void oversizedPostWebIdentityRequestFailsBeforeBypass() + throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + String body = formBody( + S3STSWebIdentityRequestParser.MAX_FORM_BODY_BYTES + 1); + ContainerRequestContext context = context(HttpMethod.POST, null, body); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> filter.filter(context)); + + assertEquals(413, ex.getResponse().getStatus()); + verify(context, never()).setProperty(anyString(), any()); + verify(context, never()).setEntityStream(any()); + } + + @Test + public void adversarialActionValuesDoNotSkipAwsAuth() throws Exception { + assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity%20"); + assertDoesNotSkipPost("action=assumerolewithwebidentity"); + assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity%00"); + assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity" + + "&Action=AssumeRole"); + assertDoesNotSkipPost("{\"Action\":\"AssumeRoleWithWebIdentity\"}"); + assertDoesNotSkipPost("Version=2011-06-15"); + assertDoesNotSkipPost("Action="); + } + + @Test + public void duplicateQueryActionDoesNotSkipAwsAuth() throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + ContainerRequestContext context = contextWithQueryActions( + "AssumeRoleWithWebIdentity", "AssumeRole"); + + filter.filter(context); + + verify(context, never()).setProperty(anyString(), any()); + } + + private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) { + OzoneConfiguration conf = new OzoneConfiguration(); + conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, enabled); + return new S3STSWebIdentityAuthBypassFilter(conf); + } + + private static void assertDoesNotSkipPost(String body) throws Exception { + S3STSWebIdentityAuthBypassFilter filter = filter(true); + ContainerRequestContext context = context(HttpMethod.POST, null, body); + + filter.filter(context); + + verify(context, never()).setProperty(anyString(), any()); + } + + private static ContainerRequestContext context(String method, + String queryAction, String body) { + ContainerRequestContext context = mock(ContainerRequestContext.class); + UriInfo uriInfo = mock(UriInfo.class); + MultivaluedHashMap queryParams = + new MultivaluedHashMap<>(); + if (queryAction != null) { + queryParams.putSingle("Action", queryAction); + } + when(context.getMethod()).thenReturn(method); + when(context.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getQueryParameters()).thenReturn(queryParams); + if (body != null) { + when(context.getEntityStream()).thenReturn(new ByteArrayInputStream( + body.getBytes(StandardCharsets.UTF_8))); + } + return context; + } + + private static ContainerRequestContext contextWithQueryActions( + String... queryActions) { + ContainerRequestContext context = mock(ContainerRequestContext.class); + UriInfo uriInfo = mock(UriInfo.class); + MultivaluedHashMap queryParams = + new MultivaluedHashMap<>(); + queryParams.put("Action", Arrays.asList(queryActions)); + when(context.getMethod()).thenReturn(HttpMethod.GET); + when(context.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getQueryParameters()).thenReturn(queryParams); + return context; + } + + private static String read(InputStream stream) throws Exception { + return new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8); + } + + private static String formBody(int size) { + String prefix = "Action=AssumeRoleWithWebIdentity&WebIdentityToken="; + return prefix + repeat('a', size - prefix.length()); + } + + private static String repeat(char ch, int count) { + char[] chars = new char[count]; + Arrays.fill(chars, ch); + return new String(chars); + } +} diff --git a/pom.xml b/pom.xml index 696eb499952b..2de60387e3a5 100644 --- a/pom.xml +++ b/pom.xml @@ -224,6 +224,7 @@ 1.5.4 ${test.build.dir} ${project.build.directory}/test-dir + 2.0.5 4 @@ -1570,6 +1571,11 @@ + + org.testcontainers + testcontainers + ${testcontainers.version} + org.vafer jdeb