diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 92cb4298c2ef..32c1e70d9bc5 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -1,3 +1,16 @@ +* 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``. +* 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 21f79e490599..6a092ad993b5 100644 --- a/doc/radosgw/keystone.rst +++ b/doc/radosgw/keystone.rst @@ -54,6 +54,88 @@ 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. + +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/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/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. diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index d5c1983e1210..15af1cf7b170 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -879,7 +879,40 @@ 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 +- 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 diff --git a/src/rgw/rgw_auth_keystone.cc b/src/rgw/rgw_auth_keystone.cc index 2185efd81082..de67f89754ad 100644 --- a/src/rgw/rgw_auth_keystone.cc +++ b/src/rgw/rgw_auth_keystone.cc @@ -150,11 +150,16 @@ 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, 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 */ auto keystone_scope = rgw::keystone::build_scope_info(cct, token); @@ -163,9 +168,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 +220,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 +262,20 @@ 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); + get_str_vec(cct->_conf->rgw_keystone_implicit_deny_roles, implicit_deny); - /* 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)); + plain.insert(std::end(plain), std::begin(implicit_deny), std::end(implicit_deny)); } std::vector plain; std::vector admin; - std::vector reader; + std::vector system_reader; + std::vector project_reader; + std::vector implicit_deny; } roles(cct); static const struct ServiceTokenRolesCacher { @@ -367,7 +377,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, roles.implicit_deny); /* Verify expiration. */ if (t->expired()) { @@ -384,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)); } @@ -657,22 +669,27 @@ 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; } } + /* Role-tier cap: read-only when every granting role is a + * 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 */ auto keystone_scope = rgw::keystone::build_scope_info(cct, token); @@ -686,9 +703,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 +733,25 @@ 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); + get_str_vec(cct->_conf->rgw_keystone_implicit_deny_roles, implicit_deny); + + /* 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 */ @@ -749,30 +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; - - 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)); } + + /* 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, 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_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..38cb790f0679 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 + * (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); + +/* 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..74e71cd2b362 100644 --- a/src/rgw/rgw_keystone.cc +++ b/src/rgw/rgw_keystone.cc @@ -4,6 +4,8 @@ #include #include +#include + #include #include #include @@ -311,25 +313,44 @@ 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) -{ - for (auto& iter: roles) { - for (const auto& r : admin) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_admin = true; - break; +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 & implicit_deny) +{ + 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 : reader) { - if (fnmatch(r.c_str(), iter.name.c_str(), 0) == 0) { - iter.is_reader = 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); } } } +uint32_t TokenEnvelope::effective_perm_mask() const +{ + return perm_tier.value_or(RGW_PERM_NONE); +} + 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..fdc3118de823 100644 --- a/src/rgw/rgw_keystone.h +++ b/src/rgw/rgw_keystone.h @@ -153,17 +153,17 @@ class TokenEnvelope { class Role { public: - Role() : is_admin(false), is_reader(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_reader = r.is_reader; + is_system_reader = r.is_system_reader; } std::string id; std::string name; bool is_admin; - bool is_reader; + bool is_system_reader; void decode_json(JSONObj *obj); }; @@ -188,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); @@ -205,6 +214,8 @@ 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 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()); @@ -212,8 +223,11 @@ 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, + const std::vector & implicit_deny); }; 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..14930738cb50 --- /dev/null +++ b/src/test/rgw/test_rgw_keystone.cc @@ -0,0 +1,446 @@ +// -*- 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() 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 +// 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", "objectstore_authed", + "admin"}, // plain + {"admin"}, // admin + {}, // system_reader + {"objectstore_viewer"}, // project_reader + {"objectstore_authed"}); // implicit_deny + return t; +} + +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) { + 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) +{ + // 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 + 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: 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) +{ + // 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. +// +// 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 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)); +} + +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)); +}