diff --git a/api_core/google/api_core/iam.py b/api_core/google/api_core/iam.py new file mode 100644 index 000000000000..c17bddcd9dfd --- /dev/null +++ b/api_core/google/api_core/iam.py @@ -0,0 +1,248 @@ +# Copyright 2017 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Non-API-specific IAM policy definitions + +For allowed roles / permissions, see: +https://cloud.google.com/iam/docs/understanding-roles +""" + +import collections +try: + from collections import abc as collections_abc +except ImportError: # Python 2.7 + import collections as collections_abc +import warnings + +# Generic IAM roles + +OWNER_ROLE = "roles/owner" +"""Generic role implying all rights to an object.""" + +EDITOR_ROLE = "roles/editor" +"""Generic role implying rights to modify an object.""" + +VIEWER_ROLE = "roles/viewer" +"""Generic role implying rights to access an object.""" + +_ASSIGNMENT_DEPRECATED_MSG = """\ +Assigning to '{}' is deprecated. Replace with 'policy[{}] = members.""" + + +class Policy(collections_abc.MutableMapping): + """IAM Policy + + See + https://cloud.google.com/iam/reference/rest/v1/Policy + + :type etag: str + :param etag: ETag used to identify a unique of the policy + + :type version: int + :param version: unique version of the policy + """ + + _OWNER_ROLES = (OWNER_ROLE,) + """Roles mapped onto our ``owners`` attribute.""" + + _EDITOR_ROLES = (EDITOR_ROLE,) + """Roles mapped onto our ``editors`` attribute.""" + + _VIEWER_ROLES = (VIEWER_ROLE,) + """Roles mapped onto our ``viewers`` attribute.""" + + def __init__(self, etag=None, version=None): + self.etag = etag + self.version = version + self._bindings = collections.defaultdict(set) + + def __iter__(self): + return iter(self._bindings) + + def __len__(self): + return len(self._bindings) + + def __getitem__(self, key): + return self._bindings[key] + + def __setitem__(self, key, value): + self._bindings[key] = set(value) + + def __delitem__(self, key): + del self._bindings[key] + + @property + def owners(self): + """Legacy access to owner role.""" + result = set() + for role in self._OWNER_ROLES: + for member in self._bindings.get(role, ()): + result.add(member) + return frozenset(result) + + @owners.setter + def owners(self, value): + """Update owners.""" + warnings.warn( + _ASSIGNMENT_DEPRECATED_MSG.format("owners", OWNER_ROLE), DeprecationWarning + ) + self[OWNER_ROLE] = value + + @property + def editors(self): + """Legacy access to editor role.""" + result = set() + for role in self._EDITOR_ROLES: + for member in self._bindings.get(role, ()): + result.add(member) + return frozenset(result) + + @editors.setter + def editors(self, value): + """Update editors.""" + warnings.warn( + _ASSIGNMENT_DEPRECATED_MSG.format("editors", EDITOR_ROLE), + DeprecationWarning, + ) + self[EDITOR_ROLE] = value + + @property + def viewers(self): + """Legacy access to viewer role.""" + result = set() + for role in self._VIEWER_ROLES: + for member in self._bindings.get(role, ()): + result.add(member) + return frozenset(result) + + @viewers.setter + def viewers(self, value): + """Update viewers.""" + warnings.warn( + _ASSIGNMENT_DEPRECATED_MSG.format("viewers", VIEWER_ROLE), + DeprecationWarning, + ) + self[VIEWER_ROLE] = value + + @staticmethod + def user(email): + """Factory method for a user member. + + :type email: str + :param email: E-mail for this particular user. + + :rtype: str + :returns: A member string corresponding to the given user. + """ + return "user:%s" % (email,) + + @staticmethod + def service_account(email): + """Factory method for a service account member. + + :type email: str + :param email: E-mail for this particular service account. + + :rtype: str + :returns: A member string corresponding to the given service account. + """ + return "serviceAccount:%s" % (email,) + + @staticmethod + def group(email): + """Factory method for a group member. + + :type email: str + :param email: An id or e-mail for this particular group. + + :rtype: str + :returns: A member string corresponding to the given group. + """ + return "group:%s" % (email,) + + @staticmethod + def domain(domain): + """Factory method for a domain member. + + :type domain: str + :param domain: The domain for this member. + + :rtype: str + :returns: A member string corresponding to the given domain. + """ + return "domain:%s" % (domain,) + + @staticmethod + def all_users(): + """Factory method for a member representing all users. + + :rtype: str + :returns: A member string representing all users. + """ + return "allUsers" + + @staticmethod + def authenticated_users(): + """Factory method for a member representing all authenticated users. + + :rtype: str + :returns: A member string representing all authenticated users. + """ + return "allAuthenticatedUsers" + + @classmethod + def from_api_repr(cls, resource): + """Create a policy from the resource returned from the API. + + :type resource: dict + :param resource: resource returned from the ``getIamPolicy`` API. + + :rtype: :class:`Policy` + :returns: the parsed policy + """ + version = resource.get("version") + etag = resource.get("etag") + policy = cls(etag, version) + for binding in resource.get("bindings", ()): + role = binding["role"] + members = sorted(binding["members"]) + policy[role] = members + return policy + + def to_api_repr(self): + """Construct a Policy resource. + + :rtype: dict + :returns: a resource to be passed to the ``setIamPolicy`` API. + """ + resource = {} + + if self.etag is not None: + resource["etag"] = self.etag + + if self.version is not None: + resource["version"] = self.version + + if self._bindings: + bindings = resource["bindings"] = [] + for role, members in sorted(self._bindings.items()): + if members: + bindings.append({"role": role, "members": sorted(set(members))}) + + if not bindings: + del resource["bindings"] + + return resource + + +collections_abc.MutableMapping.register(Policy) diff --git a/api_core/tests/unit/test_iam.py b/api_core/tests/unit/test_iam.py new file mode 100644 index 000000000000..59f3b2c8a309 --- /dev/null +++ b/api_core/tests/unit/test_iam.py @@ -0,0 +1,275 @@ +# Copyright 2017 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + + +class TestPolicy: + @staticmethod + def _get_target_class(): + from google.api_core.iam import Policy + + return Policy + + def _make_one(self, *args, **kw): + return self._get_target_class()(*args, **kw) + + def test_ctor_defaults(self): + empty = frozenset() + policy = self._make_one() + assert policy.etag is None + assert policy.version is None + assert policy.owners == empty + assert policy.editors == empty + assert policy.viewers == empty + assert len(policy) == 0 + assert dict(policy) == {} + + def test_ctor_explicit(self): + VERSION = 17 + ETAG = "ETAG" + empty = frozenset() + policy = self._make_one(ETAG, VERSION) + assert policy.etag == ETAG + assert policy.version == VERSION + assert policy.owners == empty + assert policy.editors == empty + assert policy.viewers == empty + assert len(policy) == 0 + assert dict(policy) == {} + + def test___getitem___miss(self): + policy = self._make_one() + assert policy["nonesuch"] == set() + + def test___setitem__(self): + USER = "user:phred@example.com" + PRINCIPALS = set([USER]) + policy = self._make_one() + policy["rolename"] = [USER] + assert policy["rolename"] == PRINCIPALS + assert len(policy) == 1 + assert dict(policy) == {"rolename": PRINCIPALS} + + def test___delitem___hit(self): + policy = self._make_one() + policy._bindings["rolename"] = ["phred@example.com"] + del policy["rolename"] + assert len(policy) == 0 + assert dict(policy) == {} + + def test___delitem___miss(self): + policy = self._make_one() + with pytest.raises(KeyError): + del policy["nonesuch"] + + def test_owners_getter(self): + from google.api_core.iam import OWNER_ROLE + + MEMBER = "user:phred@example.com" + expected = frozenset([MEMBER]) + policy = self._make_one() + policy[OWNER_ROLE] = [MEMBER] + assert policy.owners == expected + + def test_owners_setter(self): + import warnings + from google.api_core.iam import OWNER_ROLE + + MEMBER = "user:phred@example.com" + expected = set([MEMBER]) + policy = self._make_one() + with warnings.catch_warnings(): + warnings.simplefilter("always") + policy.owners = [MEMBER] + assert policy[OWNER_ROLE] == expected + + def test_editors_getter(self): + from google.api_core.iam import EDITOR_ROLE + + MEMBER = "user:phred@example.com" + expected = frozenset([MEMBER]) + policy = self._make_one() + policy[EDITOR_ROLE] = [MEMBER] + assert policy.editors == expected + + def test_editors_setter(self): + import warnings + from google.api_core.iam import EDITOR_ROLE + + MEMBER = "user:phred@example.com" + expected = set([MEMBER]) + policy = self._make_one() + with warnings.catch_warnings(): + warnings.simplefilter("always") + policy.editors = [MEMBER] + assert policy[EDITOR_ROLE] == expected + + def test_viewers_getter(self): + from google.api_core.iam import VIEWER_ROLE + + MEMBER = "user:phred@example.com" + expected = frozenset([MEMBER]) + policy = self._make_one() + policy[VIEWER_ROLE] = [MEMBER] + assert policy.viewers == expected + + def test_viewers_setter(self): + import warnings + from google.api_core.iam import VIEWER_ROLE + + MEMBER = "user:phred@example.com" + expected = set([MEMBER]) + policy = self._make_one() + with warnings.catch_warnings(): + warnings.simplefilter("always") + policy.viewers = [MEMBER] + assert policy[VIEWER_ROLE] == expected + + def test_user(self): + EMAIL = "phred@example.com" + MEMBER = "user:%s" % (EMAIL,) + policy = self._make_one() + assert policy.user(EMAIL) == MEMBER + + def test_service_account(self): + EMAIL = "phred@example.com" + MEMBER = "serviceAccount:%s" % (EMAIL,) + policy = self._make_one() + assert policy.service_account(EMAIL) == MEMBER + + def test_group(self): + EMAIL = "phred@example.com" + MEMBER = "group:%s" % (EMAIL,) + policy = self._make_one() + assert policy.group(EMAIL) == MEMBER + + def test_domain(self): + DOMAIN = "example.com" + MEMBER = "domain:%s" % (DOMAIN,) + policy = self._make_one() + assert policy.domain(DOMAIN) == MEMBER + + def test_all_users(self): + policy = self._make_one() + assert policy.all_users() == "allUsers" + + def test_authenticated_users(self): + policy = self._make_one() + assert policy.authenticated_users() == "allAuthenticatedUsers" + + def test_from_api_repr_only_etag(self): + empty = frozenset() + RESOURCE = {"etag": "ACAB"} + klass = self._get_target_class() + policy = klass.from_api_repr(RESOURCE) + assert policy.etag == "ACAB" + assert policy.version is None + assert policy.owners == empty + assert policy.editors == empty + assert policy.viewers == empty + assert dict(policy) == {} + + def test_from_api_repr_complete(self): + from google.api_core.iam import OWNER_ROLE, EDITOR_ROLE, VIEWER_ROLE + + OWNER1 = "group:cloud-logs@google.com" + OWNER2 = "user:phred@example.com" + EDITOR1 = "domain:google.com" + EDITOR2 = "user:phred@example.com" + VIEWER1 = "serviceAccount:1234-abcdef@service.example.com" + VIEWER2 = "user:phred@example.com" + RESOURCE = { + "etag": "DEADBEEF", + "version": 17, + "bindings": [ + {"role": OWNER_ROLE, "members": [OWNER1, OWNER2]}, + {"role": EDITOR_ROLE, "members": [EDITOR1, EDITOR2]}, + {"role": VIEWER_ROLE, "members": [VIEWER1, VIEWER2]}, + ], + } + klass = self._get_target_class() + policy = klass.from_api_repr(RESOURCE) + assert policy.etag == "DEADBEEF" + assert policy.version == 17 + assert policy.owners, frozenset([OWNER1 == OWNER2]) + assert policy.editors, frozenset([EDITOR1 == EDITOR2]) + assert policy.viewers, frozenset([VIEWER1 == VIEWER2]) + assert dict(policy) == { + OWNER_ROLE: set([OWNER1, OWNER2]), + EDITOR_ROLE: set([EDITOR1, EDITOR2]), + VIEWER_ROLE: set([VIEWER1, VIEWER2]), + } + + def test_from_api_repr_unknown_role(self): + USER = "user:phred@example.com" + GROUP = "group:cloud-logs@google.com" + RESOURCE = { + "etag": "DEADBEEF", + "version": 17, + "bindings": [{"role": "unknown", "members": [USER, GROUP]}], + } + klass = self._get_target_class() + policy = klass.from_api_repr(RESOURCE) + assert policy.etag == "DEADBEEF" + assert policy.version == 17 + assert dict(policy), {"unknown": set([GROUP == USER])} + + def test_to_api_repr_defaults(self): + policy = self._make_one() + assert policy.to_api_repr() == {} + + def test_to_api_repr_only_etag(self): + policy = self._make_one("DEADBEEF") + assert policy.to_api_repr() == {"etag": "DEADBEEF"} + + def test_to_api_repr_binding_wo_members(self): + policy = self._make_one() + policy["empty"] = [] + assert policy.to_api_repr() == {} + + def test_to_api_repr_binding_w_duplicates(self): + from google.api_core.iam import OWNER_ROLE + + OWNER = "group:cloud-logs@google.com" + policy = self._make_one() + policy.owners = [OWNER, OWNER] + assert policy.to_api_repr() == { + "bindings": [{"role": OWNER_ROLE, "members": [OWNER]}] + } + + def test_to_api_repr_full(self): + import operator + from google.api_core.iam import OWNER_ROLE, EDITOR_ROLE, VIEWER_ROLE + + OWNER1 = "group:cloud-logs@google.com" + OWNER2 = "user:phred@example.com" + EDITOR1 = "domain:google.com" + EDITOR2 = "user:phred@example.com" + VIEWER1 = "serviceAccount:1234-abcdef@service.example.com" + VIEWER2 = "user:phred@example.com" + BINDINGS = [ + {"role": OWNER_ROLE, "members": [OWNER1, OWNER2]}, + {"role": EDITOR_ROLE, "members": [EDITOR1, EDITOR2]}, + {"role": VIEWER_ROLE, "members": [VIEWER1, VIEWER2]}, + ] + policy = self._make_one("DEADBEEF", 17) + policy.owners = [OWNER1, OWNER2] + policy.editors = [EDITOR1, EDITOR2] + policy.viewers = [VIEWER1, VIEWER2] + resource = policy.to_api_repr() + assert resource["etag"] == "DEADBEEF" + assert resource["version"] == 17 + key = operator.itemgetter("role") + assert sorted(resource["bindings"], key=key) == sorted(BINDINGS, key=key)