Skip to content

CLDSRV-674: handle cross-account bucket policies #5855

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: development/7.70
Choose a base branch
from

Conversation

leif-scality
Copy link

@leif-scality leif-scality commented Jul 2, 2025

Handle cross account cases when using bucket policies

https://scality.atlassian.net/browse/S3C-9896

@bert-e
Copy link
Contributor

bert-e commented Jul 2, 2025

Hello leif-scality,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval
/bypass_build_status Bypass the build and test status
/bypass_commit_size Bypass the check on the size of the changeset TBA
/bypass_incompatible_branch Bypass the check on the source branch prefix
/bypass_jira_check Bypass the Jira issue check
/bypass_peer_approval Bypass the pull request peers' approval
/bypass_leader_approval Bypass the pull request leaders' approval
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request TBA
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e
Copy link
Contributor

bert-e commented Jul 2, 2025

Branches have diverged

This pull request's source branch bugfix/CLDSRV-674 has diverged from
development/9.0 by more than 50 commits.

To avoid any integration risks, please re-synchronize them using one of the
following solutions:

  • Merge origin/development/9.0 into bugfix/CLDSRV-674
  • Rebase bugfix/CLDSRV-674 onto origin/development/9.0

Note: If you choose to rebase, you may have to ask me to rebuild
integration branches using the reset command.

@leif-scality leif-scality force-pushed the bugfix/CLDSRV-674 branch 2 times, most recently from ccf75c1 to b4639ec Compare July 2, 2025 09:47
@leif-scality leif-scality requested a review from Copilot July 2, 2025 09:51
Copilot

This comment was marked as outdated.

@leif-scality leif-scality requested a review from Copilot July 2, 2025 09:58
@leif-scality leif-scality changed the base branch from development/9.0 to development/7.70 July 2, 2025 09:59
@bert-e
Copy link
Contributor

bert-e commented Jul 2, 2025

Request integration branches

Waiting for integration branch creation to be requested by the user.

To request integration branches, please comment on this pull request with the following command:

/create_integration_branches

Alternatively, the /approve and /create_pull_requests commands will automatically
create the integration branches.

Copilot

This comment was marked as outdated.

@leif-scality leif-scality requested a review from Copilot July 2, 2025 10:05
Copilot

This comment was marked as outdated.

return false;
}

