From a739f6604531d42895f40dc7b046b956e294593c Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Thu, 26 Feb 2026 09:59:52 +0100 Subject: [PATCH 1/7] rgw: Inject keystone roles into IAM policy Keystone roles are injected into the IAM policy evaluation environment as "keystone:role" condition keys. This enables bucket policies to grant or deny access based on Keystone roles. It bridges the gap between Openstack RBAC and S3 IAM policy. Document keystone:role in bucket policy and Keystone S3 integration Signed-off-by: Supriti Singh Signed-off-by: Marcel Lauhoff On-behalf-of: SAP On-behalf-of: SAP (cherry picked from commit 1e6e39346667f82591644760311093729db8a915) --- doc/radosgw/bucketpolicy.rst | 4 + doc/radosgw/keystone.rst | 6 ++ src/rgw/rgw_auth.cc | 9 +++ src/rgw/rgw_auth.h | 7 +- src/rgw/rgw_auth_keystone.cc | 16 +++- src/test/rgw/test_rgw_iam_policy.cc | 116 ++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 6 deletions(-) diff --git a/doc/radosgw/bucketpolicy.rst b/doc/radosgw/bucketpolicy.rst index 23356624a46a..6c27f1828e08 100644 --- a/doc/radosgw/bucketpolicy.rst +++ b/doc/radosgw/bucketpolicy.rst @@ -122,6 +122,10 @@ For all requests, condition keys we support are: - aws:UserAgent - aws:username +Request that authenticate with Keystone also include: + +- keystone:role + We support certain s3 condition keys for bucket and object requests. *Support for the following bucket-related operations was added in the Mimic diff --git a/doc/radosgw/keystone.rst b/doc/radosgw/keystone.rst index 068eac24ff2c..c817cfca4f3c 100644 --- a/doc/radosgw/keystone.rst +++ b/doc/radosgw/keystone.rst @@ -159,6 +159,12 @@ S3 API (with AWS-like access and secret keys), if the ``rgw s3 auth use keystone`` option is set. For details, see :doc:`s3/authentication`. +Requests authenticated via Keystone expose the Keystone role names in the +IAM policy environment as the condition key ``keystone:role``. It can be +used in bucket policies and idenitity policies to allow or deny access by +role (e.g. ``StringEquals`` on ``keystone:role``). +See :doc:`bucketpolicy` for list of supported condition keys. + Service token support --------------------- diff --git a/src/rgw/rgw_auth.cc b/src/rgw/rgw_auth.cc index 74805fbb186b..a40f94cd796b 100644 --- a/src/rgw/rgw_auth.cc +++ b/src/rgw/rgw_auth.cc @@ -1033,6 +1033,15 @@ void rgw::auth::RemoteApplier::modify_request_state(const DoutPrefixProvider* dp // copy our identity policies into req_state s->iam_identity_policies.insert(s->iam_identity_policies.end(), policies.begin(), policies.end()); + + for (auto role : this->info.keystone_roles) { + // Keystone roles are case-insensitive. Normalize the roles to + // lowercase before placing them into the environment. + std::transform(role.begin(), role.end(), role.begin(), + [](unsigned char c){ return std::tolower(c); }); + s->env.emplace("keystone:role", std::move(role)); + } + } /* rgw::auth::LocalApplier */ diff --git a/src/rgw/rgw_auth.h b/src/rgw/rgw_auth.h index b29f863d030f..ef2def743754 100644 --- a/src/rgw/rgw_auth.h +++ b/src/rgw/rgw_auth.h @@ -592,6 +592,7 @@ class RemoteApplier : public IdentityApplier { const std::string access_key_id; const std::string subuser; const std::optional keystone_scope; + const std::vector keystone_roles; public: enum class acct_privilege_t { @@ -609,7 +610,8 @@ class RemoteApplier : public IdentityApplier { const std::string access_key_id, const std::string subuser, const uint32_t acct_type=TYPE_NONE, - std::optional keystone_scope=std::nullopt) + std::optional keystone_scope=std::nullopt, + std::vector keystone_roles = {}) : acct_user(acct_user), acct_name(acct_name), perm_mask(perm_mask), @@ -617,7 +619,8 @@ class RemoteApplier : public IdentityApplier { acct_type(acct_type), access_key_id(access_key_id), subuser(subuser), - keystone_scope(std::move(keystone_scope)) { + keystone_scope(std::move(keystone_scope)), + keystone_roles(std::move(keystone_roles)) { } }; diff --git a/src/rgw/rgw_auth_keystone.cc b/src/rgw/rgw_auth_keystone.cc index e0678e6fb9f5..1717b957aa2e 100644 --- a/src/rgw/rgw_auth_keystone.cc +++ b/src/rgw/rgw_auth_keystone.cc @@ -144,13 +144,14 @@ TokenEngine::get_creds_info(const TokenEngine::token_envelope_t& token ) const noexcept { using acct_privilege_t = rgw::auth::RemoteApplier::AuthInfo::acct_privilege_t; + std::vector role_names; /* Check whether the user has an admin status. */ acct_privilege_t level = acct_privilege_t::IS_PLAIN_ACCT; for (const auto& role : token.roles) { + role_names.push_back(role.name); if (role.is_admin && !role.is_reader) { level = acct_privilege_t::IS_ADMIN_ACCT; - break; } } @@ -169,8 +170,9 @@ TokenEngine::get_creds_info(const TokenEngine::token_envelope_t& token rgw::auth::RemoteApplier::AuthInfo::NO_ACCESS_KEY, rgw::auth::RemoteApplier::AuthInfo::NO_SUBUSER, TYPE_KEYSTONE, - std::move(keystone_scope) -}; + std::move(keystone_scope), + std::move(role_names) + }; } static inline const std::string @@ -673,6 +675,11 @@ EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, /* Build keystone scope info if ops logging is enabled */ auto keystone_scope = rgw::keystone::build_scope_info(cct, token); + std::vector role_names; + for (const auto& role : token.roles) { + role_names.push_back(role.name); + } + return auth_info_t { /* Suggested account name for the authenticated user. */ rgw_user(token.get_project_id()), @@ -685,7 +692,8 @@ EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, access_key_id, rgw::auth::RemoteApplier::AuthInfo::NO_SUBUSER, TYPE_KEYSTONE, - std::move(keystone_scope) + std::move(keystone_scope), + std::move(role_names) }; } diff --git a/src/test/rgw/test_rgw_iam_policy.cc b/src/test/rgw/test_rgw_iam_policy.cc index b7c9eb229d4c..be794cae8a24 100644 --- a/src/test/rgw/test_rgw_iam_policy.cc +++ b/src/test/rgw/test_rgw_iam_policy.cc @@ -1779,3 +1779,119 @@ TEST_F(ConditionTest, Null) EXPECT_TRUE(notNull.eval({{key, "admin/config.txt"}})); } } + +TEST_F(ConditionTest, KeyStoneRoleStringEquals) +{ + std::string key = "keystone:role"; + + Condition cond{TokenID::StringEquals, key.data(), key.size(), false}; + cond.vals.push_back("testrole"); + + // No roles + EXPECT_FALSE(cond.eval({})); + + // Single matching role + EXPECT_TRUE(cond.eval({{key, "testrole"}})); + + // Single non-matching role + EXPECT_FALSE(cond.eval({{key, "member"}})); + + //Multiple roles in env,one matches + Environment multi_env; + multi_env.emplace(key, "member"); + multi_env.emplace(key, "testrole"); + multi_env.emplace(key, "reader"); + EXPECT_TRUE(cond.eval(multi_env)); + + //Multiple roles in env, no match + Environment no_match_env; + no_match_env.emplace(key, "member"); + no_match_env.emplace(key, "reader"); + EXPECT_FALSE(cond.eval(no_match_env)); + + // Multiple identical roles (redundancy check) + Environment duplicate_env; + duplicate_env.emplace(key, "testrole"); + duplicate_env.emplace(key, "testrole"); + EXPECT_TRUE(cond.eval(duplicate_env)); +} + +TEST_F(ConditionTest, KeyStoneRoleNotStringEquals) +{ + std::string key = "keystone:role"; + + Condition cond{TokenID::StringNotEquals, key.data(), key.size(), false}; + cond.vals.push_back("admin"); + + // No roles + EXPECT_FALSE(cond.eval({})); + + // Role matches + EXPECT_FALSE(cond.eval({{key, "admin"}})); + + // Role doesn't match + EXPECT_TRUE(cond.eval({{key, "member"}})); + + // Multiple roles in env, one matches -> false + Environment multi_env; + multi_env.emplace(key, "member"); + multi_env.emplace(key, "admin"); + EXPECT_FALSE(multi_env.count(key) == 0); + EXPECT_FALSE(cond.eval(multi_env)); + + // Multiple roles, none match -> true + Environment no_match_env; + no_match_env.emplace(key, "member"); + no_match_env.emplace(key, "reader"); + EXPECT_TRUE(cond.eval(no_match_env)); +} + +TEST_F(ConditionTest, KeyStoneRolePolicyParsing) +{ + string keystone_role_policy = R"( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect" : "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": [ + "arn:aws:s3:::example_bucket/*" + ], + "Condition": { + "StringEquals": { + "keystone:role": "testrole" + } + } + }] + } + )"; + + string tenant = "arbitrary_tenant"; + boost::optional p; + + ASSERT_NO_THROW( + p = Policy(cct.get(), &tenant, keystone_role_policy, true)); + ASSERT_TRUE(p); + EXPECT_EQ(p->statements.size(), 1U); + EXPECT_EQ(p->statements[0].conditions.size(), 1U); + EXPECT_EQ(p->statements[0].conditions[0].key, "keystone:role"); + EXPECT_EQ(p->statements[0].conditions[0].vals.size(), 1U); + EXPECT_EQ(p->statements[0].conditions[0].vals[0], "testrole"); + + // Eval with matching role in environment + Environment match_env; + match_env.emplace("keystone:role", "testrole"); + EXPECT_TRUE(p->statements[0].conditions[0].eval(match_env)); + + // Eval with non-matching role + Environment nomatch_env; + nomatch_env.emplace("keystone:role", "member"); + EXPECT_FALSE(p->statements[0].conditions[0].eval(nomatch_env)); + + // Eval with multiple roles, one matching + Environment multi_env; + multi_env.emplace("keystone:role", "member"); + multi_env.emplace("keystone:role", "testrole"); + EXPECT_TRUE(p->statements[0].conditions[0].eval(multi_env)); +} \ No newline at end of file From 626b232ed2ffceb96babceaa180df9144b48fbfe Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Thu, 19 Mar 2026 10:26:32 +0100 Subject: [PATCH 2/7] rgw: Inject keystone userid into IAM policy Expose the Keystone token user UUID as condition key "keystone:userid" so IAM and bucket policies can restrict access by user (e.g. per-user buckets or user-specific allow/deny). On-behalf-of: SAP Signed-off-by: Supriti Singh (cherry picked from commit b06b7a82ed1a5c57dce8b9ce2fa0931fe0f55ff7) --- doc/radosgw/bucketpolicy.rst | 1 + doc/radosgw/keystone.rst | 15 +++++++++++---- src/rgw/rgw_auth.cc | 3 +++ src/rgw/rgw_auth.h | 8 ++++++-- src/rgw/rgw_auth_keystone.cc | 6 ++++-- src/test/rgw/test_rgw_iam_policy.cc | 11 +++++++++++ 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/doc/radosgw/bucketpolicy.rst b/doc/radosgw/bucketpolicy.rst index 6c27f1828e08..0ade1b080f1c 100644 --- a/doc/radosgw/bucketpolicy.rst +++ b/doc/radosgw/bucketpolicy.rst @@ -125,6 +125,7 @@ For all requests, condition keys we support are: Request that authenticate with Keystone also include: - keystone:role +- keystone:userid We support certain s3 condition keys for bucket and object requests. diff --git a/doc/radosgw/keystone.rst b/doc/radosgw/keystone.rst index c817cfca4f3c..21f79e490599 100644 --- a/doc/radosgw/keystone.rst +++ b/doc/radosgw/keystone.rst @@ -159,10 +159,17 @@ S3 API (with AWS-like access and secret keys), if the ``rgw s3 auth use keystone`` option is set. For details, see :doc:`s3/authentication`. -Requests authenticated via Keystone expose the Keystone role names in the -IAM policy environment as the condition key ``keystone:role``. It can be -used in bucket policies and idenitity policies to allow or deny access by -role (e.g. ``StringEquals`` on ``keystone:role``). +Requests authenticated via Keystone (Swift tokens or S3 with Keystone-managed +credentials) expose the Keystone idenitity in the IAM policy environment +as the condition keys: ``keystone:role`` (role names) and +``keystone:userid`` (user UUID). These conditions can be used in bucket +policies and idenitity policies with ``StringEquals``, ``StringNotEquals`` etc. + +- **keystone:role** - Allow or deny by *role* (e.g only users with role + ``reader`` get read access). Use for RBAC. +- **keystone:userid** - Restrict to specific *user*. Use when policy + depends on a specific user, not just their role. + See :doc:`bucketpolicy` for list of supported condition keys. Service token support diff --git a/src/rgw/rgw_auth.cc b/src/rgw/rgw_auth.cc index a40f94cd796b..ab7cc143a030 100644 --- a/src/rgw/rgw_auth.cc +++ b/src/rgw/rgw_auth.cc @@ -1042,6 +1042,9 @@ void rgw::auth::RemoteApplier::modify_request_state(const DoutPrefixProvider* dp s->env.emplace("keystone:role", std::move(role)); } + if (!info.keystone_user_id.empty()) { + s->env.emplace("keystone:userid", info.keystone_user_id); + } } /* rgw::auth::LocalApplier */ diff --git a/src/rgw/rgw_auth.h b/src/rgw/rgw_auth.h index ef2def743754..10009c5d8f6e 100644 --- a/src/rgw/rgw_auth.h +++ b/src/rgw/rgw_auth.h @@ -593,6 +593,7 @@ class RemoteApplier : public IdentityApplier { const std::string subuser; const std::optional keystone_scope; const std::vector keystone_roles; + const std::string keystone_user_id; public: enum class acct_privilege_t { @@ -611,7 +612,9 @@ class RemoteApplier : public IdentityApplier { const std::string subuser, const uint32_t acct_type=TYPE_NONE, std::optional keystone_scope=std::nullopt, - std::vector keystone_roles = {}) + std::vector keystone_roles = {}, + const std::string keystone_user_id = {} + ) : acct_user(acct_user), acct_name(acct_name), perm_mask(perm_mask), @@ -620,7 +623,8 @@ class RemoteApplier : public IdentityApplier { access_key_id(access_key_id), subuser(subuser), keystone_scope(std::move(keystone_scope)), - keystone_roles(std::move(keystone_roles)) { + keystone_roles(std::move(keystone_roles)), + keystone_user_id(keystone_user_id) { } }; diff --git a/src/rgw/rgw_auth_keystone.cc b/src/rgw/rgw_auth_keystone.cc index 1717b957aa2e..2185efd81082 100644 --- a/src/rgw/rgw_auth_keystone.cc +++ b/src/rgw/rgw_auth_keystone.cc @@ -171,7 +171,8 @@ TokenEngine::get_creds_info(const TokenEngine::token_envelope_t& token rgw::auth::RemoteApplier::AuthInfo::NO_SUBUSER, TYPE_KEYSTONE, std::move(keystone_scope), - std::move(role_names) + std::move(role_names), + token.get_user_id() }; } @@ -693,7 +694,8 @@ EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, rgw::auth::RemoteApplier::AuthInfo::NO_SUBUSER, TYPE_KEYSTONE, std::move(keystone_scope), - std::move(role_names) + std::move(role_names), + token.get_user_id() }; } diff --git a/src/test/rgw/test_rgw_iam_policy.cc b/src/test/rgw/test_rgw_iam_policy.cc index be794cae8a24..ec6228df7c47 100644 --- a/src/test/rgw/test_rgw_iam_policy.cc +++ b/src/test/rgw/test_rgw_iam_policy.cc @@ -1894,4 +1894,15 @@ TEST_F(ConditionTest, KeyStoneRolePolicyParsing) multi_env.emplace("keystone:role", "member"); multi_env.emplace("keystone:role", "testrole"); EXPECT_TRUE(p->statements[0].conditions[0].eval(multi_env)); +} + +TEST_F(ConditionTest, KeystoneUserIdStringEquals) +{ + const std::string key = "keystone:userid"; + Condition cond{TokenID::StringEquals, key.data(), key.size(), false}; + cond.vals.push_back("user-123"); + + EXPECT_FALSE(cond.eval({})); + EXPECT_TRUE(cond.eval({{key, "user-123"}})); + EXPECT_FALSE(cond.eval({{key, "user-456"}})); } \ No newline at end of file From 467672104f18ea7f5beb611d06846456e9caf9cb Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Fri, 23 Oct 2026 10:23:05 +0200 Subject: [PATCH 3/7] backport: rgw keystone roles and userid in IAM policy Signed-off-by: Supriti Singh --- .../01a16411-8df8-7e1f-baaf-8aa40a09ecdd.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 release-management/backports/01a16411-8df8-7e1f-baaf-8aa40a09ecdd.md diff --git a/release-management/backports/01a16411-8df8-7e1f-baaf-8aa40a09ecdd.md b/release-management/backports/01a16411-8df8-7e1f-baaf-8aa40a09ecdd.md new file mode 100644 index 000000000000..2b36cd87152c --- /dev/null +++ b/release-management/backports/01a16411-8df8-7e1f-baaf-8aa40a09ecdd.md @@ -0,0 +1,80 @@ +--- +id: 01a16411-8df8-7e1f-baaf-8aa40a09ecdd +title: 'IAM / Bucket Policy: Keystone role and user ID support' +component: rgw +category: feature +provenance: + type: upstream_pr + upstream_prs: + - https://github.com/ceph/ceph/pull/67752 + upstream_pr_state: merged-main + upstream_commits: + - b06b7a82ed1a5c57dce8b9ce2fa0931fe0f55ff7 + previous_backport: 01a0cdb3-3f84-74ab-9403-c7638a039697 +justification: + type: customer + refs: + - cobaltcore-dev/cloud-storage#566 + - cobaltcore-dev/cloud-storage#565 + - https://github.com/cobaltcore-dev/cloud-storage/issues/380 + - https://github.com/cobaltcore-dev/cloud-storage/issues/372 +risk: + blast: cosmetic + conflict: trivial + coverage: strong +--- + +## Justification (public) + +Allows Keystone role names and user IDs to be injected as `keystone:role` and +`keystone:userid` condition keys in S3 bucket policies, enabling role-based and +per-user access control via standard IAM policy conditions. + +## Justification (internal) + +Improves keystone authorization features. + +## Risk notes + +**blast: cosmetic** — purely additive. Two new condition keys are populated for +Keystone-authenticated requests. No existing condition key, policy or auth path +changes meaning, and non-Keystone requests are untouched. + +**conflict: trivial** + +**coverage: strong** — +47/47 unit tests pass in `unittest_rgw_iam_policy`. +14/14 live checks pass against vstart RGW and a real Keystone, with two +multi-role users in one project: `keystone:role` matching (single value, +multi-value, shared role, absent role), case handling (`StringEquals` is +case-sensitive, `StringEqualsIgnoreCase` is not), `keystone:userid` matching per +user, `StringLike` wildcards, and three `StringNotEquals` cases that confirm the +AND semantics with multiple roles. +7/7 live grant checks pass with non-owner users from a second project: `Allow` +on `keystone:role` or `keystone:userid` grants exactly the listed read, list, +write and delete actions, users without the role or ID get nothing, and `Deny` +overrides `Allow`. + +## Behavior change + +Requests authenticated via Keystone now expose two additional IAM policy +condition keys: `keystone:role` (one entry per role, lowercased) and +`keystone:userid` (the Keystone user UUID). Requests not authenticated via +Keystone are unchanged, and no existing policy changes behaviour. + +### Two points for policy authors + +Role names in policies are case-sensitive. Roles are lowercased on the way into +the policy environment, so how the role is spelled in Keystone does not matter, +but the value typed in the policy is compared exactly. A policy containing +`"TestRole"` never matches and gives no warning. Write role names in lower case, +or use `StringEqualsIgnoreCase`. + +Rules that distinguish between users of the same project must be written as +`Deny`. All users in an OpenStack project share a single RGW account, so each is +an owner of buckets created there, and owners are not restricted by `Allow` +conditions. `Deny` statements do apply to owners and discriminate correctly. + +## Upgrade notes + +None. From d43576fe06bc64662ba2036a7ac3b845ba837663 Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Fri, 31 Jul 2026 11:11:00 +0000 Subject: [PATCH 4/7] rgw/keystone: add project-scoped reader role The existing rgw_keystone_accepted_reader_roles is system-scoped: it grants read access across all Keystone projects and only takes effect when the role is also listed in rgw_keystone_accepted_admin_roles. This commit adds rgw_keystone_accepted_project_reader_roles for a project-scoped reader, mirroring Swift's project_reader_roles. When the listed role is the only granting role on a token, the identity is capped to read-only within its Keystone project: perm_mask is set to RGW_PERM_READ | RGW_PERM_READ_ACP, read object data and inspect bucket configuration (ACLs, policy, versioning, location,..). Writes are denied. This cap is a floor, not a hard ceiling: a bucket or IAM policy naming the user (keystone:userid / keystone:role) can still grant specific writes. CreateBucket and Swift account-metadata writes, which bypass the per-bucket ACL check, are refused up front for a capped Keystone identity. Like an admin role, a project-reader role admits the user on its own; it need not also appear in rgw_keystone_accepted_roles. The most permissive accepted role wins, so a user that also holds a plain accepted role (e.g. member) or admin receives full control. Every Keystone user in a project maps to the same RGW owner: the project account (acct_user = project id), so a reader reads and lists every bucket the project owns through the normal owner-scoped paths, and cannot reach another project (a different owner). This scoping is by ownership and holds regardless of rgw_keystone_implicit_tenants, which only controls the bucket-name namespace (per-project vs. global); implicit_tenants=true is recommended. Because the reader is itself the project owner, owner-scoped grant paths (bucket mdsearch, SNS topics) go through a new verify_owner_permission() helper so ownership alone does not lift the cap. STS AssumeRole is a separate case: it would swap in a role identity that carries no perm_mask, so it is refused outright for a capped identity. Also rename is_reader to is_system_reader and add is_project_reader to tell the two role types apart. Add unit tests. Fixes: https://tracker.ceph.com/issues/81098 On-behalf-of: SAP Signed-off-by: Supriti Singh (cherry picked from commit 87252a5deac6450dafc5d2e279aa0671509d1c1a) --- PendingReleaseNotes | 100 ++++++ doc/radosgw/keystone.rst | 43 +++ doc/radosgw/s3/authentication.rst | 20 ++ src/common/options/rgw.yaml.in | 22 +- src/rgw/rgw_auth_keystone.cc | 53 +++- src/rgw/rgw_common.cc | 27 ++ src/rgw/rgw_common.h | 20 ++ src/rgw/rgw_iam_policy.h | 10 + src/rgw/rgw_keystone.cc | 50 ++- src/rgw/rgw_keystone.h | 19 +- src/rgw/rgw_op.cc | 9 +- src/rgw/rgw_rest_pubsub.cc | 8 + src/rgw/rgw_rest_sts.cc | 10 + src/test/rgw/CMakeLists.txt | 4 + src/test/rgw/test_rgw_auth_keystone.cc | 6 +- src/test/rgw/test_rgw_keystone.cc | 411 +++++++++++++++++++++++++ 16 files changed, 782 insertions(+), 30 deletions(-) create mode 100644 src/test/rgw/test_rgw_keystone.cc diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 92cb4298c2ef..236e958b5c7f 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -1,3 +1,103 @@ +>=22.0.0 + +* RBD: The Python ``Image.list_lockers()`` method now returns a dictionary + containing ``tag``, ``exclusive``, and ``lockers`` fields even when no clients + hold the lock, instead of an empty list. Callers should check the + ``lockers`` field rather than the truth value of the returned object + to determine whether any clients hold the lock. + +* RGW: The default value of ``rgw_rest_conn_connect_to_resolved_ips`` changed + from ``false`` to ``true``. +* RGW: The default value of ``rgw_rest_conn_ip_fail_timeout_secs`` changed + from ``2`` to ``30`` seconds. + +* RGW: Fixed a bug in same-account evaluation of bucket policy statements that + allow the account principal. Users and roles in that account now require a + corresponding allow from identity policy to match AWS behavior. The evaluation + of bucket policy against user or role principals is unchanged and still does + not require identity policy. +* RGW: The STS AssumeRole and AssumeRoleWithWebIdentity actions now consult + identity policy according to the AWS same-account and cross-account policy + evaluation rules. They had previously only consulted the role's trust policy. + This means that account users will now require an Allow from identity policy + to assume roles in other accounts. + +* ``src/script/ceph-backport.sh`` now runs on macOS in addition to Linux: it + re-execs under GNU bash >= 4 when the system bash is too old, and honors a + ``$GETOPT`` environment override so GNU getopt can be selected + (``brew install bash gnu-getopt``). +* CephFS: Added a multi-tiered scoring matrix to the MDS Monitor to dynamically + prioritize standby promotion during failover events. The `mds_join_fs` configuration + variable is now checked in combination with infrastructure topology rules to enforce + node anti-affinity constraints. An isolated, generic standby daemon (Score 5) will + now be chosen over a preferred standby daemon targeting that file system if the + preferred standby resides on the same physical host as the failed active rank (Score 3). + This safely mitigates localized hardware failure boundaries during rapid automatic + recovery cycles. This behavior can be disabled per file system using the command + `ceph fs set standby_enable_host_anti_affinity false` (default: true) + to restore legacy strict 'mds_join_fs' prioritization. + +* CephFS: New MDS configuration options for non-blocking dirfrag prefetch have + been added. ``mds_dir_prefetch_backend``, when enabled with + ``mds_dir_prefetch=false``, serves dentry requests via a fast targeted keyed + OSD fetch while lazily launching a full dirfrag prefetch in the background. + The background prefetch is triggered only after + ``mds_dir_prefetch_backend_hit_threshold`` (default 16) accesses to the same + dirfrag, avoiding wasted full-omap reads for directories that are only hit + a few times in random workloads. ``mds_dir_prefetch_backend_max`` (default 1) + limits the number of concurrent background fetches. This reduces latency in + large directories while retaining cache warmth for hot directories. + +* CephFS: The ``client_force_lazyio`` configuration option is now correctly marked + as not supporting runtime updates. Previously, the configuration schema indicated + this option could be changed at runtime, but changes had no effect on opened file + handlers in ceph-fuse or libcephfs clients because there is no logic to propagate + changes to open file handles. + +* RADOS: PG autoscaler allows overlapping roots. Each root receives a PG target based + on its OSDs, with OSDs shared across multiple roots contributing proportionally + less to each root's allocation. + +* CephFS: CephFS snapshots metadata is now mutable. It is now possible to add, + update and remove existing key-value pairs that are part of a snapshot + metadata via libcephfs API ceph_do_snap_md_op(). + +* CephFS: MDS dmclock support for subvolume QoS. With this feature, MDS assigns QoS + to throttle client metadata requests (e.g., create, mkdir, lookup, and so on) to + subvolumes, where each subvolume QoS is shared among multiple client sessions at + that time. +* MON: Each monitor now reports four new perf counters covering its data + directory: ``data_disk_total_bytes``, ``data_disk_avail_bytes``, + ``data_disk_avail_percent``, and ``db_total_bytes`` (estimated on-disk size + of the key/value store). They surface automatically as ``ceph_mon_*`` + Prometheus metrics via the ``prometheus`` mgr module and ``ceph-exporter``, + enabling trending of mon DB growth and filesystem free space. + +* librados/neorados: The C++ APIs for executing Ceph Class (CLS) methods + have undergone a breaking change to enforce compile-time type safety, replacing + legacy string-based parameters with strongly-typed ClsMethod structs. C++ + developers must update their method calls to use these new structs, which ensure + read/write semantics are correctly applied. + +* RGW: S3 ListObjects and ListObjectVersions now support the + ``x-amz-optional-object-attributes: RestoreStatus`` request header to include + restore status in listing responses. Restore status is stored in the bucket + index, so only objects written or restored after this upgrade will populate + the field. Existing objects are unaffected. +* RGW: New S3 Control APIs to apply PublicAccessBlock configuration to User Accounts. +* RGW: PutBucketEncryption and GetBucketEncryption now support the + ``BlockedEncryptionTypes`` element. Blocking ``SSE-C`` on a bucket rejects new + SSE-C uploads with ``403 AccessDenied`` while leaving existing SSE-C objects + readable. Newly created buckets are unchanged by default. Enable the new + ``rgw_s3_block_sse_c_by_default`` option to block SSE-C on them instead, + matching the AWS behavior for new buckets. +* RGW: New config option ``rgw_keystone_accepted_project_reader_roles``. + A Keystone user whose only access-granting role is one of the configured project-reader + roles is capped to read-only access within its project, mirroring the + ``project_reader_roles`` behavior of Swift's keystoneauth middleware. A bucket + policy naming the user can still grant additional access. The reader role + admits the user on its own; it need not also be in + ``rgw_keystone_accepted_roles``. * ceph-volume: Raw BlueStore OSD preparation now pre-formats NVMe devices and skips the slower BlueStore discard phase,reducing mkfs time on very large namespaces. diff --git a/doc/radosgw/keystone.rst b/doc/radosgw/keystone.rst index 21f79e490599..d679a0b93345 100644 --- a/doc/radosgw/keystone.rst +++ b/doc/radosgw/keystone.rst @@ -54,6 +54,49 @@ only use implicit tenants, and the other protocol will never use implicit tenants. Some older versions of ceph only supported implicit tenants with swift. +Project-scoped reader role +-------------------------- + +By default any Keystone user accepted by the gateway is granted full control +over its project's object storage. ``rgw keystone accepted project reader +roles`` designates one or more Keystone roles as *project-scoped readers* +(the Swift ``project_reader_roles`` equivalent):: + + rgw keystone accepted roles = member + rgw keystone accepted project reader roles = objectstore_viewer + +When the reader role is the only access-granting role a user holds, the user +is capped to read-only within its Keystone project: it may read objects and +inspect bucket configuration (ACLs, policy, versioning, location, ...), but +not write. Every user of a project maps to the same RGW account (the project +itself), so a reader lists and reads every bucket the project owns through the +normal owner-scoped paths, and cannot reach another project (a different +owner). This scoping is by ownership and holds regardless of ``rgw keystone +implicit tenants``, which only controls the bucket-name namespace: with it +off, a reader reaching another project's bucket gets ``403`` (the bucket is +in the shared namespace, visible but not owned); with it on, it gets ``404`` +(the bucket is in a separate namespace and not visible). Either way the +reader stays confined to its own project. + +Note the following: + +- **A project reader role admits the user on its own.** It need not also + appear in ``rgw keystone accepted roles`` (mirroring Swift's + ``project_reader_roles``): a user whose only granting role is a project + reader role is admitted and capped to read-only. + +- **The cap is a floor, not a hard ceiling.** A bucket policy that names the + user (via a ``keystone:userid`` / ``keystone:role`` condition or a principal + ARN) can still grant specific actions such as writes on specific buckets; + an explicit policy ``Deny`` can likewise remove access. Only bucket creation + and Swift account-metadata writes are refused up front regardless of policy. + +- **A role that grants more than read overrides the cap.** If the same user + also holds ``admin`` or any other role in ``rgw keystone accepted roles`` + (for example ``member``), that role wins and the user receives full control. + To keep a user read-only, assign it *only* the reader role. Roles that are + in no accepted list at all are ignored and do not affect the cap. + Ocata (and later) ----------------- diff --git a/doc/radosgw/s3/authentication.rst b/doc/radosgw/s3/authentication.rst index 115d0bbf5ee0..e5bd8132f08e 100644 --- a/doc/radosgw/s3/authentication.rst +++ b/doc/radosgw/s3/authentication.rst @@ -216,6 +216,26 @@ access the same resources when things like Swift user ACLs are in play. This is one of the many reasons that you should use S3 bucket policies rather than S3 ACLs when possible. +The SNS topic actions are mapped the same way, so that the Keystone +project-reader role (see :ref:`radosgw-keystone`) can inspect topics but +not modify them: + ++---------------------------------------+---------------+ +| Operation | Permission | ++=======================================+===============+ +| ``sns:GetTopicAttributes`` | ``READ`` | ++---------------------------------------+---------------+ +| ``sns:ListTopics`` | ``READ`` | ++---------------------------------------+---------------+ +| ``sns:CreateTopic`` | ``WRITE`` | ++---------------------------------------+---------------+ +| ``sns:SetTopicAttributes`` | ``WRITE`` | ++---------------------------------------+---------------+ +| ``sns:DeleteTopic`` | ``WRITE`` | ++---------------------------------------+---------------+ +| ``sns:Publish`` | ``WRITE`` | ++---------------------------------------+---------------+ + .. _Authenticating Requests (AWS Signature Version 4): https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html .. _Authenticating requests (AWS signature version 2): https://docs.aws.amazon.com/AmazonS3/latest/userguide/auth-request-sig-v2.html diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index d5c1983e1210..ab419a147c88 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -879,7 +879,27 @@ options: - name: rgw_keystone_accepted_reader_roles type: str level: advanced - desc: List of roles that can only be used for reads (Keystone). + desc: Keystone roles granting system-scoped read access. + long_desc: >- + Keystone roles granting system-scoped read access (Swift + system_reader_roles equivalent). The role must also be listed in + rgw_keystone_accepted_admin_roles. + services: + - rgw + with_legacy: true +- name: rgw_keystone_accepted_project_reader_roles + type: str + level: advanced + desc: Keystone roles granting project-scoped read-only access. + long_desc: >- + Keystone roles granting project-scoped read-only access (Swift + project_reader_roles equivalent). A role here admits the user on its own + and caps it to read-only. This is a floor, not a hard cap: a bucket + policy naming the user can grant more. All members of a project map to + the same RGW account (the project), so the reader sees every bucket that + account owns; this scoping is by ownership and holds regardless of + rgw_keystone_implicit_tenants, which only controls the bucket-name + namespace. services: - rgw with_legacy: true diff --git a/src/rgw/rgw_auth_keystone.cc b/src/rgw/rgw_auth_keystone.cc index 2185efd81082..01536fd60511 100644 --- a/src/rgw/rgw_auth_keystone.cc +++ b/src/rgw/rgw_auth_keystone.cc @@ -150,11 +150,15 @@ TokenEngine::get_creds_info(const TokenEngine::token_envelope_t& token acct_privilege_t level = acct_privilege_t::IS_PLAIN_ACCT; for (const auto& role : token.roles) { role_names.push_back(role.name); - if (role.is_admin && !role.is_reader) { + if (role.is_admin && !role.is_system_reader) { level = acct_privilege_t::IS_ADMIN_ACCT; } } + /* Role-tier cap: read-only when every granting role is a + * project_reader, full control otherwise. */ + const uint32_t perm_mask = token.effective_perm_mask(); + /* Build keystone scope info if ops logging is enabled */ auto keystone_scope = rgw::keystone::build_scope_info(cct, token); @@ -163,9 +167,9 @@ TokenEngine::get_creds_info(const TokenEngine::token_envelope_t& token rgw_user(token.get_project_id()), /* User's display name (aka real name). */ token.get_project_name(), - /* Keystone doesn't support RGW's subuser concept, so we cannot cut down - * the access rights through the perm_mask. At least at this layer. */ - RGW_PERM_FULL_CONTROL, + /* Keystone doesn't support RGW's subuser concept, so perm_mask is FULL_CONTROL for + * regular users. The project_reader cap above is the sole exception. */ + perm_mask, level, rgw::auth::RemoteApplier::AuthInfo::NO_ACCESS_KEY, rgw::auth::RemoteApplier::AuthInfo::NO_SUBUSER, @@ -215,7 +219,7 @@ make_keystone_acl_strategy(const std::string& tenant_id, } for (const auto& r : token_roles) { - if (r.is_reader && r.is_admin) { + if (r.is_system_reader && r.is_admin) { /* System scope reader defeats project-level permissions. */ perm |= RGW_OP_TYPE_READ; } @@ -257,15 +261,17 @@ TokenEngine::authenticate(const DoutPrefixProvider* dpp, explicit RolesCacher(CephContext* const cct) { get_str_vec(cct->_conf->rgw_keystone_accepted_roles, plain); get_str_vec(cct->_conf->rgw_keystone_accepted_admin_roles, admin); - get_str_vec(cct->_conf->rgw_keystone_accepted_reader_roles, reader); + get_str_vec(cct->_conf->rgw_keystone_accepted_reader_roles, system_reader); + get_str_vec(cct->_conf->rgw_keystone_accepted_project_reader_roles, project_reader); - /* Let's suppose that having an admin role implies also a regular one. */ plain.insert(std::end(plain), std::begin(admin), std::end(admin)); + plain.insert(std::end(plain), std::begin(project_reader), std::end(project_reader)); } std::vector plain; std::vector admin; - std::vector reader; + std::vector system_reader; + std::vector project_reader; } roles(cct); static const struct ServiceTokenRolesCacher { @@ -367,7 +373,8 @@ TokenEngine::authenticate(const DoutPrefixProvider* dpp, if (! t) { return result_t::deny(-EACCES); } - t->update_roles(roles.admin, roles.reader); + t->update_roles(roles.plain, roles.admin, roles.system_reader, + roles.project_reader); /* Verify expiration. */ if (t->expired()) { @@ -673,6 +680,10 @@ EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, } } + /* Role-tier cap: read-only when every granting role is a + * project_reader, full control otherwise. */ + const uint32_t perm_mask = token.effective_perm_mask(); + /* Build keystone scope info if ops logging is enabled */ auto keystone_scope = rgw::keystone::build_scope_info(cct, token); @@ -686,9 +697,9 @@ EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, rgw_user(token.get_project_id()), /* User's display name (aka real name). */ token.get_project_name(), - /* Keystone doesn't support RGW's subuser concept, so we cannot cut down - * the access rights through the perm_mask. At least at this layer. */ - RGW_PERM_FULL_CONTROL, + /* Keystone doesn't support RGW's subuser concept,perm_mask is FULL_CONTROL for + * regular users. The project_reader cap above is the sole exception. */ + perm_mask, level, access_key_id, rgw::auth::RemoteApplier::AuthInfo::NO_SUBUSER, @@ -716,13 +727,22 @@ rgw::auth::Engine::result_t EC2Engine::authenticate( explicit RolesCacher(CephContext* const cct) { get_str_vec(cct->_conf->rgw_keystone_accepted_roles, plain); get_str_vec(cct->_conf->rgw_keystone_accepted_admin_roles, admin); - - /* Let's suppose that having an admin role implies also a regular one. */ + get_str_vec(cct->_conf->rgw_keystone_accepted_reader_roles, system_reader); + get_str_vec(cct->_conf->rgw_keystone_accepted_project_reader_roles, project_reader); + + /* Admit the project-reader tier on its own, the same way an admin role + * is admitted. A user whose only role is a project reader is still + * accepted, and its role flag caps it to read-only. system_reader is + * not merged here: it grants cross-cluster read and also needs an + * admin role. */ plain.insert(std::end(plain), std::begin(admin), std::end(admin)); + plain.insert(std::end(plain), std::begin(project_reader), std::end(project_reader)); } std::vector plain; std::vector admin; + std::vector system_reader; + std::vector project_reader; } accepted_roles(cct); /* When we handle a HTTP OPTIONS call we must ignore the signature */ @@ -769,6 +789,11 @@ rgw::auth::Engine::result_t EC2Engine::authenticate( << ":" << t->get_user_name() << " expires: " << t->get_expires() << dendl; + t->update_roles(accepted_roles.plain, + accepted_roles.admin, + accepted_roles.system_reader, + accepted_roles.project_reader); + auto apl = apl_factory->create_apl_remote(cct, s, get_acl_strategy(*t), get_creds_info(*t, accepted_roles.admin, std::string(access_key_id))); return result_t::grant(std::move(apl), completer_factory(secret_key)); diff --git a/src/rgw/rgw_common.cc b/src/rgw/rgw_common.cc index 4599d45f3426..522c9b96f0d1 100644 --- a/src/rgw/rgw_common.cc +++ b/src/rgw/rgw_common.cc @@ -1291,6 +1291,24 @@ bool verify_user_permission(const DoutPrefixProvider* dpp, return verify_user_permission_no_policy(dpp, s, user_acl, perm); } +bool is_capped_keystone_identity(uint32_t perm_mask, + const rgw::auth::Identity& identity) +{ + return perm_mask != RGW_PERM_FULL_CONTROL && + identity.get_identity_type() == TYPE_KEYSTONE; +} + +bool verify_owner_permission(const rgw::auth::Identity& identity, + uint32_t perm_mask, + const rgw_owner& owner, uint32_t perm) +{ + if (!identity.is_owner_of(owner)) + return false; + if (is_capped_keystone_identity(perm_mask, identity)) + return (perm & perm_mask) == perm; + return true; +} + bool verify_user_permission_no_policy(const DoutPrefixProvider* dpp, struct perm_state_base * const s, const RGWAccessControlPolicy& user_acl, @@ -1299,6 +1317,15 @@ bool verify_user_permission_no_policy(const DoutPrefixProvider* dpp, if (s->identity->get_identity_type() == TYPE_ROLE) return false; + /* A capped Keystone identity must not pass the account-scoped + * default-allows below (CreateBucket, Swift account metadata, + * bulk-upload container creation). Enforce the mask before the empty-ACL + * shortcut. A bucket policy naming the user is evaluated earlier in + * verify_user_permission() and is unaffected. */ + if (is_capped_keystone_identity(s->perm_mask, *s->identity) && + (perm & (int)s->perm_mask) != perm) + return false; + /* S3 doesn't support account ACLs, so user_acl will be uninitialized. */ if (user_acl.get_owner().empty()) return true; diff --git a/src/rgw/rgw_common.h b/src/rgw/rgw_common.h index 1f3e537b003d..d07675554597 100644 --- a/src/rgw/rgw_common.h +++ b/src/rgw/rgw_common.h @@ -1766,6 +1766,26 @@ struct perm_state : public perm_state_base { } }; +/* A Keystone identity whose role tier caps its implicit permissions + * (currently the project reader). The identity-type check matters: Swift + * read-only subusers of local users also carry a reduced perm_mask but + * must not be treated as capped Keystone identities. */ +bool is_capped_keystone_identity(uint32_t perm_mask, + const rgw::auth::Identity& identity); + +/* Can this owner perform this operation? + * + * Returns true when the identity owns the resource and its permission mask + * allows the requested permission. A normal owner has full control, so + * anything passes. A capped identity (a Keystone reader or implicit-deny + * role) is limited to what its mask allows: reads pass, writes do not. + * + * This is the one place an owner grant checks the cap. A policy that allows + * the user runs earlier and can still grant more. */ +bool verify_owner_permission(const rgw::auth::Identity& identity, + uint32_t perm_mask, + const rgw_owner& owner, uint32_t perm); + /** Check if the req_state's user has the necessary permissions * to do the requested action */ bool verify_bucket_permission_no_policy( diff --git a/src/rgw/rgw_iam_policy.h b/src/rgw/rgw_iam_policy.h index 12307348e4b1..cea1b3e2432d 100644 --- a/src/rgw/rgw_iam_policy.h +++ b/src/rgw/rgw_iam_policy.h @@ -327,6 +327,16 @@ inline int op_to_perm(std::uint64_t op) { case s3PutBucketPublicAccessBlock: return RGW_PERM_WRITE_ACP; + case snsGetTopicAttributes: + case snsListTopics: + return RGW_PERM_READ; + + case snsCreateTopic: + case snsSetTopicAttributes: + case snsDeleteTopic: + case snsPublish: + return RGW_PERM_WRITE; + case s3All: return RGW_PERM_FULL_CONTROL; } diff --git a/src/rgw/rgw_keystone.cc b/src/rgw/rgw_keystone.cc index 669a409df740..670fba0a96bc 100644 --- a/src/rgw/rgw_keystone.cc +++ b/src/rgw/rgw_keystone.cc @@ -311,25 +311,67 @@ int TokenEnvelope::parse(const DoutPrefixProvider *dpp, * Maybe one day we'll have the parser find this in Keystone replies. * But for now, we use the confguration to augment the list of roles. */ -void TokenEnvelope::update_roles(const std::vector & admin, - const std::vector & reader) +void TokenEnvelope::update_roles(const std::vector & plain, + const std::vector & admin, + const std::vector & system_reader, + const std::vector & project_reader) { for (auto& iter: roles) { + for (const auto& r : plain) { + if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { + iter.is_accepted = true; + break; + } + } for (const auto& r : admin) { if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { iter.is_admin = true; break; } } - for (const auto& r : reader) { + for (const auto& r : system_reader) { + if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { + iter.is_system_reader = true; + break; + } + } + for (const auto& r : project_reader) { if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_reader = true; + iter.is_project_reader = true; break; } } } } +/* perm_mask granted by this single role: + * admin or any other accepted role (e.g. member) -> full control + * project_reader -> read-only (data + config) + * role in no accepted list -> nothing + */ +uint32_t TokenEnvelope::Role::perm_mask() const +{ + if (is_admin) return RGW_PERM_FULL_CONTROL; + if (!is_accepted) return RGW_PERM_NONE; + if (is_project_reader) return RGW_PERM_READ | RGW_PERM_READ_ACP; + return RGW_PERM_FULL_CONTROL; /* accepted, no tier -> full control */ +} + +/* Effective perm_mask for the token: the union of every role's grant, so the + * most-permissive accepted role wins. Defaults to + * RGW_PERM_NONE: a token carrying no accepted role gets no + * implicit access. In practice the engines reject such a token before this + * is called (TokenEngine/EC2Engine::authenticate()); the NONE default is + * defense in depth, never an implicit full-control grant. */ +uint32_t TokenEnvelope::effective_perm_mask() const +{ + uint32_t mask = RGW_PERM_NONE; + for (const auto& role : roles) { + mask |= role.perm_mask(); + } + return mask; +} + bool TokenCache::find(const std::string& token_id, rgw::keystone::TokenEnvelope& token) { diff --git a/src/rgw/rgw_keystone.h b/src/rgw/rgw_keystone.h index 37c30d5d4401..3286ecba65e3 100644 --- a/src/rgw/rgw_keystone.h +++ b/src/rgw/rgw_keystone.h @@ -153,18 +153,24 @@ class TokenEnvelope { class Role { public: - Role() : is_admin(false), is_reader(false) { } + Role() : is_admin(false), is_system_reader(false), is_project_reader(false), + is_accepted(false) { } Role(const Role &r) { id = r.id; name = r.name; is_admin = r.is_admin; - is_reader = r.is_reader; + is_system_reader = r.is_system_reader; + is_project_reader = r.is_project_reader; + is_accepted = r.is_accepted; } std::string id; std::string name; bool is_admin; - bool is_reader; + bool is_system_reader; + bool is_project_reader; + bool is_accepted; void decode_json(JSONObj *obj); + uint32_t perm_mask() const; }; class User { @@ -205,6 +211,7 @@ class TokenEnvelope { const std::string& get_user_id() const {return user.id;}; const std::string& get_user_name() const {return user.name;}; bool has_role(const std::string& r) const; + uint32_t effective_perm_mask() const; bool expired() const { const uint64_t now = ceph_clock_now().sec(); return std::cmp_greater_equal(now, get_expires()); @@ -212,8 +219,10 @@ class TokenEnvelope { int parse(const DoutPrefixProvider *dpp, const std::string& token_str, ceph::buffer::list& bl /* in */); - void update_roles(const std::vector & admin, - const std::vector & reader); + void update_roles(const std::vector & plain, + const std::vector & admin, + const std::vector & system_reader, + const std::vector & project_reader); }; diff --git a/src/rgw/rgw_op.cc b/src/rgw/rgw_op.cc index 211ef6f286db..6a836b8c9424 100644 --- a/src/rgw/rgw_op.cc +++ b/src/rgw/rgw_op.cc @@ -8818,7 +8818,8 @@ void RGWGetObjLayout::execute(optional_yield y) int RGWConfigBucketMetaSearch::verify_permission(optional_yield y) { - if (!s->auth.identity->is_owner_of(s->bucket_owner.id)) { + if (!verify_owner_permission(*s->auth.identity, s->perm_mask, + s->bucket_owner.id, RGW_PERM_WRITE)) { return -EACCES; } @@ -8851,7 +8852,8 @@ void RGWConfigBucketMetaSearch::execute(optional_yield y) int RGWGetBucketMetaSearch::verify_permission(optional_yield y) { - if (!s->auth.identity->is_owner_of(s->bucket_owner.id)) { + if (!verify_owner_permission(*s->auth.identity, s->perm_mask, + s->bucket_owner.id, RGW_PERM_READ)) { return -EACCES; } @@ -8865,7 +8867,8 @@ void RGWGetBucketMetaSearch::pre_exec() int RGWDelBucketMetaSearch::verify_permission(optional_yield y) { - if (!s->auth.identity->is_owner_of(s->bucket_owner.id)) { + if (!verify_owner_permission(*s->auth.identity, s->perm_mask, + s->bucket_owner.id, RGW_PERM_WRITE)) { return -EACCES; } diff --git a/src/rgw/rgw_rest_pubsub.cc b/src/rgw/rgw_rest_pubsub.cc index d8e3db4843c7..4f6c9fd054eb 100644 --- a/src/rgw/rgw_rest_pubsub.cc +++ b/src/rgw/rgw_rest_pubsub.cc @@ -207,6 +207,14 @@ bool verify_topic_permission(const DoutPrefixProvider* dpp, req_state* s, return true; } + // A capped identity (e.g. a project reader) may reach a topic only within + // its perm_mask (reads) or via an explicit policy Allow (handled above): + // the ownership and publish/legacy default-allows below never lift the cap. + if (is_capped_keystone_identity(s->perm_mask, *s->auth.identity)) { + return verify_owner_permission(*s->auth.identity, s->perm_mask, owner, + rgw::IAM::op_to_perm(op)); + } + if (s->auth.identity->is_owner_of(owner)) { ldpp_dout(dpp, 10) << __func__ << ": granted to resource owner" << dendl; return true; diff --git a/src/rgw/rgw_rest_sts.cc b/src/rgw/rgw_rest_sts.cc index 862c4b81a2a1..bf6842087828 100644 --- a/src/rgw/rgw_rest_sts.cc +++ b/src/rgw/rgw_rest_sts.cc @@ -829,6 +829,16 @@ WebTokenEngine::authenticate( const DoutPrefixProvider* dpp, int RGWREST_STS::verify_permission(optional_yield y) { + // A capped Keystone identity (e.g. a project reader) must not assume a + // role. AssumeRole does not scope access, it replaces the identity with a + // role identity (TYPE_ROLE) that has its own permissions and no perm_mask, + // so it would remove the cap entirely. Refuse up front; the role's trust + // policy, evaluated below, can never re-grant what the cap forbids. + if (s->auth.identity && is_capped_keystone_identity(s->perm_mask, *s->auth.identity)) { + ldpp_dout(this, 4) << "read-only Keystone identity may not assume a role" + << dendl; + return -EPERM; + } STS::STSService _sts(s->cct, driver, s->user->get_id(), s->auth.identity.get()); sts = std::move(_sts); diff --git a/src/test/rgw/CMakeLists.txt b/src/test/rgw/CMakeLists.txt index c53e80410ef0..7de0a2c19d88 100644 --- a/src/test/rgw/CMakeLists.txt +++ b/src/test/rgw/CMakeLists.txt @@ -252,6 +252,10 @@ target_link_libraries(unittest_rgw_iam_policy ${CRYPTO_LIBS} ) +add_executable(unittest_rgw_keystone test_rgw_keystone.cc) +add_ceph_unittest(unittest_rgw_keystone) +target_link_libraries(unittest_rgw_keystone ${rgw_libs}) + add_executable(unittest_rgw_string test_rgw_string.cc) add_ceph_unittest(unittest_rgw_string) target_include_directories(unittest_rgw_string diff --git a/src/test/rgw/test_rgw_auth_keystone.cc b/src/test/rgw/test_rgw_auth_keystone.cc index fe4fb214b43b..0f093a6d866c 100644 --- a/src/test/rgw/test_rgw_auth_keystone.cc +++ b/src/test/rgw/test_rgw_auth_keystone.cc @@ -130,7 +130,7 @@ TEST(KeystoneAclStrategy, SystemReaderAdminGrantsRead) { rgw::keystone::TokenEnvelope::Role role; role.name = "system-reader"; - role.is_reader = true; + role.is_system_reader = true; role.is_admin = true; std::list roles{role}; @@ -145,7 +145,7 @@ TEST(KeystoneAclStrategy, ReaderWithoutAdminDoesNotGrant) { rgw::keystone::TokenEnvelope::Role role; role.name = "reader"; - role.is_reader = true; + role.is_system_reader = true; role.is_admin = false; std::list roles{role}; @@ -160,7 +160,7 @@ TEST(KeystoneAclStrategy, SystemReaderAddsToAclMatchedPerm) { rgw::keystone::TokenEnvelope::Role role; role.name = "system-reader"; - role.is_reader = true; + role.is_system_reader = true; role.is_admin = true; std::list roles{role}; diff --git a/src/test/rgw/test_rgw_keystone.cc b/src/test/rgw/test_rgw_keystone.cc new file mode 100644 index 000000000000..88982ffcb3a3 --- /dev/null +++ b/src/test/rgw/test_rgw_keystone.cc @@ -0,0 +1,411 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab + +/* + * Ceph - scalable distributed file system + * + * Copyright contributors to the Ceph project + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * + */ + +#include +#include +#include + +#include +#include + +#include + +#include "common/ceph_context.h" +#include "common/ceph_json.h" +#include "common/dout.h" +#include "rgw_acl_types.h" +#include "rgw_auth.h" +#include "rgw_common.h" +#include "rgw_iam_policy.h" +#include "rgw_keystone.h" + +using rgw::auth::Identity; +using rgw::auth::Principal; + +// --------------------------------------------------------------------- +// Part 1: from roles to permission mask. +// +// These tests check how the roles on a Keystone token become a +// permission mask. update_roles() puts a flag on each role that is in +// one of the config lists. effective_perm_mask() then looks at the +// flags: a normal user gets full control; a user whose only accepted +// role is the reader role gets read-only. A role that is in no list +// must have no effect. +// --------------------------------------------------------------------- + +// Make a test token with the given role names, then flag the roles +// with fixed config lists, like the auth engines do. +static rgw::keystone::TokenEnvelope make_keystone_token( + std::initializer_list role_names) +{ + rgw::keystone::TokenEnvelope t; + for (const auto& name : role_names) { + rgw::keystone::TokenEnvelope::Role r; + r.name = name; + t.roles.push_back(std::move(r)); + } + // same lists the auth engines read from the config; an admin role + // also counts as accepted + t.update_roles({"member", "objectstore_viewer", "admin"}, // plain + {"admin"}, // admin + {}, // system_reader + {"objectstore_viewer"}); // project_reader + return t; +} + +TEST(KeystoneProjectReader, UpdateRolesFlags) +{ + auto t = make_keystone_token({"objectstore_viewer", "member", "admin", + "unlisted_role"}); + for (const auto& r : t.roles) { + if (r.name == "objectstore_viewer") { + EXPECT_TRUE(r.is_project_reader); + EXPECT_TRUE(r.is_accepted); + EXPECT_FALSE(r.is_admin); + } else if (r.name == "member") { + EXPECT_TRUE(r.is_accepted); + EXPECT_FALSE(r.is_project_reader); + EXPECT_FALSE(r.is_admin); + } else if (r.name == "admin") { + EXPECT_TRUE(r.is_admin); + EXPECT_TRUE(r.is_accepted); + EXPECT_FALSE(r.is_project_reader); + } else if (r.name == "unlisted_role") { + // this role is in no list, so all flags must stay off + EXPECT_FALSE(r.is_accepted); + EXPECT_FALSE(r.is_admin); + EXPECT_FALSE(r.is_project_reader); + EXPECT_FALSE(r.is_system_reader); + } + } +} + +TEST(KeystoneProjectReader, ReaderOnly) +{ + // only the reader role: the user is read-only (data + config reads) + EXPECT_EQ(RGW_PERM_READ | RGW_PERM_READ_ACP, + make_keystone_token({"objectstore_viewer"}).effective_perm_mask()); +} + +TEST(KeystoneProjectReader, UnrelatedRoleIgnored) +{ + // an extra unknown role must not turn off the read-only cap + EXPECT_EQ(RGW_PERM_READ | RGW_PERM_READ_ACP, + make_keystone_token({"objectstore_viewer", "unlisted_role"}) + .effective_perm_mask()); +} + +TEST(KeystoneProjectReader, MemberWins) +{ + // member is a normal accepted role: it gives full control, so the + // reader cap does not apply + EXPECT_EQ(RGW_PERM_FULL_CONTROL, + make_keystone_token({"objectstore_viewer", "member"}) + .effective_perm_mask()); +} + +TEST(KeystoneProjectReader, AdminWins) +{ + EXPECT_EQ(RGW_PERM_FULL_CONTROL, + make_keystone_token({"objectstore_viewer", "admin"}) + .effective_perm_mask()); +} + +TEST(KeystoneProjectReader, NoReaderRole) +{ + // a plain accepted role (member) grants full control + EXPECT_EQ(RGW_PERM_FULL_CONTROL, + make_keystone_token({"member"}).effective_perm_mask()); + // No accepted role (or no roles at all) -> zero permissions. We fail + // closed: deny by default, never full control. In a real request a user + // with no accepted role is already rejected at login before this runs, so + // this is just a safety net. + EXPECT_EQ(RGW_PERM_NONE, + make_keystone_token({"unlisted_role"}).effective_perm_mask()); + EXPECT_EQ(RGW_PERM_NONE, + make_keystone_token({}).effective_perm_mask()); +} + +// --------------------------------------------------------------------- +// Part 2: enforcement of the mask. +// +// Part 1 proved which mask a token gets. The tests below prove what +// that mask does during a request, using the real +// verify_bucket_permission(): the bucket policy is checked first and +// can override the mask; without a policy match the mask limits what +// the ACL can give. +// --------------------------------------------------------------------- + +// A fake user identity, like the one the Keystone auth code builds for +// a capped user: the type is Keystone, the perm_mask is fixed, and it +// is not an admin and not an owner. The Identity interface requires +// every method below. Only three of them matter for the tests: +// get_perm_mask() -> the cap we are testing +// get_identity_type() -> Keystone by default; overridable so a +// non-Keystone identity can be tested too +// get_perms_from_aclspec -> what a bucket ACL gives this user +class CappedKeystoneIdentity : public Identity { + const std::string id; + const uint32_t perm_mask; + const uint32_t itype; + const bool owns; +public: + CappedKeystoneIdentity(std::string id, uint32_t perm_mask, + uint32_t itype = TYPE_KEYSTONE, bool owns = false) + : id(std::move(id)), perm_mask(perm_mask), itype(itype), owns(owns) {} + + ACLOwner get_aclowner() const override { return {}; } + uint32_t get_perms_from_aclspec(const DoutPrefixProvider*, + const aclspec_t& aclspec) const override { + const auto iter = aclspec.find(id); + return (iter != aclspec.end()) ? iter->second : 0; + } + bool is_admin() const override { return false; } + bool is_owner_of(const rgw_owner&) const override { return owns; } + bool is_root() const override { return false; } + uint32_t get_perm_mask() const override { return perm_mask; } + void to_str(std::ostream& out) const override { out << id; } + bool is_identity(const Principal& p) const override { + return p.is_wildcard(); + } + uint32_t get_identity_type() const override { return itype; } + std::string get_acct_name() const override { return {}; } + std::string get_subuser() const override { return {}; } + const std::string& get_tenant() const override { + static const std::string no_tenant; + return no_tenant; + } + const std::optional& get_account() const override { + static const std::optional no_account; + return no_account; + } +}; + +// Shared setup for the tests below: a CephContext and a helper that +// runs one PutObject permission check through the real +// verify_bucket_permission(). Each test picks its own inputs. +class KeystoneCapEnforcement : public ::testing::Test { +protected: + static constexpr const char* USERID = "6d2c1863b0a94379b4f5ab5d78546f45"; + + boost::intrusive_ptr cct; + const std::string arbitrary_tenant; + RGWBucketInfo bucket_info; // requester_pays is false by default + + KeystoneCapEnforcement() { + cct.reset(new CephContext(CEPH_ENTITY_TYPE_CLIENT), false); + } + + // bucket policy: allow s3:PutObject on testbucket/* only for USERID + rgw::IAM::Policy allow_put_for_userid() { + static const std::string text = R"({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::testbucket/*", + "Condition": {"StringEquals": {"keystone:userid": ")" + + std::string(USERID) + R"("}} + }] + })"; + return rgw::IAM::Policy(cct.get(), &arbitrary_tenant, text, true); + } + + // Check if PutObject on testbucket/obj is allowed with the given + // mask, request environment, bucket policy and bucket ACL. + // perm_state holds the request data the permission code reads; in + // the real server it is filled in after authentication. + bool put_allowed(uint32_t perm_mask, const rgw::IAM::Environment& env, + const boost::optional& bucket_policy, + const RGWAccessControlPolicy& bucket_acl = {}) { + CappedKeystoneIdentity identity(USERID, perm_mask); + perm_state ps(cct.get(), env, &identity, bucket_info, + perm_mask, false /* defer_to_bucket_acls */, + nullptr /* referer */, false /* request_payer */); + const NoDoutPrefix ndp(cct.get(), ceph_subsys_rgw); + rgw_bucket b; + b.name = "testbucket"; + return verify_bucket_permission(&ndp, &ps, rgw::ARN(b, "obj"), false, + {} /* user_acl */, bucket_acl, + bucket_policy, {}, {}, + rgw::IAM::s3PutObject); + } +}; + +TEST_F(KeystoneCapEnforcement, MaskDeniesWriteWithoutPolicy) +{ + const rgw::IAM::Environment env = {{"keystone:userid", USERID}}; + // no policy: the read-only mask blocks the write, and a zero mask + // blocks it too + EXPECT_FALSE(put_allowed(RGW_PERM_READ, env, boost::none)); + EXPECT_FALSE(put_allowed(RGW_PERM_NONE, env, boost::none)); +} + +TEST_F(KeystoneCapEnforcement, PolicyAllowOverridesMask) +{ + const rgw::IAM::Environment env = {{"keystone:userid", USERID}}; + // the policy allows this user, so the write works even though the + // mask is read-only: the policy is checked before the mask + EXPECT_TRUE(put_allowed(RGW_PERM_READ, env, allow_put_for_userid())); + EXPECT_TRUE(put_allowed(RGW_PERM_NONE, env, allow_put_for_userid())); +} + +TEST_F(KeystoneCapEnforcement, PolicyGrantIsScopedToNamedUser) +{ + // a different user id: the policy does not match, so the mask blocks + // the write as usual + const rgw::IAM::Environment env = {{"keystone:userid", "someone-else"}}; + EXPECT_FALSE(put_allowed(RGW_PERM_READ, env, allow_put_for_userid())); + EXPECT_FALSE(put_allowed(RGW_PERM_NONE, env, allow_put_for_userid())); +} + +TEST_F(KeystoneCapEnforcement, AclGrantCannotExceedMask) +{ + const rgw::IAM::Environment env = {{"keystone:userid", USERID}}; + // an ACL that grants full control must not beat the read-only mask + RGWAccessControlPolicy bucket_acl; + ACLGrant grant; + grant.set_canon(rgw_user(USERID), "display", RGW_PERM_FULL_CONTROL); + bucket_acl.get_acl().add_grant(grant); + EXPECT_FALSE(put_allowed(RGW_PERM_READ, env, boost::none, bucket_acl)); + EXPECT_FALSE(put_allowed(RGW_PERM_NONE, env, boost::none, bucket_acl)); + // sanity check: with a full mask the same ACL does allow the write + EXPECT_TRUE(put_allowed(RGW_PERM_FULL_CONTROL, env, boost::none, + bucket_acl)); +} + +// The account-scoped default-allow path: verify_user_permission_no_policy() +// returns true for an empty user ACL (the "you own the account" shortcut that +// CreateBucket and Swift account-metadata writes rely on). The +// is_capped_keystone_identity() gate must block a capped Keystone identity +// there, and must NOT touch a non-Keystone identity. +TEST_F(KeystoneCapEnforcement, GateBlocksAccountScopedWriteForCappedKeystone) +{ + CappedKeystoneIdentity reader(USERID, RGW_PERM_READ, TYPE_KEYSTONE); + const rgw::IAM::Environment env; + perm_state ps(cct.get(), env, &reader, bucket_info, + RGW_PERM_READ, false, nullptr, false); + const NoDoutPrefix ndp(cct.get(), ceph_subsys_rgw); + const RGWAccessControlPolicy empty_user_acl; + // a write the read-only mask lacks is denied even with an empty user ACL + EXPECT_FALSE(verify_user_permission_no_policy(&ndp, &ps, empty_user_acl, + RGW_PERM_WRITE)); +} + +TEST_F(KeystoneCapEnforcement, NonKeystoneReducedMaskNotCapped) +{ + // Non-regression: the cap is scoped to Keystone identities. A non-Keystone + // identity with a reduced mask (e.g. a Swift read-only subuser of a local + // user) must still pass the account-scoped shortcut -- this is what the + // TYPE_KEYSTONE check in is_capped_keystone_identity() protects. + CappedKeystoneIdentity local_subuser(USERID, RGW_PERM_READ, TYPE_RGW); + const rgw::IAM::Environment env; + perm_state ps(cct.get(), env, &local_subuser, bucket_info, + RGW_PERM_READ, false, nullptr, false); + const NoDoutPrefix ndp(cct.get(), ceph_subsys_rgw); + const RGWAccessControlPolicy empty_user_acl; + EXPECT_TRUE(verify_user_permission_no_policy(&ndp, &ps, empty_user_acl, + RGW_PERM_WRITE)); +} + +// --------------------------------------------------------------------- +// Part 3: the shared owner-grant primitive, verify_owner_permission(). +// +// A capped identity owns its project's resources, so ownership alone must +// not let it perform a write while read-class operations still pass. This +// is the single place an owner-based grant consults the cap; the SNS topic +// path (verify_topic_permission) and bucket mdsearch both funnel through +// it, so these tests cover the SNS Set/Delete/CreateTopic-overwrite bypass +// and the mdsearch owner gates at once. +// --------------------------------------------------------------------- + +// op_to_perm() must classify the SNS actions that reach the owner grant, +// otherwise they would fall through to RGW_PERM_INVALID and the primitive +// could not tell a topic read from a topic write. +TEST(KeystoneOwnerCap, SnsOpsClassified) +{ + using namespace rgw::IAM; + EXPECT_EQ(RGW_PERM_READ, op_to_perm(snsGetTopicAttributes)); + EXPECT_EQ(RGW_PERM_READ, op_to_perm(snsListTopics)); + EXPECT_EQ(RGW_PERM_WRITE, op_to_perm(snsCreateTopic)); + EXPECT_EQ(RGW_PERM_WRITE, op_to_perm(snsSetTopicAttributes)); + EXPECT_EQ(RGW_PERM_WRITE, op_to_perm(snsDeleteTopic)); + EXPECT_EQ(RGW_PERM_WRITE, op_to_perm(snsPublish)); +} + +TEST(KeystoneOwnerCap, ReaderOwnerReadAllowedWriteDenied) +{ + using namespace rgw::IAM; + const rgw_owner owner; + const uint32_t mask = RGW_PERM_READ | RGW_PERM_READ_ACP; + // a project reader (Keystone, read-only mask) that owns the resource + CappedKeystoneIdentity reader("u", mask, TYPE_KEYSTONE, true /* owns */); + + // reads through ownership pass ... + EXPECT_TRUE(verify_owner_permission(reader, mask, owner, + op_to_perm(snsGetTopicAttributes))); + // ... writes do not: this is the SNS owner-fallback bypass being fixed + EXPECT_FALSE(verify_owner_permission(reader, mask, owner, + op_to_perm(snsSetTopicAttributes))); + EXPECT_FALSE(verify_owner_permission(reader, mask, owner, + op_to_perm(snsDeleteTopic))); + EXPECT_FALSE(verify_owner_permission(reader, mask, owner, + op_to_perm(snsCreateTopic))); + // Publish is a write too: a read-only owner may not publish through the + // topic owner fallback (verify_topic_permission gates the publish + // default-allow on the cap). + EXPECT_FALSE(verify_owner_permission(reader, mask, owner, + op_to_perm(snsPublish))); +} + +TEST(KeystoneOwnerCap, NonOwnerDenied) +{ + const rgw_owner owner; + const uint32_t mask = RGW_PERM_READ | RGW_PERM_READ_ACP; + // the same reader, but it does not own the resource: denied even a read + CappedKeystoneIdentity reader("u", mask, TYPE_KEYSTONE, false /* owns */); + EXPECT_FALSE(verify_owner_permission(reader, mask, owner, RGW_PERM_READ)); +} + +TEST(KeystoneOwnerCap, FullControlOwnerWriteAllowed) +{ + const rgw_owner owner; + // a normal (uncapped) owner: full control, so the write passes + CappedKeystoneIdentity user("u", RGW_PERM_FULL_CONTROL, TYPE_KEYSTONE, + true /* owns */); + EXPECT_TRUE(verify_owner_permission(user, RGW_PERM_FULL_CONTROL, owner, + RGW_PERM_WRITE)); +} + +TEST(KeystoneOwnerCap, ImplicitDenyOwnerGetsNothing) +{ + const rgw_owner owner; + // implicit-deny mask (0): even a read is refused through ownership alone + CappedKeystoneIdentity authed("u", RGW_PERM_NONE, TYPE_KEYSTONE, + true /* owns */); + EXPECT_FALSE(verify_owner_permission(authed, RGW_PERM_NONE, owner, + RGW_PERM_READ)); +} + +TEST(KeystoneOwnerCap, NonKeystoneReducedMaskOwnerNotCapped) +{ + const rgw_owner owner; + // non-regression: the cap is scoped to Keystone identities, so a + // non-Keystone owner with a reduced mask still grants by ownership + CappedKeystoneIdentity local("u", RGW_PERM_READ, TYPE_RGW, true /* owns */); + EXPECT_TRUE(verify_owner_permission(local, RGW_PERM_READ, owner, + RGW_PERM_WRITE)); +} From 4f87c7b4cde630ff2d1207ad92fa389e475ef439 Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Fri, 31 Jul 2026 11:11:31 +0000 Subject: [PATCH 5/7] rgw/keystone: add implicit-deny role Building on the project-scoped reader, add rgw_keystone_implicit_deny_roles: a Keystone role that authenticates a user but grants no implicit permissions. When such a role is the only granting role on a token, effective_perm_mask() returns RGW_PERM_NONE, so the user is authenticated but denied every operation by default (implicit deny). Access must then come from an explicit bucket or IAM policy naming the user (keystone:userid / keystone:role), and applies only to the buckets and objects the policy names. Like the reader, an implicit-deny role admits the user on its own (it need not also be in rgw_keystone_accepted_roles), and the most permissive accepted role still wins: admin, a project-reader, or another accepted role such as member takes precedence over the implicit-deny cap. Add the is_implicit_deny role flag and the config list, extend effective_perm_mask, and add unit tests. Fixes: https://tracker.ceph.com/issues/81098 On-behalf-of: SAP Signed-off-by: Supriti Singh (cherry picked from commit ba0c6ade977334f9b5cd01d5dc56354c9ad43507) --- PendingReleaseNotes | 6 +++ doc/radosgw/keystone.rst | 39 +++++++++++++++++++ src/common/options/rgw.yaml.in | 13 +++++++ src/rgw/rgw_auth_keystone.cc | 27 ++++++++----- src/rgw/rgw_common.h | 6 +-- src/rgw/rgw_keystone.cc | 11 +++++- src/rgw/rgw_keystone.h | 7 +++- src/test/rgw/test_rgw_keystone.cc | 63 +++++++++++++++++++++++++++---- 8 files changed, 150 insertions(+), 22 deletions(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 236e958b5c7f..26bac57a7992 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -98,6 +98,12 @@ policy naming the user can still grant additional access. The reader role admits the user on its own; it need not also be in ``rgw_keystone_accepted_roles``. +* RGW: New config option ``rgw_keystone_implicit_deny_roles``. + A Keystone user whose only access-granting role is one of the configured + implicit-deny roles is authenticated but granted no implicit permissions. + Access must be granted explicitly through a bucket policy or IAM policy, and + applies only to the buckets and objects the policy names. The role admits the + user on its own. * ceph-volume: Raw BlueStore OSD preparation now pre-formats NVMe devices and skips the slower BlueStore discard phase,reducing mkfs time on very large namespaces. diff --git a/doc/radosgw/keystone.rst b/doc/radosgw/keystone.rst index d679a0b93345..6a092ad993b5 100644 --- a/doc/radosgw/keystone.rst +++ b/doc/radosgw/keystone.rst @@ -97,6 +97,45 @@ Note the following: To keep a user read-only, assign it *only* the reader role. Roles that are in no accepted list at all are ignored and do not affect the cap. +Implicit-deny roles +------------------- + +``rgw keystone implicit deny roles`` designates Keystone roles +that authenticate a user without granting any implicit permissions:: + + rgw keystone accepted roles = member + rgw keystone implicit deny roles = objectstore_authenticated + +A user whose only access-granting role is such a role is denied every +operation by default. Access must be granted explicitly through a bucket +policy or IAM policy (for example with a ``keystone:userid`` or +``keystone:role`` condition), and an explicit ``Allow`` then applies only to +the buckets and objects the policy names. Unlike a project reader, a user +with only an implicit-deny role cannot list the buckets of its project; +bucket creation and Swift account-metadata writes are always refused. + +The project-reader notes above apply here as well: the role admits the user +on its own (no co-listing in ``rgw keystone accepted roles`` needed), an +explicit policy ``Deny`` always wins, and any role granting more (``admin``, +a project reader role, or another accepted role such as ``member``) takes +precedence over the implicit-deny cap. + +.. note:: These tiers cap what a request may do at authorization time. They + apply to the default Keystone mapping, where each project maps to a plain + tenant-scoped gateway user reaching the gateway over S3 or Swift. They do + **not** apply in the following cases: + + - **The librgw frontend (NFS-Ganesha).** It authorizes every request with + full control, so a gateway reached over that path is not capped. Assign + these roles only where clients use S3 or Swift. + - **Keystone users associated with an** :doc:`RGW account `. + Access for an account is governed entirely by IAM policy (evaluated + before the permission mask), so the tiers place no cap there. Use them + with the default (non-account) mapping. + - **The** ``radosgw-admin`` **command and the admin REST API.** These are + backend administration paths that do not authenticate a Keystone user, + so they are not subject to these roles at all. + Ocata (and later) ----------------- diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index ab419a147c88..15af1cf7b170 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -903,6 +903,19 @@ options: services: - rgw with_legacy: true +- name: rgw_keystone_implicit_deny_roles + type: str + level: advanced + desc: Keystone roles granting no implicit permissions. + long_desc: >- + Keystone roles that authenticate a user without granting any implicit + permissions. A role here admits the user on its own; a user whose only + access-granting role is such a role is authenticated but denied every + operation by default, so access must be granted explicitly through a + bucket policy or IAM policy. + services: + - rgw + with_legacy: true - name: rgw_keystone_token_cache_size type: int level: advanced diff --git a/src/rgw/rgw_auth_keystone.cc b/src/rgw/rgw_auth_keystone.cc index 01536fd60511..dc419c8321dc 100644 --- a/src/rgw/rgw_auth_keystone.cc +++ b/src/rgw/rgw_auth_keystone.cc @@ -156,7 +156,8 @@ TokenEngine::get_creds_info(const TokenEngine::token_envelope_t& token } /* Role-tier cap: read-only when every granting role is a - * project_reader, full control otherwise. */ + * project_reader, no implicit permissions at all when every granting + * role is implicit_deny, full control otherwise. */ const uint32_t perm_mask = token.effective_perm_mask(); /* Build keystone scope info if ops logging is enabled */ @@ -263,15 +264,18 @@ TokenEngine::authenticate(const DoutPrefixProvider* dpp, get_str_vec(cct->_conf->rgw_keystone_accepted_admin_roles, admin); get_str_vec(cct->_conf->rgw_keystone_accepted_reader_roles, system_reader); get_str_vec(cct->_conf->rgw_keystone_accepted_project_reader_roles, project_reader); + get_str_vec(cct->_conf->rgw_keystone_implicit_deny_roles, implicit_deny); plain.insert(std::end(plain), std::begin(admin), std::end(admin)); plain.insert(std::end(plain), std::begin(project_reader), std::end(project_reader)); + plain.insert(std::end(plain), std::begin(implicit_deny), std::end(implicit_deny)); } std::vector plain; std::vector admin; std::vector system_reader; std::vector project_reader; + std::vector implicit_deny; } roles(cct); static const struct ServiceTokenRolesCacher { @@ -374,7 +378,7 @@ TokenEngine::authenticate(const DoutPrefixProvider* dpp, return result_t::deny(-EACCES); } t->update_roles(roles.plain, roles.admin, roles.system_reader, - roles.project_reader); + roles.project_reader, roles.implicit_deny); /* Verify expiration. */ if (t->expired()) { @@ -681,7 +685,8 @@ EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, } /* Role-tier cap: read-only when every granting role is a - * project_reader, full control otherwise. */ + * project_reader, no implicit permissions at all when every granting + * role is implicit_deny, full control otherwise. */ const uint32_t perm_mask = token.effective_perm_mask(); /* Build keystone scope info if ops logging is enabled */ @@ -729,20 +734,23 @@ rgw::auth::Engine::result_t EC2Engine::authenticate( get_str_vec(cct->_conf->rgw_keystone_accepted_admin_roles, admin); get_str_vec(cct->_conf->rgw_keystone_accepted_reader_roles, system_reader); get_str_vec(cct->_conf->rgw_keystone_accepted_project_reader_roles, project_reader); + get_str_vec(cct->_conf->rgw_keystone_implicit_deny_roles, implicit_deny); - /* Admit the project-reader tier on its own, the same way an admin role - * is admitted. A user whose only role is a project reader is still - * accepted, and its role flag caps it to read-only. system_reader is - * not merged here: it grants cross-cluster read and also needs an - * admin role. */ + /* Admit the project-reader and implicit-deny tiers on their own, the + * same way an admin role is admitted. A user whose only role is one of + * these is still accepted, and its role flag caps what it may do. + * system_reader is not merged here: it grants cross-cluster read and + * also needs an admin role. */ plain.insert(std::end(plain), std::begin(admin), std::end(admin)); plain.insert(std::end(plain), std::begin(project_reader), std::end(project_reader)); + plain.insert(std::end(plain), std::begin(implicit_deny), std::end(implicit_deny)); } std::vector plain; std::vector admin; std::vector system_reader; std::vector project_reader; + std::vector implicit_deny; } accepted_roles(cct); /* When we handle a HTTP OPTIONS call we must ignore the signature */ @@ -792,7 +800,8 @@ rgw::auth::Engine::result_t EC2Engine::authenticate( t->update_roles(accepted_roles.plain, accepted_roles.admin, accepted_roles.system_reader, - accepted_roles.project_reader); + accepted_roles.project_reader, + accepted_roles.implicit_deny); auto apl = apl_factory->create_apl_remote(cct, s, get_acl_strategy(*t), get_creds_info(*t, accepted_roles.admin, std::string(access_key_id))); diff --git a/src/rgw/rgw_common.h b/src/rgw/rgw_common.h index d07675554597..38cb790f0679 100644 --- a/src/rgw/rgw_common.h +++ b/src/rgw/rgw_common.h @@ -1767,9 +1767,9 @@ struct perm_state : public perm_state_base { }; /* A Keystone identity whose role tier caps its implicit permissions - * (currently the project reader). The identity-type check matters: Swift - * read-only subusers of local users also carry a reduced perm_mask but - * must not be treated as capped Keystone identities. */ + * (a project reader or an implicit-deny role). The identity-type check + * matters: Swift read-only subusers of local users also carry a reduced + * perm_mask but must not be treated as capped Keystone identities. */ bool is_capped_keystone_identity(uint32_t perm_mask, const rgw::auth::Identity& identity); diff --git a/src/rgw/rgw_keystone.cc b/src/rgw/rgw_keystone.cc index 670fba0a96bc..a65d41e46866 100644 --- a/src/rgw/rgw_keystone.cc +++ b/src/rgw/rgw_keystone.cc @@ -314,7 +314,8 @@ int TokenEnvelope::parse(const DoutPrefixProvider *dpp, void TokenEnvelope::update_roles(const std::vector & plain, const std::vector & admin, const std::vector & system_reader, - const std::vector & project_reader) + const std::vector & project_reader, + const std::vector & implicit_deny) { for (auto& iter: roles) { for (const auto& r : plain) { @@ -341,12 +342,19 @@ void TokenEnvelope::update_roles(const std::vector & plain, break; } } + for (const auto& r : implicit_deny) { + if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { + iter.is_implicit_deny = true; + break; + } + } } } /* perm_mask granted by this single role: * admin or any other accepted role (e.g. member) -> full control * project_reader -> read-only (data + config) + * implicit_deny -> nothing on its own * role in no accepted list -> nothing */ uint32_t TokenEnvelope::Role::perm_mask() const @@ -354,6 +362,7 @@ uint32_t TokenEnvelope::Role::perm_mask() const if (is_admin) return RGW_PERM_FULL_CONTROL; if (!is_accepted) return RGW_PERM_NONE; if (is_project_reader) return RGW_PERM_READ | RGW_PERM_READ_ACP; + if (is_implicit_deny) return RGW_PERM_NONE; return RGW_PERM_FULL_CONTROL; /* accepted, no tier -> full control */ } diff --git a/src/rgw/rgw_keystone.h b/src/rgw/rgw_keystone.h index 3286ecba65e3..2ade60adf54f 100644 --- a/src/rgw/rgw_keystone.h +++ b/src/rgw/rgw_keystone.h @@ -154,13 +154,14 @@ class TokenEnvelope { class Role { public: Role() : is_admin(false), is_system_reader(false), is_project_reader(false), - is_accepted(false) { } + is_implicit_deny(false), is_accepted(false) { } Role(const Role &r) { id = r.id; name = r.name; is_admin = r.is_admin; is_system_reader = r.is_system_reader; is_project_reader = r.is_project_reader; + is_implicit_deny = r.is_implicit_deny; is_accepted = r.is_accepted; } std::string id; @@ -168,6 +169,7 @@ class TokenEnvelope { bool is_admin; bool is_system_reader; bool is_project_reader; + bool is_implicit_deny; bool is_accepted; void decode_json(JSONObj *obj); uint32_t perm_mask() const; @@ -222,7 +224,8 @@ class TokenEnvelope { void update_roles(const std::vector & plain, const std::vector & admin, const std::vector & system_reader, - const std::vector & project_reader); + const std::vector & project_reader, + const std::vector & implicit_deny); }; diff --git a/src/test/rgw/test_rgw_keystone.cc b/src/test/rgw/test_rgw_keystone.cc index 88982ffcb3a3..1b79cb090d15 100644 --- a/src/test/rgw/test_rgw_keystone.cc +++ b/src/test/rgw/test_rgw_keystone.cc @@ -41,8 +41,9 @@ using rgw::auth::Principal; // permission mask. update_roles() puts a flag on each role that is in // one of the config lists. effective_perm_mask() then looks at the // flags: a normal user gets full control; a user whose only accepted -// role is the reader role gets read-only. A role that is in no list -// must have no effect. +// role is the reader role gets read-only; a user whose only accepted +// role is an implicit-deny role gets no permissions at all +// (mask 0). A role that is in no list must have no effect. // --------------------------------------------------------------------- // Make a test token with the given role names, then flag the roles @@ -58,36 +59,47 @@ static rgw::keystone::TokenEnvelope make_keystone_token( } // same lists the auth engines read from the config; an admin role // also counts as accepted - t.update_roles({"member", "objectstore_viewer", "admin"}, // plain + t.update_roles({"member", "objectstore_viewer", "objectstore_authed", + "admin"}, // plain {"admin"}, // admin {}, // system_reader - {"objectstore_viewer"}); // project_reader + {"objectstore_viewer"}, // project_reader + {"objectstore_authed"}); // implicit_deny return t; } TEST(KeystoneProjectReader, UpdateRolesFlags) { auto t = make_keystone_token({"objectstore_viewer", "member", "admin", - "unlisted_role"}); + "objectstore_authed", "unlisted_role"}); for (const auto& r : t.roles) { if (r.name == "objectstore_viewer") { EXPECT_TRUE(r.is_project_reader); EXPECT_TRUE(r.is_accepted); EXPECT_FALSE(r.is_admin); + EXPECT_FALSE(r.is_implicit_deny); } else if (r.name == "member") { EXPECT_TRUE(r.is_accepted); EXPECT_FALSE(r.is_project_reader); EXPECT_FALSE(r.is_admin); + EXPECT_FALSE(r.is_implicit_deny); } else if (r.name == "admin") { EXPECT_TRUE(r.is_admin); EXPECT_TRUE(r.is_accepted); EXPECT_FALSE(r.is_project_reader); + EXPECT_FALSE(r.is_implicit_deny); + } else if (r.name == "objectstore_authed") { + EXPECT_TRUE(r.is_implicit_deny); + EXPECT_TRUE(r.is_accepted); + EXPECT_FALSE(r.is_project_reader); + EXPECT_FALSE(r.is_admin); } else if (r.name == "unlisted_role") { // this role is in no list, so all flags must stay off EXPECT_FALSE(r.is_accepted); EXPECT_FALSE(r.is_admin); EXPECT_FALSE(r.is_project_reader); EXPECT_FALSE(r.is_system_reader); + EXPECT_FALSE(r.is_implicit_deny); } } } @@ -138,6 +150,43 @@ TEST(KeystoneProjectReader, NoReaderRole) make_keystone_token({}).effective_perm_mask()); } +TEST(KeystoneImplicitDeny, AuthedOnly) +{ + // only the implicit-deny role: no permissions at all + EXPECT_EQ(RGW_PERM_NONE, + make_keystone_token({"objectstore_authed"}).effective_perm_mask()); +} + +TEST(KeystoneImplicitDeny, UnrelatedRoleIgnored) +{ + // an extra unknown role must not lift the cap + EXPECT_EQ(RGW_PERM_NONE, + make_keystone_token({"objectstore_authed", "unlisted_role"}) + .effective_perm_mask()); +} + +TEST(KeystoneImplicitDeny, ReaderWins) +{ + // the most permissive accepted role wins: reader beats implicit-deny + EXPECT_EQ(RGW_PERM_READ | RGW_PERM_READ_ACP, + make_keystone_token({"objectstore_authed", "objectstore_viewer"}) + .effective_perm_mask()); +} + +TEST(KeystoneImplicitDeny, MemberWins) +{ + EXPECT_EQ(RGW_PERM_FULL_CONTROL, + make_keystone_token({"objectstore_authed", "member"}) + .effective_perm_mask()); +} + +TEST(KeystoneImplicitDeny, AdminWins) +{ + EXPECT_EQ(RGW_PERM_FULL_CONTROL, + make_keystone_token({"objectstore_authed", "admin"}) + .effective_perm_mask()); +} + // --------------------------------------------------------------------- // Part 2: enforcement of the mask. // @@ -248,8 +297,8 @@ class KeystoneCapEnforcement : public ::testing::Test { TEST_F(KeystoneCapEnforcement, MaskDeniesWriteWithoutPolicy) { const rgw::IAM::Environment env = {{"keystone:userid", USERID}}; - // no policy: the read-only mask blocks the write, and a zero mask - // blocks it too + // no policy: the read-only mask blocks the write, and the + // implicit-deny mask (0) blocks it too EXPECT_FALSE(put_allowed(RGW_PERM_READ, env, boost::none)); EXPECT_FALSE(put_allowed(RGW_PERM_NONE, env, boost::none)); } From 8dd0afbc8cc2dc37f6015d54f73c252eb260663b Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Wed, 12 Aug 2026 12:43:41 +0000 Subject: [PATCH 6/7] rgw/keystone: collapse per-role permission flags into one token tier Simplify how roles map to permissions by introducing an optional permission value (perm_tier) in the TokenEnvelope. Compute it once while updating the roles, instead of keeping several per-role flags and resolving them later in Role::perm_mask() / effective_perm_mask(). update_roles() now derives the token's permission mask directly: nullopt no accepted role -> token rejected RGW_PERM_NONE implicit-deny READ|READ_ACP project-reader FULL_CONTROL plain accepted or admin The most permissive role wins. admitted() replaces the duplicate role checks in both auth engines and the admin check uses the is_admin flag; only admin and system-reader stay as per-role flags. Fixes: https://tracker.ceph.com/issues/81098 On-behalf-of: SAP Signed-off-by: Supriti Singh (cherry picked from commit dcc1de7d4d28bf825b1479cf48018c4ab847bf6a) --- src/rgw/rgw_auth_keystone.cc | 102 ++++++++++++++---------------- src/rgw/rgw_auth_keystone.h | 1 - src/rgw/rgw_keystone.cc | 78 +++++++---------------- src/rgw/rgw_keystone.h | 20 +++--- src/test/rgw/test_rgw_keystone.cc | 80 ++++++++++------------- 5 files changed, 116 insertions(+), 165 deletions(-) diff --git a/src/rgw/rgw_auth_keystone.cc b/src/rgw/rgw_auth_keystone.cc index dc419c8321dc..de67f89754ad 100644 --- a/src/rgw/rgw_auth_keystone.cc +++ b/src/rgw/rgw_auth_keystone.cc @@ -395,35 +395,36 @@ TokenEngine::authenticate(const DoutPrefixProvider* dpp, } } - /* Check for necessary roles. */ - for (const auto& role : roles.plain) { - if (t->has_role(role) == true) { - /* If this token was an allowed expired token because we got a - * service token we need to update the expiration before we cache it. */ - if (allow_expired) { - time_t now = ceph_clock_now().sec(); - time_t new_expires = now + g_conf()->rgw_keystone_expired_token_cache_expiration; - ldpp_dout(dpp, 20) << "updating expiration of allowed expired token" - << " from old " << t->get_expires() << " to now " << now << " + " - << g_conf()->rgw_keystone_expired_token_cache_expiration - << " secs = " - << new_expires << dendl; - t->set_expires(new_expires); - } - ldpp_dout(dpp, 0) << "validated token: " << t->get_project_name() - << ":" << t->get_user_name() - << " expires: " << t->get_expires() << dendl; - token_cache.add(token_id, *t); - auto apl = apl_factory->create_apl_remote(cct, s, get_acl_strategy(*t), - get_creds_info(*t)); - return result_t::grant(std::move(apl)); - } - } - ldpp_dout(dpp, 0) << "user does not hold a matching role; required roles: " - << g_conf()->rgw_keystone_accepted_roles << dendl; + /** + * Reject a token that holds no accepted role. Admission was computed in + * update_roles(). + */ + if(!t->admitted()) { + ldpp_dout(dpp, 0) << "user does not hold a matching role; required roles: " + << g_conf()->rgw_keystone_accepted_roles << dendl; + return result_t::deny(-EPERM); + } - return result_t::deny(-EPERM); + /* If this token was an allowed expired token because we got a + * service token we need to update the expiration before we cache it. */ + if (allow_expired) { + time_t now = ceph_clock_now().sec(); + time_t new_expires = now + g_conf()->rgw_keystone_expired_token_cache_expiration; + ldpp_dout(dpp, 20) << "updating expiration of allowed expired token" + << " from old " << t->get_expires() << " to now " << now << " + " + << g_conf()->rgw_keystone_expired_token_cache_expiration + << " secs = " + << new_expires << dendl; + t->set_expires(new_expires); + } + ldpp_dout(dpp, 0) << "validated token: " << t->get_project_name() + << ":" << t->get_user_name() + << " expires: " << t->get_expires() << dendl; + token_cache.add(token_id, *t); + auto apl = apl_factory->create_apl_remote(cct, s, get_acl_strategy(*t), + get_creds_info(*t)); + return result_t::grant(std::move(apl)); } @@ -668,17 +669,17 @@ EC2Engine::get_acl_strategy(const EC2Engine::token_envelope_t& token) const EC2Engine::auth_info_t EC2Engine::get_creds_info(const EC2Engine::token_envelope_t& token, - const std::vector& admin_roles, const std::string& access_key_id ) const noexcept { using acct_privilege_t = \ rgw::auth::RemoteApplier::AuthInfo::acct_privilege_t; - /* Check whether the user has an admin status. */ + /* Check whether the user has an admin status. is_admin is set by + * update_roles() from the accepted-admin-roles list. */ acct_privilege_t level = acct_privilege_t::IS_PLAIN_ACCT; - for (const auto& admin_role : admin_roles) { - if (token.has_role(admin_role)) { + for (const auto& role : token.roles) { + if (role.is_admin) { level = acct_privilege_t::IS_ADMIN_ACCT; break; } @@ -777,36 +778,29 @@ rgw::auth::Engine::result_t EC2Engine::authenticate( return result_t::deny(); } - /* check if we have a valid role */ - bool found = false; - for (const auto& role : accepted_roles.plain) { - if (t->has_role(role) == true) { - found = true; - break; - } - } + /* Classify roles first, then admit on the result rather than rescanning + * the config lists. */ + t->update_roles(accepted_roles.plain, + accepted_roles.admin, + accepted_roles.system_reader, + accepted_roles.project_reader, + accepted_roles.implicit_deny); - if (! found) { + if (! t->admitted()) { ldpp_dout(dpp, 5) << "s3 keystone: user does not hold a matching role;" " required roles: " << cct->_conf->rgw_keystone_accepted_roles << dendl; return result_t::deny(); - } else { - /* everything seems fine, continue with this user */ - ldpp_dout(dpp, 5) << "s3 keystone: validated token: " << t->get_project_name() - << ":" << t->get_user_name() - << " expires: " << t->get_expires() << dendl; + } - t->update_roles(accepted_roles.plain, - accepted_roles.admin, - accepted_roles.system_reader, - accepted_roles.project_reader, - accepted_roles.implicit_deny); + /* everything seems fine, continue with this user */ + ldpp_dout(dpp, 5) << "s3 keystone: validated token: " << t->get_project_name() + << ":" << t->get_user_name() + << " expires: " << t->get_expires() << dendl; - auto apl = apl_factory->create_apl_remote(cct, s, get_acl_strategy(*t), - get_creds_info(*t, accepted_roles.admin, std::string(access_key_id))); - return result_t::grant(std::move(apl), completer_factory(secret_key)); - } + auto apl = apl_factory->create_apl_remote(cct, s, get_acl_strategy(*t), + get_creds_info(*t, std::string(access_key_id))); + return result_t::grant(std::move(apl), completer_factory(secret_key)); } bool SecretCache::find(const std::string& token_id, diff --git a/src/rgw/rgw_auth_keystone.h b/src/rgw/rgw_auth_keystone.h index 89498c9fb00c..dcbab256c043 100644 --- a/src/rgw/rgw_auth_keystone.h +++ b/src/rgw/rgw_auth_keystone.h @@ -154,7 +154,6 @@ class EC2Engine : public rgw::auth::s3::AWSEngine { /* Helper methods. */ acl_strategy_t get_acl_strategy(const token_envelope_t& token) const; auth_info_t get_creds_info(const token_envelope_t& token, - const std::vector& admin_roles, const std::string& access_key_id ) const noexcept; std::pair, int> diff --git a/src/rgw/rgw_keystone.cc b/src/rgw/rgw_keystone.cc index a65d41e46866..74e71cd2b362 100644 --- a/src/rgw/rgw_keystone.cc +++ b/src/rgw/rgw_keystone.cc @@ -4,6 +4,8 @@ #include #include +#include + #include #include #include @@ -317,68 +319,36 @@ void TokenEnvelope::update_roles(const std::vector & plain, const std::vector & project_reader, const std::vector & implicit_deny) { - for (auto& iter: roles) { - for (const auto& r : plain) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_accepted = true; - break; - } - } - for (const auto& r : admin) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_admin = true; - break; - } - } - for (const auto& r : system_reader) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_system_reader = true; - break; - } - } - for (const auto& r : project_reader) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_project_reader = true; - break; + auto contains = [](const std::vector& list, const std::string& name) { + for (const auto& r: list) { + if (fnmatch(r.c_str(), name.c_str(), 0) == 0) { + return true; } } - for (const auto& r : implicit_deny) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_implicit_deny = true; - break; - } + return false; + }; + + // nullopt = no accepted role yet + perm_tier.reset(); + for (auto& role: roles) { + role.is_admin = contains(admin, role.name); + role.is_system_reader = contains(system_reader, role.name); + + std::optional tier; + if (role.is_admin) tier = RGW_PERM_FULL_CONTROL; + else if (contains(project_reader, role.name)) tier = RGW_PERM_READ | RGW_PERM_READ_ACP; + else if (contains(implicit_deny, role.name)) tier = RGW_PERM_NONE; + else if (contains(plain, role.name)) tier = RGW_PERM_FULL_CONTROL; + + if(tier){ + perm_tier = std::max(perm_tier.value_or(RGW_PERM_NONE), *tier); } } } -/* perm_mask granted by this single role: - * admin or any other accepted role (e.g. member) -> full control - * project_reader -> read-only (data + config) - * implicit_deny -> nothing on its own - * role in no accepted list -> nothing - */ -uint32_t TokenEnvelope::Role::perm_mask() const -{ - if (is_admin) return RGW_PERM_FULL_CONTROL; - if (!is_accepted) return RGW_PERM_NONE; - if (is_project_reader) return RGW_PERM_READ | RGW_PERM_READ_ACP; - if (is_implicit_deny) return RGW_PERM_NONE; - return RGW_PERM_FULL_CONTROL; /* accepted, no tier -> full control */ -} - -/* Effective perm_mask for the token: the union of every role's grant, so the - * most-permissive accepted role wins. Defaults to - * RGW_PERM_NONE: a token carrying no accepted role gets no - * implicit access. In practice the engines reject such a token before this - * is called (TokenEngine/EC2Engine::authenticate()); the NONE default is - * defense in depth, never an implicit full-control grant. */ uint32_t TokenEnvelope::effective_perm_mask() const { - uint32_t mask = RGW_PERM_NONE; - for (const auto& role : roles) { - mask |= role.perm_mask(); - } - return mask; + return perm_tier.value_or(RGW_PERM_NONE); } bool TokenCache::find(const std::string& token_id, diff --git a/src/rgw/rgw_keystone.h b/src/rgw/rgw_keystone.h index 2ade60adf54f..fdc3118de823 100644 --- a/src/rgw/rgw_keystone.h +++ b/src/rgw/rgw_keystone.h @@ -153,26 +153,18 @@ class TokenEnvelope { class Role { public: - Role() : is_admin(false), is_system_reader(false), is_project_reader(false), - is_implicit_deny(false), is_accepted(false) { } + Role() : is_admin(false), is_system_reader(false) { } Role(const Role &r) { id = r.id; name = r.name; is_admin = r.is_admin; is_system_reader = r.is_system_reader; - is_project_reader = r.is_project_reader; - is_implicit_deny = r.is_implicit_deny; - is_accepted = r.is_accepted; } std::string id; std::string name; bool is_admin; bool is_system_reader; - bool is_project_reader; - bool is_implicit_deny; - bool is_accepted; void decode_json(JSONObj *obj); - uint32_t perm_mask() const; }; class User { @@ -196,6 +188,15 @@ class TokenEnvelope { Project project; User user; std::list roles; + /* Permission tier for the token, computed once + * in update_roles(): the most permissive accepted + * role wins. + * - nullopt: token holds no accepted role. + * - RGW_PERM_NONE: implicit-deny + * - READ|READ-ACP: project-reader + * - FULL_CONTROL: plain or admin + */ + std::optional perm_tier; std::optional app_cred; void decode(JSONObj* obj); @@ -214,6 +215,7 @@ class TokenEnvelope { const std::string& get_user_name() const {return user.name;}; bool has_role(const std::string& r) const; uint32_t effective_perm_mask() const; + bool admitted() const { return perm_tier.has_value(); } bool expired() const { const uint64_t now = ceph_clock_now().sec(); return std::cmp_greater_equal(now, get_expires()); diff --git a/src/test/rgw/test_rgw_keystone.cc b/src/test/rgw/test_rgw_keystone.cc index 1b79cb090d15..14930738cb50 100644 --- a/src/test/rgw/test_rgw_keystone.cc +++ b/src/test/rgw/test_rgw_keystone.cc @@ -38,12 +38,14 @@ using rgw::auth::Principal; // Part 1: from roles to permission mask. // // These tests check how the roles on a Keystone token become a -// permission mask. update_roles() puts a flag on each role that is in -// one of the config lists. effective_perm_mask() then looks at the -// flags: a normal user gets full control; a user whose only accepted -// role is the reader role gets read-only; a user whose only accepted -// role is an implicit-deny role gets no permissions at all -// (mask 0). A role that is in no list must have no effect. +// permission mask. update_roles() resolves the token's roles to a single +// permission tier (TokenEnvelope::perm_tier): a normal user gets full +// control; a user whose only accepted role is the reader role gets +// read-only; a user whose only accepted role is an implicit-deny role gets +// no permissions at all (mask 0); a user with no accepted role is not +// admitted at all. effective_perm_mask() returns that tier (or +// RGW_PERM_NONE), and admitted() reports whether any accepted role was +// present. A role that is in no list must have no effect. // --------------------------------------------------------------------- // Make a test token with the given role names, then flag the roles @@ -72,36 +74,15 @@ TEST(KeystoneProjectReader, UpdateRolesFlags) { auto t = make_keystone_token({"objectstore_viewer", "member", "admin", "objectstore_authed", "unlisted_role"}); + // Only the admin/system-reader personas survive as per-role flags; the + // permission tier is now a single token-level result. for (const auto& r : t.roles) { - if (r.name == "objectstore_viewer") { - EXPECT_TRUE(r.is_project_reader); - EXPECT_TRUE(r.is_accepted); - EXPECT_FALSE(r.is_admin); - EXPECT_FALSE(r.is_implicit_deny); - } else if (r.name == "member") { - EXPECT_TRUE(r.is_accepted); - EXPECT_FALSE(r.is_project_reader); - EXPECT_FALSE(r.is_admin); - EXPECT_FALSE(r.is_implicit_deny); - } else if (r.name == "admin") { - EXPECT_TRUE(r.is_admin); - EXPECT_TRUE(r.is_accepted); - EXPECT_FALSE(r.is_project_reader); - EXPECT_FALSE(r.is_implicit_deny); - } else if (r.name == "objectstore_authed") { - EXPECT_TRUE(r.is_implicit_deny); - EXPECT_TRUE(r.is_accepted); - EXPECT_FALSE(r.is_project_reader); - EXPECT_FALSE(r.is_admin); - } else if (r.name == "unlisted_role") { - // this role is in no list, so all flags must stay off - EXPECT_FALSE(r.is_accepted); - EXPECT_FALSE(r.is_admin); - EXPECT_FALSE(r.is_project_reader); - EXPECT_FALSE(r.is_system_reader); - EXPECT_FALSE(r.is_implicit_deny); - } + EXPECT_EQ(r.name == "admin", r.is_admin); + EXPECT_FALSE(r.is_system_reader); // no system_reader role in the lists } + // the token carries accepted roles (member, admin) -> admitted, full control + EXPECT_TRUE(t.admitted()); + EXPECT_EQ(RGW_PERM_FULL_CONTROL, t.effective_perm_mask()); } TEST(KeystoneProjectReader, ReaderOnly) @@ -138,23 +119,28 @@ TEST(KeystoneProjectReader, AdminWins) TEST(KeystoneProjectReader, NoReaderRole) { // a plain accepted role (member) grants full control - EXPECT_EQ(RGW_PERM_FULL_CONTROL, - make_keystone_token({"member"}).effective_perm_mask()); - // No accepted role (or no roles at all) -> zero permissions. We fail - // closed: deny by default, never full control. In a real request a user - // with no accepted role is already rejected at login before this runs, so - // this is just a safety net. - EXPECT_EQ(RGW_PERM_NONE, - make_keystone_token({"unlisted_role"}).effective_perm_mask()); - EXPECT_EQ(RGW_PERM_NONE, - make_keystone_token({}).effective_perm_mask()); + auto member = make_keystone_token({"member"}); + EXPECT_TRUE(member.admitted()); + EXPECT_EQ(RGW_PERM_FULL_CONTROL, member.effective_perm_mask()); + // No accepted role (or no roles at all) -> not admitted, zero permissions. + // We fail closed: deny by default, never full control. In a real request a + // user with no accepted role is already rejected at login before this runs, + // so this is just a safety net. + auto unlisted = make_keystone_token({"unlisted_role"}); + EXPECT_FALSE(unlisted.admitted()); + EXPECT_EQ(RGW_PERM_NONE, unlisted.effective_perm_mask()); + auto none = make_keystone_token({}); + EXPECT_FALSE(none.admitted()); + EXPECT_EQ(RGW_PERM_NONE, none.effective_perm_mask()); } TEST(KeystoneImplicitDeny, AuthedOnly) { - // only the implicit-deny role: no permissions at all - EXPECT_EQ(RGW_PERM_NONE, - make_keystone_token({"objectstore_authed"}).effective_perm_mask()); + // only the implicit-deny role: admitted (it is an accepted role) but with + // no permissions at all -- distinct from an unadmitted token. + auto t = make_keystone_token({"objectstore_authed"}); + EXPECT_TRUE(t.admitted()); + EXPECT_EQ(RGW_PERM_NONE, t.effective_perm_mask()); } TEST(KeystoneImplicitDeny, UnrelatedRoleIgnored) From 93070989dd56a01762be39246ccae298b7d31b6b Mon Sep 17 00:00:00 2001 From: Supriti Singh Date: Fri, 30 Oct 2026 16:57:31 +0100 Subject: [PATCH 7/7] backport: rgw keystone project reader and implicit deny roles Signed-off-by: Supriti Singh --- .../01a18d83-9554-7c5f-8407-f6a30b7f3c9f.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 release-management/backports/01a18d83-9554-7c5f-8407-f6a30b7f3c9f.md diff --git a/release-management/backports/01a18d83-9554-7c5f-8407-f6a30b7f3c9f.md b/release-management/backports/01a18d83-9554-7c5f-8407-f6a30b7f3c9f.md new file mode 100644 index 000000000000..beb961e4b640 --- /dev/null +++ b/release-management/backports/01a18d83-9554-7c5f-8407-f6a30b7f3c9f.md @@ -0,0 +1,53 @@ +--- +id: 01a18d83-9554-7c5f-8407-f6a30b7f3c9f +title: Keystone Project RO/Viewer role + Implicit Deny +component: rgw +category: feature +provenance: + type: upstream_pr + upstream_prs: + - https://github.com/ceph/ceph/pull/70372 + upstream_pr_state: merged-main + upstream_commits: + - 87252a5deac6450dafc5d2e279aa0671509d1c1a + - ba0c6ade977334f9b5cd01d5dc56354c9ad43507 + - dcc1de7d4d28bf825b1479cf48018c4ab847bf6a + previous_backport: 01a14417-d161-7221-87ad-c32f7e8170ec +justification: + type: customer + refs: + - cobaltcore-dev/cloud-storage#566 +risk: + blast: availability + conflict: substantive + coverage: strong +--- + +## Justification (public) + +Adds two Keystone roles: a project reader (`rgw_keystone_accepted_project_reader_roles`), read-only by default, and an implicit-deny role (`rgw_keystone_implicit_deny_roles`), no access by default. Both can be granted more through bucket policy using `keystone:userid` or `keystone:role`. + +## Justification (internal) + +Customer roadmap feature, mirroring Swift's `project_reader_roles`. + +## Risk notes + +**blast: availability** — off by default, but it changes how permissions are computed for every Keystone user. A mistake could remove access; no data is at risk. + +**conflict: substantive** — tentacle lacks some newer upstream code, so STS, SNS topics and the new unit test needed small manual adaptations. Merged code matches upstream otherwise. + +**coverage: strong** — unit tests and live tests against RGW and Keystone pass. They cover read-only and no-access defaults, policy overrides by `keystone:userid` and `keystone:role`, `Deny` precedence and cross-project isolation. + +## Behavior change + +Two new options, empty by default: + +- `rgw keystone accepted project reader roles` — user is read-only within its project. +- `rgw keystone implicit deny roles` — user has no access without an explicit policy grant. + +The most permissive accepted role wins (`member` or `admin` gives full control). A bucket policy naming the user or role can grant more, for that bucket only. Bucket creation and STS `AssumeRole` are always refused for these users. + +## Upgrade notes + +None. Both options default to empty.