This is an automated email from the ASF dual-hosted git repository.

ChenSammi pushed a commit to branch HDDS-13323-sts
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/HDDS-13323-sts by this push:
     new 0ab01a4777f HDDS-16313. [STS] Handle linked bucket and session 
policies (#11196)
0ab01a4777f is described below

commit 0ab01a4777f3287203054b5ddaa9e4085056ad19
Author: fmorg-git <[email protected]>
AuthorDate: Thu Sep 3 03:11:17 2026 -0700

    HDDS-16313. [STS] Handle linked bucket and session policies (#11196)
---
 hadoop-hdds/docs/content/design/ozone-sts.md       |  19 +-
 .../security/ozone-secure-sts-multitenant.robot    |   7 +-
 .../smoketest/security/ozone-secure-sts.resource   |   8 +
 .../main/smoketest/security/ozone-secure-sts.robot |  91 +++++
 .../src/main/proto/OmClientProtocol.proto          |   2 +
 .../org/apache/hadoop/ozone/om/OzoneManager.java   |  31 +-
 .../org/apache/hadoop/ozone/om/ResolvedBucket.java |  34 ++
 .../request/s3/security/S3AssumeRoleRequest.java   | 281 +++++++++++----
 .../ozone/security/STSTokenSecretManager.java      |   6 +-
 .../s3/security/TestS3AssumeRoleRequest.java       | 387 +++++++++++++++++++--
 .../hadoop/ozone/security/TestSTSSecurityUtil.java |  50 +--
 .../ozone/security/TestSTSTokenSecretManager.java  |  10 +-
 12 files changed, 760 insertions(+), 166 deletions(-)

diff --git a/hadoop-hdds/docs/content/design/ozone-sts.md 
b/hadoop-hdds/docs/content/design/ozone-sts.md
index 3acbafdcf9a..9963534d848 100644
--- a/hadoop-hdds/docs/content/design/ozone-sts.md
+++ b/hadoop-hdds/docs/content/design/ozone-sts.md
@@ -117,6 +117,15 @@ team agreed that behavior is fine for actions, but does 
not work for Conditions,
 restrict calls by sourceIp, and if we silently ignore this, the client may 
incorrectly think the temporary credentials 
 are restricted for use by that IP address, so the consensus was to reject the 
request for that scenario.
 
+### 3.3.2 Additional Context on Linked Buckets
+
+In Ozone, one may configure a chain of bucket links.  In the scenario where 
one desires to call the AssumeRole API where the resource
+is a linked bucket, ensure the Ranger policies for the role have the proper 
permissions for each link in the chain as well 
+as the source bucket.  For example, if there is a source bucket S, that is 
linked to bucket A, which is linked to bucket B,
+and you want the token to be able to issue operations against linked bucket B, 
ensure that the role has read access to bucket B,
+read access to bucket A, and the requisite access for bucket S (such as read 
on keys for GetObject, create/write on keys for PutObject, etc.).
+The role must have at least read access to the volume(s) where these buckets 
live as well.
+
 ## 3.4 SessionToken Format
 
 As mentioned above, one of the return values from the AssumeRole call will be 
the sessionToken. To support not
@@ -203,14 +212,14 @@ The format of this String is entirely up to the Ranger 
team.  What is required f
 subsequent S3 API calls are made that use STS tokens.  In order to achieve 
this, the sessionPolicy String from Ranger will 
 be included in the sessionToken response to the AssumeRole API call (as 
mentioned above), and Ozone will supply this String
 to Ranger whenever STS tokens are used on S3 API calls via a new 
`RequestContext.sessionPolicy` field in the 
-`IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)` call.  Another 
requirement from the Ozone side is to pass the action (without the s3: prefix) 
corresponding to the S3 api call into the `RequestContext.s3Action` field.
+`IAccessAuthorizer#checkAccess(IOzoneObj, RequestContext)` call.  Another 
requirement from the Ozone side is to pass the action (without the s3: prefix) 
corresponding to the S3 API call into the `RequestContext.s3Action` field.
 
 ### 3.6.2 Additional Context on Permissions and Actions
 
 In a prior iteration of this design, only permissions corresponding to Ozone 
`ACLType` (i.e. read, write, create, read_acl, etc.) were included in Ranger 
roles and session policies.
-However, after testing against AWS, it was found that ACLs used by Ozone and 
Ranger are not granular enough. For example, read on volume, read on bucket, 
and write on key can be used by either the S3 PutObjectTagging api (requiring 
`s3:PutObjectTagging` action) or the S3 DeleteObjectTagging api (requiring 
`s3:DeleteObjectTagging` action). 
-Similarly, because the S3 PutObject api (`s3:PutObject` action) requires read 
on volume, read on bucket, and create and write on key, someone with 
`s3:PutObject` access could previously also call the S3 PutObjectTagging api, 
even though they did not have access to the `s3:PutObjectTagging` action (as an 
example). 
-AWS does not allow an STS token that is restricted for one action to issue 
calls to an api that is associated with a different action.  To prevent having 
more access than requested (or different access than requested), ACL 
permissions can be constrained further by S3 actions.
+However, after testing against AWS, it was found that ACLs used by Ozone and 
Ranger are not granular enough. For example, read on volume, read on bucket, 
and write on key can be used by either the S3 PutObjectTagging API (requiring 
`s3:PutObjectTagging` action) or the S3 DeleteObjectTagging API (requiring 
`s3:DeleteObjectTagging` action). 
+Similarly, because the S3 PutObject API (`s3:PutObject` action) requires read 
on volume, read on bucket, and create and write on key, someone with 
`s3:PutObject` access could previously also call the S3 PutObjectTagging API, 
even though they did not have access to the `s3:PutObjectTagging` action (as an 
example). 
+AWS does not allow an STS token that is restricted for one action to issue 
calls to an API that is associated with a different action.  To prevent having 
more access than requested (or different access than requested), ACL 
permissions can be constrained further by S3 actions.
 
 To do this constraining, the `RequestContext.s3Action` field is introduced so 
that if populated, the RangerOzoneAuthorizer would further restrict the 
permissions according to the action.
 Additionally, the OzoneGrant would contain a Set<String> representing the S3 
actions that are allowed for an inline policy. If all actions are allowed, then 
the Set<String> would be empty or null.
@@ -224,7 +233,7 @@ created in Ranger as per the Prerequisites above.
 - This authorized user (having permanent S3 credentials) makes the AssumeRole 
STS call to Ozone.
 - If successful, Ozone responds with the temporary credentials.
 - A client makes S3 API calls with the temporary credentials for up to as long 
as the credentials last.  
-- When Ozone receives an S3 api call using temporary credentials, it will use 
the Kerberos identity associated with the 
+- When Ozone receives an S3 API call using temporary credentials, it will use 
the Kerberos identity associated with the 
 originalAccessKeyId in the session token and perform the following checks:
   - Ensure that if the accessKeyId starts with "ASIA", that a sessionToken was 
included in the `x-amz-security-token` header
   - Ensure the sessionToken is not expired
diff --git 
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot
 
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot
index c7b367d74cf..f4c56839dca 100644
--- 
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot
+++ 
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts-multitenant.robot
@@ -17,7 +17,6 @@
 Suite Setup         Skip If    '${RANGER_ENDPOINT_URL}' == ''    No Ranger
 Documentation       Smoke test for S3 STS AssumeRole + Temp Creds 
(Multi-Tenant Scenario)
 Resource            ./ozone-secure-sts.resource
-Resource            ../admincli/lib.resource
 Test Timeout        10 minutes
 
 *** Variables ***
@@ -137,11 +136,7 @@ Create Iceberg Bucket and Another Bucket Table Access 
policies
     ${policy_json} =              Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "${TENANT_TWO} 
${TENANT_TWO_ANOTHER_BUCKET} table access", "policyType": 0, "policyPriority": 
0, "isAuditEnabled": true, "resources": { "volume": { "values": [ 
"${TENANT_TWO}" ], "isExcludes": false, "isRecursive": false }, "bucket": { 
"values": [ "${TENANT_TWO_ANOTHER_BUCKET}" ], "isExcludes": false, 
"isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, [...]
     Create Ranger Policy          ${policy_json}
 
-    # Update Ranger policy cache
-    Kinit test user               ${OM_ADMIN_USER}              
${OM_ADMIN_USER}.keytab
-    ${om_param} =                 Get OM Service Param
-    ${output} =                   Execute                       ozone admin om 
updateranger ${om_param}
-    Should contain                ${output}                     Operation 
completed successfully
+    Refresh Ranger Policy Cache
 
 Get S3 Credentials for Principals, Create Buckets, and Upload Files to Buckets
     Kinit test user               ${USER_A}                     
${USER_A}.keytab
diff --git 
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource 
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource
index 8e63f622545..ebfbe55a653 100644
--- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource
+++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource
@@ -19,6 +19,7 @@ Library             String
 Library             BuiltIn
 Library             DateTime
 Library             Collections
+Resource            ../admincli/lib.resource
 Resource            ../commonlib.robot
 Resource            ../s3/commonawslib.robot
 
@@ -85,6 +86,13 @@ Update Ranger Policy Items
     ${result} =                   Execute                       curl --silent 
--show-error --include --location --netrc -X PUT -H "Content-Type: 
application/json" -H "accept: application/json" --data '${updated}' 
"${RANGER_ENDPOINT_URL}/service/public/v2/api/policy/${policy_id}"
     Should Contain                ${result}                     HTTP/1.1 200
 
+Refresh Ranger Policy Cache
+    [Arguments]                   ${admin_user}=hdfs  
${admin_keytab}=hdfs.keytab
+    Kinit test user               ${admin_user}                 ${admin_keytab}
+    ${om_param} =                 Get OM Service Param
+    ${output} =                   Execute                       ozone admin om 
updateranger ${om_param}
+    Should Contain                ${output}                     Operation 
completed successfully
+
 Assume Role And Get Temporary Credentials
     [Arguments]                   ${perm_access_key_id}  ${perm_secret_key}  
${policy_json}=${EMPTY}  ${role_arn}=${ROLE_ARN_OBS}  
${role_session_name}=${ROLE_SESSION_NAME}  ${duration_seconds}=900
     Configure AWS Profile         permanent  ${perm_access_key_id}  
${perm_secret_key}
diff --git 
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot 
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot
index c07ee6afc98..b07a38e1a23 100644
--- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot
+++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot
@@ -66,6 +66,15 @@ ${ACTION_MATCHES_PUTOBJECT_CREATE_WRITE_ROLE_ARN}  
arn:aws:iam::123456789012:rol
 ${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE_ARN}  
arn:aws:iam::123456789012:role/${ACTION_MATCHES_GETOBJECT_PUTOBJECT_ROLE}
 ${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE_ARN}  
arn:aws:iam::123456789012:role/${ACTION_MATCHES_UPLOADPARTCOPY_EXPECTED_OWNER_ROLE}
 ${ACTION_MATCHES_GET_STAR_READ_ROLE_ARN}        
arn:aws:iam::123456789012:role/${ACTION_MATCHES_GET_STAR_READ_ROLE}
+${STS_LINK_BUCKET_ROLE}                 sts-link-bucket-role
+${STS_LINK_BUCKET_ROLE_ARN}             
arn:aws:iam::123456789012:role/${STS_LINK_BUCKET_ROLE}
+${STS_LINK_BUCKET_SOURCE}               sts-link-bucket-source
+${STS_LINK_BUCKET_LINKED}               s3v-sts-link-bucket-source
+${STS_LINK_BUCKET_TESTFILE}             link-bucket-testfile.txt
+${STS_LINK_BUCKET_CHAIN_SOURCE}         sts-chain-source
+${STS_LINK_BUCKET_CHAIN_INTERMEDIATE}   s3v-sts-chain-intermediate
+${STS_LINK_BUCKET_CHAIN_FINAL}          s3v-sts-chain-final
+${STS_LINK_BUCKET_CHAIN_TESTFILE}       chain-link-testfile.txt
 ${TEST_USER_ADMIN}                      testuser
 ${TEST_USER_NON_ADMIN}                  testuser2
 @{ICEBERG_OBJECT_KEYS}                  file1.txt    file1again.txt    
folder/pepper.txt    folder/salt.txt    userA/userA.txt    userB/userB.txt    
userAfile.txt
@@ -94,6 +103,27 @@ Populate Iceberg Bucket
     Create File                   ${TEMP_DIR}/zero-byte-marker
     Execute                       ozone sh key put 
/s3v/${bucket}/zeroByteFolder/ ${TEMP_DIR}/zero-byte-marker
 
+Setup Same Volume Link Bucket
+    # Create a source bucket and a same-volume linked bucket (s3v/source -> 
s3v/s3v-source).
+    Kinit test user               hdfs                          hdfs.keytab
+    Execute                       ozone sh bucket create --layout 
${ICEBERG_LAYOUT_OBS} /s3v/${STS_LINK_BUCKET_SOURCE}
+    Create File                   ${TEMP_DIR}/${STS_LINK_BUCKET_TESTFILE}    
link bucket test content
+    Execute                       ozone sh key put 
/s3v/${STS_LINK_BUCKET_SOURCE}/${STS_LINK_BUCKET_TESTFILE} 
${TEMP_DIR}/${STS_LINK_BUCKET_TESTFILE}
+    Execute                       ozone sh bucket link 
/s3v/${STS_LINK_BUCKET_SOURCE} /s3v/${STS_LINK_BUCKET_LINKED}
+
+Setup Chained Same Volume Link Buckets
+    # Create source <- intermediate <- final chained links in s3v.
+    Kinit test user               hdfs                          hdfs.keytab
+    Execute                       ozone sh bucket create --layout 
${ICEBERG_LAYOUT_OBS} /s3v/${STS_LINK_BUCKET_CHAIN_SOURCE}
+    Create File                   
${TEMP_DIR}/${STS_LINK_BUCKET_CHAIN_TESTFILE}    chained link test content
+    Execute                       ozone sh key put 
/s3v/${STS_LINK_BUCKET_CHAIN_SOURCE}/${STS_LINK_BUCKET_CHAIN_TESTFILE} 
${TEMP_DIR}/${STS_LINK_BUCKET_CHAIN_TESTFILE}
+    Execute                       ozone sh bucket link 
/s3v/${STS_LINK_BUCKET_CHAIN_SOURCE} /s3v/${STS_LINK_BUCKET_CHAIN_INTERMEDIATE}
+    Execute                       ozone sh bucket link 
/s3v/${STS_LINK_BUCKET_CHAIN_INTERMEDIATE} /s3v/${STS_LINK_BUCKET_CHAIN_FINAL}
+
+Head Bucket Should Succeed
+    [Arguments]                   ${bucket}  ${profile}=sts
+    Execute and checkrc           aws s3api --endpoint-url ${S3G_ENDPOINT_URL} 
head-bucket --bucket ${bucket} --profile ${profile}  0
+
 Run List Prefix And Delimiter Policy Matrix For Bucket And Api
     [Arguments]                   ${bucket}  ${role_arn}  ${api}
     # Capture baseline (non-STS) behavior using the permanent credentials.  
When using STS, it will behave just like
@@ -337,6 +367,31 @@ Create Iceberg Multi-Bucket Role Policies
     ${key_policy} =               Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "iceberg multi table access", 
"policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { 
"volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, 
"bucket": { "values": [ "${ICEBERG_BUCKET_OBS}", "${ICEBERG_BUCKET_FSO}" ], 
"isExcludes": false, "isRecursive": false }, "key": { "values": [ "*" ], 
"isExcludes": false, "isRecursive": true  [...]
     Create Ranger Policy          ${key_policy}
 
+Create STS Link Bucket Role in Ranger
+    ${role_json} =                Set Variable                  { "name": 
"${STS_LINK_BUCKET_ROLE}", "description": "STS linked bucket authorization 
regression role" }
+    Create Ranger Role            ${role_json}
+    Create Ranger Assume Role Policy  ${STS_LINK_BUCKET_ROLE}   
${ICEBERG_SVC_CATALOG_USER}
+
+Create STS Link Bucket Access Policies
+    # Grant volume access and linked-bucket bucket/key policies for 
session-policy scoping.
+    # Also grant source-bucket key access on the role so Ranger allows the 
resolved key path; the STS
+    # session policy (linked bucket only) is what this regression exercises.
+    ${policy_items} =             Set Variable                  [ { 
"accesses": [ { "type": "read", "isAllowed": true }, { "type": "list", 
"isAllowed": true } ], "roles": [ "${STS_LINK_BUCKET_ROLE}" ], "delegateAdmin": 
false } ]
+    Update Ranger Policy Items    iceberg volume access         ${policy_items}
+    ${bucket_policy} =            Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "sts linked bucket access", "policyType": 
0, "policyPriority": 0, "isAuditEnabled": true, "resources": { "volume": { 
"values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, "bucket": { 
"values": [ "${STS_LINK_BUCKET_LINKED}" ], "isExcludes": false, "isRecursive": 
false } }, "policyItems": [ { "accesses": [ { "type": "all", "isAllowed": true 
} ], "roles": [ "${STS [...]
+    Create Ranger Policy          ${bucket_policy}
+    ${key_policy} =               Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "sts linked bucket table access", 
"policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { 
"volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, 
"bucket": { "values": [ "${STS_LINK_BUCKET_LINKED}" ], "isExcludes": false, 
"isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, 
"isRecursive": true } }, "policyItems [...]
+    Create Ranger Policy          ${key_policy}
+    ${source_key_policy} =        Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "sts linked bucket source table access", 
"policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { 
"volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, 
"bucket": { "values": [ "${STS_LINK_BUCKET_SOURCE}" ], "isExcludes": false, 
"isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, 
"isRecursive": true } }, "poli [...]
+    Create Ranger Policy          ${source_key_policy}
+    ${chain_bucket_policy} =      Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "sts chained link bucket access", 
"policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { 
"volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, 
"bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_FINAL}", 
"${STS_LINK_BUCKET_CHAIN_INTERMEDIATE}" ], "isExcludes": false, "isRecursive": 
false } }, "policyItems": [ { "accesses": [ { "ty [...]
+    Create Ranger Policy          ${chain_bucket_policy}
+    ${chain_key_policy} =         Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "sts chained link bucket table access", 
"policyType": 0, "policyPriority": 0, "isAuditEnabled": true, "resources": { 
"volume": { "values": [ "s3v" ], "isExcludes": false, "isRecursive": false }, 
"bucket": { "values": [ "${STS_LINK_BUCKET_CHAIN_FINAL}" ], "isExcludes": 
false, "isRecursive": false }, "key": { "values": [ "*" ], "isExcludes": false, 
"isRecursive": true } }, " [...]
+    Create Ranger Policy          ${chain_key_policy}
+    ${chain_source_key_policy} =  Set Variable                  { "isEnabled": 
true, "service": "dev_ozone", "name": "sts chained link bucket source table 
access", "policyType": 0, "policyPriority": 0, "isAuditEnabled": true, 
"resources": { "volume": { "values": [ "s3v" ], "isExcludes": false, 
"isRecursive": false }, "bucket": { "values": [ 
"${STS_LINK_BUCKET_CHAIN_SOURCE}" ], "isExcludes": false, "isRecursive": false 
}, "key": { "values": [ "*" ], "isExcludes": false, "isRecursive": tru [...]
+    Create Ranger Policy          ${chain_source_key_policy}
+    Refresh Ranger Policy Cache
+
 Create Partial Access Roles in Ranger
     FOR    ${role}    IN    ${PARTIAL_LIST_ALL_BUCKETS_VOL_READ_ROLE}    
${PARTIAL_LIST_ALL_BUCKETS_VOL_LIST_ROLE}    ${PARTIAL_BUCKET_READ_ROLE}    
${PARTIAL_BUCKET_READ_UPLOAD_PREFIX_ROLE}    ${PARTIAL_BUCKET_LIST_ROLE}    
${PARTIAL_BUCKET_READ_ACL_ROLE}    ${PARTIAL_PUT_OBJECT_KEY_CREATE_ROLE}    
${PARTIAL_PUT_OBJECT_KEY_WRITE_ROLE}
         ${role_json} =            Set Variable                  { "name": 
"${role}", "description": "Partial access role" }
@@ -416,6 +471,8 @@ Get S3 Credentials for Service Catalog Principal, Create 
Iceberg Buckets, and Up
     Execute                       ozone sh bucket create --layout 
${ICEBERG_LAYOUT_FSO} /s3v/${ICEBERG_BUCKET_FSO}
     Populate Iceberg Bucket       ${ICEBERG_BUCKET_OBS}
     Populate Iceberg Bucket       ${ICEBERG_BUCKET_FSO}
+    Setup Same Volume Link Bucket
+    Setup Chained Same Volume Link Buckets
 
     # Switch back to the service catalog principal for running S3/STS requests.
     Kinit test user               ${ICEBERG_SVC_CATALOG_USER}   
${ICEBERG_SVC_CATALOG_USER}.keytab
@@ -1276,6 +1333,40 @@ STS session policy containing only GetObject must deny 
DeleteObjects
     ${output} =                  Execute                        aws s3api 
--endpoint-url ${S3G_ENDPOINT_URL} delete-bucket --bucket ${bucket} --profile 
sts
     Should Not Contain           ${output}                      AccessDenied
 
+STS Session Policy On Linked Bucket Grants GetObject On Source Bucket
+    # Same-volume linked buckets (e.g. s3v/s3v-iceberg -> s3v/iceberg): 
session policy scoped to the linked
+    # bucket must also authorize ListBucket and GetObject on the source bucket.
+    Kinit test user               ${ICEBERG_SVC_CATALOG_USER}   
${ICEBERG_SVC_CATALOG_USER}.keytab
+    ${session_policy} =           Set Variable                  
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetObject"],"Resource":["arn:aws:s3:::${STS_LINK_BUCKET_LINKED}","arn:aws:s3:::${STS_LINK_BUCKET_LINKED}/*"]}]}
+    Assume Role And Configure STS Profile                       
policy_json=${session_policy}  perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  role_arn=${STS_LINK_BUCKET_ROLE_ARN}
+    Head Bucket Should Succeed    ${STS_LINK_BUCKET_LINKED}
+    Get Object Should Succeed     ${STS_LINK_BUCKET_LINKED}     
${STS_LINK_BUCKET_TESTFILE}
+
+STS Role On Linked Bucket Grants GetObject On Source Bucket
+    # Same-volume linked buckets: role permissions on the linked bucket must 
authorize
+    # key access on the source bucket when no inline session policy is 
supplied.
+    Kinit test user               ${ICEBERG_SVC_CATALOG_USER}   
${ICEBERG_SVC_CATALOG_USER}.keytab
+    Assume Role And Configure STS Profile                       
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  role_arn=${STS_LINK_BUCKET_ROLE_ARN}
+    Head Bucket Should Succeed    ${STS_LINK_BUCKET_LINKED}
+    Get Object Should Succeed     ${STS_LINK_BUCKET_LINKED}     
${STS_LINK_BUCKET_TESTFILE}
+
+STS Session Policy On Chained Linked Bucket Grants GetObject On Source Bucket
+    # Chained linked buckets (source <- intermediate <- final): session policy 
scoped to the final
+    # linked bucket must also authorize ListBucket and GetObject on the source 
bucket.
+    Kinit test user               ${ICEBERG_SVC_CATALOG_USER}   
${ICEBERG_SVC_CATALOG_USER}.keytab
+    ${session_policy} =           Set Variable                  
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetObject"],"Resource":["arn:aws:s3:::${STS_LINK_BUCKET_CHAIN_FINAL}","arn:aws:s3:::${STS_LINK_BUCKET_CHAIN_FINAL}/*"]}]}
+    Assume Role And Configure STS Profile                       
policy_json=${session_policy}  perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  role_arn=${STS_LINK_BUCKET_ROLE_ARN}
+    Head Bucket Should Succeed    ${STS_LINK_BUCKET_CHAIN_FINAL}
+    Get Object Should Succeed     ${STS_LINK_BUCKET_CHAIN_FINAL}            
${STS_LINK_BUCKET_CHAIN_TESTFILE}
+
+STS Role On Chained Linked Bucket Grants GetObject On Source Bucket
+    # Chained linked buckets: role permissions on the final linked bucket must 
authorize
+    # key access on the source bucket when no inline session policy is 
supplied.
+    Kinit test user               ${ICEBERG_SVC_CATALOG_USER}   
${ICEBERG_SVC_CATALOG_USER}.keytab
+    Assume Role And Configure STS Profile                       
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  role_arn=${STS_LINK_BUCKET_ROLE_ARN}
+    Head Bucket Should Succeed    ${STS_LINK_BUCKET_CHAIN_FINAL}
+    Get Object Should Succeed     ${STS_LINK_BUCKET_CHAIN_FINAL}            
${STS_LINK_BUCKET_CHAIN_TESTFILE}
+
 Expired STS temporary credentials must return ExpiredToken on S3 APIs
     # Increase timeout to account for 15 minute STS token expiration plus the 
time to execute the api calls
     [Timeout]                     25 minutes
diff --git 
a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto 
b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
index 638cf99bd1c..53f7c20e5d9 100644
--- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
+++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
@@ -2531,6 +2531,8 @@ message UpdateAssumeRoleRequest {
   required string tempAccessKeyId         = 6;
   required string secretAccessKey         = 7;
   required string roleId                  = 8;
+  optional string sessionToken            = 9;
+  optional uint64 expirationEpochSeconds  = 10;
 }
 
 message RevokeSTSTokenRequest {
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 04455a525a9..f8c4d0a244a 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
@@ -145,6 +145,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -5119,19 +5120,16 @@ public ResolvedBucket resolveBucketLink(OmKeyArgs args)
   public ResolvedBucket resolveBucketLink(Pair<String, String> requested,
       OMClientRequest omClientRequest)
       throws IOException {
+    final Set<Pair<String, String>> linkChain = new LinkedHashSet<>();
     OmBucketInfo resolved;
     if (getAclsEnabled()) {
-      resolved = resolveBucketLink(requested, new HashSet<>(),
-              omClientRequest.createUGIForApi(),
-              omClientRequest.getRemoteAddress(),
-              omClientRequest.getHostName(),
-              false);
+      resolved = resolveBucketLink(
+          requested, linkChain, omClientRequest.createUGIForApi(), 
omClientRequest.getRemoteAddress(),
+          omClientRequest.getHostName(), false);
     } else {
-      resolved = resolveBucketLink(requested, new HashSet<>(),
-          null, null, null, false);
+      resolved = resolveBucketLink(requested, linkChain, null, null, null, 
false);
     }
-    return new ResolvedBucket(requested.getLeft(), requested.getRight(),
-        resolved);
+    return new ResolvedBucket(requested.getLeft(), requested.getRight(), 
resolved, linkChain);
   }
 
   public ResolvedBucket resolveBucketLink(Pair<String, String> requested,
@@ -5143,6 +5141,7 @@ public ResolvedBucket resolveBucketLink(Pair<String, 
String> requested,
                                           boolean allowDanglingBuckets,
                                           boolean aclEnabled)
       throws IOException {
+    final Set<Pair<String, String>> linkChain = new LinkedHashSet<>();
     OmBucketInfo resolved;
     if (aclEnabled) {
       UserGroupInformation ugi = getRemoteUser();
@@ -5151,17 +5150,13 @@ public ResolvedBucket resolveBucketLink(Pair<String, 
String> requested,
         ugi = UserGroupInformation.createRemoteUser(principal);
       }
       InetAddress remoteIp = Server.getRemoteIp();
-      resolved = resolveBucketLink(requested, new HashSet<>(),
-          ugi,
-          remoteIp != null ? remoteIp : omRpcAddress.getAddress(),
-          remoteIp != null ? remoteIp.getHostName() :
-              omRpcAddress.getHostName(), allowDanglingBuckets, aclEnabled);
+      resolved = resolveBucketLink(
+          requested, linkChain, ugi, remoteIp != null ? remoteIp : 
omRpcAddress.getAddress(),
+          remoteIp != null ? remoteIp.getHostName() : 
omRpcAddress.getHostName(), allowDanglingBuckets, aclEnabled);
     } else {
-      resolved = resolveBucketLink(requested, new HashSet<>(),
-          null, null, null, allowDanglingBuckets, aclEnabled);
+      resolved = resolveBucketLink(requested, linkChain, null, null, null, 
allowDanglingBuckets, aclEnabled);
     }
-    return new ResolvedBucket(requested.getLeft(), requested.getRight(),
-        resolved);
+    return new ResolvedBucket(requested.getLeft(), requested.getRight(), 
resolved, linkChain);
   }
 
   private OmBucketInfo resolveBucketLink(
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java
index 19b41355d09..6dd50feff61 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ResolvedBucket.java
@@ -17,6 +17,9 @@
 
 package org.apache.hadoop.ozone.om;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Objects;
@@ -44,11 +47,18 @@ public class ResolvedBucket {
   private final String realBucket;
   private final String bucketOwner;
   private final BucketLayout bucketLayout;
+  private final Collection<Pair<String, String>> linkChain;
 
   public ResolvedBucket(String requestedVolume, String requestedBucket,
       OmBucketInfo resolved) {
+    this(requestedVolume, requestedBucket, resolved, Collections.emptyList());
+  }
+
+  public ResolvedBucket(String requestedVolume, String requestedBucket,
+      OmBucketInfo resolved, Collection<Pair<String, String>> linkChain) {
     this.requestedVolume = requestedVolume;
     this.requestedBucket = requestedBucket;
+    this.linkChain = unmodifiableLinkChain(linkChain);
     if (resolved != null) {
       this.realVolume = resolved.getVolumeName();
       this.realBucket = resolved.getBucketName();
@@ -65,12 +75,28 @@ public ResolvedBucket(String requestedVolume, String 
requestedBucket,
   public ResolvedBucket(String requestedVolume, String requestedBucket,
       String realVolume, String realBucket, String bucketOwner,
       BucketLayout bucketLayout) {
+    this(requestedVolume, requestedBucket, realVolume, realBucket, 
bucketOwner, bucketLayout,
+        Collections.emptyList());
+  }
+
+  public ResolvedBucket(String requestedVolume, String requestedBucket,
+      String realVolume, String realBucket, String bucketOwner,
+      BucketLayout bucketLayout, Collection<Pair<String, String>> linkChain) {
     this.requestedVolume = requestedVolume;
     this.requestedBucket = requestedBucket;
     this.realVolume = realVolume;
     this.realBucket = realBucket;
     this.bucketOwner = bucketOwner;
     this.bucketLayout = bucketLayout;
+    this.linkChain = unmodifiableLinkChain(linkChain);
+  }
+
+  private static Collection<Pair<String, String>> unmodifiableLinkChain(
+      Collection<Pair<String, String>> linkChain) {
+    if (linkChain == null || linkChain.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return Collections.unmodifiableList(new ArrayList<>(linkChain));
   }
 
   public ResolvedBucket(Pair<String, String> requested,
@@ -104,6 +130,14 @@ public BucketLayout bucketLayout() {
     return bucketLayout;
   }
 
+  /**
+   * Ordered link buckets followed when resolving from the requested bucket to 
the real bucket.
+   * Each pair is {@code (volume, bucket)}. Empty when the requested bucket is 
not a link.
+   */
+  public Collection<Pair<String, String>> linkChain() {
+    return linkChain;
+  }
+
   public OmKeyArgs update(OmKeyArgs args) {
     return isLink()
         ? args.toBuilder()
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 b6d650cc439..693d280419b 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
@@ -29,10 +29,15 @@
 import java.net.InetAddress;
 import java.security.SecureRandom;
 import java.time.Clock;
+import java.time.Instant;
+import java.util.EnumSet;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
 import org.apache.hadoop.hdds.scm.client.HddsClientUtils;
 import org.apache.hadoop.ipc_.ProtobufRpcEngine;
 import org.apache.hadoop.ozone.OzoneConsts;
@@ -40,6 +45,7 @@
 import org.apache.hadoop.ozone.audit.OMAction;
 import org.apache.hadoop.ozone.om.OzoneAclUtils;
 import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.ResolvedBucket;
 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;
@@ -53,6 +59,10 @@
 import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse;
 import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
 import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleRequest;
+import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType;
+import org.apache.hadoop.ozone.security.acl.IOzoneObj;
+import org.apache.hadoop.ozone.security.acl.OzoneObj;
+import org.apache.hadoop.ozone.security.acl.OzoneObjInfo;
 import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver;
 import org.apache.hadoop.security.UserGroupInformation;
 
@@ -94,42 +104,81 @@ public OMRequest preExecute(OzoneManager ozoneManager) 
throws IOException {
     final OMRequest omRequest = super.preExecute(ozoneManager);
     final AssumeRoleRequest assumeRoleRequest = 
omRequest.getAssumeRoleRequest();
 
-    // Brief overview of flow:
-    // The STS Endpoint makes the AssumeRole call, which when received by OM 
leader (via this method),
-    // it will generate the temporary credentials (tempAccessKeyId, 
secretAccessKey) and roleId.
-    // The original AssumeRole request is converted to an 
UpdateAssumeRoleRequest with the generated
-    // credentials. This update request will be submitted to Ratis and the 
credentials
-    // created by the leader will be replicated across all OMs.  All OMs in
-    // 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(
-        STS_ACCESS_KEY_ID_ALLOWED_CHARS, 
STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH,
-        STS_ACCESS_KEY_ID_RANDOM_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(
-        STS_ACCESS_KEY_ID_ALLOWED_CHARS, 
STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH,
-        STS_ROLE_ID_LENGTH);
-
-    // Build UpdateAssumeRoleRequest with leader-generated credentials
-    final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder =
-        UpdateAssumeRoleRequest.newBuilder()
-            .setRoleArn(assumeRoleRequest.getRoleArn())
-            .setRoleSessionName(assumeRoleRequest.getRoleSessionName())
-            .setDurationSeconds(assumeRoleRequest.getDurationSeconds())
-            .setRequestId(assumeRoleRequest.getRequestId())
-            .setTempAccessKeyId(tempAccessKeyId)
-            .setSecretAccessKey(secretAccessKey)
-            .setRoleId(roleId);
-
-    if (assumeRoleRequest.hasAwsIamSessionPolicy()) {
-      
updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(assumeRoleRequest.getAwsIamSessionPolicy());
-    }
+    final int durationSeconds = assumeRoleRequest.getDurationSeconds();
+    final String roleSessionName = assumeRoleRequest.getRoleSessionName();
+    final String roleArn = assumeRoleRequest.getRoleArn();
+    final String awsIamSessionPolicy = 
assumeRoleRequest.getAwsIamSessionPolicy();
+    final String requestId = assumeRoleRequest.getRequestId();
+    final OzoneManagerProtocolProtos.UserInfo userInfo = 
omRequest.getUserInfo();
+    final AuditLogger auditLogger = ozoneManager.getAuditLogger();
+    final Map<String, String> auditMap = new HashMap<>();
+    S3STSUtils.addAssumeRoleAuditParams(
+        auditMap, roleArn, roleSessionName, awsIamSessionPolicy, 
durationSeconds, requestId);
 
-    return omRequest.toBuilder()
-        .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build())
-        .build();
+    try {
+      if (!omRequest.hasS3Authentication()) {
+        throw new OMException(
+            "S3AssumeRoleRequest does not have S3 authentication", 
OMException.ResultCodes.INVALID_REQUEST);
+      }
+
+      // Brief overview of flow:
+      // The STS Endpoint makes the AssumeRole call, which when received by OM 
leader (via this method),
+      // it will validate the request, authorize via Ranger, generate 
temporary credentials
+      // (tempAccessKeyId, secretAccessKey), roleId, and the signed session 
token.
+      // The original AssumeRole request is converted to an 
UpdateAssumeRoleRequest with the generated
+      // values. This update request will be submitted to Ratis and replicated 
across all OMs.
+      // All OMs in HA mode therefore will have identical audit logs with the 
same tempAccessKeyId.
+      S3STSUtils.validateDuration(durationSeconds);
+      S3STSUtils.validateRoleSessionName(roleSessionName);
+      final String targetRoleName = 
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn);
+      
+      // Generate temporary AWS credentials using cryptographically strong 
SecureRandom
+      final String tempAccessKeyId = STS_TOKEN_PREFIX + 
generateSecureRandomStringUsingChars(
+          STS_ACCESS_KEY_ID_ALLOWED_CHARS, 
STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH,
+          STS_ACCESS_KEY_ID_RANDOM_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(
+          STS_ACCESS_KEY_ID_ALLOWED_CHARS, 
STS_ACCESS_KEY_ID_ALLOWED_CHARS_LENGTH,
+          STS_ROLE_ID_LENGTH);
+
+      final Instant creationInstant = clock.instant();
+      final String sessionToken = generateSessionToken(
+          targetRoleName, omRequest, ozoneManager, assumeRoleRequest, 
secretAccessKey, tempAccessKeyId,
+          creationInstant);
+      final long expirationEpochSeconds = 
creationInstant.plusSeconds(durationSeconds).getEpochSecond();
+
+      auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId);
+
+      // Build UpdateAssumeRoleRequest with leader-generated credentials and 
session token
+      final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder =
+          UpdateAssumeRoleRequest.newBuilder()
+              .setRoleArn(roleArn)
+              .setRoleSessionName(roleSessionName)
+              .setDurationSeconds(durationSeconds)
+              .setRequestId(requestId)
+              .setTempAccessKeyId(tempAccessKeyId)
+              .setSecretAccessKey(secretAccessKey)
+              .setRoleId(roleId)
+              .setSessionToken(sessionToken)
+              .setExpirationEpochSeconds(expirationEpochSeconds);
+
+      if (assumeRoleRequest.hasAwsIamSessionPolicy()) {
+        
updateAssumeRoleRequestBuilder.setAwsIamSessionPolicy(awsIamSessionPolicy);
+      }
+
+      return omRequest.toBuilder()
+          .setUpdateAssumeRoleRequest(updateAssumeRoleRequestBuilder.build())
+          .build();
+    } catch (OMException e) {
+      markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, 
auditMap, e, userInfo));
+      throw e;
+    } catch (IOException e) {
+      final OMException omException = new OMException(
+          "Failed to generate STS token for role: " + roleArn, e, 
OMException.ResultCodes.INTERNAL_ERROR);
+      markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, 
auditMap, omException, userInfo));
+      throw omException;
+    }
   }
 
   @Override
@@ -148,6 +197,8 @@ public OMClientResponse validateAndUpdateCache(OzoneManager 
ozoneManager, Execut
     final String tempAccessKeyId = 
updateAssumeRoleRequest.getTempAccessKeyId();
     final String secretAccessKey = 
updateAssumeRoleRequest.getSecretAccessKey();
     final String roleId = updateAssumeRoleRequest.getRoleId();
+    final String sessionToken = updateAssumeRoleRequest.getSessionToken();
+    final long expirationEpochSeconds = 
updateAssumeRoleRequest.getExpirationEpochSeconds();
 
     final Map<String, String> auditMap = new HashMap<>();
     final AuditLogger auditLogger = ozoneManager.getAuditLogger();
@@ -158,33 +209,15 @@ public OMClientResponse 
validateAndUpdateCache(OzoneManager ozoneManager, Execut
     Exception exception = null;
     OMClientResponse omClientResponse;
     try {
-      // Validate duration
-      S3STSUtils.validateDuration(durationSeconds);
-
-      // Validate role session name
-      S3STSUtils.validateRoleSessionName(roleSessionName);
-
-      // Validate role ARN and extract role
-      final String targetRoleName = 
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn);
-
-      // Note: The IamSessionPolicyResolver validates the awsIamPolicy length 
internally
-
-      if (!omRequest.hasS3Authentication()) {
+      if (Strings.isNullOrEmpty(tempAccessKeyId) || 
Strings.isNullOrEmpty(secretAccessKey) ||
+          Strings.isNullOrEmpty(roleId) || Strings.isNullOrEmpty(sessionToken) 
|| expirationEpochSeconds <= 0) {
         throw new OMException(
-            "S3AssumeRoleRequest does not have S3 authentication", 
OMException.ResultCodes.INVALID_REQUEST);
+            "UpdateAssumeRoleRequest is missing leader-generated AssumeRole 
fields",
+            OMException.ResultCodes.INVALID_REQUEST);
       }
 
-      // Generate session token using leader-generated credentials
-      final String sessionToken = generateSessionToken(
-          targetRoleName, omRequest, ozoneManager, assumeRoleRequest, 
secretAccessKey, tempAccessKeyId);
-
-      // Generate AssumedRoleId for response using leader-generated roleId
       final String assumedRoleId = roleId + ":" + roleSessionName;
 
-      // Calculate expiration of session token
-      final long expirationEpochSeconds = 
clock.instant().plusSeconds(durationSeconds).getEpochSecond();
-
-      // Add tempAccessKeyId to the log so it can be determined which 
permanent user created the tempAccessKeyId
       auditMap.put(OzoneConsts.S3_STS_TEMP_ACCESS_KEY_ID, tempAccessKeyId);
 
       final AssumeRoleResponse.Builder responseBuilder = 
AssumeRoleResponse.newBuilder()
@@ -202,15 +235,8 @@ public OMClientResponse 
validateAndUpdateCache(OzoneManager ozoneManager, Execut
       exception = e;
       omClientResponse = new S3AssumeRoleResponse(
           
createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), e));
-    } catch (IOException e) {
-      final OMException omException = new OMException(
-          "Failed to generate STS token for role: " + roleArn, e, 
OMException.ResultCodes.INTERNAL_ERROR);
-      exception = omException;
-      omClientResponse = new S3AssumeRoleResponse(
-          
createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest), 
omException));
     }
 
-    // Audit log
     markForAudit(auditLogger, buildAuditMessage(OMAction.S3_ASSUME_ROLE, 
auditMap, exception, userInfo));
 
     return omClientResponse;
@@ -221,7 +247,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager 
ozoneManager, Execut
    */
   private String generateSessionToken(String targetRoleName, OMRequest 
omRequest,
       OzoneManager ozoneManager, AssumeRoleRequest assumeRoleRequest, String 
secretAccessKey,
-      String tempAccessKeyId) throws IOException {
+      String tempAccessKeyId, Instant creationInstant) throws IOException {
 
     InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp();
     if (remoteIp == null) {
@@ -246,7 +272,7 @@ private String generateSessionToken(String targetRoleName, 
OMRequest omRequest,
 
     return ozoneManager.getSTSTokenSecretManager().createSTSTokenString(
         tempAccessKeyId, originalAccessKeyId, roleArn, 
assumeRoleRequest.getDurationSeconds(), secretAccessKey,
-        sessionPolicy, clock);
+        sessionPolicy, creationInstant);
   }
 
   /**
@@ -274,13 +300,134 @@ String getSessionPolicy(OzoneManager ozoneManager, 
String originalAccessKeyId, S
 
     final Set<OzoneGrant> grants = Strings.isNullOrEmpty(awsIamPolicy) ?
         null :
-        IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, 
IamSessionPolicyResolver.AuthorizerType.RANGER);
+        resolveGrantsAgainstBucketLinks(
+            IamSessionPolicyResolver.resolve(awsIamPolicy, volumeName, 
IamSessionPolicyResolver.AuthorizerType.RANGER),
+            (linkVolume, linkBucket) -> 
ozoneManager.resolveBucketLink(Pair.of(linkVolume, linkBucket), true, false));
 
     return ozoneManager.getAccessAuthorizer().generateAssumeRoleSessionPolicy(
         new org.apache.hadoop.ozone.security.acl.AssumeRoleRequest(
             hostName, remoteIp, ugi, targetRoleName, grants));
   }
 
+  /**
+   * Rewrites the resolved session-policy grants so that any bucket, key, or 
prefix resource that names a
+   * bucket link is anchored to the link's source volume and bucket - the 
resource paths the OM authorizes
+   * against once the link is resolved at request time. READ on each link 
bucket in the chain (and, when the
+   * chain crosses volumes, READ on each distinct volume except the requested 
one) is retained so OM can follow
+   * every hop at request time, which keeps the generated token as small as 
possible.
+   * <p>
+   * The link target is resolved when the token is generated, so the token 
grants access to whatever the link
+   * points to at that moment. If the link is later re-pointed, the token no 
longer grants access to the new
+   * target.
+   *
+   * @param grants       the grants produced by {@link 
IamSessionPolicyResolver}, possibly {@code null}
+   * @param linkResolver resolves a (volume, bucket) pair to its link target
+   * @return the link-aware grants, or the input unchanged when there is 
nothing to resolve
+   */
+  @VisibleForTesting
+  static Set<OzoneGrant> resolveGrantsAgainstBucketLinks(Set<OzoneGrant> 
grants,
+      BucketLinkResolver linkResolver) throws IOException {
+    if (grants == null || grants.isEmpty()) {
+      return grants;
+    }
+
+    final Map<Pair<String, String>, ResolvedBucket> resolutionCache = new 
HashMap<>();
+    final Set<IOzoneObj> linkFollowObjects = new LinkedHashSet<>();
+    final Set<OzoneGrant> resolvedGrants = new LinkedHashSet<>();
+
+    for (OzoneGrant grant : grants) {
+      final Set<IOzoneObj> resolvedObjects = new LinkedHashSet<>();
+      for (IOzoneObj object : grant.getObjects()) {
+        resolvedObjects.add(
+            resolveObjectAgainstBucketLink((OzoneObj) object, linkResolver, 
resolutionCache, linkFollowObjects));
+      }
+      resolvedGrants.add(new OzoneGrant(resolvedObjects, 
grant.getPermissions(), grant.getS3Actions()));
+    }
+
+    // Retain only the READ required to follow each link hop at request time.
+    if (!linkFollowObjects.isEmpty()) {
+      resolvedGrants.add(new OzoneGrant(linkFollowObjects, 
EnumSet.of(ACLType.READ)));
+    }
+
+    return resolvedGrants;
+  }
+
+  /**
+   * Resolves a single grant object against its bucket link. Bucket, key, and 
prefix objects that name a
+   * link bucket are rewritten to the link's source volume and bucket, and the 
READ needed to follow each hop
+   * in the link chain is collected in {@code linkFollowObjects}. All other 
objects (volume resources and
+   * wildcard buckets) are returned unchanged.
+   */
+  private static IOzoneObj resolveObjectAgainstBucketLink(OzoneObj object, 
BucketLinkResolver linkResolver,
+      Map<Pair<String, String>, ResolvedBucket> resolutionCache, 
Set<IOzoneObj> linkFollowObjects)
+      throws IOException {
+    final OzoneObj.ResourceType resourceType = object.getResourceType();
+    if (resourceType != OzoneObj.ResourceType.BUCKET
+        && resourceType != OzoneObj.ResourceType.KEY
+        && resourceType != OzoneObj.ResourceType.PREFIX) {
+      return object;
+    }
+
+    final String volumeName = object.getVolumeName();
+    final String bucketName = object.getBucketName();
+    // Wildcard or unspecified names cannot correspond to a concrete link 
bucket.
+    if (StringUtils.isBlank(volumeName) || StringUtils.isBlank(bucketName) || 
hasWildcard(volumeName) ||
+        hasWildcard(bucketName)) {
+      return object;
+    }
+
+    final Pair<String, String> requested = Pair.of(volumeName, bucketName);
+    ResolvedBucket resolved = resolutionCache.get(requested);
+    if (resolved == null) {
+      resolved = linkResolver.resolve(volumeName, bucketName);
+      resolutionCache.put(requested, resolved);
+    }
+    if (resolved == null || resolved.isDangling() || !resolved.isLink()) {
+      return object;
+    }
+
+    final Set<String> chainVolumes = new LinkedHashSet<>();
+    for (Pair<String, String> link : resolved.linkChain()) {
+      linkFollowObjects.add(newResourceObj(OzoneObj.ResourceType.BUCKET, 
link.getLeft(), link.getRight()));
+      chainVolumes.add(link.getLeft());
+    }
+    chainVolumes.add(resolved.realVolume());
+    chainVolumes.remove(volumeName);
+    for (String vol : chainVolumes) {
+      linkFollowObjects.add(newResourceObj(OzoneObj.ResourceType.VOLUME, vol, 
null));
+    }
+
+    return OzoneObjInfo.Builder.fromOzoneObj(object)
+        .setVolumeName(resolved.realVolume())
+        .setBucketName(resolved.realBucket())
+        .build();
+  }
+
+  private static IOzoneObj newResourceObj(OzoneObj.ResourceType resourceType, 
String volumeName, String bucketName) {
+    final OzoneObjInfo.Builder builder = OzoneObjInfo.Builder.newBuilder()
+        .setResType(resourceType)
+        .setStoreType(OzoneObj.StoreType.OZONE)
+        .setVolumeName(volumeName);
+    if (bucketName != null) {
+      builder.setBucketName(bucketName);
+    }
+    return builder.build();
+  }
+
+  private static boolean hasWildcard(String name) {
+    return name.indexOf('*') >= 0 || name.indexOf('?') >= 0;
+  }
+
+  /**
+   * Resolves a (volume, bucket) pair to its link target, following bucket 
links. Implementations must not
+   * enforce ACLs, so that session-policy generation stays deterministic 
across OMs and does not depend on
+   * the external authorizer.
+   */
+  @FunctionalInterface
+  interface BucketLinkResolver {
+    ResolvedBucket resolve(String volumeName, String bucketName) throws 
IOException;
+  }
+
   /**
    * Generates a cryptographically strong String of the supplied stringLength 
using supplied chars.
    */
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 63c4d8121ed..d9e5c4caf76 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
@@ -18,7 +18,6 @@
 package org.apache.hadoop.ozone.security;
 
 import java.io.IOException;
-import java.time.Clock;
 import java.time.Instant;
 import java.util.Objects;
 import org.apache.hadoop.hdds.annotation.InterfaceAudience;
@@ -83,12 +82,11 @@ public Token<STSTokenIdentifier> 
generateToken(STSTokenIdentifier tokenIdentifie
    * @param secretAccessKey     the secret access key associated with the 
temporary access key ID
    * @param sessionPolicy       an optional opaque identifier that further 
limits the scope of
    *                            the permissions granted by the role
-   * @param clock               the system clock
+   * @param creationTime        token creation time
    * @return base64 encoded token string
    */
   public String createSTSTokenString(String tempAccessKeyId, String 
originalAccessKeyId, String roleArn,
-      int durationSeconds, String secretAccessKey, String sessionPolicy, Clock 
clock) throws IOException {
-    final Instant creationTime = clock.instant();
+      int durationSeconds, String secretAccessKey, String sessionPolicy, 
Instant creationTime) throws IOException {
     final Instant expiration = creationTime.plusSeconds(durationSeconds);
 
     final STSTokenIdentifier identifier = new 
STSTokenIdentifier(STSTokenIdentifier.Params.newBuilder()
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java
index 3ae775b716b..57068d1a169 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java
@@ -19,6 +19,7 @@
 
 import static java.util.Collections.emptySet;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
@@ -33,13 +34,17 @@
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.ZoneOffset;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
 import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
 import java.util.regex.Pattern;
 import javax.crypto.SecretKey;
 import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.lang3.tuple.Pair;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
 import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient;
@@ -47,7 +52,10 @@
 import org.apache.hadoop.ozone.audit.AuditMessage;
 import org.apache.hadoop.ozone.om.OMMultiTenantManager;
 import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.ResolvedBucket;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
 import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
+import org.apache.hadoop.ozone.om.helpers.BucketLayout;
 import org.apache.hadoop.ozone.om.helpers.OMAuditLogger;
 import org.apache.hadoop.ozone.om.response.OMClientResponse;
 import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleRequest;
@@ -57,9 +65,14 @@
 import 
org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.S3Authentication;
 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.UpdateAssumeRoleRequest;
 import org.apache.hadoop.ozone.security.STSTokenSecretManager;
 import org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.OzoneGrant;
 import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
+import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer.ACLType;
+import org.apache.hadoop.ozone.security.acl.IOzoneObj;
+import org.apache.hadoop.ozone.security.acl.OzoneObj;
+import org.apache.hadoop.ozone.security.acl.OzoneObjInfo;
 import org.apache.hadoop.ozone.security.acl.iam.IamSessionPolicyResolver;
 import org.apache.hadoop.security.UserGroupInformation;
 import org.apache.hadoop.security.token.TokenIdentifier;
@@ -150,13 +163,11 @@ public void testInvalidDurationTooShort() {
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMClientResponse response = 
request.validateAndUpdateCache(ozoneManager, context);
-    final OMResponse omResponse = response.getOMResponse();
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
 
-    assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST);
-    assertThat(omResponse.getMessage()).isEqualTo(
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+    assertThat(exception.getMessage()).isEqualTo(
         "Invalid Value: DurationSeconds must be between 900 and 43200 
seconds");
-    assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
     assertMarkForAuditCalled(request);
   }
 
@@ -172,13 +183,11 @@ public void testInvalidDurationTooLong() {
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMClientResponse response = 
request.validateAndUpdateCache(ozoneManager, context);
-    final OMResponse omResponse = response.getOMResponse();
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
 
-    assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST);
-    assertThat(omResponse.getMessage()).isEqualTo(
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+    assertThat(exception.getMessage()).isEqualTo(
         "Invalid Value: DurationSeconds must be between 900 and 43200 
seconds");
-    assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
     assertMarkForAuditCalled(request);
   }
 
@@ -196,12 +205,17 @@ public void testValidDurationMaxBoundary() throws 
IOException {
     // Call preExecute first to generate credentials
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
     final OMRequest preExecutedRequest = request.preExecute(ozoneManager);
+
+    assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, 43200);
+
     final S3AssumeRoleRequest requestWithCredentials = new 
S3AssumeRoleRequest(preExecutedRequest, CLOCK);
     final OMClientResponse response = 
requestWithCredentials.validateAndUpdateCache(ozoneManager, context);
     final OMResponse omResponse = response.getOMResponse();
 
     assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
     assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+    verify(accessAuthorizer).generateAssumeRoleSessionPolicy(
+        any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class));
     assertMarkForAuditCalled(requestWithCredentials);
   }
 
@@ -219,12 +233,17 @@ public void testValidDurationMinBoundary() throws 
IOException {
     // Call preExecute first to generate credentials
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
     final OMRequest preExecutedRequest = request.preExecute(ozoneManager);
+
+    assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, 900);
+
     final S3AssumeRoleRequest requestWithCredentials = new 
S3AssumeRoleRequest(preExecutedRequest, CLOCK);
     final OMClientResponse response = 
requestWithCredentials.validateAndUpdateCache(ozoneManager, context);
     final OMResponse omResponse = response.getOMResponse();
 
     assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
     assertThat(omResponse.hasAssumeRoleResponse()).isTrue();
+    verify(accessAuthorizer).generateAssumeRoleSessionPolicy(
+        any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class));
     assertMarkForAuditCalled(requestWithCredentials);
   }
 
@@ -242,12 +261,10 @@ public void testMissingS3Authentication() {
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMClientResponse response = 
request.validateAndUpdateCache(ozoneManager, context);
-    final OMResponse omResponse = response.getOMResponse();
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
 
-    assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST);
-    assertThat(omResponse.getMessage()).isEqualTo("S3AssumeRoleRequest does 
not have S3 authentication");
-    assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+    assertThat(exception.getMessage()).isEqualTo("S3AssumeRoleRequest does not 
have S3 authentication");
     assertMarkForAuditCalled(request);
   }
 
@@ -266,6 +283,9 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() 
throws IOException {
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
     // Call preExecute first to generate credentials
     final OMRequest preExecutedRequest = request.preExecute(ozoneManager);
+
+    assertLeaderGeneratedAssumeRoleFields(preExecutedRequest, durationSeconds);
+
     final S3AssumeRoleRequest requestWithCredentials = new 
S3AssumeRoleRequest(preExecutedRequest, CLOCK);
     final OMClientResponse clientResponse = 
requestWithCredentials.validateAndUpdateCache(ozoneManager, context);
     final OMResponse omResponse = clientResponse.getOMResponse();
@@ -275,6 +295,8 @@ public void testSuccessfulAssumeRoleGeneratesCredentials() 
throws IOException {
     assertThat(omResponse.getCmdType()).isEqualTo(Type.AssumeRole);
 
     final AssumeRoleResponse assumeRoleResponse = 
omResponse.getAssumeRoleResponse();
+    assertThat(assumeRoleResponse.getSessionToken()).isEqualTo(
+        preExecutedRequest.getUpdateAssumeRoleRequest().getSessionToken());
 
     // AccessKeyId: prefix ASIA + 20 chars
     assertThat(assumeRoleResponse.getAccessKeyId()).startsWith("ASIA");
@@ -359,6 +381,29 @@ public void testAssumeRoleCredentialsAreUnique() throws 
IOException {
     verify(auditLogger, times(2)).logWrite(any(AuditMessage.class));
   }
 
+  @Test
+  public void testValidateAndUpdateCacheDoesNotCallAuthorizer() throws 
IOException {
+    final OMRequest omRequest = baseOmRequestBuilder()
+        .setAssumeRoleRequest(
+            AssumeRoleRequest.newBuilder()
+                .setRoleArn(ROLE_ARN_1)
+                .setRoleSessionName(SESSION_NAME)
+                .setDurationSeconds(3600)
+                .setRequestId(REQUEST_ID)
+        ).build();
+
+    final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
+    final OMRequest preExecutedRequest = request.preExecute(ozoneManager);
+    verify(accessAuthorizer).generateAssumeRoleSessionPolicy(
+        any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class));
+
+    final S3AssumeRoleRequest requestWithCredentials = new 
S3AssumeRoleRequest(preExecutedRequest, CLOCK);
+    requestWithCredentials.validateAndUpdateCache(ozoneManager, context);
+
+    verify(accessAuthorizer, times(1)).generateAssumeRoleSessionPolicy(
+        any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class));
+  }
+
   @Test
   public void testAssumeRoleWithEmptySessionName() {
     final OMRequest omRequest = baseOmRequestBuilder()
@@ -371,9 +416,9 @@ public void testAssumeRoleWithEmptySessionName() {
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMClientResponse response = 
request.validateAndUpdateCache(ozoneManager, context);
-    
assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST);
-    assertThat(response.getOMResponse().getMessage()).isEqualTo(
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+    assertThat(exception.getMessage()).isEqualTo(
         "Value null at 'roleSessionName' failed to satisfy constraint: Member 
must not be null");
     assertMarkForAuditCalled(request);
   }
@@ -389,14 +434,12 @@ public void testInvalidAssumeRoleSessionNameTooShort() {
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMClientResponse response = 
request.validateAndUpdateCache(ozoneManager, context);
-    final OMResponse omResponse = response.getOMResponse();
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
 
-    assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST);
-    assertThat(omResponse.getMessage()).isEqualTo(
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+    assertThat(exception.getMessage()).isEqualTo(
         "Invalid RoleSessionName length 1: it must be 2-64 characters long and 
contain only alphanumeric " +
         "characters and +, =, ,, ., @, -");
-    assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
     assertMarkForAuditCalled(request);
   }
 
@@ -412,15 +455,13 @@ public void testInvalidRoleSessionNameTooLong() {
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMClientResponse response = 
request.validateAndUpdateCache(ozoneManager, context);
-    final OMResponse omResponse = response.getOMResponse();
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
 
-    assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST);
-    assertThat(omResponse.getMessage()).isEqualTo(
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST);
+    assertThat(exception.getMessage()).isEqualTo(
         "Invalid RoleSessionName length 70: it must be 2-64 characters long 
and contain only alphanumeric " +
         "characters and +, =, ,, ., @, -"
     );
-    assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
     assertMarkForAuditCalled(request);
   }
 
@@ -511,17 +552,13 @@ public void 
testMalformedSessionPolicyDoesNotIssueCredentials() throws IOExcepti
         ).build();
 
     final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, 
CLOCK);
-    final OMRequest preExecutedRequest = request.preExecute(ozoneManager);
-    final S3AssumeRoleRequest requestWithCredentials = new 
S3AssumeRoleRequest(preExecutedRequest, CLOCK);
-    final OMClientResponse response = 
requestWithCredentials.validateAndUpdateCache(ozoneManager, context);
-    final OMResponse omResponse = response.getOMResponse();
+    final OMException exception = assertThrows(OMException.class, () -> 
request.preExecute(ozoneManager));
 
-    
assertThat(omResponse.getStatus()).isEqualTo(Status.MALFORMED_POLICY_DOCUMENT);
-    assertThat(omResponse.getMessage()).isEqualTo("IAM session policy: 
Duplicate field 'Action' in session policy");
-    assertThat(omResponse.hasAssumeRoleResponse()).isFalse();
+    
assertThat(exception.getResult()).isEqualTo(OMException.ResultCodes.MALFORMED_POLICY_DOCUMENT);
+    assertThat(exception.getMessage()).isEqualTo("IAM session policy: 
Duplicate field 'Action' in session policy");
     verify(accessAuthorizer, never()).generateAssumeRoleSessionPolicy(
         any(org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class));
-    assertMarkForAuditCalled(requestWithCredentials);
+    assertMarkForAuditCalled(request);
   }
 
   @Test
@@ -604,6 +641,273 @@ public void 
testGetSessionPolicyWithBlankAwsPolicyCapturesNullGrants() throws Ex
     assertThat(capturedAssumeRoleRequest.getGrants()).isNull();
   }
 
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksLeavesNonLinkGrantsUnchanged() throws 
IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null),
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "mybucket", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "mybucket", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> new ResolvedBucket(
+            volume, bucket, volume, bucket, "owner", 
BucketLayout.OBJECT_STORE));
+
+    assertThat(result).isEqualTo(grants);
+  }
+
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksRewritesToSourceForSameVolumeLink() throws 
IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null),
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> resolved(
+            volume, bucket, "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg")));
+
+    final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", 
"s3v-iceberg", null);
+    final Set<IOzoneObj> allObjects = allObjectsIn(result);
+    // Key and bucket are anchored to the link's source.
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", 
"iceberg", "*"));
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.BUCKET, "s3v", 
"iceberg", null));
+    // The link key is gone; only a READ on the link bucket remains, for 
following the link.
+    assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.KEY, 
"s3v", "s3v-iceberg", "*"));
+    assertThat(allObjects).contains(linkBucket);
+    // Same volume, so no extra source-volume grant is added.
+    assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, 
"iceberg", null, null));
+
+    final OzoneGrant followGrant = grantContaining(result, linkBucket);
+    assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ);
+    assertThat(followGrant.getS3Actions()).isEmpty();
+  }
+
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksRewritesPrefixOnSameVolumeLink() throws 
IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                prefixObj("s3v", "s3v-iceberg", "folder/")),
+            EnumSet.of(ACLType.READ, ACLType.LIST), 
Collections.singleton("ListBucket")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> resolved(
+            volume, bucket, "s3v", "iceberg", Pair.of("s3v", "s3v-iceberg")));
+
+    final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", 
"s3v-iceberg", null);
+    final Set<IOzoneObj> allObjects = allObjectsIn(result);
+    assertThat(allObjects).contains(prefixObj("s3v", "iceberg", "folder/"));
+    assertThat(allObjects).doesNotContain(prefixObj("s3v", "s3v-iceberg", 
"folder/"));
+    assertThat(allObjects).contains(linkBucket);
+
+    final OzoneGrant followGrant = grantContaining(result, linkBucket);
+    assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ);
+    assertThat(followGrant.getS3Actions()).isEmpty();
+  }
+
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksAddsSourceVolumeReadForCrossVolumeLink() 
throws IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null),
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> resolved(
+            volume, bucket, "tenantvol", "iceberg", Pair.of("s3v", 
"s3v-iceberg")));
+
+    final IOzoneObj linkBucket = obj(OzoneObj.ResourceType.BUCKET, "s3v", 
"s3v-iceberg", null);
+    final IOzoneObj sourceVolume = obj(OzoneObj.ResourceType.VOLUME, 
"tenantvol", null, null);
+    final Set<IOzoneObj> allObjects = allObjectsIn(result);
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, 
"tenantvol", "iceberg", "*"));
+    assertThat(allObjects).contains(linkBucket);
+    assertThat(allObjects).contains(sourceVolume);
+
+    final OzoneGrant followGrant = grantContaining(result, sourceVolume);
+    assertThat(followGrant.getPermissions()).containsExactly(ACLType.READ);
+    assertThat(followGrant.getObjects()).contains(linkBucket);
+  }
+
+  @Test
+  public void testResolveGrantsAgainstBucketLinksSkipsWildcardBuckets() throws 
IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null),
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "*", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "*", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> {
+          throw new AssertionError("link resolver must not be called for 
wildcard buckets");
+        });
+
+    assertThat(result).isEqualTo(grants);
+  }
+
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksLeavesDanglingBucketsUnchanged() throws 
IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.KEY, "s3v", "danglingBucket", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> new ResolvedBucket(volume, bucket, null, 
null, null, null));
+
+    assertThat(result).isEqualTo(grants);
+  }
+
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksRewritesChainedSameVolumeLink() throws 
IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "linkA", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> resolved(
+            volume, bucket, "s3v", "source", Pair.of("s3v", "linkA"), 
Pair.of("s3v", "linkB")));
+
+    final IOzoneObj linkA = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", 
null);
+    final IOzoneObj linkB = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkB", 
null);
+    final Set<IOzoneObj> allObjects = allObjectsIn(result);
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", 
"source", "*"));
+    assertThat(allObjects).contains(linkA);
+    assertThat(allObjects).contains(linkB);
+    assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, 
"s3v", null, null));
+  }
+
+  @Test
+  public void 
testResolveGrantsAgainstBucketLinksAddsVolumeReadForChainedCrossVolumeHop() 
throws IOException {
+    final Set<OzoneGrant> grants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "linkA", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    final Set<OzoneGrant> result = 
S3AssumeRoleRequest.resolveGrantsAgainstBucketLinks(
+        grants, (volume, bucket) -> resolved(
+            volume, bucket, "tenant", "source", Pair.of("s3v", "linkA"), 
Pair.of("tenant", "linkB")));
+
+    final IOzoneObj linkA = obj(OzoneObj.ResourceType.BUCKET, "s3v", "linkA", 
null);
+    final IOzoneObj linkB = obj(OzoneObj.ResourceType.BUCKET, "tenant", 
"linkB", null);
+    final IOzoneObj tenantVolume = obj(OzoneObj.ResourceType.VOLUME, "tenant", 
null, null);
+    final Set<IOzoneObj> allObjects = allObjectsIn(result);
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "tenant", 
"source", "*"));
+    assertThat(allObjects).contains(linkA);
+    assertThat(allObjects).contains(linkB);
+    assertThat(allObjects).contains(tenantVolume);
+    assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.VOLUME, 
"s3v", null, null));
+  }
+
+  @Test
+  public void testGetSessionPolicyRewritesLinkBucketGrantsToSource() throws 
Exception {
+    when(ozoneManager.isS3MultiTenancyEnabled()).thenReturn(false);
+
+    final Set<OzoneGrant> resolverGrants = Collections.singleton(
+        new OzoneGrant(
+            objectsOf(
+                obj(OzoneObj.ResourceType.VOLUME, "s3v", null, null),
+                obj(OzoneObj.ResourceType.BUCKET, "s3v", "s3v-iceberg", null),
+                obj(OzoneObj.ResourceType.KEY, "s3v", "s3v-iceberg", "*")),
+            EnumSet.of(ACLType.READ), Collections.singleton("GetObject")));
+
+    when(ozoneManager.resolveBucketLink(Pair.of("s3v", "s3v-iceberg"), true, 
false))
+        .thenReturn(resolved("s3v", "s3v-iceberg", "s3v", "iceberg", 
Pair.of("s3v", "s3v-iceberg")));
+
+    try (MockedStatic<IamSessionPolicyResolver> resolverMock = 
mockStatic(IamSessionPolicyResolver.class)) {
+      resolverMock.when(() -> IamSessionPolicyResolver.resolve(
+              AWS_IAM_POLICY, "s3v", 
IamSessionPolicyResolver.AuthorizerType.RANGER))
+          .thenReturn(resolverGrants);
+
+      final String result = new 
S3AssumeRoleRequest(baseOmRequestBuilder().build(), CLOCK)
+          .getSessionPolicy(
+              ozoneManager, ORIGINAL_ACCESS_KEY_ID, AWS_IAM_POLICY, OM_HOST, 
LOOPBACK_IP,
+              UserGroupInformation.createRemoteUser("userNameLink"), 
TARGET_ROLE_NAME);
+      // Ensure no exception was thrown and that the method actually delegated 
to generateAssumeRoleSessionPolicy
+      // and returned its value (not null, not something else).
+      assertThat(result).isEqualTo(SESSION_POLICY_VALUE);
+    }
+
+    final 
ArgumentCaptor<org.apache.hadoop.ozone.security.acl.AssumeRoleRequest> captor = 
ArgumentCaptor.forClass(
+        org.apache.hadoop.ozone.security.acl.AssumeRoleRequest.class);
+    verify(accessAuthorizer).generateAssumeRoleSessionPolicy(captor.capture());
+
+    final Set<IOzoneObj> allObjects = 
allObjectsIn(captor.getValue().getGrants());
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.KEY, "s3v", 
"iceberg", "*"));
+    assertThat(allObjects).contains(obj(OzoneObj.ResourceType.BUCKET, "s3v", 
"s3v-iceberg", null));
+    assertThat(allObjects).doesNotContain(obj(OzoneObj.ResourceType.KEY, 
"s3v", "s3v-iceberg", "*"));
+
+    verify(ozoneManager).resolveBucketLink(Pair.of("s3v", "s3v-iceberg"), 
true, false);
+  }
+
+  @SafeVarargs
+  private static ResolvedBucket resolved(String requestedVol, String 
requestedBucket, String realVol, String realBucket,
+      Pair<String, String>... linkChain) {
+    return new ResolvedBucket(
+        requestedVol, requestedBucket, realVol, realBucket, "owner",
+        BucketLayout.OBJECT_STORE, Arrays.asList(linkChain));
+  }
+
+  private static IOzoneObj obj(OzoneObj.ResourceType type, String volume, 
String bucket, String key) {
+    final OzoneObjInfo.Builder builder = OzoneObjInfo.Builder.newBuilder()
+        .setResType(type)
+        .setStoreType(OzoneObj.StoreType.OZONE)
+        .setVolumeName(volume);
+    if (bucket != null) {
+      builder.setBucketName(bucket);
+    }
+    if (key != null) {
+      builder.setKeyName(key);
+    }
+    return builder.build();
+  }
+
+  @SuppressWarnings("SameParameterValue")
+  private static IOzoneObj prefixObj(String volume, String bucket, String 
prefix) {
+    return OzoneObjInfo.Builder.newBuilder()
+        .setResType(OzoneObj.ResourceType.PREFIX)
+        .setStoreType(OzoneObj.StoreType.OZONE)
+        .setVolumeName(volume)
+        .setBucketName(bucket)
+        .setPrefixName(prefix)
+        .build();
+  }
+
+  private static Set<IOzoneObj> objectsOf(IOzoneObj... objects) {
+    return new LinkedHashSet<>(Arrays.asList(objects));
+  }
+
+  private static Set<IOzoneObj> allObjectsIn(Set<OzoneGrant> grants) {
+    final Set<IOzoneObj> all = new LinkedHashSet<>();
+    for (OzoneGrant grant : grants) {
+      all.addAll(grant.getObjects());
+    }
+    return all;
+  }
+
+  private static OzoneGrant grantContaining(Set<OzoneGrant> grants, IOzoneObj 
object) {
+    return grants.stream()
+        .filter(grant -> grant.getObjects().contains(object))
+        .findFirst()
+        .orElseThrow(() -> new AssertionError("No grant contains " + object));
+  }
+
   private org.apache.hadoop.ozone.security.acl.AssumeRoleRequest 
captureAssumeRoleRequest(String volumeName,
       String userName) throws Exception {
     try (MockedStatic<IamSessionPolicyResolver> resolverMock = 
mockStatic(IamSessionPolicyResolver.class)) {
@@ -637,6 +941,17 @@ private static OMRequest.Builder baseOmRequestBuilder() {
         );
   }
 
+  private void assertLeaderGeneratedAssumeRoleFields(OMRequest 
preExecutedRequest, int durationSeconds) {
+    assertThat(preExecutedRequest.hasUpdateAssumeRoleRequest()).isTrue();
+    final UpdateAssumeRoleRequest updateAssumeRoleRequest = 
preExecutedRequest.getUpdateAssumeRoleRequest();
+    
assertThat(updateAssumeRoleRequest.getTempAccessKeyId()).startsWith("ASIA");
+    assertThat(updateAssumeRoleRequest.getSecretAccessKey()).isNotEmpty();
+    assertThat(updateAssumeRoleRequest.getRoleId()).startsWith("AROA");
+    assertThat(updateAssumeRoleRequest.getSessionToken()).isNotEmpty();
+    assertThat(updateAssumeRoleRequest.getExpirationEpochSeconds())
+        .isEqualTo(CLOCK.instant().getEpochSecond() + durationSeconds);
+  }
+
   private void assertMarkForAuditCalled(S3AssumeRoleRequest request) {
     OMAuditLogger.log(request.getAuditBuilder());
     verify(auditLogger).logWrite(any(AuditMessage.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 608be8f6603..2f3a0350b57 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
@@ -80,8 +80,8 @@ public void 
testConstructValidateAndDecryptSTSTokenInvalidProtobuf() throws IOEx
   @Test
   public void testConstructValidateAndDecryptSTSTokenSuccess() throws 
IOException {
     // Create a valid token
-    final String tokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String tokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     // Validate and decrypt the token
     final STSTokenIdentifier result = 
STSSecurityUtil.constructValidateAndDecryptSTSToken(
@@ -103,7 +103,7 @@ public void 
testConstructValidateAndDecryptSTSTokenSuccess() throws IOException
   public void 
testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws 
Exception {
     // Create a valid token with null session policy
     final String tokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, null, clock);
+        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, null, clock.instant());
 
     // Validate and decrypt the token
     final STSTokenIdentifier result = 
STSSecurityUtil.constructValidateAndDecryptSTSToken(
@@ -135,8 +135,8 @@ public void 
testConstructValidateAndDecryptSTSTokenRuntimeDecodeFailure() {
   @Test
   public void testConstructValidateAndDecryptSTSTokenInvalidKind() throws 
Exception {
     // Create a valid identifier to use as base
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     final Token<STSTokenIdentifier> validToken = new Token<>();
     validToken.decodeFromUrlString(validTokenString);
@@ -158,8 +158,8 @@ public void 
testConstructValidateAndDecryptSTSTokenInvalidKind() throws Exceptio
   @Test
   public void testConstructValidateAndDecryptSTSTokenInvalidService() throws 
Exception {
     // Create a token with incorrect service
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     final Token<STSTokenIdentifier> validToken = new Token<>();
     validToken.decodeFromUrlString(validTokenString);
@@ -180,7 +180,7 @@ public void 
testConstructValidateAndDecryptSTSTokenInvalidService() throws Excep
   public void testConstructValidateAndDecryptSTSTokenExpired() throws 
Exception {
     // Create a token that expires immediately (durationSeconds of 0)
     final String tokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, 0, SECRET_ACCESS_KEY, 
SESSION_POLICY, clock);
+        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, 0, SECRET_ACCESS_KEY, 
SESSION_POLICY, clock.instant());
 
     // Fast-forward time to ensure token is expired
     clock.fastForward(100);
@@ -196,8 +196,8 @@ public void 
testConstructValidateAndDecryptSTSTokenExpired() throws Exception {
   @Test
   public void testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() 
throws Exception {
     // Create a valid token string
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     // Create a mock secret key client that returns null for the key
     final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class);
@@ -215,8 +215,8 @@ public void 
testConstructValidateAndDecryptSTSTokenSecretKeyNotFound() throws Ex
   @Test
   public void testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() 
throws Exception {
     // Create a valid identifier to use as base
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     final Token<STSTokenIdentifier> validToken = new Token<>();
     validToken.decodeFromUrlString(validTokenString);
@@ -241,8 +241,8 @@ public void 
testConstructValidateAndDecryptSTSTokenInvalidSecretKeyId() throws E
   @Test
   public void testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws 
Exception {
     // Create a valid token string
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     // Create a mock secret key that is expired
     final ManagedSecretKey expiredSecretKey = mock(ManagedSecretKey.class);
@@ -264,8 +264,8 @@ public void 
testConstructValidateAndDecryptSTSTokenExpiredSecretKey() throws Exc
   @Test
   public void 
testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException() throws 
Exception {
     // Create a valid token string
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     // Create a mock secret key client that throws an exception
     final SecretKeyClient mockKeyClient = mock(SecretKeyClient.class);
@@ -283,8 +283,8 @@ public void 
testConstructValidateAndDecryptSTSTokenSecretKeyRetrievalException()
   @Test
   public void testConstructValidateAndDecryptSTSTokenInvalidSignature() throws 
Exception {
     // Create a valid token string
-    final String validTokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String validTokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     final Token<STSTokenIdentifier> validToken = new Token<>();
     validToken.decodeFromUrlString(validTokenString);
@@ -307,7 +307,7 @@ public void 
testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exc
   public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() 
throws Exception {
     final String tokenString = tokenSecretManager.createSTSTokenString(
         TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS,
-        SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+        SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant());
 
     assertThatThrownBy(() ->
         STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + 
tokenString, secretKeyClient, clock))
@@ -320,7 +320,7 @@ public void 
testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() throws
   public void testConstructValidateAndDecryptSTSTokenRejectsTokenWithSuffix() 
throws Exception {
     final String tokenString = tokenSecretManager.createSTSTokenString(
         TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS,
-        SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+        SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant());
 
     assertThatThrownBy(() ->
         STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + 
"garbage", secretKeyClient, clock))
@@ -344,11 +344,11 @@ public void 
testConstructValidateAndDecryptMultipleTokens() throws Exception {
     // Create multiple tokens and validate them all
     final String token1 = tokenSecretManager.createSTSTokenString(
         "temp-key-1", "orig-key-1", "role-arn-1", DURATION_SECONDS,
-        "secret-key-1", "policy-1", clock);
+        "secret-key-1", "policy-1", clock.instant());
 
     final String token2 = tokenSecretManager.createSTSTokenString(
         "temp-key-2", "orig-key-2", "role-arn-2", DURATION_SECONDS,
-        "secret-key-2", "policy-2", clock);
+        "secret-key-2", "policy-2", clock.instant());
 
     final STSTokenIdentifier result1 = 
STSSecurityUtil.constructValidateAndDecryptSTSToken(
         token1, secretKeyClient, clock);
@@ -418,8 +418,8 @@ public void 
testEnsureEssentialFieldsArePresentInTokenMissingCreationTime() {
 
   @Test
   public void testEnsureResolvedStsFieldsInvariantsSuccess() throws Exception {
-    final String tokenString = tokenSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String tokenString = 
tokenSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     final S3Authentication s3Auth = S3Authentication.newBuilder()
         .setSessionToken(tokenString)
@@ -460,7 +460,7 @@ public void 
testEnsureResolvedStsFieldsInvariantsMissingSessionToken() {
   public void testEnsureResolvedStsFieldsInvariantsMissingResolvedFields() 
throws Exception {
     final String tokenString = tokenSecretManager.createSTSTokenString(
         TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS,
-        SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+        SECRET_ACCESS_KEY, SESSION_POLICY, clock.instant());
 
     final S3Authentication s3Auth = S3Authentication.newBuilder()
         .setSessionToken(tokenString)
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java
index 4408652dbb9..fe20c9dd2a4 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenSecretManager.java
@@ -84,8 +84,8 @@ public void setUp() throws Exception {
 
   @Test
   public void testCreateSTSTokenStringContainsCorrectFields() throws 
IOException {
-    final String tokenString = secretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String tokenString = 
secretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     // Decode the token
     final Token<STSTokenIdentifier> token = new Token<>();
@@ -114,7 +114,7 @@ public void testCreateSTSTokenStringContainsCorrectFields() 
throws IOException {
   @Test
   public void testCreateSTSTokenStringWithNullSessionPolicy() throws 
IOException {
     final String tokenString = secretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, null, clock);
+        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, null, clock.instant());
 
     // Decode the token
     final Token<STSTokenIdentifier> token = new Token<>();
@@ -150,8 +150,8 @@ public void 
testCreateSTSTokenStringValidatesWhenSecretKeyRotatesDuringCreation(
         encryptionKey, signingKey);
     final STSTokenSecretManager rotatingSecretManager = new 
STSTokenSecretManager(rotatingSecretKeyClient);
 
-    final String tokenString = rotatingSecretManager.createSTSTokenString(
-        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, 
SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+    final String tokenString = 
rotatingSecretManager.createSTSTokenString(TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY,
+        ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY, SESSION_POLICY, 
clock.instant());
 
     final STSTokenIdentifier result = 
STSSecurityUtil.constructValidateAndDecryptSTSToken(
         tokenString, rotatingSecretKeyClient, clock);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to