const checkPrincipalResult = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const checkPrincipalResult = {
const checkPrincipalResult = Object.freeze({

}

const checkBucketPolicyResult = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const checkBucketPolicyResult = {
const checkBucketPolicyResult = Object.freeze({

(this is to ensure it will properly acts as an enum, as otherwise in js, we cannot protect against modification, just to be on the safe side...)

Comment on lines 355 to 365
for (const p of principal.CanonicalUser) {
if (out === checkPrincipalResult.OK) {
break;
}

const res = _checkPrincipal(canonicalID, p, bucketOwnerCanonicalID, canonicalID);
if (res !== checkPrincipalResult.KO) {
out = res;
}
}
return out;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to avoid nested if/for and duplication we should be able to use a helper function

something like:

function _findBestPrincipalMatch(principalArray, checkFunc) {
    let bestMatch = checkPrincipalResult.KO;
    if (!principalArray) {
        return bestMatch;
    }

    const principals = Array.isArray(principalArray) ? principalArray : [principalArray];

    for (const p of principals) {
        const result = checkFunc(p);
        if (result === checkPrincipalResult.OK) {
            return checkPrincipalResult.OK; // Highest permission, can exit early
        }
        if (result > bestMatch) {
            bestMatch = result;
        }
    }
    return bestMatch;
}

function _checkPrincipals(canonicalID, arn, principal, bucketOwnerCanonicalID) {
    if (principal === '*') {
        // Handle anonymous or authenticated wildcard
        if (arn === undefined) {
            return checkPrincipalResult.OK;
        }
        return _getPermissionLevel(arn, bucketOwnerCanonicalID, canonicalID); // Assuming _getPermissionLevel is refactored
    }

    if (principal.CanonicalUser) {
        return _findBestPrincipalMatch(principal.CanonicalUser, p =>
            _checkPrincipal(canonicalID, p, bucketOwnerCanonicalID, canonicalID));
    }

    if (principal.AWS) {
        return _findBestPrincipalMatch(principal.AWS, p =>
            _checkPrincipal(arn, p, bucketOwnerCanonicalID, canonicalID));
    }

    return checkPrincipalResult.KO;
}

Comment on lines 313 to 320
if (_getAccountId(requester) !== principal) {
return checkPrincipalResult.KO;
}

if (!_isRootUser(requester)) {
return bucketOwnerCanonicalID === requesterCanonicalID ?
checkPrincipalResult.OK : checkPrincipalResult.CROSS_ACCOUNT_OK;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated multiple time, how about something like this?

function _getPermissionLevel(requester, bucketOwnerCanonicalID, requesterCanonicalID) {
    if (_isRootUser(requester)) {
        return checkPrincipalResult.OK;
    }
    return bucketOwnerCanonicalID === requesterCanonicalID
        ? checkPrincipalResult.OK
        : checkPrincipalResult.CROSS_ACCOUNT_OK;
}

function _checkPrincipal(requester, principal, bucketOwnerCanonicalID, requesterCanonicalID) {
    if (principal === '*') {
        return _getPermissionLevel(requester, bucketOwnerCanonicalID, requesterCanonicalID);
    }
    if (requester === undefined) { // Unauthenticated user
        return checkPrincipalResult.KO;
    }
    if (principal === requester) {
        return _getPermissionLevel(requester, bucketOwnerCanonicalID, requesterCanonicalID);
    }
    if (_isAccountId(principal) && _getAccountId(requester) === principal) {
        return _getPermissionLevel(requester, bucketOwnerCanonicalID, requesterCanonicalID);
    }
    if (principal.endsWith('root') && _getAccountId(requester) === _getAccountId(principal)) {
        return _getPermissionLevel(requester, bucketOwnerCanonicalID, requesterCanonicalID);
    }
    return checkPrincipalResult.KO;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created one function for each principal type, and inlined the if statements

@@ -117,7 +117,7 @@ function checkBucketAcls(bucket, requestType, canonicalID, mainApiCall) {
// authorization check should just return true so can move on to check
// rights at the object level.
return (requestTypeParsed === 'objectPutACL' || requestTypeParsed === 'objectGetACL'
|| requestTypeParsed === 'objectGet' || requestTypeParsed === 'objectHead');
|| requestTypeParsed === 'objectGet' || requestTypeParsed === 'objectHead');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid linting / indent changes it might create many conflicts with the integration branches above that have different rules and might have already fixed it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they were added by yarn run lint --fix, do I remove them anyway?

@leif-scality leif-scality requested a review from Copilot July 4, 2025 16:50
Copilot

This comment was marked as resolved.

Comment on lines 270 to 286
return false;
}
if (principal === requester) {

// Vault returns the following arn when the account makes requests 'arn:aws:iam::295412255825:/master/',
// with an empty resource type ('user/' prefix missing).
const arns = arn.split(':');
const resource = arns.at(-1);

// If we start with '/' is because we have a empty resource type so we know it is a root account.
if (resource.startsWith('/')) {
return true;
}
if (_isAccountId(principal)) {
return _getAccountId(requester) === principal;

return false;
}

const checkPrincipalResult = Object.freeze({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: better to define consts at the top of the file for readability

Comment on lines 298 to 299
function _checkCrossAccount(requesterARN, requesterCanonicalID, bucketOwnerCanonicalID) {
if (!_isRootUser(requesterARN)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for comments consistency:

Suggested change
function _checkCrossAccount(requesterARN, requesterCanonicalID, bucketOwnerCanonicalID) {
if (!_isRootUser(requesterARN)) {
function _checkCrossAccount(requesterARN, requesterCanonicalID, bucketOwnerCanonicalID) {
// Vault returns ARNs like 'arn:aws:iam::123456789012:/master/' for root accounts
// with an empty resource type (missing 'user/' prefix)
if (!_isRootUser(requesterARN)) {

Comment on lines 272 to 279

// Vault returns the following arn when the account makes requests 'arn:aws:iam::295412255825:/master/',
// with an empty resource type ('user/' prefix missing).
const arns = arn.split(':');
const resource = arns.at(-1);

// If we start with '/' is because we have a empty resource type so we know it is a root account.
if (resource.startsWith('/')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing protection against smaller arns:

Suggested change
// Vault returns the following arn when the account makes requests 'arn:aws:iam::295412255825:/master/',
// with an empty resource type ('user/' prefix missing).
const arns = arn.split(':');
const resource = arns.at(-1);
// If we start with '/' is because we have a empty resource type so we know it is a root account.
if (resource.startsWith('/')) {
// Vault returns the following arn when the account makes requests 'arn:aws:iam::295412255825:/master/',
// with an empty resource type ('user/' prefix missing).
const arns = arn.split(':');
const resource = arns.at(-1);
if (arns.length < 6) {
return false;
}
// If we start with '/' is because we have a empty resource type so we know it is a root account.
if (resource.startsWith('/')) {

// Vault returns the following arn when the account makes requests 'arn:aws:iam::295412255825:/master/',
// with an empty resource type ('user/' prefix missing).
const arns = arn.split(':');
const resource = arns.at(-1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a not on the .at, it seems this is quite recent (es2022), as you target the 7.70 branch, we will want to be careful if the change is later backported to a hotfix branch, that might still use an older nodejs version, as if I'm not mistaken, this one was introduced in later v16 nodejs version, it's definitely not in the first v16 versions.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace at with arns[arns.length - 1]

@leif-scality
Copy link
Author

@williamlardier I kept the diagram because the one in citadel is higher level than this one, I also think the diagram makes the function easier to understand.

}

// checkBucketPolicy FSM.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FSM ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finite state machine

Comment on lines +449 to +460
const ok = principalMatch === checkPrincipalResult.OK && actionMatch && resourceMatch && conditionsMatch;
const okCross = principalMatch === checkPrincipalResult.CROSS_ACCOUNT_OK
&& actionMatch && resourceMatch && conditionsMatch;
switch (permission) {
case checkBucketPolicyResult.DEFAULT_DENY:
if ((ok || okCross) && s.Effect === 'Deny') {
return checkBucketPolicyResult.EXPLICIT_DENY;
} else if (ok && s.Effect === 'Allow') {
permission = checkBucketPolicyResult.ALLOW;
} else if (okCross && s.Effect === 'Allow') {
permission = checkBucketPolicyResult.CROSS_ACCOUNT_ALLOW;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const ok = principalMatch === checkPrincipalResult.OK && actionMatch && resourceMatch && conditionsMatch;
const okCross = principalMatch === checkPrincipalResult.CROSS_ACCOUNT_OK
&& actionMatch && resourceMatch && conditionsMatch;
switch (permission) {
case checkBucketPolicyResult.DEFAULT_DENY:
if ((ok || okCross) && s.Effect === 'Deny') {
return checkBucketPolicyResult.EXPLICIT_DENY;
} else if (ok && s.Effect === 'Allow') {
permission = checkBucketPolicyResult.ALLOW;
} else if (okCross && s.Effect === 'Allow') {
permission = checkBucketPolicyResult.CROSS_ACCOUNT_ALLOW;
}
const ok = principalMatch === checkPrincipalResult.OK && actionMatch && resourceMatch && conditionsMatch;
const okCross = principalMatch === checkPrincipalResult.CROSS_ACCOUNT_OK
&& actionMatch && resourceMatch && conditionsMatch;
const anyOk = ok || okCross;
switch (permission) {
case checkBucketPolicyResult.DEFAULT_DENY:
if (anyOk && s.Effect === 'Deny') {
return checkBucketPolicyResult.EXPLICIT_DENY;
} else if (ok && s.Effect === 'Allow') {
permission = checkBucketPolicyResult.ALLOW;
} else if (okCross && s.Effect === 'Allow') {
permission = checkBucketPolicyResult.CROSS_ACCOUNT_ALLOW;
}

to avoid the multiple || if you want

return _checkCrossAccount(requesterARN, requesterCanonicalID, bucketOwnerCanonicalID);
}

if (principal.endsWith('root') && _getAccountId(principal) === _getAccountId(requesterARN)) {
Copy link
Contributor

@williamlardier williamlardier Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be /root to avoid valid user names ending in root?

Suggested change
if (principal.endsWith('root') && _getAccountId(principal) === _getAccountId(requesterARN)) {
if (principal.endsWith('/root') && _getAccountId(principal) === _getAccountId(requesterARN)) {

maybe a more formal check here would be better

Copy link
Author

@leif-scality leif-scality Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should do :root, /root is not a valid ARN, /account_name is only returned by vault when using the root access key's as explained in _IsRoot.
https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html#principal-accounts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants