mirror of https://github.com/grpc/grpc.git
xds-k8s: grant roles/iam.workloadIdentityUser automatically (#26487)
parent
b53f60d353
commit
433c5ea261
11 changed files with 501 additions and 31 deletions
@ -1,5 +1,5 @@ |
||||
--flagfile=config/common.cfg |
||||
--project=grpc-testing |
||||
--network=default-vpc |
||||
--gcp_service_account=830293263384-compute@developer.gserviceaccount.com |
||||
--gcp_service_account=xds-k8s-interop-tests@grpc-testing.iam.gserviceaccount.com |
||||
--private_api_key_secret_name=projects/830293263384/secrets/xds-interop-tests-private-api-access-key |
||||
|
@ -0,0 +1,311 @@ |
||||
# Copyright 2021 gRPC authors. |
||||
# |
||||
# 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 dataclasses |
||||
import datetime |
||||
import functools |
||||
import logging |
||||
from typing import Any, Dict, FrozenSet, Optional |
||||
|
||||
import googleapiclient.errors |
||||
|
||||
from framework.helpers import retryers |
||||
from framework.infrastructure import gcp |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
# Type aliases |
||||
_timedelta = datetime.timedelta |
||||
|
||||
|
||||
class EtagConflict(gcp.api.Error): |
||||
""" |
||||
Indicates concurrent policy changes. |
||||
|
||||
https://cloud.google.com/iam/docs/policies#etag |
||||
""" |
||||
pass |
||||
|
||||
|
||||
def handle_etag_conflict(func): |
||||
|
||||
def wrap_retry_on_etag_conflict(*args, **kwargs): |
||||
retryer = retryers.exponential_retryer_with_timeout( |
||||
retry_on_exceptions=(EtagConflict,), |
||||
wait_min=_timedelta(seconds=1), |
||||
wait_max=_timedelta(seconds=10), |
||||
timeout=_timedelta(minutes=2)) |
||||
return retryer(func, *args, **kwargs) |
||||
|
||||
return wrap_retry_on_etag_conflict |
||||
|
||||
|
||||
def _replace_binding(policy: 'Policy', binding: 'Policy.Binding', |
||||
new_binding: 'Policy.Binding') -> 'Policy': |
||||
new_bindings = set(policy.bindings) |
||||
new_bindings.discard(binding) |
||||
new_bindings.add(new_binding) |
||||
return dataclasses.replace(policy, bindings=frozenset(new_bindings)) |
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True) |
||||
class ServiceAccount: |
||||
"""An IAM service account. |
||||
|
||||
https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts |
||||
Note: "etag" field is skipped because it's deprecated |
||||
""" |
||||
name: str |
||||
projectId: str |
||||
uniqueId: str |
||||
email: str |
||||
oauth2ClientId: str |
||||
displayName: str = '' |
||||
description: str = '' |
||||
disabled: bool = False |
||||
|
||||
@classmethod |
||||
def from_response(cls, response: Dict[str, Any]) -> 'ServiceAccount': |
||||
return cls(name=response['name'], |
||||
projectId=response['projectId'], |
||||
uniqueId=response['uniqueId'], |
||||
email=response['email'], |
||||
oauth2ClientId=response['oauth2ClientId'], |
||||
description=response.get('description', ''), |
||||
displayName=response.get('displayName', ''), |
||||
disabled=response.get('disabled', False)) |
||||
|
||||
def as_dict(self) -> Dict[str, Any]: |
||||
return dataclasses.asdict(self) |
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True) |
||||
class Expr: |
||||
""" |
||||
Represents a textual expression in the Common Expression Language syntax. |
||||
|
||||
https://cloud.google.com/iam/docs/reference/rest/v1/Expr |
||||
""" |
||||
expression: str |
||||
title: str = '' |
||||
description: str = '' |
||||
location: str = '' |
||||
|
||||
@classmethod |
||||
def from_response(cls, response: Dict[str, Any]) -> 'Expr': |
||||
return cls(**response) |
||||
|
||||
def as_dict(self) -> Dict[str, Any]: |
||||
return dataclasses.asdict(self) |
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True) |
||||
class Policy: |
||||
"""An Identity and Access Management (IAM) policy, which specifies |
||||
access controls for Google Cloud resources. |
||||
|
||||
https://cloud.google.com/iam/docs/reference/rest/v1/Policy |
||||
Note: auditConfigs not supported by this implementation. |
||||
""" |
||||
|
||||
@dataclasses.dataclass(frozen=True) |
||||
class Binding: |
||||
"""Policy Binding. Associates members with a role. |
||||
|
||||
https://cloud.google.com/iam/docs/reference/rest/v1/Policy#binding |
||||
""" |
||||
role: str |
||||
members: FrozenSet[str] |
||||
condition: Optional[Expr] = None |
||||
|
||||
@classmethod |
||||
def from_response(cls, response: Dict[str, Any]) -> 'Policy.Binding': |
||||
fields = { |
||||
'role': response['role'], |
||||
'members': frozenset(response.get('members', [])), |
||||
} |
||||
if 'condition' in response: |
||||
fields['condition'] = Expr.from_response(response['condition']) |
||||
|
||||
return cls(**fields) |
||||
|
||||
def as_dict(self) -> Dict[str, Any]: |
||||
result = { |
||||
'role': self.role, |
||||
'members': list(self.members), |
||||
} |
||||
if self.condition is not None: |
||||
result['condition'] = self.condition.as_dict() |
||||
return result |
||||
|
||||
bindings: FrozenSet[Binding] |
||||
etag: str |
||||
version: Optional[int] = None |
||||
|
||||
@functools.lru_cache(maxsize=128) |
||||
def find_binding_for_role( |
||||
self, |
||||
role: str, |
||||
condition: Optional[Expr] = None) -> Optional['Policy.Binding']: |
||||
results = (binding for binding in self.bindings |
||||
if binding.role == role and binding.condition == condition) |
||||
return next(results, None) |
||||
|
||||
@classmethod |
||||
def from_response(cls, response: Dict[str, Any]) -> 'Policy': |
||||
bindings = frozenset( |
||||
cls.Binding.from_response(b) for b in response.get('bindings', [])) |
||||
return cls(bindings=bindings, |
||||
etag=response['etag'], |
||||
version=response.get('version')) |
||||
|
||||
def as_dict(self) -> Dict[str, Any]: |
||||
result = { |
||||
'bindings': [binding.as_dict() for binding in self.bindings], |
||||
'etag': self.etag, |
||||
} |
||||
if self.version is not None: |
||||
result['version'] = self.version |
||||
return result |
||||
|
||||
|
||||
class IamV1(gcp.api.GcpProjectApiResource): |
||||
""" |
||||
Identity and Access Management (IAM) API. |
||||
|
||||
https://cloud.google.com/iam/docs/reference/rest |
||||
""" |
||||
_service_accounts: gcp.api.discovery.Resource |
||||
|
||||
# Operations that affect conditional role bindings must specify version 3. |
||||
# Otherwise conditions are omitted, and role names returned with a suffix, |
||||
# f.e. roles/iam.workloadIdentityUser_withcond_f1ec33c9beb41857dbf0 |
||||
# https://cloud.google.com/iam/docs/reference/rest/v1/Policy#FIELDS.version |
||||
POLICY_VERSION: str = 3 |
||||
|
||||
def __init__(self, api_manager: gcp.api.GcpApiManager, project: str): |
||||
super().__init__(api_manager.iam('v1'), project) |
||||
# Shortcut to projects/*/serviceAccounts/ endpoints |
||||
self._service_accounts = self.api.projects().serviceAccounts() |
||||
|
||||
def service_account_resource_name(self, account): |
||||
""" |
||||
Returns full resource name of the service account. |
||||
|
||||
The resource name of the service account in the following format: |
||||
projects/{PROJECT_ID}/serviceAccounts/{ACCOUNT}. |
||||
The ACCOUNT value can be the email address or the uniqueId of the |
||||
service account. |
||||
Ref https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts/get |
||||
|
||||
Args: |
||||
account: The ACCOUNT value |
||||
""" |
||||
return f'projects/{self.project}/serviceAccounts/{account}' |
||||
|
||||
def get_service_account(self, account: str) -> ServiceAccount: |
||||
response: Dict[str, Any] = self._service_accounts.get( |
||||
name=self.service_account_resource_name(account)).execute() |
||||
logger.debug('Loaded Service Account:\n%s', |
||||
self._resource_pretty_format(response)) |
||||
return ServiceAccount.from_response(response) |
||||
|
||||
def get_service_account_iam_policy(self, account: str) -> Policy: |
||||
response: Dict[str, Any] = self._service_accounts.getIamPolicy( |
||||
resource=self.service_account_resource_name(account), |
||||
options_requestedPolicyVersion=self.POLICY_VERSION).execute() |
||||
logger.debug('Loaded Service Account Policy:\n%s', |
||||
self._resource_pretty_format(response)) |
||||
return Policy.from_response(response) |
||||
|
||||
def set_service_account_iam_policy(self, account: str, |
||||
policy: Policy) -> Policy: |
||||
"""Sets the IAM policy that is attached to a service account. |
||||
|
||||
https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts/setIamPolicy |
||||
""" |
||||
body = {'policy': policy.as_dict()} |
||||
logger.debug('Updating Service Account %s policy:\n%s', account, |
||||
self._resource_pretty_format(body)) |
||||
try: |
||||
response: Dict[str, Any] = self._service_accounts.setIamPolicy( |
||||
resource=self.service_account_resource_name(account), |
||||
body=body).execute() |
||||
return Policy.from_response(response) |
||||
except googleapiclient.errors.HttpError as error: |
||||
# TODO(sergiitk) use status_code() when we upgrade googleapiclient |
||||
if error.resp and error.resp.status == 409: |
||||
# https://cloud.google.com/iam/docs/policies#etag |
||||
logger.debug(error) |
||||
raise EtagConflict from error |
||||
else: |
||||
raise gcp.api.Error from error |
||||
|
||||
@handle_etag_conflict |
||||
def add_service_account_iam_policy_binding(self, account: str, role: str, |
||||
member: str) -> None: |
||||
"""Add an IAM policy binding to an IAM service account. |
||||
|
||||
See for details on updating policy bindings: |
||||
https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts/setIamPolicy |
||||
""" |
||||
policy: Policy = self.get_service_account_iam_policy(account) |
||||
binding: Optional[Policy.Binding] = policy.find_binding_for_role(role) |
||||
if binding and member in binding.members: |
||||
logger.debug('Member %s already has role %s for Service Account %s', |
||||
member, role, account) |
||||
return |
||||
|
||||
if binding is None: |
||||
updated_binding = Policy.Binding(role, frozenset([member])) |
||||
else: |
||||
updated_members: FrozenSet[str] = binding.members.union({member}) |
||||
updated_binding: Policy.Binding = dataclasses.replace( |
||||
binding, members=updated_members) |
||||
|
||||
updated_policy: Policy = _replace_binding(policy, binding, |
||||
updated_binding) |
||||
self.set_service_account_iam_policy(account, updated_policy) |
||||
logger.debug('Role %s granted to member %s for Service Account %s', |
||||
role, member, account) |
||||
|
||||
@handle_etag_conflict |
||||
def remove_service_account_iam_policy_binding(self, account: str, role: str, |
||||
member: str) -> None: |
||||
"""Remove an IAM policy binding from the IAM policy of a service |
||||
account. |
||||
|
||||
See for details on updating policy bindings: |
||||
https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts/setIamPolicy |
||||
""" |
||||
policy: Policy = self.get_service_account_iam_policy(account) |
||||
binding: Optional[Policy.Binding] = policy.find_binding_for_role(role) |
||||
|
||||
if binding is None: |
||||
logger.debug('Noop: Service Account %s has no bindings for role %s', |
||||
account, role) |
||||
return |
||||
if member not in binding.members: |
||||
logger.debug( |
||||
'Noop: Service Account %s binding for role %s has no member %s', |
||||
account, role, member) |
||||
return |
||||
|
||||
updated_members: FrozenSet[str] = binding.members.difference({member}) |
||||
updated_binding: Policy.Binding = dataclasses.replace( |
||||
binding, members=updated_members) |
||||
updated_policy: Policy = _replace_binding(policy, binding, |
||||
updated_binding) |
||||
self.set_service_account_iam_policy(account, updated_policy) |
||||
logger.debug('Role %s revoked from member %s for Service Account %s', |
||||
role, member, account) |
Loading…
Reference in new issue