1# -*- coding: utf-8 -*-
2# Copyright 2016 Google Inc. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#     http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15"""Helper module for the IAM command."""
16
17from __future__ import absolute_import
18from __future__ import print_function
19from __future__ import division
20from __future__ import unicode_literals
21
22from collections import defaultdict
23from collections import namedtuple
24
25import six
26from apitools.base.protorpclite import protojson
27from gslib.exception import CommandException
28from gslib.third_party.storage_apitools import storage_v1_messages as apitools_messages
29
30TYPES = set([
31    'user', 'deleted:user', 'serviceAccount', 'deleted:serviceAccount', 'group',
32    'deleted:group', 'domain', 'principal', 'principalSet', 'principalHierarchy'
33])
34
35DISCOURAGED_TYPES = set([
36    'projectOwner',
37    'projectEditor',
38    'projectViewer',
39])
40
41DISCOURAGED_TYPES_MSG = (
42    'Assigning roles (e.g. objectCreator, legacyBucketOwner) for project '
43    'convenience groups is not supported by gsutil, as it goes against the '
44    'principle of least privilege. Consider creating and using more granular '
45    'groups with which to assign permissions. See '
46    'https://cloud.google.com/iam/docs/using-iam-securely for more '
47    'information. Assigning a role to a project group can be achieved by '
48    'setting the IAM policy directly (see gsutil help iam for specifics).')
49
50PUBLIC_MEMBERS = set([
51    'allUsers',
52    'allAuthenticatedUsers',
53])
54
55# This is a convenience class to handle returned results from
56# BindingStringToTuple. is_grant is a boolean specifying if the
57# bindings are to be granted or removed from a bucket / object,
58# and bindings is a list of BindingsValueListEntry instances.
59BindingsTuple = namedtuple('BindingsTuple', ['is_grant', 'bindings'])
60
61# This is a special role value assigned to a specific member when all roles
62# assigned to the member should be dropped in the policy. A member:DROP_ALL
63# binding will be passed from BindingStringToTuple into PatchBindings.
64# This role will only ever appear on client-side (i.e. user-generated). It
65# will never be returned as a real role from an IAM get request. All roles
66# returned by PatchBindings are guaranteed to be "real" roles, i.e. not a
67# DROP_ALL role.
68DROP_ALL = ''
69
70
71def SerializeBindingsTuple(bindings_tuple):
72  """Serializes the BindingsValueListEntry instances in a BindingsTuple.
73
74  This is necessary when passing instances of BindingsTuple through
75  Command.Apply, as apitools_messages classes are not by default pickleable.
76
77  Args:
78    bindings_tuple: A BindingsTuple instance to be serialized.
79
80  Returns:
81    A serialized BindingsTuple object.
82  """
83  return (bindings_tuple.is_grant,
84          [protojson.encode_message(t) for t in bindings_tuple.bindings])
85
86
87def DeserializeBindingsTuple(serialized_bindings_tuple):
88  (is_grant, bindings) = serialized_bindings_tuple
89  return BindingsTuple(is_grant=is_grant,
90                       bindings=[
91                           protojson.decode_message(
92                               apitools_messages.Policy.BindingsValueListEntry,
93                               t) for t in bindings
94                       ])
95
96
97def BindingsToDict(bindings):
98  """Converts a list of BindingsValueListEntry to a dictionary.
99
100  Args:
101    bindings: A list of BindingsValueListEntry instances.
102
103  Returns:
104    A {role: set(members)} dictionary.
105  """
106
107  tmp_bindings = defaultdict(set)
108  for binding in bindings:
109    tmp_bindings[binding.role].update(binding.members)
110  return tmp_bindings
111
112
113def IsEqualBindings(a, b):
114  (granted, removed) = DiffBindings(a, b)
115  return not granted.bindings and not removed.bindings
116
117
118def DiffBindings(old, new):
119  """Computes the difference between two BindingsValueListEntry lists.
120
121  Args:
122    old: The original list of BindingValuesListEntry instances
123    new: The updated list of BindingValuesListEntry instances
124
125  Returns:
126    A pair of BindingsTuple instances, one for roles granted between old and
127      new, and one for roles removed between old and new.
128  """
129  tmp_old = BindingsToDict(old)
130  tmp_new = BindingsToDict(new)
131
132  granted = BindingsToDict([])
133  removed = BindingsToDict([])
134
135  for (role, members) in six.iteritems(tmp_old):
136    removed[role].update(members.difference(tmp_new[role]))
137  for (role, members) in six.iteritems(tmp_new):
138    granted[role].update(members.difference(tmp_old[role]))
139
140  granted = [
141      apitools_messages.Policy.BindingsValueListEntry(role=r, members=list(m))
142      for (r, m) in six.iteritems(granted)
143      if m
144  ]
145  removed = [
146      apitools_messages.Policy.BindingsValueListEntry(role=r, members=list(m))
147      for (r, m) in six.iteritems(removed)
148      if m
149  ]
150
151  return (BindingsTuple(True, granted), BindingsTuple(False, removed))
152
153
154def PatchBindings(base, diff):
155  """Patches a diff list of BindingsValueListEntry to the base.
156
157  Will remove duplicate members for any given role on a grant operation.
158
159  Args:
160    base: A list of BindingsValueListEntry instances.
161    diff: A BindingsTuple instance of diff to be applied.
162
163  Returns:
164    The computed difference, as a list of
165    apitools_messages.Policy.BindingsValueListEntry instances.
166  """
167
168  # Convert the list of bindings into an {r: [m]} dictionary object.
169  tmp_base = BindingsToDict(base)
170  tmp_diff = BindingsToDict(diff.bindings)
171
172  # Patch the diff into base
173  if diff.is_grant:
174    for (role, members) in six.iteritems(tmp_diff):
175      if not role:
176        raise CommandException('Role must be specified for a grant request.')
177      tmp_base[role].update(members)
178  else:
179    for role in tmp_base:
180      tmp_base[role].difference_update(tmp_diff[role])
181      # Drop all members with the DROP_ALL role specifed from input.
182      tmp_base[role].difference_update(tmp_diff[DROP_ALL])
183
184  # Construct the BindingsValueListEntry list
185  bindings = [
186      apitools_messages.Policy.BindingsValueListEntry(role=r, members=list(m))
187      for (r, m) in six.iteritems(tmp_base)
188      if m
189  ]
190
191  return bindings
192
193
194def BindingStringToTuple(is_grant, input_str):
195  """Parses an iam ch bind string to a list of binding tuples.
196
197  Args:
198    is_grant: If true, binding is to be appended to IAM policy; else, delete
199              this binding from the policy.
200    input_str: A string representing a member-role binding.
201               e.g. user:foo@bar.com:objectAdmin
202                    user:foo@bar.com:objectAdmin,objectViewer
203                    user:foo@bar.com
204                    allUsers
205                    deleted:user:foo@bar.com?uid=123:objectAdmin,objectViewer
206                    deleted:serviceAccount:foo@bar.com?uid=123
207
208  Raises:
209    CommandException in the case of invalid input.
210
211  Returns:
212    A BindingsTuple instance.
213  """
214
215  if not input_str.count(':'):
216    input_str += ':'
217  if input_str.count(':') == 1:
218    tokens = input_str.split(':')
219    if '%s:%s' % (tokens[0], tokens[1]) in TYPES:
220      raise CommandException('Incorrect public member type for binding %s' %
221                             input_str)
222    if tokens[0] in PUBLIC_MEMBERS:
223      (member, roles) = tokens
224    elif tokens[0] in TYPES:
225      member = ':'.join(tokens)
226      roles = DROP_ALL
227    else:
228      raise CommandException('Incorrect public member type for binding %s' %
229                             input_str)
230  elif input_str.count(':') == 2:
231    tokens = input_str.split(':')
232    if '%s:%s' % (tokens[0], tokens[1]) in TYPES:
233      # case "deleted:user:foo@bar.com?uid=1234"
234      member = ':'.join(tokens)
235      roles = DROP_ALL
236    else:
237      (member_type, member_id, roles) = tokens
238      _check_member_type(member_type, input_str)
239      member = '%s:%s' % (member_type, member_id)
240  elif input_str.count(':') == 3:
241    # case "deleted:user:foo@bar.com?uid=1234:objectAdmin,objectViewer"
242    (member_type_p1, member_type_p2, member_id, roles) = input_str.split(':')
243    member_type = '%s:%s' % (member_type_p1, member_type_p2)
244    _check_member_type(member_type, input_str)
245    member = '%s:%s' % (member_type, member_id)
246  else:
247    raise CommandException('Invalid ch format %s' % input_str)
248
249  if is_grant and not roles:
250    raise CommandException('Must specify a role to grant.')
251
252  roles = [ResolveRole(r) for r in roles.split(',')]
253
254  bindings = [
255      apitools_messages.Policy.BindingsValueListEntry(members=[member], role=r)
256      for r in set(roles)
257  ]
258  return BindingsTuple(is_grant=is_grant, bindings=bindings)
259
260
261def _check_member_type(member_type, input_str):
262  if member_type in DISCOURAGED_TYPES:
263    raise CommandException(DISCOURAGED_TYPES_MSG)
264  elif member_type not in TYPES:
265    raise CommandException('Incorrect member type for binding %s' % input_str)
266
267
268def ResolveRole(role):
269  if not role:
270    return DROP_ALL
271  if 'roles/' in role:
272    return role
273  return 'roles/storage.%s' % role
274