8000 Implemented support for databaseAuthVariableOverride option (#36) · helver/firebase-admin-python@1364ce6 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1364ce6

Browse files
authored
Implemented support for databaseAuthVariableOverride option (firebase#36)
* Implemented support for databaseAuthVariableOverride option * Adding newline at the end of acl.json * Using an ordered dict for passing additional params. This makes the query string order deterministic. * Passing params as a string to requests in all API calls. This ensures the order of the query params, and the proper encoding. * Preformatting the auth override query param * Implementing support for None auth overrides (guest access). Removed options.get() from the public API. * _AppOptions is already not a part of the public API. Adding the get() method back. * Merged rule files into one * Removing unused variable * Updated documentation
1 parent 0bf122e commit 1364ce6

File tree

6 files changed

+276
-38
lines changed

6 files changed

+276
-38
lines changed

firebase_admin/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ def __init__(self, options):
149149
'must be a dictionary.'.format(type(options)))
150150
self._options = options
151151

152-
def get(self, key):
152+
def get(self, key, default=None):
153153
"""Returns the option identified by the provided key."""
154-
return self._options.get(key)
154+
return self._options.get(key, default)
155155

156156

157157
class App(object):

firebase_admin/db.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,7 @@ def set(self, value):
145145
"""
146146
if value is None:
147147
raise ValueError('Value must not be None.')
148-
params = {'print' : 'silent'}
149-
self._client.request_oneway('put', self._add_suffix(), json=value, params=params)
148+
self._client.request_oneway('put', self._add_suffix(), json=value, params='print=silent')
150149

151150
def push(self, value=''):
152151
"""Creates a new child node.
@@ -185,8 +184,7 @@ def update(self, value):
185184
raise ValueError('Value argument must be a non-empty dictionary.')
186185
if None in value.keys() or None in value.values():
187186
raise ValueError('Dictionary must not contain None keys or values.')
188-
params = {'print':'silent'}
189-
self._client.request_oneway('patch', self._add_suffix(), json=value, params=params)
187+
self._client.request_oneway('patch', self._add_suffix(), json=value, params='print=silent')
190188

191189
def delete(self):
192190
"""Deleted this node from the database.
@@ -378,7 +376,7 @@ def equal_to(self, value):
378376
return self
379377

380378
@property
381-
def querystr(self):
379+
def _querystr(self):
382380
params = []
383381
for key in sorted(self._params):
384382
params.append('{0}={1}'.format(key, self._params[key]))
@@ -396,7 +394,7 @@ def get(self):
396394
Raises:
397395
ApiCallError: If an error occurs while communicating with the remote database server.
398396
"""
399-
result = self._client.request('get', '{0}?{1}'.format(self._pathurl, self.querystr))
397+
result = self._client.request('get', self._pathurl, params=self._querystr)
400398
if isinstance(result, (dict, list)) and self._order_by != '$priority':
401399
return _Sorter(result, self._order_by).get()
402400
return result
@@ -544,14 +542,33 @@ class _Client(object):
544542
marshalling and unmarshalling of JSON data.
545543
"""
546544

547-
def __init__(self, url=None, auth=None, session=None):
548-
self._url = url
549-
self._auth = auth
550-
self._session = session
545+
def __init__(self, **kwargs):
546+
"""Creates a new _Client from the given parameters.
547+
548+
This exists primarily to enable testing. For regular use, obtain _Client instances by
549+
calling the from_app() class method.
550+
551+
Keyword Args:
552+
url: Firebase Realtime Database URL.
553+
auth: An instance of requests.auth.AuthBase for authenticating outgoing HTTP requests.
554+
session: An HTTP session created using the the requests module.
555+
auth_override: A dictionary representing auth variable overrides or None (optional).
556+
Defaults to empty dict, which provides admin privileges. A None value here provides
557+
un-authenticated guest privileges.
558+
"""
559+
self._url = kwargs.pop('url')
560+
self._auth = kwargs.pop('auth')
561+
self._session = kwargs.pop('session')
562+
auth_override = kwargs.pop('auth_override', {})
563+
if auth_override != {}:
564+
encoded = json.dumps(auth_override, separators=(',', ':'))
565+
self._auth_override = 'auth_variable_override={0}'.format(encoded)
566+
else:
567+
self._auth_override = None
551568

552569
@classmethod
553570
def from_app(cls, app):
554-
"""Created a new _Client for a given App"""
571+
"""Creates a new _Client for a given App"""
555572
url = app.options.get('databaseURL')
556573
if not url or not isinstance(url, six.string_types):
557574
raise ValueError(
@@ -565,7 +582,13 @@ def from_app(cls, app):
565582
raise ValueError(
566583
'Invalid databaseURL option: "{0}". databaseURL must be a valid URL to a '
567584
'Firebase Realtime Database instance.'.format(url))
568-
return _Client('https://{0}'.format(parsed.netloc), _OAuth(app), requests.Session())
585+
586+
auth_override = app.options.get('databaseAuthVariableOverride', {})
587+
if auth_override is not None and not isinstance(auth_override, dict):
588+
raise ValueError('Invalid databaseAuthVariableOverride option: "{0}". Override '
589+
'value must be a dict or None.'.format(auth_override))
590+
return _Client(url='https://{0}'.format(parsed.netloc), auth=_OAuth(app),
591+
session=requests.Session(), auth_override=auth_override)
569592

570593
def request(self, method, urlpath, **kwargs):
571594
return self._do_request(method, urlpath, **kwargs).json()
@@ -591,6 +614,13 @@ def _do_request(self, method, urlpath, **kwargs):
591614
Raises:
592615
ApiCallError: If an error occurs while making the HTTP call.
593616
"""
617+
if self._auth_override:
618+
params = kwargs.get('params')
619+
if params:
620+
params += '&{0}'.format(self._auth_override)
621+
else:
622+
params = self._auth_override
623+
kwargs['params'] = params
594624
try:
595625
resp = self._session.request(method, self._url + urlpath, auth=self._auth, **kwargs)
596626
resp.raise_for_status()

integration/conftest.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ def _get_cert_path(request):
3434
raise ValueError('Service account certificate not specified. Make sure to specify the '
3535
'"--cert" command-line option.')
3636

37+
def integration_conf(request):
38+
cert_path = _get_cert_path(request)
39+
with open(cert_path) as cert:
40+
project_id = json.load(cert).get('project_id')
41+
if not project_id:
42+
raise ValueError('Failed to determine project ID from service account certificate.')
43+
return credentials.Certificate(cert_path), project_id
44+
3745
@pytest.fixture(autouse=True, scope='session')
3846
def default_app(request):
3947
"""Initializes the default Firebase App instance used for all integration tests.
@@ -42,12 +50,7 @@ def default_app(request):
4250
a test session. It is also marked as autouse, and therefore runs automatically without
4351
test cases having to call it explicitly.
4452
"""
45-
cert_path = _get_cert_path(request)
46-
with open(cert_path) as cert:
47-
project_id = json.load(cert).get('project_id')
48-
if not project_id:
49-
raise ValueError('Failed to determine project ID from service account certificate.')
50-
cred = credentials.Certificate(cert_path)
53+
cred, project_id = integration_conf(request)
5154
ops = {'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id)}
5255
return firebase_admin.initialize_app(cred, ops)
5356

integration/test_db.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,20 @@
1818

1919
import pytest
2020

21+
import firebase_admin
2122
from firebase_admin import db
23+
from integration import conftest
2224
from tests import testutils
2325

24-
def _update_rules():
25-
with open(testutils.resource_filename('dinosaurs_index.json')) as index_file:
26-
index = json.load(index_file)
26+
@pytest.fixture(scope='module')
27+
def update_rules():
28+
with open(testutils.resource_filename('dinosaurs_index.json')) as rules_file:
29+
new_rules = json.load(rules_file)
2730
client = db.reference()._client
2831
rules = client.request('get', '/.settings/rules.json')
2932
existing = rules.get('rules', dict()).get('_adminsdk')
30-
if existing != index:
31-
rules['rules']['_adminsdk'] = index
33+
if existing != new_rules:
34+
rules['rules']['_adminsdk'] = new_rules
3235
client.request('put', '/.settings/rules.json', json=rules)
3336

3437
@pytest.fixture(scope='module')
@@ -37,7 +40,7 @@ def testdata():
3740
return json.load(dino_file)
3841

3942
@pytest.fixture(scope='module')
40-
def testref():
43+
def testref(update_rules):
4144
"""Adds the necessary DB indices, and sets the initial values.
4245
4346
This fixture is attached to the module scope, and therefore is guaranteed to run only once
@@ -46,7 +49,6 @@ def testref():
4649
Returns:
4750
Reference: A reference to the test dinosaur database.
4851
"""
49-
_update_rules()
5052
ref = db.reference('_adminsdk/python/dinodb')
5153
ref.set(testdata())
5254
return ref
@@ -134,6 +136,13 @@ def test_update_children_with_existing_values(self, testref):
134136
ref.update({'since' : 1905})
135137
assert ref.get() == {'name' : 'Edwin Colbert', 'since' : 1905}
136138

139+
def test_update_nested_children(self, testref):
140+
python = testref.parent
141+
ref = python.child('users').push({'name' : 'Edward Cope', 'since' : 1800})
142+
nested_key = '{0}/since'.format(ref.key)
143+
python.child('users').update({nested_key: 1840})
144+
assert ref.get() == {'name' : 'Edward Cope', 'since' : 1840}
145+
137146
def test_delete(self, testref):
138147
python = testref.parent
139148
ref = python.child('users').push('foo')
@@ -220,3 +229,93 @@ def test_filter_by_value(self, testref):
220229
assert len(value) == 2
221230
assert 'pterodactyl' in value
222231
assert 'linhenykus' in value
232+
233+
234+
@pytest.fixture(scope='module')
235+
def override_app(request, update_rules):
236+
cred, project_id = conftest.integration_conf(request)
237+
ops = {
238+
'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id),
239+
'databaseAuthVariableOverride' : {'uid' : 'user1'}
240+
}
241+
app = firebase_admin.initialize_app(cred, ops, 'db-override')
242+
yield app
243+
firebase_admin.delete_app(app)
244+
245+
@pytest.fixture(scope='module')
246+
def none_override_app(request, update_rules):
247+
cred, project_id = conftest.integration_conf(request)
248+
ops = {
249+
'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id),
250+
'databaseAuthVariableOverride' : None
251+
}
252+
app = firebase_admin.initialize_app(cred, ops, 'db-none-override')
253+
yield app
254+
firebase_admin.delete_app(app)
255+
256+
257+
class TestAuthVariableOverride(object):
258+
"""Test cases for database auth variable overrides."""
259+
260+
def init_ref(self, path):
261+
admin_ref = db.reference(path)
262+
admin_ref.set('test')
263+
assert admin_ref.get() == 'test'
264+
265+
def check_permission_error(self, excinfo):
266+
assert isinstance(excinfo.value, db.ApiCallError)
267+
assert 'Reason: Permission denied' in str(excinfo.value)
268+
269+
def test_no_access(self, override_app):
270+
path = '_adminsdk/python/admin'
271+
self.init_ref(path)
272+
user_ref = db.reference(path, override_app)
273+
with pytest.raises(db.ApiCallError) as excinfo:
274+
assert user_ref.get()
275+
self.check_permission_error(excinfo)
276+
277+
with pytest.raises(db.ApiCallError) as excinfo:
278+
user_ref.set('test2')
279+
self.check_permission_error(excinfo)
280+
281+
def test_read(self, override_app):
282+
path = '_adminsdk/python/protected/user2'
283+
self.init_ref(path)
284+
user_ref = db.reference(path, override_app)
285+
assert user_ref.get() == 'test'
286+
with pytest.raises(db.ApiCallError) as excinfo:
287+
user_ref.set('test2')
288+
self.check_permission_error(excinfo)
289+
290+
def test_read_write(self, override_app):
291+
path = '_adminsdk/python/protected/user1'
292+
self.init_ref(path)
293+
user_ref = db.reference(path, override_app)
294+
assert user_ref.get() == 'test'
295+
user_ref.set('test2')
296+
assert user_ref.get() == 'test2'
297+
298+
def test_query(self, override_app):
299+
user_ref = db.reference('_adminsdk/python/protected', override_app)
300+
with pytest.raises(db.ApiCallError) as excinfo:
301+
user_ref.order_by_key().limit_to_first(2).get()
302+
self.check_permission_error(excinfo)
303+
304+
def test_none_auth_override(self, none_override_app):
305+
path = '_adminsdk/python/public'
306+
self.init_ref(path)
307+
public_ref = db.reference(path, none_override_app)
308+
assert public_ref.get() == 'test'
309+
310+
ref = db.reference('_adminsdk/python', none_override_app)
311+
with pytest.raises(db.ApiCallError) as excinfo:
312+
assert ref.child('protected/user1').get()
313+
self.check_permission_error(excinfo)
314+
315+
with pytest.raises(db.ApiCallError) as excinfo:
316+
assert ref.child('protected/user2').get()
317+
self.check_permission_error(excinfo)
318+
319+
with pytest.raises(db.ApiCallError) as excinfo:
320+
assert ref.child('admin').get()
321+
self.check_permission_error(excinfo)

tests/data/dinosaurs_index.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77
"scores": {
88
".indexOn": ".value"
99
}
10+
},
11+
"protected": {
12+
"$uid": {
13+
".read": "auth != null",
14+
".write": "$uid === auth.uid"
15+
}
16+
},
17+
"admin": {
18+
".read": "false",
19+
".write": "false"
20+
},
21+
"public": {
22+
".read": "true"
1023
}
1124
}
1225
}

0 commit comments

Comments
 (0)
0