Skip to content

Commit 2d017de

Browse files
committed
Merge pull request #1046 from tseaver/bigquery-dataset_acls
Add Dataset access support
2 parents 3357530 + 6720b35 commit 2d017de

File tree

2 files changed

+207
-2
lines changed

2 files changed

+207
-2
lines changed

gcloud/bigquery/dataset.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,29 @@
2020
from gcloud.bigquery.table import Table
2121

2222

23+
class AccessGrant(object):
24+
"""Represent grant of an access role to an entity.
25+
26+
:type role: string (one of 'OWNER', 'WRITER', 'READER').
27+
:param role: role granted to the entity.
28+
29+
:type entity_type: string (one of 'specialGroup', 'groupByEmail', or
30+
'userByEmail')
31+
:param entity_type: type of entity being granted the role.
32+
33+
:type entity_id: string
34+
:param entity_id: ID of entity being granted the role.
35+
"""
36+
def __init__(self, role, entity_type, entity_id):
37+
self.role = role
38+
self.entity_type = entity_type
39+
self.entity_id = entity_id
40+
41+
def __repr__(self):
42+
return '<AccessGrant: role=%s, %s=%s>' % (
43+
self.role, self.entity_type, self.entity_id)
44+
45+
2346
class Dataset(object):
2447
"""Datasets are containers for tables.
2548
@@ -32,12 +55,16 @@ class Dataset(object):
3255
:type client: :class:`gcloud.bigquery.client.Client`
3356
:param client: A client which holds credentials and project configuration
3457
for the dataset (which requires a project).
58+
59+
:type access_grants: list of :class:`AccessGrant`
60+
:param access_grants: roles granted to entities for this dataset
3561
"""
3662

37-
def __init__(self, name, client):
63+
def __init__(self, name, client, access_grants=()):
3864
self.name = name
3965
self._client = client
4066
self._properties = {}
67+
self.access_grants = access_grants
4168

4269
@property
4370
def project(self):
@@ -57,6 +84,29 @@ def path(self):
5784
"""
5885
return '/projects/%s/datasets/%s' % (self.project, self.name)
5986

87+
@property
88+
def access_grants(self):
89+
"""Dataset's access grants.
90+
91+
:rtype: list of :class:`AccessGrant`
92+
:returns: roles granted to entities for this dataset
93+
"""
94+
return list(self._access_grants)
95+
96+
@access_grants.setter
97+
def access_grants(self, value):
98+
"""Update dataset's access grants
99+
100+
:type value: list of :class:`AccessGrant`
101+
:param value: roles granted to entities for this dataset
102+
103+
:raises: TypeError if 'value' is not a sequence, or ValueError if
104+
any item in the sequence is not an AccessGrant
105+
"""
106+
if not all(isinstance(field, AccessGrant) for field in value):
107+
raise ValueError('Values must be AccessGrant instances')
108+
self._access_grants = tuple(value)
109+
60110
@property
61111
def created(self):
62112
"""Datetime at which the dataset was created.
@@ -227,6 +277,27 @@ def _require_client(self, client):
227277
client = self._client
228278
return client
229279

280+
def _parse_access_grants(self, access):
281+
"""Parse a resource fragment into a set of access grants.
282+
283+
:type access: list of mappings
284+
:param access: each mapping represents a single access grant
285+
286+
:rtype: list of :class:`AccessGrant`
287+
:returns: a list of parsed grants
288+
"""
289+
result = []
290+
for grant in access:
291+
grant = grant.copy()
292+
role = grant.pop('role')
293+
# Hypothetical case: we don't know that the back-end will ever
294+
# return such structures, but they are logical. See:
295+
# https://github.com/GoogleCloudPlatform/gcloud-python/pull/1046#discussion_r36687769
296+
for entity_type, entity_id in sorted(grant.items()):
297+
result.append(
298+
AccessGrant(role, entity_type, entity_id))
299+
return result
300+
230301
def _set_properties(self, api_response):
231302
"""Update properties from resource in body of ``api_response``
232303
@@ -235,12 +306,22 @@ def _set_properties(self, api_response):
235306
"""
236307
self._properties.clear()
237308
cleaned = api_response.copy()
309+
access = cleaned.pop('access', ())
310+
self.access_grants = self._parse_access_grants(access)
238311
if 'creationTime' in cleaned:
239312
cleaned['creationTime'] = float(cleaned['creationTime'])
240313
if 'lastModifiedTime' in cleaned:
241314
cleaned['lastModifiedTime'] = float(cleaned['lastModifiedTime'])
242315
self._properties.update(cleaned)
243316

