From c1d6cf4af6ac6db86b42cc66002b63b43056a52a Mon Sep 17 00:00:00 2001 From: paf91 Date: Wed, 13 May 2026 22:36:30 +0300 Subject: [PATCH 1/9] HDDS-15273. Add OIDC validation foundation for STS WebIdentity Includes: - STS-focused OIDC/JWKS validator - OIDC config keys - AssumeRoleWithWebIdentity authorizer request shape - fail-closed default authorizer hook - S3G STS bootstrap auth bypass only for Action=AssumeRoleWithWebIdentity - design doc - unit tests --- .../apache/hadoop/ozone/OzoneConfigKeys.java | 46 ++ .../src/main/resources/ozone-default.xml | 90 ++++ .../oidc-assume-role-with-web-identity.md | 484 ++++++++++++++++++ hadoop-ozone/common/pom.xml | 4 + .../acl/AssumeRoleWithWebIdentityRequest.java | 185 +++++++ .../ozone/security/acl/IAccessAuthorizer.java | 20 + .../ozone/security/oidc/AuthCredentials.java | 46 ++ .../security/oidc/CachingJwksProvider.java | 110 ++++ .../ozone/security/oidc/JwksFetcher.java | 31 ++ .../ozone/security/oidc/JwksProvider.java | 29 ++ .../oidc/OidcAuthenticationException.java | 34 ++ .../ozone/security/oidc/OidcConfig.java | 316 ++++++++++++ .../oidc/OidcJwtIdentityProvider.java | 306 +++++++++++ .../ozone/security/oidc/OzoneIdentity.java | 197 +++++++ .../security/oidc/OzoneIdentityProvider.java | 27 + .../ozone/security/oidc/UrlJwksFetcher.java | 40 ++ .../ozone/security/oidc/package-info.java | 21 + .../TestAssumeRoleWithWebIdentityRequest.java | 189 +++++++ .../oidc/TestOidcJwtIdentityProvider.java | 402 +++++++++++++++ .../hadoop/ozone/s3/AuthorizationFilter.java | 7 + .../hadoop/ozone/s3sts/S3STSEndpoint.java | 108 +++- .../hadoop/ozone/s3sts/S3STSEndpointBase.java | 6 + .../S3STSWebIdentityAuthBypassFilter.java | 74 +++ .../s3sts/S3STSWebIdentityRequestParser.java | 95 ++++ .../ozone/s3/TestAuthorizationFilter.java | 18 + .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 66 +++ .../TestS3STSWebIdentityAuthBypassFilter.java | 137 +++++ 27 files changed, 3081 insertions(+), 7 deletions(-) create mode 100644 hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java 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..1f0a0fd99fb5 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,52 @@ 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_STS_WEB_IDENTITY_ISSUER_URI = + "ozone.sts.web.identity.issuer.uri"; + public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT = ""; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI = + "ozone.sts.web.identity.jwks.uri"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT = ""; + public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE = + "ozone.sts.web.identity.audience"; + public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT = ""; + public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM = + "ozone.sts.web.identity.username.claim"; + public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT = + "preferred_username"; + public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM = + "ozone.sts.web.identity.subject.claim"; + public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT = + "sub"; + public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM = + "ozone.sts.web.identity.groups.claim"; + public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT = + "groups"; + public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM = + "ozone.sts.web.identity.roles.claim"; + public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT = + "realm_access.roles"; + public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW = + "ozone.sts.web.identity.clock.skew"; + public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT = + "60s"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL = + "ozone.sts.web.identity.jwks.refresh.interval"; + public static final String + OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT = "10m"; + public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS = + "ozone.sts.web.identity.require.https"; + public static final boolean OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT = + true; + public static final String + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS = + "ozone.sts.web.identity.allow.insecure.http.for.tests"; + public static final boolean + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_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/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml index 2066a99baa47..615ea2ba1aa2 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -2142,6 +2142,96 @@ true, hadoop.security.authentication should be Kerberos. + + ozone.sts.web.identity.enabled + false + OZONE, SECURITY, S3, STS, OIDC + + Enables experimental Ozone STS AssumeRoleWithWebIdentity support. + When enabled, OM validates OIDC web identity JWTs and issues temporary + S3 credentials through the existing STS token infrastructure. + + + + ozone.sts.web.identity.issuer.uri + + OZONE, SECURITY, S3, STS, OIDC + Expected OIDC issuer URI for STS web identity tokens. + + + ozone.sts.web.identity.jwks.uri + + OZONE, SECURITY, S3, STS, OIDC + + JWKS URI used to validate STS web identity JWT signatures. If empty, + a later STS integration may derive it from issuer metadata. + + + + ozone.sts.web.identity.audience + + OZONE, SECURITY, S3, STS, OIDC + Required audience claim for STS web identity tokens. + + + ozone.sts.web.identity.username.claim + preferred_username + OZONE, SECURITY, S3, STS, OIDC + OIDC claim used as the effective Ozone username. + + + ozone.sts.web.identity.subject.claim + sub + OZONE, SECURITY, S3, STS, OIDC + OIDC claim used as the immutable external subject. + + + ozone.sts.web.identity.groups.claim + groups + OZONE, SECURITY, S3, STS, OIDC + + OIDC claim path containing user groups. Nested claim paths such as + resource_access.ozone.groups are supported. + + + + ozone.sts.web.identity.roles.claim + realm_access.roles + OZONE, SECURITY, S3, STS, OIDC + + OIDC claim path containing role attributes. Roles are identity + attributes only; Ranger remains the authorization policy engine. + + + + ozone.sts.web.identity.clock.skew + 60s + OZONE, SECURITY, S3, STS, OIDC + Allowed clock skew for OIDC exp, nbf, and iat validation. + + + ozone.sts.web.identity.jwks.refresh.interval + 10m + OZONE, SECURITY, S3, STS, OIDC + Interval for refreshing cached JWKS signing keys. + + + ozone.sts.web.identity.require.https + true + OZONE, SECURITY, S3, STS, OIDC + + Require HTTPS issuer and JWKS URIs for STS web identity validation. + + + + ozone.sts.web.identity.allow.insecure.http.for.tests + false + OZONE, SECURITY, S3, STS, OIDC + + Allows HTTP issuer and JWKS URIs only for tests. Do not enable in + production. + + ozone.security.http.kerberos.enabled false diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md new file mode 100644 index 000000000000..ffbbb6ae2d64 --- /dev/null +++ b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md @@ -0,0 +1,484 @@ +--- +title: OIDC AssumeRoleWithWebIdentity for Ozone STS +summary: Web identity support for Ozone STS using OIDC and Ranger authorization +date: 2026-05-13 +status: proposed +--- + + +# OIDC AssumeRoleWithWebIdentity for Ozone STS + +## Status + +Proposed staged implementation. + +This document narrows the previous broad OIDC direction to an upstream-friendly +MVP: extend the Ozone STS temporary S3 credential model with an AWS-compatible +`AssumeRoleWithWebIdentity` action. + +## Problem + +Secure Ozone S3 deployments currently depend on Kerberos-backed identities for +S3 credential issuance. Kubernetes workloads commonly already have OIDC tokens +from Keycloak or another IdP, but do not have an easy Kerberos bootstrap path. + +The target is not a Kerberos-free Ozone cluster. The target is a narrow STS +exchange: + +1. Keycloak authenticates the caller and issues a signed OIDC JWT. +2. Ozone STS validates the JWT locally using JWKS. +3. Ranger or the configured Ozone authorizer decides whether the identity may + assume the requested role. +4. Ozone STS issues temporary S3 credentials. +5. S3 Gateway and OM validate those temporary credentials and authorize object + operations using the assumed identity and session context. + +Ranger remains the authorization source of truth. Keycloak roles and groups are +identity attributes only. + +## Current STS And S3 Security Path + +This design is based on the `origin/HDDS-13323-sts` branch at commit +`37a224b217`, which contains the STS runtime that was missing from earlier +base branches. + +The current STS runtime contains: + +- `/sts` HTTP endpoint in `org.apache.hadoop.ozone.s3sts.S3STSEndpoint`; +- endpoint authentication setup in `S3STSEndpointBase`; +- AWS STS `AssumeRole` XML response model in `S3AssumeRoleResponseXml`; +- S3G to OM client path through `ObjectStore`, `ClientProtocol`, `RpcClient`, + `OzoneManagerProtocol`, and + `OzoneManagerProtocolClientSideTranslatorPB.assumeRole()`; +- OM request handling in + `org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest`; +- OM response handling in `S3AssumeRoleResponse`; +- session token identifier and secret manager in `STSTokenIdentifier`, + `STSTokenSecretManager`, and `STSSecurityUtil`; +- revoked STS token metadata and cleanup through `S3RevokeSTSTokenRequest`, + `S3DeleteRevokedSTSTokensRequest`, and + `RevokedSTSTokenCleanupService`; +- authorization extension points in `AssumeRoleRequest`, + `IAccessAuthorizer.generateAssumeRoleSessionPolicy()`, and + `RequestContext.sessionPolicy`. + +The current S3 request authentication path is: + +- S3G parses AWS SigV4 in `AuthorizationFilter`, + `SignatureProcessor`, `AuthorizationV4HeaderParser`, + `AuthorizationV4QueryParser`, and `StringToSignProducer`. +- `EndpointBase` creates `S3Auth` from the parsed access key, signature, and + string-to-sign, then stores it in the `ClientProtocol` thread-local. +- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into + `OMRequest.s3Authentication`. +- `AWSSignatureProcessor` extracts `x-amz-security-token` for temporary + credentials into `SignatureInfo.sessionToken`. +- `EndpointBase` and `S3STSEndpointBase` propagate the session token into + `S3Auth`. +- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into + `OMRequest.s3Authentication`. +- `S3SecurityUtil.validateS3Credential()` validates either permanent S3 + credentials or STS temporary credentials. +- For STS credentials, `STSSecurityUtil` decodes, validates, and decrypts the + session token, then OM validates the SigV4 signature with the temporary + secret access key. +- `OmMetadataReader` attaches STS session policy from the OM thread-local + `STSTokenIdentifier` to `RequestContext` for subsequent authorization. + +The current permanent S3 credential storage path is: + +- `S3SecretManager` and `S3SecretManagerImpl`. +- `S3SecretValue`. +- OM metadata S3 secret table via `OmMetadataManagerImpl`. +- `ozone s3 getsecret`, `setsecret`, and `revokesecret` client paths. + +## Dependency On Existing Ozone STS Runtime + +`AssumeRoleWithWebIdentity` must be an incremental extension of the existing +`AssumeRole` runtime in `origin/HDDS-13323-sts`. It must not introduce a second +STS endpoint, a separate S3 authentication system, or local S3G-only temporary +credential state. + +Existing runtime: + +```text +AssumeRole + -> S3 SigV4-authenticated /sts request + -> OM S3AssumeRoleRequest + -> temporary access key / secret / session token + -> STSSecurityUtil validation on later S3 requests + -> RequestContext.sessionPolicy + -> Ranger or configured authorizer +``` + +New runtime: + +```text +AssumeRoleWithWebIdentity + -> unauthenticated /sts bootstrap request only for this action + -> OM validates Keycloak/OIDC JWT + -> OM authorizes role assumption through Ranger or configured authorizer + -> existing temporary credential issuer / session token path + -> existing STSSecurityUtil validation on later S3 requests + -> RequestContext.sessionPolicy and assumed identity + -> Ranger or configured authorizer +``` + +The first implementation slice intentionally stops before the OM runtime path: +S3G can parse `Action=AssumeRoleWithWebIdentity`, validate the basic STS +parameters, and return a clear unsupported error once the request reaches the +placeholder. The next stage must add the OM request/protobuf path and move JWT +validation, identity mapping, authorization, and credential issuance into OM. + +Temporary credentials must not be stored only in S3G memory. S3G can have +multiple replicas and can restart. The issuing and validation authority must be +OM-backed, persisted in Ozone metadata, or based on self-contained signed tokens +whose signing keys are rotation-safe and available to all validating components. + +## Endpoint Placement + +The existing STS runtime places `/sts` on the S3 Gateway HTTP/HTTPS port. +WebIdentity follows that placement: S3G exposes the AWS-compatible STS API +surface, while OM remains authoritative for JWT validation, identity mapping, +role-assumption authorization, credential issuance, revocation, and later +temporary credential validation. + +Because existing `/sts` `AssumeRole` is protected by the normal S3 SigV4 +`AuthorizationFilter`, `AssumeRoleWithWebIdentity` needs a narrow bootstrap +exception: + +- only for the STS application path; +- only for `Action=AssumeRoleWithWebIdentity`; +- only when `ozone.sts.web.identity.enabled=true`; +- never for normal S3 object APIs; +- never for existing `AssumeRole` or other STS actions. + +This exception must not make S3G a JWT source of truth. S3G may parse and route +the request, but it must forward the web identity token and request context to +OM. OM validates the JWT itself and issues the credentials. + +## RoleArn Semantics + +The current `AssumeRoleRequest` model contains `targetRoleName`, not a full AWS +IAM role database. No role metadata store or IAM-like role lifecycle was found +in this tree. `RoleArn` should therefore be treated as the authorization +resource and request context for Ranger or the configured Ozone authorizer in +the MVP. + +The Web Identity patch must not invent a new IAM role database. If the STS +runtime already defines role ARN parsing or role-name normalization, Web +Identity should reuse it. Otherwise, `RoleArn` remains an opaque policy resource +for the authorizer and for audit/session context. + +## New Flow + +`AssumeRoleWithWebIdentity` is handled by the Ozone STS endpoint. + +Request parameters: + +- `Action=AssumeRoleWithWebIdentity` +- `RoleArn=` +- `RoleSessionName=` +- `WebIdentityToken=` +- `DurationSeconds=` +- `Policy=` +- `ProviderId=` + +Flow: + +1. The client or workload obtains an OIDC access token from Keycloak. +2. The client calls Ozone STS with `AssumeRoleWithWebIdentity`. +3. Ozone STS rejects the request unless `ozone.sts.web.identity.enabled=true`. +4. S3G validates only the STS request shape that is safe to validate at the + edge: action, version, role ARN syntax, role session name, duration bounds, + and presence of `WebIdentityToken`. +5. S3G forwards `RoleArn`, `RoleSessionName`, `WebIdentityToken`, + `DurationSeconds`, `ProviderId`, and request context to OM. +6. OM validates the JWT: + - token is a signed JWT; + - `alg=none` is rejected; + - signature validates against the configured JWKS; + - `iss` equals `ozone.sts.web.identity.issuer.uri`; + - configured audience is present; + - `exp`, `nbf`, and `iat` are validated with configured clock skew; + - configured username and subject claims are present. +7. OM maps claims into an Ozone identity: + - username; + - subject; + - issuer; + - groups; + - roles; + - token expiration. +8. OM builds an assume-role authorization request and calls Ranger or the + configured Ozone authorizer before issuing any credential. +9. If authorized, OM issues temporary S3 credentials: + - `Credentials.AccessKeyId`; + - `Credentials.SecretAccessKey`; + - `Credentials.SessionToken`; + - `Credentials.Expiration`; + - `SubjectFromWebIdentityToken`; + - `AssumedRoleUser`; + - `Audience`; + - `Provider`. +10. The client uses those credentials with ordinary AWS SigV4 against S3G. +11. S3G and OM validate the temporary credential, recover the assumed identity + and session policy, and pass them to the authorizer for every S3 operation. + +## Configuration + +The MVP uses STS-focused configuration keys: + +```properties +ozone.sts.web.identity.enabled=false +ozone.sts.web.identity.issuer.uri= +ozone.sts.web.identity.jwks.uri= +ozone.sts.web.identity.audience= +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.clock.skew=60s +ozone.sts.web.identity.jwks.refresh.interval=10m +ozone.sts.web.identity.require.https=true +ozone.sts.web.identity.allow.insecure.http.for.tests=false +``` + +The feature is opt-in. When disabled, Kerberos, existing S3 SigV4 handling, +existing S3 secret handling, and non-secure mode behavior are unchanged. + +## OIDC Validation + +The reusable validation module is intentionally small: + +- `AuthCredentials` wraps bearer token material and redacts it in `toString()`. +- `OzoneIdentity` carries normalized username, subject, issuer, groups, roles, + auth method, authentication time, expiration time, and raw claims. +- `OidcJwtIdentityProvider` validates signed JWTs and maps claims. +- `JwksProvider`, `JwksFetcher`, `UrlJwksFetcher`, and + `CachingJwksProvider` load and cache JWKS with refresh-on-unknown-kid + behavior. +- `OidcAuthenticationException` fails closed without embedding the raw token in + exception messages. + +The module does not call Keycloak for every S3 request. JWKS validation is local, +with refresh on cache expiry and unknown key id. + +## Ranger Authorization Points + +The first authorization point is credential issuance. Before generating +temporary credentials, Ozone must call the configured authorizer with: + +- user: mapped OIDC username; +- groups: mapped OIDC groups; +- action: `AssumeRoleWithWebIdentity`; +- resource: `RoleArn` or normalized Ozone role resource; +- context: issuer, subject, audience, role session name, provider id, + requested duration, and client IP/host if available. + +Deny is the default. If Ranger denies or the authorizer cannot decide, Ozone +returns `AccessDenied` and does not issue credentials. + +The common request-shape extension point is +`AssumeRoleWithWebIdentityRequest`, with +`IAccessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy()` as the +default authorizer hook. Existing authorizers are not forced to implement this +immediately because the new method has a fail-closed default implementation. + +The second authorization point is every S3 operation made with the temporary +credentials. OM must recover the assumed identity and session policy from the +session token and build a `RequestContext` carrying: + +- `clientUgi` for the assumed OIDC username and groups; +- `sessionPolicy` returned by the assume-role authorization step; +- `s3Action` mapped from the S3 endpoint method; +- bucket/key resource information. + +Ranger evaluates normal resource/action policies using that context. + +## Temporary Credential Lifecycle + +Temporary credentials must: + +- expire no later than the requested `DurationSeconds` and Ozone's configured + STS maximum; +- require `x-amz-security-token` or the equivalent SigV4 query parameter; +- fail closed if the session token is unknown, expired, revoked, malformed, or + fails signature/MAC verification; +- never log the secret access key, session token, WebIdentityToken, refresh + token, or client secret; +- map back to the assumed OIDC identity and role session context; +- preserve the authorizer session policy used for subsequent S3 authorization. + +The existing STS runtime uses self-contained session tokens containing the +encrypted secret access key, original identity, role ARN, session policy, +expiration, signing key id, and MAC. `AssumeRoleWithWebIdentity` should reuse +that issuer and validator instead of creating a parallel token format. + +In `origin/HDDS-13323-sts`, `STSTokenIdentifier` stores the +`originalAccessKeyId` because `AssumeRole` starts from an existing S3 access +key. `AssumeRoleWithWebIdentity` has no permanent S3 access key. The token +model must therefore be extended backward-compatibly, for example with an +`authType` field plus optional WebIdentity fields: + +- `authType=ASSUME_ROLE` for existing tokens, with `originalAccessKeyId` + preserved; +- `authType=WEB_IDENTITY` for new tokens, with effective user, groups, issuer, + subject, audience, role ARN, role session name, provider id, and session + policy; +- old `AssumeRole` tokens must continue to deserialize and validate exactly as + before; +- `STSSecurityUtil.ensureEssentialFieldsArePresentInToken()` must not require + `originalAccessKeyId` for `WEB_IDENTITY` tokens, but must still require all + fields needed to validate signatures and authorize later S3 operations. + +Revocation should follow the STS design: store revoked session token identifiers +in OM metadata and fail closed if revocation status cannot be checked. + +## AWS-Compatible Response + +The XML response should follow the AWS STS shape where practical: + +```xml + + + ... + ozone + https://keycloak.example.com/realms/ozone + + ... + ... + + + ... + ... + ... + ... + + + +``` + +Errors should use STS/S3-compatible codes where possible: + +- invalid or expired JWT: `InvalidIdentityToken`; +- disabled feature: `AccessDenied` or `InvalidAction`; +- unauthorized role assumption: `AccessDenied`; +- unsupported optional parameter: `InvalidParameterValue`; +- internal validation or revocation failures: fail closed. + +## Security Model + +This feature does not replace Kerberos for daemon authentication or the broader +Ozone secure cluster model. + +Security boundaries: + +- Keycloak authenticates the caller by signing JWTs. +- Ozone STS validates the token and issues short-lived S3 credentials. +- Ranger or the configured Ozone authorizer decides whether the caller can + assume a role and what object-store actions are allowed. +- Ozone S3G/OM enforce the temporary credential and Ranger decisions. + +Required protections: + +- TLS is required for production STS and Keycloak endpoints. +- Unsigned JWTs and `alg=none` are rejected. +- Incorrect issuer, audience, signature, time claims, or required claims are + rejected. +- JWKS rotation is handled by cache refresh and refresh-on-unknown-kid. +- Web identity token and temporary credentials are never logged. +- Direct OM/SCM/DN access is still governed by existing Ozone security. This + MVP only adds an STS path for temporary S3 credentials. + +## Non-Goals + +The MVP explicitly does not include: + +- full OIDC-only secure Ozone cluster; +- replacing Kerberos daemon login; +- OFS OIDC login; +- `ozone auth login --oidc`; +- device-code flow; +- Keycloak Authorization Services as the object-store PDP; +- replacing Ranger with Keycloak roles; +- daemon-to-daemon OIDC authentication. + +## Test Strategy + +Unit tests: + +- valid JWT from a test RSA key validates successfully; +- expired JWT fails; +- wrong issuer fails; +- wrong audience fails; +- wrong signature fails; +- `alg=none` fails; +- manipulated groups claim fails because the signature no longer matches; +- unknown `kid` triggers JWKS refresh or fails safely; +- username, subject, groups, and roles claim mapping works; +- token material is not present in exceptions. + +STS authorization tests: + +- fake authorizer sees user, groups, action `AssumeRoleWithWebIdentity`, role + resource, issuer, subject, audience, and session name; +- allowed identity receives temporary credentials; +- denied identity receives `AccessDenied`; +- no credential is generated before authorization succeeds. + +Temporary credential tests: + +- credentials require a session token; +- expired credentials fail; +- tampered session token fails; +- unknown/revoked session token fails; +- allowed bucket operation succeeds with allowed role/session policy; +- denied bucket operation fails. + +Integration tests: + +- Keycloak Testcontainers or docker-compose realm `ozone-test`; +- client `ozone-sts`; +- users `tomato-user` and `denied-user`; +- group `ozone-tomato`; +- `tomato-user` token includes `preferred_username`, `sub`, `groups`, + `realm_access.roles`, and `aud=ozone`; +- real Keycloak JWT validates with Ozone provider; +- fake/Ranger authorizer allows `tomato-user` to assume a test role and denies + `denied-user`. + +Full Ranger container testing is optional for the MVP. Unit and mock-layer tests +must prove request shape and fail-closed behavior. + +## Migration And Future Work + +Migration path: + +1. Existing Kerberos and S3 secret behavior remains unchanged. +2. Operators enable `ozone.sts.web.identity.enabled=true` for STS only. +3. Workloads exchange Keycloak JWTs for temporary S3 credentials. +4. Ranger policies grant role assumption and object access. + +Future work: + +- reuse the OIDC validation module for OIDC-to-Ozone delegation tokens for + OFS/CLI; +- add daemon authentication without Kerberos via mTLS, SPIFFE, Kubernetes + ServiceAccount JWTs, or Keycloak client credentials; +- add hybrid Kerberos plus OIDC migration mode if broader Ozone authentication + is pursued; +- improve AWS STS API compatibility; +- add an optional real Ranger integration test profile. diff --git a/hadoop-ozone/common/pom.xml b/hadoop-ozone/common/pom.xml index 13a53cdc7a88..7e15ac212428 100644 --- a/hadoop-ozone/common/pom.xml +++ b/hadoop-ozone/common/pom.xml @@ -47,6 +47,10 @@ com.google.protobuf protobuf-java + + com.nimbusds + nimbus-jose-jwt + io.grpc grpc-api 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..3512356a9b44 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,185 @@ +/* + * 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; + +/** + * 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 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; + + public AssumeRoleWithWebIdentityRequest(String host, InetAddress ip, + String user, Set groups, Set roles, String roleArn, + String roleSessionName, String issuer, String subject, String audience, + String providerId, Set grants) { + this.host = host; + this.ip = ip; + this.user = requireNonBlank(user, "user"); + this.groups = immutableSet(groups); + this.roles = immutableSet(roles); + this.roleArn = requireNonBlank(roleArn, "roleArn"); + this.roleSessionName = + requireNonBlank(roleSessionName, "roleSessionName"); + this.issuer = requireNonBlank(issuer, "issuer"); + this.subject = requireNonBlank(subject, "subject"); + this.audience = requireNonBlank(audience, "audience"); + this.providerId = providerId; + this.grants = grants; + } + + 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 (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return value; + } +} 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..437eb96d94ae 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,26 @@ 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.