317+
def _build_access_resource(self):
318+
"""Generate a resource fragment for dataset's access grants."""
319+
result = []
320+
for grant in self.access_grants:
321+
info = {'role': grant.role, grant.entity_type: grant.entity_id}
322+
result.append(info)
323+
return result
324+
244325
def _build_resource(self):
245326
"""Generate a resource for ``create`` or ``update``."""
246327
resource = {
@@ -260,6 +341,9 @@ def _build_resource(self):
260341
if self.location is not None:
261342
resource['location'] = self.location
262343

344+
if len(self.access_grants) > 0:
345+
resource['access'] = self._build_access_resource()
346+
263347
return resource
264348

265349
def create(self, client=None):

gcloud/bigquery/test_dataset.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
import unittest2
1616

1717

18+
class TestAccessGrant(unittest2.TestCase):
19+
20+
def _getTargetClass(self):
21+
from gcloud.bigquery.dataset import AccessGrant
22+
return AccessGrant
23+
24+
def _makeOne(self, *args, **kw):
25+
return self._getTargetClass()(*args, **kw)
26+
27+
def test_ctor_defaults(self):
28+
grant = self._makeOne('OWNER', 'userByEmail', 'phred@example.com')
29+
self.assertEqual(grant.role, 'OWNER')
30+
self.assertEqual(grant.entity_type, 'userByEmail')
31+
self.assertEqual(grant.entity_id, 'phred@example.com')
32+
33+
1834
class TestDataset(unittest2.TestCase):
1935
PROJECT = 'project'
2036
DS_NAME = 'dataset-name'
@@ -39,6 +55,8 @@ def _setUpConstants(self):
3955

4056
def _makeResource(self):
4157
self._setUpConstants()
58+
USER_EMAIL = 'phred@example.com'
59+
GROUP_EMAIL = 'group-name@lists.example.com'
4260
return {
4361
'creationTime': self.WHEN_TS * 1000,
4462
'datasetReference':
@@ -48,10 +66,32 @@ def _makeResource(self):
4866
'lastModifiedTime': self.WHEN_TS * 1000,
4967
'location': 'US',
5068
'selfLink': self.RESOURCE_URL,
69+
'access': [
70+
{'role': 'OWNER', 'userByEmail': USER_EMAIL},
71+
{'role': 'OWNER', 'groupByEmail': GROUP_EMAIL},
72+
{'role': 'WRITER', 'specialGroup': 'projectWriters'},
73+
{'role': 'READER', 'specialGroup': 'projectReaders'}],
5174
}
5275

53-
def _verifyResourceProperties(self, dataset, resource):
76+
def _verifyAccessGrants(self, access_grants, resource):
77+
r_grants = []
78+
for r_grant in resource['access']:
79+
role = r_grant.pop('role')
80+
for entity_type, entity_id in sorted(r_grant.items()):
81+
r_grants.append({'role': role,
82+
'entity_type': entity_type,
83+
'entity_id': entity_id})
84+
85+
self.assertEqual(len(access_grants), len(r_grants))
86+
for a_grant, r_grant in zip(access_grants, r_grants):
87+
self.assertEqual(a_grant.role, r_grant['role'])
88+
self.assertEqual(a_grant.entity_type, r_grant['entity_type'])
89+
self.assertEqual(a_grant.entity_id, r_grant['entity_id'])
90+
91+
def _verifyReadonlyResourceProperties(self, dataset, resource):
92+
5493
self.assertEqual(dataset.dataset_id, self.DS_ID)
94+
5595
if 'creationTime' in resource:
5696
self.assertEqual(dataset.created, self.WHEN)
5797
else:
@@ -69,12 +109,21 @@ def _verifyResourceProperties(self, dataset, resource):
69109
else:
70110
self.assertEqual(dataset.self_link, None)
71111

112+
def _verifyResourceProperties(self, dataset, resource):
113+
114+
self._verifyReadonlyResourceProperties(dataset, resource)
115+
72116
self.assertEqual(dataset.default_table_expiration_ms,
73117
resource.get('defaultTableExpirationMs'))
74118
self.assertEqual(dataset.description, resource.get('description'))
75119
self.assertEqual(dataset.friendly_name, resource.get('friendlyName'))
76120
self.assertEqual(dataset.location, resource.get('location'))
77121

122+
if 'access' in resource:
123+
self._verifyAccessGrants(dataset.access_grants, resource)
124+
else:
125+
self.assertEqual(dataset.access_grants, [])
126+
78127
def test_ctor(self):
79128
client = _Client(self.PROJECT)
80129
dataset = self._makeOne(self.DS_NAME, client)
@@ -84,6 +133,7 @@ def test_ctor(self):
84133
self.assertEqual(
85134
dataset.path,
86135
'/projects/%s/datasets/%s' % (self.PROJECT, self.DS_NAME))
136+
self.assertEqual(dataset.access_grants, [])
87137

88138
self.assertEqual(dataset.created, None)
89139
self.assertEqual(dataset.dataset_id, None)
@@ -96,6 +146,29 @@ def test_ctor(self):
96146
self.assertEqual(dataset.friendly_name, None)
97147
self.assertEqual(dataset.location, None)
98148

149+
def test_access_roles_setter_non_list(self):
150+
client = _Client(self.PROJECT)
151+
dataset = self._makeOne(self.DS_NAME, client)
152+
with self.assertRaises(TypeError):
153+
dataset.access_grants = object()
154+
155+
def test_access_roles_setter_invalid_field(self):
156+
from gcloud.bigquery.dataset import AccessGrant
157+
client = _Client(self.PROJECT)
158+
dataset = self._makeOne(self.DS_NAME, client)
159+
phred = AccessGrant('OWNER', 'userByEmail', 'phred@example.com')
160+
with self.assertRaises(ValueError):
161+
dataset.access_grants = [phred, object()]
162+
163+
def test_access_roles_setter(self):
164+
from gcloud.bigquery.dataset import AccessGrant
165+
client = _Client(self.PROJECT)
166+
dataset = self._makeOne(self.DS_NAME, client)
167+
phred = AccessGrant('OWNER', 'userByEmail', 'phred@example.com')
168+
bharney = AccessGrant('OWNER', 'userByEmail', 'bharney@example.com')
169+
dataset.access_grants = [phred, bharney]
170+
self.assertEqual(dataset.access_grants, [phred, bharney])
171+
99172
def test_default_table_expiration_ms_setter_bad_value(self):
100173
client = _Client(self.PROJECT)
101174
dataset = self._makeOne(self.DS_NAME, client)
@@ -175,6 +248,41 @@ def test_from_api_repr_w_properties(self):
175248
self.assertTrue(dataset._client is client)
176249
self._verifyResourceProperties(dataset, RESOURCE)
177250

251+
def test__parse_access_grants_w_unknown_entity_type(self):
252+
USER_EMAIL = 'phred@example.com'
253+
GROUP_EMAIL = 'group-name@lists.example.com'
254+
RESOURCE = {
255+
'access': [
256+
{'role': 'OWNER', 'userByEmail': USER_EMAIL},
257+
{'role': 'WRITER', 'groupByEmail': GROUP_EMAIL},
258+
{'role': 'READER', 'specialGroup': 'projectReaders'},
259+
{'role': 'READER', 'unknown': 'UNKNOWN'}]
260+
}
261+
client = _Client(self.PROJECT)
262+
dataset = self._makeOne(self.DS_NAME, client=client)
263+
grants = dataset._parse_access_grants(RESOURCE['access'])
264+
self._verifyAccessGrants(grants, RESOURCE)
265+
266+
def test__parse_access_grants_w_multiple_entity_types(self):
267+
# Hypothetical case: we don't know that the back-end will ever
268+
# return such structures, but they are logical. See:
269+
# https://github.com/GoogleCloudPlatform/gcloud-python/pull/1046#discussion_r36687769
270+
USER_EMAIL = 'phred@example.com'
271+
OTHER_EMAIL = 'bharney@example.com'
272+
GROUP_EMAIL = 'group-name@lists.example.com'
273+
RESOURCE = {
274+
'access': [
275+
{'role': 'OWNER', 'userByEmail': USER_EMAIL},
276+
{'role': 'WRITER', 'groupByEmail': GROUP_EMAIL},
277+
{'role': 'READER',
278+
'specialGroup': 'projectReaders',
279+
'userByEmail': OTHER_EMAIL}]
280+
}
281+
client = _Client(self.PROJECT)
282+
dataset = self._makeOne(self.DS_NAME, client=client)
283+
grants = dataset._parse_access_grants(RESOURCE['access'])
284+
self._verifyAccessGrants(grants, RESOURCE)
285+
178286
def test_create_w_bound_client(self):
179287
PATH = 'projects/%s/datasets' % self.PROJECT
180288
RESOURCE = self._makeResource()
@@ -196,7 +304,10 @@ def test_create_w_bound_client(self):
196304
self._verifyResourceProperties(dataset, RESOURCE)
197305

198306
def test_create_w_alternate_client(self):
307+
from gcloud.bigquery.dataset import AccessGrant
199308
PATH = 'projects/%s/datasets' % self.PROJECT
309+
USER_EMAIL = 'phred@example.com'
310+
GROUP_EMAIL = 'group-name@lists.example.com'
200311
DESCRIPTION = 'DESCRIPTION'
201312
TITLE = 'TITLE'
202313
RESOURCE = self._makeResource()
@@ -209,6 +320,11 @@ def test_create_w_alternate_client(self):
209320
dataset = self._makeOne(self.DS_NAME, client=CLIENT1)
210321
dataset.friendly_name = TITLE
211322
dataset.description = DESCRIPTION
323+
dataset.access_grants = [
324+
AccessGrant('OWNER', 'userByEmail', USER_EMAIL),
325+
AccessGrant('OWNER', 'groupByEmail', GROUP_EMAIL),
326+
AccessGrant('READER', 'specialGroup', 'projectReaders'),
327+
AccessGrant('WRITER', 'specialGroup', 'projectWriters')]
212328

213329
dataset.create(client=CLIENT2)
214330

@@ -222,6 +338,11 @@ def test_create_w_alternate_client(self):
222338
{'projectId': self.PROJECT, 'datasetId': self.DS_NAME},
223339
'description': DESCRIPTION,
224340
'friendlyName': TITLE,
341+
'access': [
342+
{'role': 'OWNER', 'userByEmail': USER_EMAIL},
343+
{'role': 'OWNER', 'groupByEmail': GROUP_EMAIL},
344+
{'role': 'READER', 'specialGroup': 'projectReaders'},
345+
{'role': 'WRITER', 'specialGroup': 'projectWriters'}],
225346
}
226347
self.assertEqual(req['data'], SENT)
227348
self._verifyResourceProperties(dataset, RESOURCE)

0 commit comments

Comments
 (0)