+ * + * @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/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java new file mode 100644 index 000000000000..a8f4cf61b4f0 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java @@ -0,0 +1,46 @@ +/* + * 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; + +/** + * 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 (token == null || token.trim().isEmpty()) { + 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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java new file mode 100644 index 000000000000..007174dc98c8 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java @@ -0,0 +1,110 @@ +/* + * 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; + +/** + * Thread-safe JWKS cache with refresh-on-unknown-kid semantics. + */ +public final class CachingJwksProvider implements JwksProvider { + + private final JwksFetcher fetcher; + private final Duration refreshInterval; + private final Clock clock; + private volatile JWKSet jwkSet; + private volatile Instant loadedAt = Instant.EPOCH; + + public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) { + this(fetcher, refreshInterval, Clock.systemUTC()); + } + + CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval, + 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"); + } + this.fetcher = fetcher; + this.refreshInterval = refreshInterval; + this.clock = clock; + } + + @Override + public List getKeys(String keyId) throws OidcAuthenticationException { + refreshIfNeeded(false); + List keys = findKeys(jwkSet, keyId); + if (keys.isEmpty() && keyId != null && !keyId.trim().isEmpty()) { + refreshIfNeeded(true); + 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 static List findKeys(JWKSet set, String keyId) { + if (set == null) { + return Collections.emptyList(); + } + if (keyId == null || keyId.trim().isEmpty()) { + 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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java new file mode 100644 index 000000000000..8139076161a2 --- /dev/null +++ b/hadoop-ozone/common/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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java new file mode 100644 index 000000000000..4323138c58f0 --- /dev/null +++ b/hadoop-ozone/common/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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java new file mode 100644 index 000000000000..faf19282c229 --- /dev/null +++ b/hadoop-ozone/common/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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java new file mode 100644 index 000000000000..5665c01195e8 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java @@ -0,0 +1,316 @@ +/* + * 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.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT; +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 static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.hdds.conf.ConfigurationSource; + +/** + * Configuration for the experimental OIDC identity provider. + */ +public final class OidcConfig { + + private final boolean enabled; + private final String issuerUri; + private final String jwksUri; + private final String audience; + private final String usernameClaim; + private final String subjectClaim; + private final String groupsClaim; + private final String rolesClaim; + private final Duration clockSkew; + private final Duration jwksRefreshInterval; + private final boolean requireHttps; + private final boolean allowInsecureHttpForTests; + + private OidcConfig(Builder builder) { + this.enabled = builder.enabled; + this.issuerUri = trimToEmpty(builder.issuerUri); + this.jwksUri = trimToEmpty(builder.jwksUri); + this.audience = trimToEmpty(builder.audience); + this.usernameClaim = requireNonBlank(builder.usernameClaim, + OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM); + this.subjectClaim = requireNonBlank(builder.subjectClaim, + OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM); + this.groupsClaim = requireNonBlank(builder.groupsClaim, + OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM); + this.rolesClaim = requireNonBlank(builder.rolesClaim, + OZONE_STS_WEB_IDENTITY_ROLES_CLAIM); + this.clockSkew = requireNonNegative(builder.clockSkew, + OZONE_STS_WEB_IDENTITY_CLOCK_SKEW); + this.jwksRefreshInterval = requireNonNegative(builder.jwksRefreshInterval, + OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL); + this.requireHttps = builder.requireHttps; + this.allowInsecureHttpForTests = builder.allowInsecureHttpForTests; + } + + public static OidcConfig from(ConfigurationSource conf) { + OidcConfig config = newBuilder() + .setEnabled(conf.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, + OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT)) + .setIssuerUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ISSUER_URI, + OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT)) + .setJwksUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_JWKS_URI, + OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT)) + .setAudience(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_AUDIENCE, + OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT)) + .setUsernameClaim(conf.getTrimmed( + OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM, + OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT)) + .setSubjectClaim(conf.getTrimmed( + OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM, + OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT)) + .setGroupsClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM, + OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT)) + .setRolesClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ROLES_CLAIM, + OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT)) + .setClockSkew(duration(conf, OZONE_STS_WEB_IDENTITY_CLOCK_SKEW, + OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT)) + .setJwksRefreshInterval(duration(conf, + OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL, + OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT)) + .setRequireHttps(conf.getBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, + OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT)) + .setAllowInsecureHttpForTests(conf.getBoolean( + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT)) + .build(); + if (config.isEnabled()) { + config.validateForProvider(); + } + return config; + } + + 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 boolean isRequireHttps() { + return requireHttps; + } + + public boolean isAllowInsecureHttpForTests() { + return allowInsecureHttpForTests; + } + + void validateForProvider() { + requireNonBlank(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI); + requireNonBlank(audience, OZONE_STS_WEB_IDENTITY_AUDIENCE); + + if (requireHttps && !allowInsecureHttpForTests) { + requireHttpsUri(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI); + if (!jwksUri.isEmpty()) { + requireHttpsUri(jwksUri, OZONE_STS_WEB_IDENTITY_JWKS_URI); + } + } + } + + private static Duration duration(ConfigurationSource conf, String key, + String defaultValue) { + return Duration.ofMillis(conf.getTimeDuration(key, defaultValue, + TimeUnit.MILLISECONDS)); + } + + 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 String requireNonBlank(String value, String key) { + String trimmed = trimToEmpty(value); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException(key + " must not be empty"); + } + return trimmed; + } + + private static String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + 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 = OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT; + private String issuerUri = OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT; + private String jwksUri = OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT; + private String audience = OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT; + private String usernameClaim = + OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT; + private String subjectClaim = + OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT; + private String groupsClaim = OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT; + private String rolesClaim = OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT; + private Duration clockSkew = Duration.ofSeconds(60); + private Duration jwksRefreshInterval = Duration.ofMinutes(10); + private boolean requireHttps = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT; + private boolean allowInsecureHttpForTests = + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; + + 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 setRequireHttps(boolean value) { + this.requireHttps = value; + return this; + } + + public Builder setAllowInsecureHttpForTests(boolean value) { + this.allowInsecureHttpForTests = value; + return this; + } + + public OidcConfig build() { + return new OidcConfig(this); + } + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java new file mode 100644 index 000000000000..ce0b0dbafd91 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java @@ -0,0 +1,306 @@ +/* + * 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; + +/** + * 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 + || credentials.getBearerToken().trim().isEmpty()) { + 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) || ((String) value).trim().isEmpty()) { + 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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java new file mode 100644 index 000000000000..1516bae08bb9 --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java @@ -0,0 +1,197 @@ +/* + * 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; + +/** + * 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 (value == null || value.trim().isEmpty()) { + 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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java new file mode 100644 index 000000000000..550e41a15c46 --- /dev/null +++ b/hadoop-ozone/common/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/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java new file mode 100644 index 000000000000..a2fe2b4dad3f --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java @@ -0,0 +1,40 @@ +/* + * 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; + +/** + * JWKS fetcher backed by a URL. + */ +public final class UrlJwksFetcher implements JwksFetcher { + + private final URL url; + + public UrlJwksFetcher(URL url) { + this.url = url; + } + + @Override + public JWKSet fetch() throws IOException, ParseException { + return JWKSet.load(url); + } +} diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java new file mode 100644 index 000000000000..fb89e48eb780 --- /dev/null +++ b/hadoop-ozone/common/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/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..19651ba34d9d --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,189 @@ +/* + * 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 = + new AssumeRoleWithWebIdentityRequest( + "s3g.example.com", ip, "tomato-user", groups, roles, ROLE_ARN, + "tomato-session", "https://keycloak.example.com/realms/ozone", + "subject-1", "ozone", "keycloak", null); + + 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, + () -> new AssumeRoleWithWebIdentityRequest( + null, null, "tomato-user", set("ozone-tomato"), set(), + " ", "tomato-session", "issuer", "subject", "audience", + null, null)); + } + + @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 new AssumeRoleWithWebIdentityRequest( + "host", null, user, set("ozone-tomato"), set("role:writer"), + ROLE_ARN, "session", "issuer", "subject", "audience", + "provider", null); + } + + 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/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java new file mode 100644 index 000000000000..11ec51cb4173 --- /dev/null +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java @@ -0,0 +1,402 @@ +/* + * 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.nio.charset.StandardCharsets; +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.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 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 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 (keyId == null || keyId.trim().isEmpty()) { + 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/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java index 091f8851fa3f..5244989812ed 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 @@ -17,6 +17,8 @@ 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 static javax.ws.rs.core.Response.Status.BAD_REQUEST; import static javax.ws.rs.core.Response.Status.FORBIDDEN; import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; @@ -49,6 +51,7 @@ import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; 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; @@ -128,9 +131,20 @@ public Response get( @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); } /** @@ -152,13 +166,25 @@ public Response post( @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); } 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 +199,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 +225,73 @@ 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()); + } + + // S3G has parsed and routed the request. The OM request path will validate + // the JWT and issue credentials; S3G must not trust or log the token here. + throw new OSTSException(UNSUPPORTED_OPERATION, + "AssumeRoleWithWebIdentity OM runtime is not implemented yet.", + NOT_IMPLEMENTED.getStatusCode()); + } + + 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"; @@ -351,4 +446,3 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR } } } - 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..e406bc0b4af4 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java @@ -0,0 +1,74 @@ +/* + * 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 com.google.common.annotations.VisibleForTesting; +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 { + + @Inject + private OzoneConfiguration ozoneConfiguration; + + @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); + } + + @VisibleForTesting + void setOzoneConfiguration(OzoneConfiguration conf) { + this.ozoneConfiguration = conf; + } +} 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..7862f076106b --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java @@ -0,0 +1,95 @@ +/* + * 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 javax.ws.rs.HttpMethod; +import javax.ws.rs.container.ContainerRequestContext; + +/** + * 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"; + + 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 context.getUriInfo().getQueryParameters().getFirst(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 = readFully(stream); + context.setEntityStream(new ByteArrayInputStream(body)); + String form = new String(body, StandardCharsets.UTF_8); + for (String pair : form.split("&")) { + int equals = pair.indexOf('='); + if (equals < 0) { + continue; + } + String name = decode(pair.substring(0, equals)); + if (ACTION.equals(name)) { + return decode(pair.substring(equals + 1)); + } + } + return null; + } + + private static byte[] readFully(InputStream stream) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = stream.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + + private static String decode(String value) throws IOException { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } +} 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..57bac2281f06 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; @@ -77,6 +78,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); @@ -360,6 +362,63 @@ 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()); + + 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()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", + "webIdentityToken"); + } + + @Test + public void testStsAssumeRoleWithWebIdentityRoutesWhenEnabled() + throws Exception { + enableWebIdentity(); + + 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()); + + ex.setRequestId(REQUEST_ID); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", + "AssumeRoleWithWebIdentity OM runtime is not implemented yet."); + } + @Test public void testStsMissingRoleSessionName() throws Exception { final OSTSException ex = assertThrows(OSTSException.class, () -> @@ -570,6 +629,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..8914825ab5ba --- /dev/null +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java @@ -0,0 +1,137 @@ +/* + * 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.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.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.UriInfo; +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())); + } + + private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) { + OzoneConfiguration conf = new OzoneConfiguration(); + conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, enabled); + S3STSWebIdentityAuthBypassFilter filter = + new S3STSWebIdentityAuthBypassFilter(); + filter.setOzoneConfiguration(conf); + return filter; + } + + 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 String read(InputStream stream) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[256]; + int read; + while ((read = stream.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } +} From f7e139f3d62612d65dc91b40f23ea4900c91c906 Mon Sep 17 00:00:00 2001 From: paf91 Date: Wed, 13 May 2026 23:44:41 +0300 Subject: [PATCH 2/9] HDDS-15273. Implement OM-authoritative AssumeRoleWithWebIdentity runtime Includes: - OM protocol/client path for AssumeRoleWithWebIdentity - OM-side JWT validation in preExecute() - sanitized Ratis request without raw WebIdentityToken - WebIdentity-backed STS token identity model - backward-compatible STSTokenIdentifier authType - reuse of existing STS validation path for subsequent S3 requests - S3G STS XML response/routing for WebIdentity - tests proving no raw JWT persistence and replay determinism --- .../oidc-assume-role-with-web-identity.md | 44 ++- .../hadoop/ozone/client/ObjectStore.java | 12 + .../ozone/client/protocol/ClientProtocol.java | 18 + .../hadoop/ozone/client/rpc/RpcClient.java | 11 + .../java/org/apache/hadoop/ozone/OmUtils.java | 2 + .../om/helpers/AssumeRoleResponseInfo.java | 4 +- ...AssumeRoleWithWebIdentityResponseInfo.java | 151 ++++++++ .../om/protocol/OzoneManagerProtocol.java | 21 ++ ...ManagerProtocolClientSideTranslatorPB.java | 29 ++ .../helpers/TestAssumeRoleResponseInfo.java | 14 +- .../src/main/proto/OmClientProtocol.proto | 65 ++++ .../apache/hadoop/ozone/audit/OMAction.java | 1 + .../apache/hadoop/ozone/om/OzoneManager.java | 9 + .../ozone/om/helpers/OMAuditLogger.java | 2 + .../ratis/utils/OzoneManagerRatisUtils.java | 4 + .../s3/security/S3AssumeRoleRequest.java | 27 +- .../S3AssumeRoleWithWebIdentityRequest.java | 351 +++++++++++++++++ .../s3/security/S3RevokeSTSTokenRequest.java | 2 +- .../S3AssumeRoleWithWebIdentityResponse.java | 43 +++ .../hadoop/ozone/security/S3SecurityUtil.java | 10 +- .../ozone/security/STSSecurityUtil.java | 23 +- .../ozone/security/STSTokenIdentifier.java | 206 +++++++++- .../ozone/security/STSTokenSecretManager.java | 44 ++- ...estS3AssumeRoleWithWebIdentityRequest.java | 352 ++++++++++++++++++ .../om/response/TestCleanupTableInfo.java | 2 + .../ozone/security/TestSTSSecurityUtil.java | 105 ++++++ .../security/TestSTSTokenIdentifier.java | 89 ++++- ...3AssumeRoleWithWebIdentityResponseXml.java | 159 ++++++++ .../hadoop/ozone/s3sts/S3STSEndpoint.java | 106 +++++- .../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 53 ++- 30 files changed, 1897 insertions(+), 62 deletions(-) create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md index ffbbb6ae2d64..b61233376642 100644 --- a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md +++ b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md @@ -136,11 +136,24 @@ AssumeRoleWithWebIdentity -> Ranger or configured authorizer ``` -The first implementation slice intentionally stops before the OM runtime path: -S3G can parse `Action=AssumeRoleWithWebIdentity`, validate the basic STS -parameters, and return a clear unsupported error once the request reaches the -placeholder. The next stage must add the OM request/protobuf path and move JWT -validation, identity mapping, authorization, and credential issuance into OM. +The OM runtime slice adds the WebIdentity request/protobuf path while preserving +the existing `AssumeRole` flow. S3G parses and routes +`Action=AssumeRoleWithWebIdentity`, but OM remains the authoritative validator +and issuer. + +The raw `WebIdentityToken` is accepted only in the external OM RPC request. The +OM leader validates it in `S3AssumeRoleWithWebIdentityRequest.preExecute()`, +maps claims into a sanitized identity/session request, authorizes role +assumption, generates temporary credential material using the existing STS +helpers, and returns an `UpdateAssumeRoleWithWebIdentityRequest` for Ratis +replication. The replicated request must not contain the raw JWT. + +`validateAndUpdateCache()` consumes only the sanitized update request. It must +not call Keycloak, refresh JWKS, revalidate JWTs, or otherwise depend on current +external IdP state during Ratis apply or replay. Credential expiration is +computed by the leader before replication and stored as +`credentialExpirationEpochSeconds` so replay does not depend on the apply-time +clock. Temporary credentials must not be stored only in S3G memory. S3G can have multiple replicas and can restart. The issuing and validation authority must be @@ -206,7 +219,8 @@ Flow: edge: action, version, role ARN syntax, role session name, duration bounds, and presence of `WebIdentityToken`. 5. S3G forwards `RoleArn`, `RoleSessionName`, `WebIdentityToken`, - `DurationSeconds`, `ProviderId`, and request context to OM. + `DurationSeconds`, `ProviderId`, and request context to OM in the external + RPC request only. 6. OM validates the JWT: - token is a signed JWT; - `alg=none` is rejected; @@ -224,7 +238,19 @@ Flow: - token expiration. 8. OM builds an assume-role authorization request and calls Ranger or the configured Ozone authorizer before issuing any credential. -9. If authorized, OM issues temporary S3 credentials: +9. OM strips the raw JWT before Ratis replication and submits only sanitized + identity/session fields: + - role ARN and role session name; + - provider id; + - effective user; + - subject, issuer, audience; + - groups and roles; + - web identity token expiration; + - token fingerprint; + - requested/effective duration; + - credential expiration; + - derived session policy. +10. If authorized, OM issues temporary S3 credentials: - `Credentials.AccessKeyId`; - `Credentials.SecretAccessKey`; - `Credentials.SessionToken`; @@ -233,8 +259,8 @@ Flow: - `AssumedRoleUser`; - `Audience`; - `Provider`. -10. The client uses those credentials with ordinary AWS SigV4 against S3G. -11. S3G and OM validate the temporary credential, recover the assumed identity +11. The client uses those credentials with ordinary AWS SigV4 against S3G. +12. S3G and OM validate the temporary credential, recover the assumed identity and session policy, and pass them to the authorizer for every S3 operation. ## Configuration 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..77654ac8a50d --- /dev/null +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java @@ -0,0 +1,151 @@ +/* + * 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; + + 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/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/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 9bb0d801ee7b..536c064eac59 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 { + required string roleArn = 1; + required string roleSessionName = 2; + optional int32 durationSeconds = 3 [default = 3600]; + required string webIdentityToken = 4; + optional string providerId = 5; + required string requestId = 6; +} + +message AssumeRoleWithWebIdentityResponse { + required string accessKeyId = 1; + required string secretAccessKey = 2; + required string sessionToken = 3; + required uint64 expirationEpochSeconds = 4; + required string assumedRoleId = 5; + required string subjectFromWebIdentityToken = 6; + required 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 { + required string roleArn = 1; + required string roleSessionName = 2; + required int32 durationSeconds = 3; + optional string providerId = 4; + required string requestId = 5; + required string tempAccessKeyId = 6; + required string secretAccessKey = 7; + required string roleId = 8; + required string effectiveUser = 9; + required string subject = 10; + required string issuer = 11; + required string audience = 12; + repeated string groups = 13; + repeated string roles = 14; + required uint64 webIdentityTokenExpiresAt = 15; + required uint64 authenticatedAt = 16; + required string tokenFingerprint = 17; + required string sessionPolicy = 18; + required uint64 credentialExpirationEpochSeconds = 19; +} + message RevokeSTSTokenRequest { required string sessionToken = 1; } 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..282aec1d1cd4 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,351 @@ +/* + * 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_JWKS_URI; +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 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); + } + + 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 { + S3STSUtils.validateDuration(updateRequest.getDurationSeconds()); + S3STSUtils.validateRoleSessionName(updateRequest.getRoleSessionName()); + AwsRoleArnValidator.validateAndExtractRoleNameFromArn( + updateRequest.getRoleArn()); + if (Strings.isNullOrEmpty(updateRequest.getSessionPolicy())) { + throw new OMException("Missing WebIdentity session policy", + ACCESS_DENIED); + } + + 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.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( + new org.apache.hadoop.ozone.security.acl + .AssumeRoleWithWebIdentityRequest( + hostName, + remoteIp, + identity.getUsername(), + identity.getGroups(), + identity.getRoles(), + request.getRoleArn(), + request.getRoleSessionName(), + identity.getIssuer(), + identity.getSubject(), + audience, + request.hasProviderId() ? request.getProviderId() : null, + null)); + } + + 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..24ec8fa17b84 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 @@ -172,12 +172,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..48250da74f75 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,11 +44,29 @@ public class STSTokenIdentifier extends ShortLivedTokenIdentifier { public static final Text KIND_NAME = new Text("STSToken"); + /** + * Identifies the authentication path that produced an STS token. + */ + public enum AuthType { + ASSUME_ROLE, + WEB_IDENTITY + } + // 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; @@ -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,31 @@ public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, St this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null; } + /** + * Create a new WebIdentity-backed STS token identifier. + */ + 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 +173,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 +207,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 +231,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 +310,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 +326,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 +395,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..e39fe57c368b 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; @@ -100,6 +101,47 @@ public String createSTSTokenString(String tempAccessKeyId, String originalAccess final Token token = generateToken(identifier); return token.encodeToUrlString(); } -} + /** + * Create a WebIdentity-backed STS token and return it as an encoded string. + */ + 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. + */ + 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/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..c14805be4c62 --- /dev/null +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java @@ -0,0 +1,352 @@ +/* + * 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_AUDIENCE; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED; +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.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.ozone.security.acl.IAccessAuthorizer; +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(); + } + + private static OMRequest externalRequest(int durationSeconds) { + return OMRequest.newBuilder() + .setCmdType(Type.AssumeRoleWithWebIdentity) + .setClientId("client-1") + .setAssumeRoleWithWebIdentityRequest( + 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(RAW_JWT) + .build()) + .build(); + } + + 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/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..f8d4bacbbf05 --- /dev/null +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java @@ -0,0 +1,159 @@ +/* + * 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; + } + + @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 void setCredentials(Credentials credentials) { + this.credentials = credentials; + } + + public void setSubjectFromWebIdentityToken(String value) { + this.subjectFromWebIdentityToken = value; + } + + public void setAssumedRoleUser(AssumedRoleUser assumedRoleUser) { + this.assumedRoleUser = assumedRoleUser; + } + + public void setAudience(String audience) { + this.audience = audience; + } + + public void setProvider(String provider) { + this.provider = provider; + } + } + + @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 void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + + public void setExpiration(String expiration) { + this.expiration = expiration; + } + } + + @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 void setAssumedRoleId(String assumedRoleId) { + this.assumedRoleId = assumedRoleId; + } + + public void setArn(String arn) { + this.arn = arn; + } + } + + @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 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 5244989812ed..068977d8ba46 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 @@ -49,6 +49,7 @@ 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; @@ -98,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); } @@ -278,11 +280,53 @@ private Response handleAssumeRoleWithWebIdentity(String roleArn, VALIDATION_ERROR, validationMessage, BAD_REQUEST.getStatusCode()); } - // S3G has parsed and routed the request. The OM request path will validate - // the JWT and issue credentials; S3G must not trust or log the token here. - throw new OSTSException(UNSUPPORTED_OPERATION, - "AssumeRoleWithWebIdentity OM runtime is not implemented yet.", - NOT_IMPLEMENTED.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() { @@ -445,4 +489,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/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java index 57bac2281f06..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 @@ -51,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; @@ -100,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(); @@ -373,6 +385,8 @@ public void testStsAssumeRoleWithWebIdentityDisabled() throws Exception { 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", @@ -393,6 +407,8 @@ public void testStsAssumeRoleWithWebIdentityMissingToken() 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", @@ -404,19 +420,36 @@ public void testStsAssumeRoleWithWebIdentityRoutesWhenEnabled() throws Exception { enableWebIdentity(); - final OSTSException ex = assertThrows(OSTSException.class, () -> - endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN, - ROLE_SESSION_NAME, 3600, "2011-06-15", null, - "sensitive-token-material", "keycloak")); + final Response response = endpoint.get("AssumeRoleWithWebIdentity", + ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null, + "sensitive-token-material", "keycloak"); - assertEquals(501, ex.getHttpCode()); + assertEquals(200, response.getStatus()); verifyNoInteractions(auditLogger); - verify(objectStore, never()).assumeRole(anyString(), anyString(), - anyInt(), any(), anyString()); + verify(objectStore).assumeRoleWithWebIdentity(ROLE_ARN, + ROLE_SESSION_NAME, 3600, "sensitive-token-material", "keycloak", + REQUEST_ID); - ex.setRequestId(REQUEST_ID); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation", - "AssumeRoleWithWebIdentity OM runtime is not implemented yet."); + 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 From 2834204f080e9b93c02b22124f639a2a92f37da6 Mon Sep 17 00:00:00 2001 From: paf91 Date: Thu, 14 May 2026 00:37:28 +0300 Subject: [PATCH 3/9] HDDS-15273. Add end-to-end WebIdentity STS S3 credential test --- ...TestAssumeRoleWithWebIdentityEndToEnd.java | 562 ++++++++++++++++++ .../ozone/security/STSSecurityUtil.java | 4 + .../ozone/security/STSTokenSecretManager.java | 8 +- 3 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java 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..4a224fd86ee3 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java @@ -0,0 +1,562 @@ +/* + * 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_ALLOW_INSECURE_HTTP_FOR_TESTS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; +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_HTTP_ADDRESS_KEY; +import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY; +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 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.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +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 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.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.junit.jupiter.api.Test; +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; + +/** + * End-to-end coverage for the WebIdentity STS bootstrap path and the existing + * S3 SigV4 temporary credential validation path. + */ +class TestAssumeRoleWithWebIdentityEndToEnd + extends ClusterForTests { + + private static final String ISSUER = "http://keycloak.test/realms/ozone"; + private static final String AUDIENCE = "ozone"; + private static final String ALLOWED_BUCKET = "tomato-files"; + private static final String DENIED_BUCKET = "denied-files"; + private static final String ROLE_ARN = + "arn:aws:iam::123456789012:role/tomato-role"; + private static final String SESSION_NAME = "tomato-session"; + + private final TestJwtIssuer jwtIssuer = new TestJwtIssuer(); + private S3GatewayService s3GatewayService; + + @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, + WebIdentityEndToEndAuthorizer.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, ISSUER); + conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE); + conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwtIssuer.jwksUri()); + conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false); + conf.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, + true); + 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); + WebIdentityEndToEndAuthorizer.reset(); + s3GatewayService = new S3GatewayService(); + return newClusterBuilder() + .setNumDatanodes(1) + .setSecretKeyClient(new InMemorySecretKeyClient()) + .addService(s3GatewayService) + .build(); + } + + @Override + protected void onClusterReady() throws Exception { + try (OzoneClient client = getCluster().newClient()) { + client.getObjectStore().createS3Bucket(ALLOWED_BUCKET); + client.getObjectStore().createS3Bucket(DENIED_BUCKET); + } + } + + @AfterAll + void resetTestSecurityFlag() { + OzoneManager.setTestSecureOmFlag(false); + } + + @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)))); + + AssumeRoleWithWebIdentityRequest request = + WebIdentityEndToEndAuthorizer.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(AUDIENCE, request.getAudience()); + + try (S3Client s3 = 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())); + + assertThrows(S3Exception.class, () -> + s3.putObject(PutObjectRequest.builder() + .bucket(DENIED_BUCKET) + .key("denied.txt") + .build(), + RequestBody.fromBytes("denied".getBytes(UTF_8)))); + } + + assertThat(WebIdentityEndToEndAuthorizer.accessChecksWithSessionPolicy) + .hasPositiveValue(); + } + + @Test + void invalidWebIdentityTokensFailBeforeCredentialsAreIssued() + throws Exception { + HttpResponse missingToken = postSts(null); + assertThat(missingToken.code).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 { + 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); + } + + @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)))); + + try (S3Client missingSessionToken = + s3Client(credentials.accessKeyId, credentials.secretAccessKey, + null)) { + assertThrows(S3Exception.class, () -> + missingSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); + } + + try (S3Client wrongSessionToken = + s3Client(credentials.accessKeyId, credentials.secretAccessKey, + credentials.sessionToken + "wrong")) { + assertThrows(S3Exception.class, () -> + wrongSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); + } + + try (S3Client wrongSecret = + s3Client(credentials.accessKeyId, + credentials.secretAccessKey + "wrong", + credentials.sessionToken)) { + assertThrows(S3Exception.class, () -> + wrongSecret.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); + } + } + + private StsCredentials assumeRoleWithWebIdentity(String token) + throws Exception { + HttpResponse response = postSts(token); + assertEquals(200, response.code, response.body); + Document document = parseXml(response.body); + StsCredentials credentials = new StsCredentials( + xmlText(document, "AccessKeyId"), + xmlText(document, "SecretAccessKey"), + xmlText(document, "SessionToken")); + assertThat(credentials.accessKeyId).startsWith("ASIA"); + assertThat(credentials.secretAccessKey).isNotBlank(); + assertThat(credentials.sessionToken).isNotBlank(); + assertThat(xmlText(document, "SubjectFromWebIdentityToken")) + .isEqualTo("subject-tomato"); + assertThat(xmlText(document, "Audience")).isEqualTo(AUDIENCE); + assertThat(xmlText(document, "AssumedRoleId")).contains(SESSION_NAME); + return credentials; + } + + private void assertStsFails(String token, int expectedStatus) + throws Exception { + HttpResponse response = postSts(token); + assertEquals(expectedStatus, response.code, response.body); + assertThat(response.body).doesNotContain(token); + } + + private 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) + + "&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); + } + + private S3Client s3Client(StsCredentials credentials) { + return s3Client(credentials.accessKeyId, credentials.secretAccessKey, + credentials.sessionToken); + } + + private 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(); + } + + 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(); + } + + private static final class HttpResponse { + private final int code; + private final String body; + + private HttpResponse(int code, String body) { + this.code = code; + this.body = body; + } + } + + private 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; + } + } + + public static final class WebIdentityEndToEndAuthorizer + implements IAccessAuthorizer { + private static final AtomicInteger accessChecksWithSessionPolicy = + new AtomicInteger(); + private static volatile AssumeRoleWithWebIdentityRequest + lastAssumeRoleRequest; + + public static void reset() { + accessChecksWithSessionPolicy.set(0); + lastAssumeRoleRequest = null; + } + + @Override + public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) { + if (context.getSessionPolicy() == null) { + return true; + } + accessChecksWithSessionPolicy.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 { + lastAssumeRoleRequest = request; + if (!"tomato-user".equals(request.getUser()) + || !request.getGroups().contains("ozone-tomato") + || !ROLE_ARN.equals(request.getRoleArn())) { + 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 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); + } + } + } + + 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/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 24ec8fa17b84..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); 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 e39fe57c368b..aaa4f14dd681 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 @@ -40,7 +40,7 @@ public class STSTokenSecretManager extends ShortLivedTokenSecretManager Date: Thu, 14 May 2026 03:09:02 +0300 Subject: [PATCH 4/9] HDDS-15273. Add Keycloak IT and hardening for WebIdentity STS --- .../apache/hadoop/ozone/OzoneConfigKeys.java | 12 + .../src/main/resources/ozone-default.xml | 23 +- .../oidc-assume-role-with-web-identity.md | 20 +- .../OzoneSTSWebIdentityKeycloakRanger.md | 300 ++++++++++++ ...AssumeRoleWithWebIdentityResponseInfo.java | 1 + .../acl/AssumeRoleWithWebIdentityRequest.java | 1 + .../ozone/security/acl/IAccessAuthorizer.java | 5 + .../security/oidc/CachingJwksProvider.java | 44 +- .../ozone/security/oidc/OidcConfig.java | 79 ++++ .../ozone/security/oidc/UrlJwksFetcher.java | 35 +- .../oidc/TestOidcJwtIdentityProvider.java | 31 ++ hadoop-ozone/integration-test-s3/pom.xml | 5 + ...stractAssumeRoleWithWebIdentityS3Test.java | 426 ++++++++++++++++++ ...TestAssumeRoleWithWebIdentityEndToEnd.java | 370 +-------------- ...stAssumeRoleWithWebIdentityKeycloakIT.java | 211 +++++++++ .../resources/keycloak/ozone-test-realm.json | 137 ++++++ .../apache/hadoop/ozone/om/OzoneManager.java | 18 + .../S3AssumeRoleWithWebIdentityRequest.java | 5 +- .../ozone/security/STSTokenIdentifier.java | 1 + .../ozone/security/STSTokenSecretManager.java | 2 + ...estS3AssumeRoleWithWebIdentityRequest.java | 39 +- ...3AssumeRoleWithWebIdentityResponseXml.java | 4 + .../hadoop/ozone/s3sts/S3STSEndpoint.java | 7 +- .../s3sts/S3STSWebIdentityRequestParser.java | 16 +- .../TestS3STSWebIdentityAuthBypassFilter.java | 46 ++ pom.xml | 6 + 26 files changed, 1486 insertions(+), 358 deletions(-) create mode 100644 hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java create mode 100644 hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json 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 1f0a0fd99fb5..e798e2f8271b 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 @@ -481,6 +481,18 @@ public final class OzoneConfigKeys { "ozone.sts.web.identity.jwks.refresh.interval"; public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT = "10m"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT = + "ozone.sts.web.identity.jwks.connect.timeout"; + public static final String + OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT = "5s"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT = + "ozone.sts.web.identity.jwks.read.timeout"; + public static final String + OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT = "5s"; + public static final String OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT = + "ozone.sts.web.identity.jwks.size.limit"; + public static final String + OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT = "1MB"; public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS = "ozone.sts.web.identity.require.https"; public static final boolean OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT = diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml index 615ea2ba1aa2..19bd629fec49 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -2213,7 +2213,28 @@ ozone.sts.web.identity.jwks.refresh.interval 10m OZONE, SECURITY, S3, STS, OIDC - Interval for refreshing cached JWKS signing keys. + + Interval for refreshing cached JWKS signing keys. Unknown key IDs may + trigger an earlier refresh, but repeated unknown key IDs are debounced. + + + + ozone.sts.web.identity.jwks.connect.timeout + 5s + OZONE, SECURITY, S3, STS, OIDC + Connection timeout for fetching OIDC JWKS signing keys. + + + ozone.sts.web.identity.jwks.read.timeout + 5s + OZONE, SECURITY, S3, STS, OIDC + Read timeout for fetching OIDC JWKS signing keys. + + + ozone.sts.web.identity.jwks.size.limit + 1MB + OZONE, SECURITY, S3, STS, OIDC + Maximum JWKS response size accepted by Ozone STS. ozone.sts.web.identity.require.https diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md index b61233376642..34f0ef995c77 100644 --- a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md +++ b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md @@ -278,6 +278,9 @@ ozone.sts.web.identity.groups.claim=groups ozone.sts.web.identity.roles.claim=realm_access.roles ozone.sts.web.identity.clock.skew=60s 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 ozone.sts.web.identity.require.https=true ozone.sts.web.identity.allow.insecure.http.for.tests=false ``` @@ -300,7 +303,11 @@ The reusable validation module is intentionally small: exception messages. The module does not call Keycloak for every S3 request. JWKS validation is local, -with refresh on cache expiry and unknown key id. +with refresh on cache expiry and unknown key id. The default JWKS refresh +interval is 10 minutes. Unknown key ids may trigger an earlier refresh, but +repeated unknown kids are debounced to avoid refresh storms from attacker +supplied token headers. JWKS fetches use bounded connect/read timeouts and a +bounded response size. ## Ranger Authorization Points @@ -322,6 +329,12 @@ The common request-shape extension point is `IAccessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy()` as the default authorizer hook. Existing authorizers are not forced to implement this immediately because the new method has a fail-closed default implementation. +For production Ranger deployments, the external Ranger Ozone plugin must add a +companion override for this method. The `RangerOzoneAuthorizer` class is +provided by Apache Ranger, not this Ozone repository. Without a WebIdentity +capable Ranger/Ozone authorizer, the default hook returns +`NOT_SUPPORTED_OPERATION`, Ozone fails closed, and no WebIdentity temporary +credentials are issued. The second authorization point is every S3 operation made with the temporary credentials. OM must recover the assumed identity and session policy from the @@ -352,6 +365,11 @@ The existing STS runtime uses self-contained session tokens containing the encrypted secret access key, original identity, role ARN, session policy, expiration, signing key id, and MAC. `AssumeRoleWithWebIdentity` should reuse that issuer and validator instead of creating a parallel token format. +The sanitized replicated OM request still carries temporary credential material +through Ratis in the same way as the existing `AssumeRole` implementation. +Operators must protect OM metadata, Ratis logs, snapshots, and backups as +sensitive security material. The raw WebIdentity JWT must not be replicated or +stored in OM metadata. In `origin/HDDS-13323-sts`, `STSTokenIdentifier` stores the `originalAccessKeyId` because `AssumeRole` starts from an existing S3 access diff --git a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md new file mode 100644 index 000000000000..1846e793552a --- /dev/null +++ b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md @@ -0,0 +1,300 @@ +--- +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: + +```xml + + ozone.sts.web.identity.require.https + false + + + ozone.sts.web.identity.allow.insecure.http.for.tests + true + +``` + +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/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 index 77654ac8a50d..82ef3079caba 100644 --- 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 @@ -36,6 +36,7 @@ public class AssumeRoleWithWebIdentityResponseInfo { 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, 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 index 3512356a9b44..d365557d46c4 100644 --- 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 @@ -50,6 +50,7 @@ public class AssumeRoleWithWebIdentityRequest { private final String providerId; private final Set grants; + @SuppressWarnings("checkstyle:ParameterNumber") public AssumeRoleWithWebIdentityRequest(String host, InetAddress ip, String user, Set groups, Set roles, String roleArn, String roleSessionName, String issuer, String subject, String audience, 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 437eb96d94ae..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 @@ -77,6 +77,11 @@ default String generateAssumeRoleSessionPolicy(AssumeRoleRequest assumeRoleReque * 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. diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java index 007174dc98c8..bff570ccf780 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java @@ -33,11 +33,16 @@ */ 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()); @@ -45,6 +50,12 @@ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) { 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"); } @@ -52,8 +63,14 @@ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) { 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; } @@ -62,7 +79,7 @@ public List getKeys(String keyId) throws OidcAuthenticationException { refreshIfNeeded(false); List keys = findKeys(jwkSet, keyId); if (keys.isEmpty() && keyId != null && !keyId.trim().isEmpty()) { - refreshIfNeeded(true); + refreshForUnknownKidIfNeeded(); keys = findKeys(jwkSet, keyId); } return keys; @@ -94,6 +111,31 @@ private void refreshIfNeeded(boolean force) } } + 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(); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java index 5665c01195e8..53a91995a445 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java @@ -29,8 +29,14 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; @@ -47,6 +53,7 @@ import java.time.Duration; import java.util.concurrent.TimeUnit; import org.apache.hadoop.hdds.conf.ConfigurationSource; +import org.apache.hadoop.hdds.conf.StorageUnit; /** * Configuration for the experimental OIDC identity provider. @@ -63,6 +70,9 @@ public final class OidcConfig { private final String rolesClaim; private final Duration clockSkew; private final Duration jwksRefreshInterval; + private final Duration jwksConnectTimeout; + private final Duration jwksReadTimeout; + private final int jwksSizeLimit; private final boolean requireHttps; private final boolean allowInsecureHttpForTests; @@ -83,6 +93,12 @@ private OidcConfig(Builder builder) { OZONE_STS_WEB_IDENTITY_CLOCK_SKEW); this.jwksRefreshInterval = requireNonNegative(builder.jwksRefreshInterval, OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL); + this.jwksConnectTimeout = requirePositive(builder.jwksConnectTimeout, + OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT); + this.jwksReadTimeout = requirePositive(builder.jwksReadTimeout, + OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT); + this.jwksSizeLimit = requirePositive(builder.jwksSizeLimit, + OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT); this.requireHttps = builder.requireHttps; this.allowInsecureHttpForTests = builder.allowInsecureHttpForTests; } @@ -112,6 +128,15 @@ public static OidcConfig from(ConfigurationSource conf) { .setJwksRefreshInterval(duration(conf, OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL, OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT)) + .setJwksConnectTimeout(duration(conf, + OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT, + OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT)) + .setJwksReadTimeout(duration(conf, + OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT, + OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT)) + .setJwksSizeLimit(storageSize(conf, + OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT, + OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT)) .setRequireHttps(conf.getBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT)) .setAllowInsecureHttpForTests(conf.getBoolean( @@ -168,6 +193,18 @@ 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; } @@ -194,6 +231,16 @@ private static Duration duration(ConfigurationSource conf, String key, TimeUnit.MILLISECONDS)); } + private static int storageSize(ConfigurationSource conf, String key, + String defaultValue) { + double bytes = conf.getStorageSize(key, defaultValue, StorageUnit.BYTES); + if (bytes > Integer.MAX_VALUE) { + throw new IllegalArgumentException(key + " must not exceed " + + Integer.MAX_VALUE + " bytes"); + } + return (int) bytes; + } + private static Duration requireNonNegative(Duration value, String key) { if (value == null || value.isNegative()) { throw new IllegalArgumentException(key + " must not be negative"); @@ -201,6 +248,20 @@ private static Duration requireNonNegative(Duration value, String key) { 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 = trimToEmpty(value); if (trimmed.isEmpty()) { @@ -242,6 +303,9 @@ public static final class Builder { private String rolesClaim = OZONE_STS_WEB_IDENTITY_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 = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT; private boolean allowInsecureHttpForTests = OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; @@ -299,6 +363,21 @@ public Builder setJwksRefreshInterval(Duration 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; diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java index a2fe2b4dad3f..483ccf21342e 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.net.URL; import java.text.ParseException; +import java.time.Duration; /** * JWKS fetcher backed by a URL. @@ -28,13 +29,45 @@ 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); + 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/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java index 11ec51cb4173..1480afc4a47e 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java @@ -29,7 +29,10 @@ 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; @@ -211,6 +214,23 @@ public void unknownKidTriggersJwksRefresh() throws Exception { 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(); @@ -291,6 +311,17 @@ public void missingGroupsAreMappedToEmptySet() throws Exception { 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"; diff --git a/hadoop-ozone/integration-test-s3/pom.xml b/hadoop-ozone/integration-test-s3/pom.xml index 8ee8d5794576..7c1742f65d83 100644 --- a/hadoop-ozone/integration-test-s3/pom.xml +++ b/hadoop-ozone/integration-test-s3/pom.xml @@ -157,6 +157,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..fa600074b743 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java @@ -0,0 +1,426 @@ +/* + * 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_ALLOW_INSECURE_HTTP_FOR_TESTS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; +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.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.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.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.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, + true); + 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 = 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 = 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 { + lastAssumeRoleRequest = request; + if (!"tomato-user".equals(request.getUser()) + || !request.getGroups().contains("ozone-tomato") + || !ROLE_ARN.equals(request.getRoleArn()) + || !PROVIDER_ID.equals(request.getProviderId()) + || request.getSubject() == null + || request.getSubject().trim().isEmpty()) { + 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 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 index 4a224fd86ee3..06dcbf449494 100644 --- 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 @@ -18,22 +18,6 @@ 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_ALLOW_INSECURE_HTTP_FOR_TESTS; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; -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_HTTP_ADDRESS_KEY; -import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY; -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; @@ -46,17 +30,10 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.PlainJWT; import com.nimbusds.jwt.SignedJWT; -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.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.time.Duration; @@ -66,103 +43,30 @@ import java.util.Date; import java.util.LinkedHashMap; import java.util.Map; -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.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.junit.jupiter.api.Test; -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; /** - * End-to-end coverage for the WebIdentity STS bootstrap path and the existing - * S3 SigV4 temporary credential validation path. + * End-to-end coverage for the WebIdentity STS bootstrap path using a generated + * JWT and local JWKS file. */ class TestAssumeRoleWithWebIdentityEndToEnd - extends ClusterForTests { + extends AbstractAssumeRoleWithWebIdentityS3Test { private static final String ISSUER = "http://keycloak.test/realms/ozone"; - private static final String AUDIENCE = "ozone"; - private static final String ALLOWED_BUCKET = "tomato-files"; - private static final String DENIED_BUCKET = "denied-files"; - private static final String ROLE_ARN = - "arn:aws:iam::123456789012:role/tomato-role"; - private static final String SESSION_NAME = "tomato-session"; private final TestJwtIssuer jwtIssuer = new TestJwtIssuer(); - private S3GatewayService s3GatewayService; @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, - WebIdentityEndToEndAuthorizer.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, ISSUER); - conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE); - conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwtIssuer.jwksUri()); - conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false); - conf.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, - true); - 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; + protected String issuerUri() { + return ISSUER; } @Override - protected MiniOzoneCluster createCluster() throws Exception { - OzoneManager.setTestSecureOmFlag(true); - WebIdentityEndToEndAuthorizer.reset(); - s3GatewayService = new S3GatewayService(); - return newClusterBuilder() - .setNumDatanodes(1) - .setSecretKeyClient(new InMemorySecretKeyClient()) - .addService(s3GatewayService) - .build(); - } - - @Override - protected void onClusterReady() throws Exception { - try (OzoneClient client = getCluster().newClient()) { - client.getObjectStore().createS3Bucket(ALLOWED_BUCKET); - client.getObjectStore().createS3Bucket(DENIED_BUCKET); - } - } - - @AfterAll - void resetTestSecurityFlag() { - OzoneManager.setTestSecureOmFlag(false); + protected String jwksUri() { + return jwtIssuer.jwksUri(); } @Test @@ -171,50 +75,28 @@ void webIdentityTemporaryCredentialsAuthorizeS3Operations() StsCredentials credentials = assumeRoleWithWebIdentity( jwtIssuer.token("tomato-user", "subject-tomato", Collections.singletonList("ozone-tomato"), AUDIENCE, - Instant.now().plus(Duration.ofHours(1)))); + Instant.now().plus(Duration.ofHours(1))), "subject-tomato"); - AssumeRoleWithWebIdentityRequest request = - WebIdentityEndToEndAuthorizer.lastAssumeRoleRequest; + 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()); - try (S3Client s3 = 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())); - - assertThrows(S3Exception.class, () -> - s3.putObject(PutObjectRequest.builder() - .bucket(DENIED_BUCKET) - .key("denied.txt") - .build(), - RequestBody.fromBytes("denied".getBytes(UTF_8)))); - } - - assertThat(WebIdentityEndToEndAuthorizer.accessChecksWithSessionPolicy) - .hasPositiveValue(); + assertTemporaryCredentialsAuthorizeS3Operations(credentials); + assertThat(accessChecksWithSessionPolicy()).isPositive(); } @Test void invalidWebIdentityTokensFailBeforeCredentialsAreIssued() throws Exception { HttpResponse missingToken = postSts(null); - assertThat(missingToken.code).isGreaterThanOrEqualTo(400); + assertThat(missingToken.getCode()).isGreaterThanOrEqualTo(400); assertStsFails(jwtIssuer.token("tomato-user", "subject-tomato", Collections.singletonList("ozone-tomato"), "wrong-audience", @@ -235,12 +117,7 @@ void invalidWebIdentityTokensFailBeforeCredentialsAreIssued() @Test void normalS3RequestWithoutSigV4IsStillDenied() 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); + assertNormalS3RequestWithoutSigV4IsDenied(); } @Test @@ -249,232 +126,33 @@ void temporaryCredentialsRequireCorrectSecretAndSessionToken() StsCredentials credentials = assumeRoleWithWebIdentity( jwtIssuer.token("tomato-user", "subject-tomato", Collections.singletonList("ozone-tomato"), AUDIENCE, - Instant.now().plus(Duration.ofHours(1)))); + Instant.now().plus(Duration.ofHours(1))), "subject-tomato"); try (S3Client missingSessionToken = - s3Client(credentials.accessKeyId, credentials.secretAccessKey, + s3Client(credentials.getAccessKeyId(), + credentials.getSecretAccessKey(), null)) { assertThrows(S3Exception.class, () -> missingSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); } try (S3Client wrongSessionToken = - s3Client(credentials.accessKeyId, credentials.secretAccessKey, - credentials.sessionToken + "wrong")) { + s3Client(credentials.getAccessKeyId(), + credentials.getSecretAccessKey(), + credentials.getSessionToken() + "wrong")) { assertThrows(S3Exception.class, () -> wrongSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); } try (S3Client wrongSecret = - s3Client(credentials.accessKeyId, - credentials.secretAccessKey + "wrong", - credentials.sessionToken)) { + s3Client(credentials.getAccessKeyId(), + credentials.getSecretAccessKey() + "wrong", + credentials.getSessionToken())) { assertThrows(S3Exception.class, () -> wrongSecret.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET))); } } - private StsCredentials assumeRoleWithWebIdentity(String token) - throws Exception { - HttpResponse response = postSts(token); - assertEquals(200, response.code, response.body); - Document document = parseXml(response.body); - StsCredentials credentials = new StsCredentials( - xmlText(document, "AccessKeyId"), - xmlText(document, "SecretAccessKey"), - xmlText(document, "SessionToken")); - assertThat(credentials.accessKeyId).startsWith("ASIA"); - assertThat(credentials.secretAccessKey).isNotBlank(); - assertThat(credentials.sessionToken).isNotBlank(); - assertThat(xmlText(document, "SubjectFromWebIdentityToken")) - .isEqualTo("subject-tomato"); - assertThat(xmlText(document, "Audience")).isEqualTo(AUDIENCE); - assertThat(xmlText(document, "AssumedRoleId")).contains(SESSION_NAME); - return credentials; - } - - private void assertStsFails(String token, int expectedStatus) - throws Exception { - HttpResponse response = postSts(token); - assertEquals(expectedStatus, response.code, response.body); - assertThat(response.body).doesNotContain(token); - } - - private 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) - + "&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); - } - - private S3Client s3Client(StsCredentials credentials) { - return s3Client(credentials.accessKeyId, credentials.secretAccessKey, - credentials.sessionToken); - } - - private 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(); - } - - 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(); - } - - private static final class HttpResponse { - private final int code; - private final String body; - - private HttpResponse(int code, String body) { - this.code = code; - this.body = body; - } - } - - private 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; - } - } - - public static final class WebIdentityEndToEndAuthorizer - implements IAccessAuthorizer { - private static final AtomicInteger accessChecksWithSessionPolicy = - new AtomicInteger(); - private static volatile AssumeRoleWithWebIdentityRequest - lastAssumeRoleRequest; - - public static void reset() { - accessChecksWithSessionPolicy.set(0); - lastAssumeRoleRequest = null; - } - - @Override - public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) { - if (context.getSessionPolicy() == null) { - return true; - } - accessChecksWithSessionPolicy.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 { - lastAssumeRoleRequest = request; - if (!"tomato-user".equals(request.getUser()) - || !request.getGroups().contains("ozone-tomato") - || !ROLE_ARN.equals(request.getRoleArn())) { - 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 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); - } - } - } - private static final class TestJwtIssuer { private final RSAKey key; private final Path jwksFile; 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..6b81190d6d96 --- /dev/null +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.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.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 void stopKeycloak() { + if (keycloak != null) { + keycloak.stop(); + keycloak = null; + } + } + + @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/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 d2334199d7ab..f75939aa1429 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 @@ -39,6 +39,10 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_READONLY_ADMINISTRATORS; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE_DEFAULT; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; +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 static org.apache.hadoop.ozone.OzoneConsts.DB_TRANSIENT_MARKER; import static org.apache.hadoop.ozone.OzoneConsts.DEFAULT_OM_UPDATE_ID; import static org.apache.hadoop.ozone.OzoneConsts.LAYOUT_VERSION_KEY; @@ -1914,6 +1918,7 @@ public DeletingServiceMetrics getDeletionMetrics() { public void start() throws IOException { Map auditMap = new HashMap(); auditMap.put("OmState", omState.name()); + logInsecureWebIdentityHttpWarning(); if (omState == State.BOOTSTRAPPING) { if (isBootstrapping) { auditMap.put("Bootstrap", "normal"); @@ -2022,6 +2027,19 @@ public void start() throws IOException { SYSTEMAUDIT.logWriteSuccess(buildAuditMessageForSuccess(OMSystemAction.STARTUP, auditMap)); } + private void logInsecureWebIdentityHttpWarning() { + if (configuration.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, + OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT) + && configuration.getBoolean( + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT)) { + LOG.warn("STS WebIdentity is configured with {}=true. This permits " + + "insecure HTTP issuer/JWKS URIs for tests only and is unsafe " + + "for production deployments.", + OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS); + } + } + /** * Restarts the service. This method re-initializes the rpc server. */ 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 index 282aec1d1cd4..8d4e6eed7725 100644 --- 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 @@ -271,7 +271,10 @@ private OzoneIdentityProvider identityProvider(OidcConfig oidcConfig) try { URL jwksUrl = new URL(oidcConfig.getJwksUri()); return new OidcJwtIdentityProvider(oidcConfig, - new CachingJwksProvider(new UrlJwksFetcher(jwksUrl), + 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 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 48250da74f75..8b1dbe25b488 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 @@ -107,6 +107,7 @@ public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, St /** * 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, 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 aaa4f14dd681..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 @@ -111,6 +111,7 @@ public String createSTSTokenString(String tempAccessKeyId, String originalAccess /** * 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, @@ -131,6 +132,7 @@ public String createWebIdentitySTSTokenString(String tempAccessKeyId, * 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, 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 index c14805be4c62..fb96f37aac18 100644 --- 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 @@ -20,6 +20,7 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; +import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED; import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED; import static org.assertj.core.api.Assertions.assertThat; @@ -56,11 +57,11 @@ 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.ozone.security.acl.IAccessAuthorizer; import org.apache.hadoop.security.token.TokenIdentifier; import org.apache.ozone.test.TestClock; import org.junit.jupiter.api.BeforeEach; @@ -278,7 +279,29 @@ public void testDisabledFeatureFailsBeforeTokenValidation() { assertThat(identityProvider.getCapturedToken()).isNull(); } + @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 OMRequest.newBuilder() .setCmdType(Type.AssumeRoleWithWebIdentity) .setClientId("client-1") @@ -290,11 +313,23 @@ private static OMRequest externalRequest(int durationSeconds) { .setDurationSeconds(durationSeconds) .setProviderId(PROVIDER_ID) .setRequestId(REQUEST_ID) - .setWebIdentityToken(RAW_JWT) + .setWebIdentityToken(webIdentityToken) .build()) .build(); } + 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) 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 index f8d4bacbbf05..7afa41ba7694 100644 --- 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 @@ -53,6 +53,7 @@ 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/") @@ -93,6 +94,7 @@ public void setProvider(String provider) { } } + /** XML model for temporary STS credentials. */ @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "AssumeRoleWithWebIdentityCredentialsType", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") @@ -126,6 +128,7 @@ public void setExpiration(String expiration) { } } + /** XML model for the assumed role user. */ @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "AssumeRoleWithWebIdentityAssumedRoleUserType", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") @@ -145,6 +148,7 @@ public void setArn(String arn) { } } + /** XML model for response metadata. */ @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "AssumeRoleWithWebIdentityResponseMetadataType", namespace = "https://sts.amazonaws.com/doc/2011-06-15/") 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 068977d8ba46..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 @@ -17,12 +17,12 @@ 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 static javax.ws.rs.core.Response.Status.BAD_REQUEST; 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; @@ -127,6 +127,7 @@ public void setRequestIdentifier(RequestIdentifier requestIdentifier) { */ @GET @Produces(MediaType.APPLICATION_XML) + @SuppressWarnings("checkstyle:ParameterNumber") public Response get( @QueryParam("Action") String action, @QueryParam("RoleArn") String roleArn, @@ -162,6 +163,7 @@ public Response get(String action, String roleArn, String roleSessionName, */ @POST @Produces(MediaType.APPLICATION_XML) + @SuppressWarnings("checkstyle:ParameterNumber") public Response post( @FormParam("Action") String action, @FormParam("RoleArn") String roleArn, @@ -184,6 +186,7 @@ public Response post(String action, String roleArn, String roleSessionName, version, awsIamSessionPolicy, null, null); } + @SuppressWarnings("checkstyle:ParameterNumber") private Response handleSTSRequest(String action, String roleArn, String roleSessionName, Integer durationSeconds, String version, String awsIamSessionPolicy, String webIdentityToken, String providerId) throws OS3Exception { 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 index 7862f076106b..7489b1b58777 100644 --- 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 @@ -23,6 +23,7 @@ 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.container.ContainerRequestContext; @@ -48,7 +49,8 @@ private static String getAction(ContainerRequestContext context) throws IOException { String method = context.getMethod(); if (HttpMethod.GET.equalsIgnoreCase(method)) { - return context.getUriInfo().getQueryParameters().getFirst(ACTION); + return singleAction(context.getUriInfo().getQueryParameters() + .get(ACTION)); } if (HttpMethod.POST.equalsIgnoreCase(method)) { return getFormAction(context); @@ -66,6 +68,7 @@ private static String getFormAction(ContainerRequestContext context) byte[] body = readFully(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) { @@ -73,10 +76,13 @@ private static String getFormAction(ContainerRequestContext context) } String name = decode(pair.substring(0, equals)); if (ACTION.equals(name)) { - return decode(pair.substring(equals + 1)); + if (action != null) { + return null; + } + action = decode(pair.substring(equals + 1)); } } - return null; + return action; } private static byte[] readFully(InputStream stream) throws IOException { @@ -92,4 +98,8 @@ private static byte[] readFully(InputStream stream) throws IOException { private static String decode(String value) throws IOException { return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); } + + 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/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java index 8914825ab5ba..1d3ea0ecaa4d 100644 --- 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 @@ -30,6 +30,7 @@ import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import javax.ws.rs.HttpMethod; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.core.MultivaluedHashMap; @@ -97,6 +98,29 @@ public void postWebIdentityRequestSkipsAwsAuthAndRestoresBody() assertEquals(body, read(streamCaptor.getValue())); } + @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); @@ -106,6 +130,15 @@ private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) { return filter; } + 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); @@ -125,6 +158,19 @@ private static ContainerRequestContext context(String method, 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 { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[256]; diff --git a/pom.xml b/pom.xml index 696eb499952b..e5a94101c6eb 100644 --- a/pom.xml +++ b/pom.xml @@ -224,6 +224,7 @@ 1.5.4 ${test.build.dir} ${project.build.directory}/test-dir + 1.21.3 4 @@ -1570,6 +1571,11 @@ + + org.testcontainers + testcontainers + ${testcontainers.version} + org.vafer jdeb From 727e9332f1e96b41f865439d2cc7cdf6d00a8d6f Mon Sep 17 00:00:00 2001 From: paf91 Date: Thu, 14 May 2026 14:10:10 +0300 Subject: [PATCH 5/9] HDDS-15273. Fix WebIdentity STS compile checks --- hadoop-ozone/integration-test-s3/pom.xml | 10 ++++++++++ .../apache/hadoop/ozone/client/ClientProtocolStub.java | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/hadoop-ozone/integration-test-s3/pom.xml b/hadoop-ozone/integration-test-s3/pom.xml index 7c1742f65d83..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 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 { } From f048827ae479783c05aa62c57541112198a131cc Mon Sep 17 00:00:00 2001 From: paf91 Date: Thu, 14 May 2026 16:58:03 +0300 Subject: [PATCH 6/9] HDDS-15273. Fix WebIdentity STS static analysis issues --- .../acl/AssumeRoleWithWebIdentityRequest.java | 3 +- .../ozone/security/oidc/AuthCredentials.java | 4 +- .../security/oidc/CachingJwksProvider.java | 5 +- .../oidc/OidcJwtIdentityProvider.java | 5 +- .../ozone/security/oidc/OzoneIdentity.java | 3 +- .../oidc/TestOidcJwtIdentityProvider.java | 3 +- ...stractAssumeRoleWithWebIdentityS3Test.java | 17 +++++-- ...stAssumeRoleWithWebIdentityKeycloakIT.java | 9 ++-- .../ozone/security/STSTokenIdentifier.java | 18 +++---- ...3AssumeRoleWithWebIdentityResponseXml.java | 48 +++++++++++++++++++ 10 files changed, 89 insertions(+), 26 deletions(-) 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 index d365557d46c4..6d393ba47081 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -178,7 +179,7 @@ private static Set immutableSet(Set values) { } private static String requireNonBlank(String value, String name) { - if (value == null || value.trim().isEmpty()) { + if (StringUtils.isBlank(value)) { throw new IllegalArgumentException(name + " must not be empty"); } return value; diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java index a8f4cf61b4f0..2b4ca3b06b37 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java @@ -17,6 +17,8 @@ package org.apache.hadoop.ozone.security.oidc; +import org.apache.commons.lang3.StringUtils; + /** * Authentication material accepted by Ozone identity providers. */ @@ -29,7 +31,7 @@ private AuthCredentials(String bearerToken) { } public static AuthCredentials bearerToken(String token) { - if (token == null || token.trim().isEmpty()) { + if (StringUtils.isBlank(token)) { throw new IllegalArgumentException("Bearer token must not be empty"); } return new AuthCredentials(token); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java index bff570ccf780..625337c5d623 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java @@ -27,6 +27,7 @@ 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. @@ -78,7 +79,7 @@ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) { public List getKeys(String keyId) throws OidcAuthenticationException { refreshIfNeeded(false); List keys = findKeys(jwkSet, keyId); - if (keys.isEmpty() && keyId != null && !keyId.trim().isEmpty()) { + if (keys.isEmpty() && StringUtils.isNotBlank(keyId)) { refreshForUnknownKidIfNeeded(); keys = findKeys(jwkSet, keyId); } @@ -140,7 +141,7 @@ private static List findKeys(JWKSet set, String keyId) { if (set == null) { return Collections.emptyList(); } - if (keyId == null || keyId.trim().isEmpty()) { + if (StringUtils.isBlank(keyId)) { return Collections.unmodifiableList(new ArrayList<>(set.getKeys())); } JWK key = set.getKeyByKeyId(keyId); diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java index ce0b0dbafd91..590cbb97ea08 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java @@ -40,6 +40,7 @@ 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. @@ -73,7 +74,7 @@ public OidcJwtIdentityProvider(OidcConfig config, public OzoneIdentity authenticate(AuthCredentials credentials) throws OidcAuthenticationException { if (credentials == null || credentials.getBearerToken() == null - || credentials.getBearerToken().trim().isEmpty()) { + || StringUtils.isBlank(credentials.getBearerToken())) { throw new OidcAuthenticationException("Missing OIDC bearer token"); } @@ -239,7 +240,7 @@ private void validateAudience(JWTClaimsSet claims) private String extractStringClaim(JWTClaimsSet claims, String claimPath) throws OidcAuthenticationException { Object value = claimValue(claims, claimPath); - if (!(value instanceof String) || ((String) value).trim().isEmpty()) { + if (!(value instanceof String) || StringUtils.isBlank((String) value)) { throw new OidcAuthenticationException( "OIDC token is missing required claim"); } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java index 1516bae08bb9..bb39dc51265d 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java @@ -23,6 +23,7 @@ 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. @@ -114,7 +115,7 @@ private static Set immutableSet(Set values) { } private static String requireNonBlank(String value, String name) { - if (value == null || value.trim().isEmpty()) { + if (StringUtils.isBlank(value)) { throw new IllegalArgumentException(name + " must not be empty"); } return value; diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java index 1480afc4a47e..850c9b3774fc 100644 --- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java @@ -50,6 +50,7 @@ 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; @@ -356,7 +357,7 @@ private static OidcConfig.Builder configBuilder() { private static JwksProvider jwksProvider(RSAKey... keys) { JWKSet jwkSet = jwkSet(keys); return keyId -> { - if (keyId == null || keyId.trim().isEmpty()) { + if (StringUtils.isBlank(keyId)) { return new ArrayList<>(jwkSet.getKeys()); } JWK key = jwkSet.getKeyByKeyId(keyId); 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 index fa600074b743..1bcf2252d2e0 100644 --- 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 @@ -49,11 +49,13 @@ 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; @@ -138,7 +140,8 @@ protected MiniOzoneCluster createCluster() throws Exception { @Override protected void onClusterReady() throws Exception { - try (OzoneClient client = getCluster().newClient()) { + try (OzoneClient client = + Objects.requireNonNull(getCluster().newClient())) { client.getObjectStore().createS3Bucket(ALLOWED_BUCKET); client.getObjectStore().createS3Bucket(DENIED_BUCKET); } @@ -173,7 +176,7 @@ protected StsCredentials assumeRoleWithWebIdentity(String token, protected void assertTemporaryCredentialsAuthorizeS3Operations( StsCredentials credentials) { - try (S3Client s3 = s3Client(credentials)) { + try (S3Client s3 = Objects.requireNonNull(s3Client(credentials))) { String key = "allowed.txt"; s3.putObject(PutObjectRequest.builder() .bucket(ALLOWED_BUCKET) @@ -372,13 +375,12 @@ public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) { @Override public String generateAssumeRoleWithWebIdentitySessionPolicy( AssumeRoleWithWebIdentityRequest request) throws OMException { - lastAssumeRoleRequest = request; + recordAssumeRoleRequest(request); if (!"tomato-user".equals(request.getUser()) || !request.getGroups().contains("ozone-tomato") || !ROLE_ARN.equals(request.getRoleArn()) || !PROVIDER_ID.equals(request.getProviderId()) - || request.getSubject() == null - || request.getSubject().trim().isEmpty()) { + || StringUtils.isBlank(request.getSubject())) { throw new OMException("WebIdentity role assumption denied", OMException.ResultCodes.ACCESS_DENIED); } @@ -391,6 +393,11 @@ public String generateAssumeRoleWithWebIdentitySessionPolicy( + "\",\"arn:aws:s3:::" + ALLOWED_BUCKET + "/*\"]" + "}]}"; } + + private static void recordAssumeRoleRequest( + AssumeRoleWithWebIdentityRequest request) { + lastAssumeRoleRequest = request; + } } private static final class InMemorySecretKeyClient 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 index 6b81190d6d96..e970a3ce8124 100644 --- 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 @@ -62,10 +62,11 @@ protected String jwksUri() { } @AfterAll - static void stopKeycloak() { - if (keycloak != null) { - keycloak.stop(); - keycloak = null; + static synchronized void stopKeycloak() { + GenericContainer container = keycloak; + keycloak = null; + if (container != null) { + container.stop(); } } 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 8b1dbe25b488..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 @@ -44,13 +44,8 @@ public class STSTokenIdentifier extends ShortLivedTokenIdentifier { public static final Text KIND_NAME = new Text("STSToken"); - /** - * Identifies the authentication path that produced an STS token. - */ - public enum AuthType { - ASSUME_ROLE, - WEB_IDENTITY - } + // Service name for STS tokens + public static final String STS_SERVICE = "STS"; // STS-specific fields private AuthType authType = AuthType.ASSUME_ROLE; @@ -71,8 +66,13 @@ public enum AuthType { // 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. 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 index 7afa41ba7694..6b844e1f00b6 100644 --- 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 @@ -73,22 +73,42 @@ public static class AssumeRoleWithWebIdentityResult { @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; } @@ -111,18 +131,34 @@ public static class Credentials { @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; } @@ -139,10 +175,18 @@ public static class AssumedRoleUser { @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; } @@ -156,6 +200,10 @@ public static class ResponseMetadata { @XmlElement(name = "RequestId") private String requestId; + public String getRequestId() { + return requestId; + } + public void setRequestId(String requestId) { this.requestId = requestId; } From 8062f0cc2ec371237d307f0f3cc8ec02b84bc7c7 Mon Sep 17 00:00:00 2001 From: paf91 Date: Sat, 23 May 2026 04:18:00 +0300 Subject: [PATCH 7/9] HDDS-15273. Remove split design doc from WebIdentity implementation PR --- .../oidc-assume-role-with-web-identity.md | 528 ------------------ 1 file changed, 528 deletions(-) delete mode 100644 hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md deleted file mode 100644 index 34f0ef995c77..000000000000 --- a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md +++ /dev/null @@ -1,528 +0,0 @@ ---- -title: OIDC AssumeRoleWithWebIdentity for Ozone STS -summary: Web identity support for Ozone STS using OIDC and Ranger authorization -date: 2026-05-13 -status: proposed ---- - - -# OIDC AssumeRoleWithWebIdentity for Ozone STS - -## Status - -Proposed staged implementation. - -This document narrows the previous broad OIDC direction to an upstream-friendly -MVP: extend the Ozone STS temporary S3 credential model with an AWS-compatible -`AssumeRoleWithWebIdentity` action. - -## Problem - -Secure Ozone S3 deployments currently depend on Kerberos-backed identities for -S3 credential issuance. Kubernetes workloads commonly already have OIDC tokens -from Keycloak or another IdP, but do not have an easy Kerberos bootstrap path. - -The target is not a Kerberos-free Ozone cluster. The target is a narrow STS -exchange: - -1. Keycloak authenticates the caller and issues a signed OIDC JWT. -2. Ozone STS validates the JWT locally using JWKS. -3. Ranger or the configured Ozone authorizer decides whether the identity may - assume the requested role. -4. Ozone STS issues temporary S3 credentials. -5. S3 Gateway and OM validate those temporary credentials and authorize object - operations using the assumed identity and session context. - -Ranger remains the authorization source of truth. Keycloak roles and groups are -identity attributes only. - -## Current STS And S3 Security Path - -This design is based on the `origin/HDDS-13323-sts` branch at commit -`37a224b217`, which contains the STS runtime that was missing from earlier -base branches. - -The current STS runtime contains: - -- `/sts` HTTP endpoint in `org.apache.hadoop.ozone.s3sts.S3STSEndpoint`; -- endpoint authentication setup in `S3STSEndpointBase`; -- AWS STS `AssumeRole` XML response model in `S3AssumeRoleResponseXml`; -- S3G to OM client path through `ObjectStore`, `ClientProtocol`, `RpcClient`, - `OzoneManagerProtocol`, and - `OzoneManagerProtocolClientSideTranslatorPB.assumeRole()`; -- OM request handling in - `org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest`; -- OM response handling in `S3AssumeRoleResponse`; -- session token identifier and secret manager in `STSTokenIdentifier`, - `STSTokenSecretManager`, and `STSSecurityUtil`; -- revoked STS token metadata and cleanup through `S3RevokeSTSTokenRequest`, - `S3DeleteRevokedSTSTokensRequest`, and - `RevokedSTSTokenCleanupService`; -- authorization extension points in `AssumeRoleRequest`, - `IAccessAuthorizer.generateAssumeRoleSessionPolicy()`, and - `RequestContext.sessionPolicy`. - -The current S3 request authentication path is: - -- S3G parses AWS SigV4 in `AuthorizationFilter`, - `SignatureProcessor`, `AuthorizationV4HeaderParser`, - `AuthorizationV4QueryParser`, and `StringToSignProducer`. -- `EndpointBase` creates `S3Auth` from the parsed access key, signature, and - string-to-sign, then stores it in the `ClientProtocol` thread-local. -- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into - `OMRequest.s3Authentication`. -- `AWSSignatureProcessor` extracts `x-amz-security-token` for temporary - credentials into `SignatureInfo.sessionToken`. -- `EndpointBase` and `S3STSEndpointBase` propagate the session token into - `S3Auth`. -- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into - `OMRequest.s3Authentication`. -- `S3SecurityUtil.validateS3Credential()` validates either permanent S3 - credentials or STS temporary credentials. -- For STS credentials, `STSSecurityUtil` decodes, validates, and decrypts the - session token, then OM validates the SigV4 signature with the temporary - secret access key. -- `OmMetadataReader` attaches STS session policy from the OM thread-local - `STSTokenIdentifier` to `RequestContext` for subsequent authorization. - -The current permanent S3 credential storage path is: - -- `S3SecretManager` and `S3SecretManagerImpl`. -- `S3SecretValue`. -- OM metadata S3 secret table via `OmMetadataManagerImpl`. -- `ozone s3 getsecret`, `setsecret`, and `revokesecret` client paths. - -## Dependency On Existing Ozone STS Runtime - -`AssumeRoleWithWebIdentity` must be an incremental extension of the existing -`AssumeRole` runtime in `origin/HDDS-13323-sts`. It must not introduce a second -STS endpoint, a separate S3 authentication system, or local S3G-only temporary -credential state. - -Existing runtime: - -```text -AssumeRole - -> S3 SigV4-authenticated /sts request - -> OM S3AssumeRoleRequest - -> temporary access key / secret / session token - -> STSSecurityUtil validation on later S3 requests - -> RequestContext.sessionPolicy - -> Ranger or configured authorizer -``` - -New runtime: - -```text -AssumeRoleWithWebIdentity - -> unauthenticated /sts bootstrap request only for this action - -> OM validates Keycloak/OIDC JWT - -> OM authorizes role assumption through Ranger or configured authorizer - -> existing temporary credential issuer / session token path - -> existing STSSecurityUtil validation on later S3 requests - -> RequestContext.sessionPolicy and assumed identity - -> Ranger or configured authorizer -``` - -The OM runtime slice adds the WebIdentity request/protobuf path while preserving -the existing `AssumeRole` flow. S3G parses and routes -`Action=AssumeRoleWithWebIdentity`, but OM remains the authoritative validator -and issuer. - -The raw `WebIdentityToken` is accepted only in the external OM RPC request. The -OM leader validates it in `S3AssumeRoleWithWebIdentityRequest.preExecute()`, -maps claims into a sanitized identity/session request, authorizes role -assumption, generates temporary credential material using the existing STS -helpers, and returns an `UpdateAssumeRoleWithWebIdentityRequest` for Ratis -replication. The replicated request must not contain the raw JWT. - -`validateAndUpdateCache()` consumes only the sanitized update request. It must -not call Keycloak, refresh JWKS, revalidate JWTs, or otherwise depend on current -external IdP state during Ratis apply or replay. Credential expiration is -computed by the leader before replication and stored as -`credentialExpirationEpochSeconds` so replay does not depend on the apply-time -clock. - -Temporary credentials must not be stored only in S3G memory. S3G can have -multiple replicas and can restart. The issuing and validation authority must be -OM-backed, persisted in Ozone metadata, or based on self-contained signed tokens -whose signing keys are rotation-safe and available to all validating components. - -## Endpoint Placement - -The existing STS runtime places `/sts` on the S3 Gateway HTTP/HTTPS port. -WebIdentity follows that placement: S3G exposes the AWS-compatible STS API -surface, while OM remains authoritative for JWT validation, identity mapping, -role-assumption authorization, credential issuance, revocation, and later -temporary credential validation. - -Because existing `/sts` `AssumeRole` is protected by the normal S3 SigV4 -`AuthorizationFilter`, `AssumeRoleWithWebIdentity` needs a narrow bootstrap -exception: - -- only for the STS application path; -- only for `Action=AssumeRoleWithWebIdentity`; -- only when `ozone.sts.web.identity.enabled=true`; -- never for normal S3 object APIs; -- never for existing `AssumeRole` or other STS actions. - -This exception must not make S3G a JWT source of truth. S3G may parse and route -the request, but it must forward the web identity token and request context to -OM. OM validates the JWT itself and issues the credentials. - -## RoleArn Semantics - -The current `AssumeRoleRequest` model contains `targetRoleName`, not a full AWS -IAM role database. No role metadata store or IAM-like role lifecycle was found -in this tree. `RoleArn` should therefore be treated as the authorization -resource and request context for Ranger or the configured Ozone authorizer in -the MVP. - -The Web Identity patch must not invent a new IAM role database. If the STS -runtime already defines role ARN parsing or role-name normalization, Web -Identity should reuse it. Otherwise, `RoleArn` remains an opaque policy resource -for the authorizer and for audit/session context. - -## New Flow - -`AssumeRoleWithWebIdentity` is handled by the Ozone STS endpoint. - -Request parameters: - -- `Action=AssumeRoleWithWebIdentity` -- `RoleArn=` -- `RoleSessionName=` -- `WebIdentityToken=` -- `DurationSeconds=` -- `Policy=` -- `ProviderId=` - -Flow: - -1. The client or workload obtains an OIDC access token from Keycloak. -2. The client calls Ozone STS with `AssumeRoleWithWebIdentity`. -3. Ozone STS rejects the request unless `ozone.sts.web.identity.enabled=true`. -4. S3G validates only the STS request shape that is safe to validate at the - edge: action, version, role ARN syntax, role session name, duration bounds, - and presence of `WebIdentityToken`. -5. S3G forwards `RoleArn`, `RoleSessionName`, `WebIdentityToken`, - `DurationSeconds`, `ProviderId`, and request context to OM in the external - RPC request only. -6. OM validates the JWT: - - token is a signed JWT; - - `alg=none` is rejected; - - signature validates against the configured JWKS; - - `iss` equals `ozone.sts.web.identity.issuer.uri`; - - configured audience is present; - - `exp`, `nbf`, and `iat` are validated with configured clock skew; - - configured username and subject claims are present. -7. OM maps claims into an Ozone identity: - - username; - - subject; - - issuer; - - groups; - - roles; - - token expiration. -8. OM builds an assume-role authorization request and calls Ranger or the - configured Ozone authorizer before issuing any credential. -9. OM strips the raw JWT before Ratis replication and submits only sanitized - identity/session fields: - - role ARN and role session name; - - provider id; - - effective user; - - subject, issuer, audience; - - groups and roles; - - web identity token expiration; - - token fingerprint; - - requested/effective duration; - - credential expiration; - - derived session policy. -10. If authorized, OM issues temporary S3 credentials: - - `Credentials.AccessKeyId`; - - `Credentials.SecretAccessKey`; - - `Credentials.SessionToken`; - - `Credentials.Expiration`; - - `SubjectFromWebIdentityToken`; - - `AssumedRoleUser`; - - `Audience`; - - `Provider`. -11. The client uses those credentials with ordinary AWS SigV4 against S3G. -12. S3G and OM validate the temporary credential, recover the assumed identity - and session policy, and pass them to the authorizer for every S3 operation. - -## Configuration - -The MVP uses STS-focused configuration keys: - -```properties -ozone.sts.web.identity.enabled=false -ozone.sts.web.identity.issuer.uri= -ozone.sts.web.identity.jwks.uri= -ozone.sts.web.identity.audience= -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.clock.skew=60s -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 -ozone.sts.web.identity.require.https=true -ozone.sts.web.identity.allow.insecure.http.for.tests=false -``` - -The feature is opt-in. When disabled, Kerberos, existing S3 SigV4 handling, -existing S3 secret handling, and non-secure mode behavior are unchanged. - -## OIDC Validation - -The reusable validation module is intentionally small: - -- `AuthCredentials` wraps bearer token material and redacts it in `toString()`. -- `OzoneIdentity` carries normalized username, subject, issuer, groups, roles, - auth method, authentication time, expiration time, and raw claims. -- `OidcJwtIdentityProvider` validates signed JWTs and maps claims. -- `JwksProvider`, `JwksFetcher`, `UrlJwksFetcher`, and - `CachingJwksProvider` load and cache JWKS with refresh-on-unknown-kid - behavior. -- `OidcAuthenticationException` fails closed without embedding the raw token in - exception messages. - -The module does not call Keycloak for every S3 request. JWKS validation is local, -with refresh on cache expiry and unknown key id. The default JWKS refresh -interval is 10 minutes. Unknown key ids may trigger an earlier refresh, but -repeated unknown kids are debounced to avoid refresh storms from attacker -supplied token headers. JWKS fetches use bounded connect/read timeouts and a -bounded response size. - -## Ranger Authorization Points - -The first authorization point is credential issuance. Before generating -temporary credentials, Ozone must call the configured authorizer with: - -- user: mapped OIDC username; -- groups: mapped OIDC groups; -- action: `AssumeRoleWithWebIdentity`; -- resource: `RoleArn` or normalized Ozone role resource; -- context: issuer, subject, audience, role session name, provider id, - requested duration, and client IP/host if available. - -Deny is the default. If Ranger denies or the authorizer cannot decide, Ozone -returns `AccessDenied` and does not issue credentials. - -The common request-shape extension point is -`AssumeRoleWithWebIdentityRequest`, with -`IAccessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy()` as the -default authorizer hook. Existing authorizers are not forced to implement this -immediately because the new method has a fail-closed default implementation. -For production Ranger deployments, the external Ranger Ozone plugin must add a -companion override for this method. The `RangerOzoneAuthorizer` class is -provided by Apache Ranger, not this Ozone repository. Without a WebIdentity -capable Ranger/Ozone authorizer, the default hook returns -`NOT_SUPPORTED_OPERATION`, Ozone fails closed, and no WebIdentity temporary -credentials are issued. - -The second authorization point is every S3 operation made with the temporary -credentials. OM must recover the assumed identity and session policy from the -session token and build a `RequestContext` carrying: - -- `clientUgi` for the assumed OIDC username and groups; -- `sessionPolicy` returned by the assume-role authorization step; -- `s3Action` mapped from the S3 endpoint method; -- bucket/key resource information. - -Ranger evaluates normal resource/action policies using that context. - -## Temporary Credential Lifecycle - -Temporary credentials must: - -- expire no later than the requested `DurationSeconds` and Ozone's configured - STS maximum; -- require `x-amz-security-token` or the equivalent SigV4 query parameter; -- fail closed if the session token is unknown, expired, revoked, malformed, or - fails signature/MAC verification; -- never log the secret access key, session token, WebIdentityToken, refresh - token, or client secret; -- map back to the assumed OIDC identity and role session context; -- preserve the authorizer session policy used for subsequent S3 authorization. - -The existing STS runtime uses self-contained session tokens containing the -encrypted secret access key, original identity, role ARN, session policy, -expiration, signing key id, and MAC. `AssumeRoleWithWebIdentity` should reuse -that issuer and validator instead of creating a parallel token format. -The sanitized replicated OM request still carries temporary credential material -through Ratis in the same way as the existing `AssumeRole` implementation. -Operators must protect OM metadata, Ratis logs, snapshots, and backups as -sensitive security material. The raw WebIdentity JWT must not be replicated or -stored in OM metadata. - -In `origin/HDDS-13323-sts`, `STSTokenIdentifier` stores the -`originalAccessKeyId` because `AssumeRole` starts from an existing S3 access -key. `AssumeRoleWithWebIdentity` has no permanent S3 access key. The token -model must therefore be extended backward-compatibly, for example with an -`authType` field plus optional WebIdentity fields: - -- `authType=ASSUME_ROLE` for existing tokens, with `originalAccessKeyId` - preserved; -- `authType=WEB_IDENTITY` for new tokens, with effective user, groups, issuer, - subject, audience, role ARN, role session name, provider id, and session - policy; -- old `AssumeRole` tokens must continue to deserialize and validate exactly as - before; -- `STSSecurityUtil.ensureEssentialFieldsArePresentInToken()` must not require - `originalAccessKeyId` for `WEB_IDENTITY` tokens, but must still require all - fields needed to validate signatures and authorize later S3 operations. - -Revocation should follow the STS design: store revoked session token identifiers -in OM metadata and fail closed if revocation status cannot be checked. - -## AWS-Compatible Response - -The XML response should follow the AWS STS shape where practical: - -```xml - - - ... - ozone - https://keycloak.example.com/realms/ozone - - ... - ... - - - ... - ... - ... - ... - - - -``` - -Errors should use STS/S3-compatible codes where possible: - -- invalid or expired JWT: `InvalidIdentityToken`; -- disabled feature: `AccessDenied` or `InvalidAction`; -- unauthorized role assumption: `AccessDenied`; -- unsupported optional parameter: `InvalidParameterValue`; -- internal validation or revocation failures: fail closed. - -## Security Model - -This feature does not replace Kerberos for daemon authentication or the broader -Ozone secure cluster model. - -Security boundaries: - -- Keycloak authenticates the caller by signing JWTs. -- Ozone STS validates the token and issues short-lived S3 credentials. -- Ranger or the configured Ozone authorizer decides whether the caller can - assume a role and what object-store actions are allowed. -- Ozone S3G/OM enforce the temporary credential and Ranger decisions. - -Required protections: - -- TLS is required for production STS and Keycloak endpoints. -- Unsigned JWTs and `alg=none` are rejected. -- Incorrect issuer, audience, signature, time claims, or required claims are - rejected. -- JWKS rotation is handled by cache refresh and refresh-on-unknown-kid. -- Web identity token and temporary credentials are never logged. -- Direct OM/SCM/DN access is still governed by existing Ozone security. This - MVP only adds an STS path for temporary S3 credentials. - -## Non-Goals - -The MVP explicitly does not include: - -- full OIDC-only secure Ozone cluster; -- replacing Kerberos daemon login; -- OFS OIDC login; -- `ozone auth login --oidc`; -- device-code flow; -- Keycloak Authorization Services as the object-store PDP; -- replacing Ranger with Keycloak roles; -- daemon-to-daemon OIDC authentication. - -## Test Strategy - -Unit tests: - -- valid JWT from a test RSA key validates successfully; -- expired JWT fails; -- wrong issuer fails; -- wrong audience fails; -- wrong signature fails; -- `alg=none` fails; -- manipulated groups claim fails because the signature no longer matches; -- unknown `kid` triggers JWKS refresh or fails safely; -- username, subject, groups, and roles claim mapping works; -- token material is not present in exceptions. - -STS authorization tests: - -- fake authorizer sees user, groups, action `AssumeRoleWithWebIdentity`, role - resource, issuer, subject, audience, and session name; -- allowed identity receives temporary credentials; -- denied identity receives `AccessDenied`; -- no credential is generated before authorization succeeds. - -Temporary credential tests: - -- credentials require a session token; -- expired credentials fail; -- tampered session token fails; -- unknown/revoked session token fails; -- allowed bucket operation succeeds with allowed role/session policy; -- denied bucket operation fails. - -Integration tests: - -- Keycloak Testcontainers or docker-compose realm `ozone-test`; -- client `ozone-sts`; -- users `tomato-user` and `denied-user`; -- group `ozone-tomato`; -- `tomato-user` token includes `preferred_username`, `sub`, `groups`, - `realm_access.roles`, and `aud=ozone`; -- real Keycloak JWT validates with Ozone provider; -- fake/Ranger authorizer allows `tomato-user` to assume a test role and denies - `denied-user`. - -Full Ranger container testing is optional for the MVP. Unit and mock-layer tests -must prove request shape and fail-closed behavior. - -## Migration And Future Work - -Migration path: - -1. Existing Kerberos and S3 secret behavior remains unchanged. -2. Operators enable `ozone.sts.web.identity.enabled=true` for STS only. -3. Workloads exchange Keycloak JWTs for temporary S3 credentials. -4. Ranger policies grant role assumption and object access. - -Future work: - -- reuse the OIDC validation module for OIDC-to-Ozone delegation tokens for - OFS/CLI; -- add daemon authentication without Kerberos via mTLS, SPIFFE, Kubernetes - ServiceAccount JWTs, or Keycloak client credentials; -- add hybrid Kerberos plus OIDC migration mode if broader Ozone authentication - is pursued; -- improve AWS STS API compatibility; -- add an optional real Ranger integration test profile. From 12b1ef2662ab00a0d800654df2e82778ada8baf6 Mon Sep 17 00:00:00 2001 From: paf91 Date: Sun, 24 May 2026 20:53:55 +0300 Subject: [PATCH 8/9] HDDS-15273. Address WebIdentity STS review comments --- .../apache/hadoop/ozone/OzoneConfigKeys.java | 54 -- .../src/main/resources/ozone-default.xml | 111 ----- .../OzoneSTSWebIdentityKeycloakRanger.md | 7 +- hadoop-ozone/common/pom.xml | 4 - .../acl/AssumeRoleWithWebIdentityRequest.java | 121 ++++- .../ozone/security/oidc/OidcConfig.java | 395 --------------- .../TestAssumeRoleWithWebIdentityRequest.java | 47 +- ...stractAssumeRoleWithWebIdentityS3Test.java | 11 +- .../src/main/proto/OmClientProtocol.proto | 54 +- hadoop-ozone/ozone-manager/pom.xml | 4 + .../apache/hadoop/ozone/om/OzoneManager.java | 18 - .../S3AssumeRoleWithWebIdentityRequest.java | 111 ++++- .../ozone/security/oidc/AuthCredentials.java | 0 .../security/oidc/CachingJwksProvider.java | 0 .../ozone/security/oidc/JwksFetcher.java | 0 .../ozone/security/oidc/JwksProvider.java | 0 .../oidc/OidcAuthenticationException.java | 0 .../ozone/security/oidc/OidcConfig.java | 461 ++++++++++++++++++ .../oidc/OidcJwtIdentityProvider.java | 0 .../ozone/security/oidc/OzoneIdentity.java | 0 .../security/oidc/OzoneIdentityProvider.java | 0 .../ozone/security/oidc/UrlJwksFetcher.java | 0 .../ozone/security/oidc/package-info.java | 0 ...estS3AssumeRoleWithWebIdentityRequest.java | 79 ++- .../oidc/TestOidcJwtIdentityProvider.java | 0 .../S3STSWebIdentityAuthBypassFilter.java | 15 +- .../s3sts/S3STSWebIdentityRequestParser.java | 14 +- .../TestS3STSWebIdentityAuthBypassFilter.java | 15 +- pom.xml | 2 +- 29 files changed, 804 insertions(+), 719 deletions(-) delete mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java (100%) create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java (100%) rename hadoop-ozone/{common => ozone-manager}/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java (100%) 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 e798e2f8271b..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 @@ -448,60 +448,6 @@ public final class OzoneConfigKeys { 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_STS_WEB_IDENTITY_ISSUER_URI = - "ozone.sts.web.identity.issuer.uri"; - public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT = ""; - public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI = - "ozone.sts.web.identity.jwks.uri"; - public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT = ""; - public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE = - "ozone.sts.web.identity.audience"; - public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT = ""; - public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM = - "ozone.sts.web.identity.username.claim"; - public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT = - "preferred_username"; - public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM = - "ozone.sts.web.identity.subject.claim"; - public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT = - "sub"; - public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM = - "ozone.sts.web.identity.groups.claim"; - public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT = - "groups"; - public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM = - "ozone.sts.web.identity.roles.claim"; - public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT = - "realm_access.roles"; - public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW = - "ozone.sts.web.identity.clock.skew"; - public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT = - "60s"; - public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL = - "ozone.sts.web.identity.jwks.refresh.interval"; - public static final String - OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT = "10m"; - public static final String OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT = - "ozone.sts.web.identity.jwks.connect.timeout"; - public static final String - OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT = "5s"; - public static final String OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT = - "ozone.sts.web.identity.jwks.read.timeout"; - public static final String - OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT = "5s"; - public static final String OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT = - "ozone.sts.web.identity.jwks.size.limit"; - public static final String - OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT = "1MB"; - public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS = - "ozone.sts.web.identity.require.https"; - public static final boolean OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT = - true; - public static final String - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS = - "ozone.sts.web.identity.allow.insecure.http.for.tests"; - public static final boolean - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT = false; public static final String OZONE_HTTP_SECURITY_ENABLED_KEY = "ozone.security.http.kerberos.enabled"; diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml index 19bd629fec49..2066a99baa47 100644 --- a/hadoop-hdds/common/src/main/resources/ozone-default.xml +++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml @@ -2142,117 +2142,6 @@ true, hadoop.security.authentication should be Kerberos.
- - ozone.sts.web.identity.enabled - false - OZONE, SECURITY, S3, STS, OIDC - - Enables experimental Ozone STS AssumeRoleWithWebIdentity support. - When enabled, OM validates OIDC web identity JWTs and issues temporary - S3 credentials through the existing STS token infrastructure. - - - - ozone.sts.web.identity.issuer.uri - - OZONE, SECURITY, S3, STS, OIDC - Expected OIDC issuer URI for STS web identity tokens. - - - ozone.sts.web.identity.jwks.uri - - OZONE, SECURITY, S3, STS, OIDC - - JWKS URI used to validate STS web identity JWT signatures. If empty, - a later STS integration may derive it from issuer metadata. - - - - ozone.sts.web.identity.audience - - OZONE, SECURITY, S3, STS, OIDC - Required audience claim for STS web identity tokens. - - - ozone.sts.web.identity.username.claim - preferred_username - OZONE, SECURITY, S3, STS, OIDC - OIDC claim used as the effective Ozone username. - - - ozone.sts.web.identity.subject.claim - sub - OZONE, SECURITY, S3, STS, OIDC - OIDC claim used as the immutable external subject. - - - ozone.sts.web.identity.groups.claim - groups - OZONE, SECURITY, S3, STS, OIDC - - OIDC claim path containing user groups. Nested claim paths such as - resource_access.ozone.groups are supported. - - - - ozone.sts.web.identity.roles.claim - realm_access.roles - OZONE, SECURITY, S3, STS, OIDC - - OIDC claim path containing role attributes. Roles are identity - attributes only; Ranger remains the authorization policy engine. - - - - ozone.sts.web.identity.clock.skew - 60s - OZONE, SECURITY, S3, STS, OIDC - Allowed clock skew for OIDC exp, nbf, and iat validation. - - - ozone.sts.web.identity.jwks.refresh.interval - 10m - OZONE, SECURITY, S3, STS, OIDC - - Interval for refreshing cached JWKS signing keys. Unknown key IDs may - trigger an earlier refresh, but repeated unknown key IDs are debounced. - - - - ozone.sts.web.identity.jwks.connect.timeout - 5s - OZONE, SECURITY, S3, STS, OIDC - Connection timeout for fetching OIDC JWKS signing keys. - - - ozone.sts.web.identity.jwks.read.timeout - 5s - OZONE, SECURITY, S3, STS, OIDC - Read timeout for fetching OIDC JWKS signing keys. - - - ozone.sts.web.identity.jwks.size.limit - 1MB - OZONE, SECURITY, S3, STS, OIDC - Maximum JWKS response size accepted by Ozone STS. - - - ozone.sts.web.identity.require.https - true - OZONE, SECURITY, S3, STS, OIDC - - Require HTTPS issuer and JWKS URIs for STS web identity validation. - - - - ozone.sts.web.identity.allow.insecure.http.for.tests - false - OZONE, SECURITY, S3, STS, OIDC - - Allows HTTP issuer and JWKS URIs only for tests. Do not enable in - production. - - ozone.security.http.kerberos.enabled false diff --git a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md index 1846e793552a..d8b96d7e3ec9 100644 --- a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md +++ b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md @@ -137,17 +137,14 @@ subsequent S3 access. ``` -For local tests only, HTTP issuer and JWKS URLs can be enabled explicitly: +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 - - ozone.sts.web.identity.allow.insecure.http.for.tests - true - ``` Production deployments should use HTTPS for both Keycloak and Ozone endpoints. diff --git a/hadoop-ozone/common/pom.xml b/hadoop-ozone/common/pom.xml index 7e15ac212428..13a53cdc7a88 100644 --- a/hadoop-ozone/common/pom.xml +++ b/hadoop-ozone/common/pom.xml @@ -47,10 +47,6 @@ com.google.protobuf protobuf-java - - com.nimbusds - nimbus-jose-jwt - io.grpc grpc-api 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 index 6d393ba47081..cda41199be9e 100644 --- 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 @@ -34,7 +34,7 @@ * attributes to the authorizer. */ @Immutable -public class AssumeRoleWithWebIdentityRequest { +public final class AssumeRoleWithWebIdentityRequest { public static final String ACTION = "AssumeRoleWithWebIdentity"; @@ -51,24 +51,24 @@ public class AssumeRoleWithWebIdentityRequest { private final String providerId; private final Set grants; - @SuppressWarnings("checkstyle:ParameterNumber") - public AssumeRoleWithWebIdentityRequest(String host, InetAddress ip, - String user, Set groups, Set roles, String roleArn, - String roleSessionName, String issuer, String subject, String audience, - String providerId, Set grants) { - this.host = host; - this.ip = ip; - this.user = requireNonBlank(user, "user"); - this.groups = immutableSet(groups); - this.roles = immutableSet(roles); - this.roleArn = requireNonBlank(roleArn, "roleArn"); + 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(roleSessionName, "roleSessionName"); - this.issuer = requireNonBlank(issuer, "issuer"); - this.subject = requireNonBlank(subject, "subject"); - this.audience = requireNonBlank(audience, "audience"); - this.providerId = providerId; - this.grants = grants; + 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() { @@ -184,4 +184,89 @@ private static String requireNonBlank(String value, String name) { } 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/oidc/OidcConfig.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java deleted file mode 100644 index 53a91995a445..000000000000 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java +++ /dev/null @@ -1,395 +0,0 @@ -/* - * 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.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT; -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 static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT; - -import java.net.URI; -import java.net.URISyntaxException; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.apache.hadoop.hdds.conf.ConfigurationSource; -import org.apache.hadoop.hdds.conf.StorageUnit; - -/** - * Configuration for the experimental OIDC identity provider. - */ -public final class OidcConfig { - - private final boolean enabled; - private final String issuerUri; - private final String jwksUri; - private final String audience; - private final String usernameClaim; - private final String subjectClaim; - private final String groupsClaim; - private final String rolesClaim; - private final Duration clockSkew; - private final Duration jwksRefreshInterval; - private final Duration jwksConnectTimeout; - private final Duration jwksReadTimeout; - private final int jwksSizeLimit; - private final boolean requireHttps; - private final boolean allowInsecureHttpForTests; - - private OidcConfig(Builder builder) { - this.enabled = builder.enabled; - this.issuerUri = trimToEmpty(builder.issuerUri); - this.jwksUri = trimToEmpty(builder.jwksUri); - this.audience = trimToEmpty(builder.audience); - this.usernameClaim = requireNonBlank(builder.usernameClaim, - OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM); - this.subjectClaim = requireNonBlank(builder.subjectClaim, - OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM); - this.groupsClaim = requireNonBlank(builder.groupsClaim, - OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM); - this.rolesClaim = requireNonBlank(builder.rolesClaim, - OZONE_STS_WEB_IDENTITY_ROLES_CLAIM); - this.clockSkew = requireNonNegative(builder.clockSkew, - OZONE_STS_WEB_IDENTITY_CLOCK_SKEW); - this.jwksRefreshInterval = requireNonNegative(builder.jwksRefreshInterval, - OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL); - this.jwksConnectTimeout = requirePositive(builder.jwksConnectTimeout, - OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT); - this.jwksReadTimeout = requirePositive(builder.jwksReadTimeout, - OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT); - this.jwksSizeLimit = requirePositive(builder.jwksSizeLimit, - OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT); - this.requireHttps = builder.requireHttps; - this.allowInsecureHttpForTests = builder.allowInsecureHttpForTests; - } - - public static OidcConfig from(ConfigurationSource conf) { - OidcConfig config = newBuilder() - .setEnabled(conf.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, - OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT)) - .setIssuerUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ISSUER_URI, - OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT)) - .setJwksUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_JWKS_URI, - OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT)) - .setAudience(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_AUDIENCE, - OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT)) - .setUsernameClaim(conf.getTrimmed( - OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM, - OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT)) - .setSubjectClaim(conf.getTrimmed( - OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM, - OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT)) - .setGroupsClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM, - OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT)) - .setRolesClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ROLES_CLAIM, - OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT)) - .setClockSkew(duration(conf, OZONE_STS_WEB_IDENTITY_CLOCK_SKEW, - OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT)) - .setJwksRefreshInterval(duration(conf, - OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL, - OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT)) - .setJwksConnectTimeout(duration(conf, - OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT, - OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT)) - .setJwksReadTimeout(duration(conf, - OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT, - OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT)) - .setJwksSizeLimit(storageSize(conf, - OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT, - OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT)) - .setRequireHttps(conf.getBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, - OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT)) - .setAllowInsecureHttpForTests(conf.getBoolean( - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT)) - .build(); - if (config.isEnabled()) { - config.validateForProvider(); - } - return config; - } - - 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; - } - - public boolean isAllowInsecureHttpForTests() { - return allowInsecureHttpForTests; - } - - void validateForProvider() { - requireNonBlank(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI); - requireNonBlank(audience, OZONE_STS_WEB_IDENTITY_AUDIENCE); - - if (requireHttps && !allowInsecureHttpForTests) { - requireHttpsUri(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI); - if (!jwksUri.isEmpty()) { - requireHttpsUri(jwksUri, OZONE_STS_WEB_IDENTITY_JWKS_URI); - } - } - } - - private static Duration duration(ConfigurationSource conf, String key, - String defaultValue) { - return Duration.ofMillis(conf.getTimeDuration(key, defaultValue, - TimeUnit.MILLISECONDS)); - } - - private static int storageSize(ConfigurationSource conf, String key, - String defaultValue) { - double bytes = conf.getStorageSize(key, defaultValue, StorageUnit.BYTES); - if (bytes > Integer.MAX_VALUE) { - throw new IllegalArgumentException(key + " must not exceed " - + Integer.MAX_VALUE + " bytes"); - } - return (int) bytes; - } - - 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 = trimToEmpty(value); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException(key + " must not be empty"); - } - return trimmed; - } - - private static String trimToEmpty(String value) { - return value == null ? "" : value.trim(); - } - - 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 = OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT; - private String issuerUri = OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT; - private String jwksUri = OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT; - private String audience = OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT; - private String usernameClaim = - OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT; - private String subjectClaim = - OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT; - private String groupsClaim = OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT; - private String rolesClaim = OZONE_STS_WEB_IDENTITY_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 = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT; - private boolean allowInsecureHttpForTests = - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; - - 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 Builder setAllowInsecureHttpForTests(boolean value) { - this.allowInsecureHttpForTests = value; - return this; - } - - public OidcConfig build() { - return new OidcConfig(this); - } - } -} 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 index 19651ba34d9d..f4a40d25b8fb 100644 --- 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 @@ -45,10 +45,19 @@ public void testConstructorAndGetters() throws Exception { final InetAddress ip = InetAddress.getByName("127.0.0.1"); final AssumeRoleWithWebIdentityRequest request = - new AssumeRoleWithWebIdentityRequest( - "s3g.example.com", ip, "tomato-user", groups, roles, ROLE_ARN, - "tomato-session", "https://keycloak.example.com/realms/ozone", - "subject-1", "ozone", "keycloak", null); + 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"); @@ -75,10 +84,16 @@ public void testRequiredIdentityFieldsFailClosed() { assertThrows(IllegalArgumentException.class, () -> requestWithUser(" ")); assertThrows(IllegalArgumentException.class, - () -> new AssumeRoleWithWebIdentityRequest( - null, null, "tomato-user", set("ozone-tomato"), set(), - " ", "tomato-session", "issuer", "subject", "audience", - null, null)); + () -> AssumeRoleWithWebIdentityRequest.newBuilder() + .setUser("tomato-user") + .setGroups(set("ozone-tomato")) + .setRoles(set()) + .setRoleArn(" ") + .setRoleSessionName("tomato-session") + .setIssuer("issuer") + .setSubject("subject") + .setAudience("audience") + .build()); } @Test @@ -142,10 +157,18 @@ public void testRequestDoesNotContainTokenMaterial() { private static AssumeRoleWithWebIdentityRequest requestWithUser( String user) { - return new AssumeRoleWithWebIdentityRequest( - "host", null, user, set("ozone-tomato"), set("role:writer"), - ROLE_ARN, "session", "issuer", "subject", "audience", - "provider", null); + 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) { 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 index 1bcf2252d2e0..9ecadea55865 100644 --- 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 @@ -22,17 +22,16 @@ 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_ALLOW_INSECURE_HTTP_FOR_TESTS; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS; 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; @@ -117,8 +116,6 @@ protected OzoneConfiguration createOzoneConfig() { 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.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, - true); 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()); diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto index 536c064eac59..9d4aa22f9b67 100644 --- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto +++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto @@ -2453,22 +2453,22 @@ message UpdateAssumeRoleRequest { } message AssumeRoleWithWebIdentityRequest { - required string roleArn = 1; - required string roleSessionName = 2; + optional string roleArn = 1; + optional string roleSessionName = 2; optional int32 durationSeconds = 3 [default = 3600]; - required string webIdentityToken = 4; + optional string webIdentityToken = 4; optional string providerId = 5; - required string requestId = 6; + optional string requestId = 6; } message AssumeRoleWithWebIdentityResponse { - required string accessKeyId = 1; - required string secretAccessKey = 2; - required string sessionToken = 3; - required uint64 expirationEpochSeconds = 4; - required string assumedRoleId = 5; - required string subjectFromWebIdentityToken = 6; - required string audience = 7; + 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; } @@ -2478,25 +2478,25 @@ message AssumeRoleWithWebIdentityResponse { replicated through Ratis; it must never contain the raw JWT. */ message UpdateAssumeRoleWithWebIdentityRequest { - required string roleArn = 1; - required string roleSessionName = 2; - required int32 durationSeconds = 3; + optional string roleArn = 1; + optional string roleSessionName = 2; + optional int32 durationSeconds = 3; optional string providerId = 4; - required string requestId = 5; - required string tempAccessKeyId = 6; - required string secretAccessKey = 7; - required string roleId = 8; - required string effectiveUser = 9; - required string subject = 10; - required string issuer = 11; - required string audience = 12; + 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; - required uint64 webIdentityTokenExpiresAt = 15; - required uint64 authenticatedAt = 16; - required string tokenFingerprint = 17; - required string sessionPolicy = 18; - required uint64 credentialExpirationEpochSeconds = 19; + optional uint64 webIdentityTokenExpiresAt = 15; + optional uint64 authenticatedAt = 16; + optional string tokenFingerprint = 17; + optional string sessionPolicy = 18; + optional uint64 credentialExpirationEpochSeconds = 19; } message RevokeSTSTokenRequest { 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/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java index f75939aa1429..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 @@ -39,10 +39,6 @@ import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_READONLY_ADMINISTRATORS; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE_DEFAULT; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT; -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 static org.apache.hadoop.ozone.OzoneConsts.DB_TRANSIENT_MARKER; import static org.apache.hadoop.ozone.OzoneConsts.DEFAULT_OM_UPDATE_ID; import static org.apache.hadoop.ozone.OzoneConsts.LAYOUT_VERSION_KEY; @@ -1918,7 +1914,6 @@ public DeletingServiceMetrics getDeletionMetrics() { public void start() throws IOException { Map auditMap = new HashMap(); auditMap.put("OmState", omState.name()); - logInsecureWebIdentityHttpWarning(); if (omState == State.BOOTSTRAPPING) { if (isBootstrapping) { auditMap.put("Bootstrap", "normal"); @@ -2027,19 +2022,6 @@ public void start() throws IOException { SYSTEMAUDIT.logWriteSuccess(buildAuditMessageForSuccess(OMSystemAction.STARTUP, auditMap)); } - private void logInsecureWebIdentityHttpWarning() { - if (configuration.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, - OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT) - && configuration.getBoolean( - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS, - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT)) { - LOG.warn("STS WebIdentity is configured with {}=true. This permits " - + "insecure HTTP issuer/JWKS URIs for tests only and is unsafe " - + "for production deployments.", - OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS); - } - } - /** * Restarts the service. This method re-initializes the rpc server. */ 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 index 8d4e6eed7725..8e6afa68f81d 100644 --- 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 @@ -17,13 +17,13 @@ package org.apache.hadoop.ozone.om.request.s3.security; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; 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; @@ -105,10 +105,12 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { FEATURE_NOT_ENABLED); } + validateExternalRequest(request); final int requestedDuration = S3STSUtils.validateDuration(request.getDurationSeconds()); S3STSUtils.validateRoleSessionName(request.getRoleSessionName()); - AwsRoleArnValidator.validateAndExtractRoleNameFromArn(request.getRoleArn()); + AwsRoleArnValidator.validateAndExtractRoleNameFromArn( + request.getRoleArn()); final OzoneIdentity identity = authenticate(oidcConfig, request.getWebIdentityToken()); @@ -132,7 +134,8 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setRoleArn(request.getRoleArn()) .setRoleSessionName(request.getRoleSessionName()) .setDurationSeconds(effectiveDuration) - .setProviderId(request.hasProviderId() ? request.getProviderId() : "") + .setProviderId(request.hasProviderId() + ? request.getProviderId() : "") .setRequestId(request.getRequestId()) .setTempAccessKeyId(S3AssumeRoleRequest.generateTempAccessKeyId()) .setSecretAccessKey(S3AssumeRoleRequest.generateSecretAccessKey()) @@ -143,7 +146,8 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException { .setAudience(oidcConfig.getAudience()) .addAllGroups(identity.getGroups()) .addAllRoles(identity.getRoles()) - .setWebIdentityTokenExpiresAt(identity.getExpiresAt().toEpochMilli()) + .setWebIdentityTokenExpiresAt( + identity.getExpiresAt().toEpochMilli()) .setAuthenticatedAt(identity.getAuthenticatedAt().toEpochMilli()) .setTokenFingerprint(tokenFingerprint) .setSessionPolicy(sessionPolicy) @@ -179,14 +183,11 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Exception exception = null; OMClientResponse omClientResponse; try { + validateUpdateRequest(updateRequest); S3STSUtils.validateDuration(updateRequest.getDurationSeconds()); S3STSUtils.validateRoleSessionName(updateRequest.getRoleSessionName()); AwsRoleArnValidator.validateAndExtractRoleNameFromArn( updateRequest.getRoleArn()); - if (Strings.isNullOrEmpty(updateRequest.getSessionPolicy())) { - throw new OMException("Missing WebIdentity session policy", - ACCESS_DENIED); - } final String sessionToken = ozoneManager.getSTSTokenSecretManager() .createWebIdentitySTSTokenString( @@ -206,8 +207,8 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, updateRequest.getProviderId(), updateRequest.getTokenFingerprint()); - final String assumedRoleId = - updateRequest.getRoleId() + ":" + updateRequest.getRoleSessionName(); + final String assumedRoleId = updateRequest.getRoleId() + + ":" + updateRequest.getRoleSessionName(); final long expirationEpochSeconds = updateRequest.getCredentialExpirationEpochSeconds(); @@ -306,20 +307,82 @@ private String generateSessionPolicy(OzoneManager ozoneManager, return ozoneManager.getAccessAuthorizer() .generateAssumeRoleWithWebIdentitySessionPolicy( - new org.apache.hadoop.ozone.security.acl - .AssumeRoleWithWebIdentityRequest( - hostName, - remoteIp, - identity.getUsername(), - identity.getGroups(), - identity.getRoles(), - request.getRoleArn(), - request.getRoleSessionName(), - identity.getIssuer(), - identity.getSubject(), - audience, - request.hasProviderId() ? request.getProviderId() : null, - null)); + 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, diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java 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/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java 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 index fb96f37aac18..8563e73daca7 100644 --- 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 @@ -17,12 +17,13 @@ package org.apache.hadoop.ozone.om.request.s3.security; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE; import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI; -import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI; 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; @@ -279,6 +280,47 @@ public void testDisabledFeatureFailsBeforeTokenValidation() { 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." @@ -302,22 +344,33 @@ private static OMRequest externalRequest(int durationSeconds) { 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( - 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) - .build()) + .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; diff --git a/hadoop-ozone/common/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 similarity index 100% rename from hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java rename to hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java 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 index e406bc0b4af4..616ec0214104 100644 --- 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 @@ -20,7 +20,6 @@ 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; import javax.annotation.Priority; import javax.inject.Inject; @@ -45,8 +44,16 @@ public class S3STSWebIdentityAuthBypassFilter implements ContainerRequestFilter { + private final OzoneConfiguration ozoneConfiguration; + + public S3STSWebIdentityAuthBypassFilter() { + this(null); + } + @Inject - private OzoneConfiguration ozoneConfiguration; + public S3STSWebIdentityAuthBypassFilter(OzoneConfiguration conf) { + this.ozoneConfiguration = conf; + } @Override public void filter(ContainerRequestContext context) throws IOException { @@ -67,8 +74,4 @@ private boolean isWebIdentityEnabled() { OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT); } - @VisibleForTesting - void setOzoneConfiguration(OzoneConfiguration conf) { - this.ozoneConfiguration = conf; - } } 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 index 7489b1b58777..7a0bd0f16f87 100644 --- 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 @@ -18,7 +18,6 @@ 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; @@ -26,6 +25,7 @@ import java.util.List; import javax.ws.rs.HttpMethod; import javax.ws.rs.container.ContainerRequestContext; +import org.apache.commons.io.IOUtils; /** * Parses the STS action without logging request parameters. @@ -65,7 +65,7 @@ private static String getFormAction(ContainerRequestContext context) return null; } - byte[] body = readFully(stream); + byte[] body = IOUtils.toByteArray(stream); context.setEntityStream(new ByteArrayInputStream(body)); String form = new String(body, StandardCharsets.UTF_8); String action = null; @@ -85,16 +85,6 @@ private static String getFormAction(ContainerRequestContext context) return action; } - private static byte[] readFully(InputStream stream) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; - int read; - while ((read = stream.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - return out.toByteArray(); - } - private static String decode(String value) throws IOException { return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); } 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 index 1d3ea0ecaa4d..4e3abd5b37c4 100644 --- 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 @@ -27,7 +27,6 @@ import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -35,6 +34,7 @@ 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; @@ -124,10 +124,7 @@ public void duplicateQueryActionDoesNotSkipAwsAuth() throws Exception { private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) { OzoneConfiguration conf = new OzoneConfiguration(); conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, enabled); - S3STSWebIdentityAuthBypassFilter filter = - new S3STSWebIdentityAuthBypassFilter(); - filter.setOzoneConfiguration(conf); - return filter; + return new S3STSWebIdentityAuthBypassFilter(conf); } private static void assertDoesNotSkipPost(String body) throws Exception { @@ -172,12 +169,6 @@ private static ContainerRequestContext contextWithQueryActions( } private static String read(InputStream stream) throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buffer = new byte[256]; - int read; - while ((read = stream.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - return new String(out.toByteArray(), StandardCharsets.UTF_8); + return new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8); } } diff --git a/pom.xml b/pom.xml index e5a94101c6eb..2de60387e3a5 100644 --- a/pom.xml +++ b/pom.xml @@ -224,7 +224,7 @@ 1.5.4 ${test.build.dir} ${project.build.directory}/test-dir - 1.21.3 + 2.0.5 4 From 38c6a8e2367a0fa301539fd6b069679c5b5b8448 Mon Sep 17 00:00:00 2001 From: paf91 Date: Mon, 25 May 2026 00:32:19 +0300 Subject: [PATCH 9/9] HDDS-15273. Bound WebIdentity STS request body size --- .../S3STSWebIdentityAuthBypassFilter.java | 5 ++ .../s3sts/S3STSWebIdentityRequestParser.java | 32 ++++++++++++- .../TestS3STSWebIdentityAuthBypassFilter.java | 46 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) 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 index 616ec0214104..8096542c92b2 100644 --- 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 @@ -46,6 +46,11 @@ public class S3STSWebIdentityAuthBypassFilter 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); } 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 index 7a0bd0f16f87..2765b308c62e 100644 --- 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 @@ -18,14 +18,16 @@ 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 org.apache.commons.io.IOUtils; +import javax.ws.rs.core.Response; /** * Parses the STS action without logging request parameters. @@ -35,6 +37,10 @@ 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() { } @@ -65,7 +71,7 @@ private static String getFormAction(ContainerRequestContext context) return null; } - byte[] body = IOUtils.toByteArray(stream); + byte[] body = readBoundedFormBody(stream); context.setEntityStream(new ByteArrayInputStream(body)); String form = new String(body, StandardCharsets.UTF_8); String action = null; @@ -89,6 +95,28 @@ 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/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java index 4e3abd5b37c4..bcc22b26e6ab 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -31,6 +32,7 @@ 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; @@ -98,6 +100,39 @@ public void postWebIdentityRequestSkipsAwsAuthAndRestoresBody() 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"); @@ -171,4 +206,15 @@ private static ContainerRequestContext contextWithQueryActions( 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); + } }