Skip to content

Commit 3145ee0

Browse files
committed
Diverse updates and fixes
- Update cloud_storage and local to abstract out common code and make the systems more usable - Fix Firestore implementation - Add the key_uploader - Improve READEME.md
1 parent 9ca7df6 commit 3145ee0

File tree

11 files changed

+481
-338
lines changed

11 files changed

+481
-338
lines changed

README.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ to enable the API you need.
3636
### Secret Manager
3737

3838
Two secrets will need to be manually added to Secret Manager before the library
39-
can be used. These are the client id and client secret. The easiest way to do
39+
can be used. These are the client id and client secret. One way to do
4040
this is using a small shell script like this:
4141

4242
```
@@ -78,11 +78,18 @@ echo "{ \"client_id\": \"${CLIENT_SECRET}\" }" | gcloud --project ${PROJECT} sec
7878
The library will create any further secrets and versions automatically. It will
7979
also remove all but the latest secret each time an update occurs. This reduces
8080
the usage cost of Secret Manager substantially as projects are charged based
81-
partially on number of _active_ (ie not destroyed) secret versions.
81+
partially on number of _active_ and _disabled_ (ie not destroyed) secret
82+
versions.
83+
84+
You can also use [`KeyUploader`](#keyuploader).
8285

8386
### Firestore
8487

85-
Firestore requires no additional configuration.
88+
Firestore requires no additional configuration, although you will have to
89+
seed the Firestore database with the client secret data, much like with Secret
90+
Manager above. Unfortunately there is no CLI access through `gcloud` to
91+
Firestore so we can't write a simple shell script. In this case, please see
92+
below in the section on [`KeyUploader`](#keyuploader).
8693

8794
### Google Cloud Storage
8895

@@ -91,6 +98,7 @@ token files and project secrets are to be stored and to which the app's service
9198
account has read/write access. This should then be locked down so that no other
9299
non-administrators have access.
93100

101+
94102
### Local files
95103

96104
No special configuration is required. This implementation is HIGHLY insecure,
@@ -157,3 +165,43 @@ manager = SecretManager(project='<gcp project name>')
157165
158166
manager.list_documents()
159167
```
168+
169+
## [`KeyUploader`](#keyuploader)
170+
171+
The Key Uploader (`key_upload.py`) is a way of inserting `json` files into your
172+
datastore from the command line. It will work for any key, and any of the
173+
supplied datastores. This will allow you to pre-load `Firestore` with the
174+
`client_secret` keys necessary, but can also be used for `CloudStorage`,
175+
`LocalFile` and `SecretManager` implementations simply by changing a switch.
176+
177+
The file to be uploaded can be stored either locally or on
178+
`Google Cloud Storage`.
179+
180+
For example: to run the `KeyUploader` and install the client secrets file into
181+
Firestore, you would do the following:
182+
183+
```
184+
python auth.cli.key_upload.py \
185+
--key=client_secret \
186+
--firestore \
187+
--email=YOUR_EMAIL \
188+
--file=PATH/TO/client_secrets.json
189+
```
190+
191+
Your available command-line switches are:
192+
193+
| Switch | | Description |
194+
| ------------------ | -------------- | -------------------------------------------------------------------------------------------- |
195+
| `--project` | _optional_ | The Google Cloud project to use (if it is not your default) |
196+
| `--email` | _optional_ | Your email address. This will be placed in the document. |
197+
| `--file` | **_required_** | The file containing the json to be uploaded. |
198+
| `--key` | **_required_** | The name of the key to install. This must be valid for your storage method. |
199+
| `--encode_key` | _optional_ | Base64 encode the key. Do this for any user tokens, but **NOT** for the `client_secret` key. |
200+
| `--local` | _optional_ | Create/write to a local file. |
201+
| `--firestore` | _optional_ | Use Firestore. |
202+
| `--secret_manager` | _optional_ | Use Secret Manager. |
203+
| `--cloud_storage` | _optional_ | Use Google Cloud Storage. |
204+
| `--bucket` | _optional_ | The bucket in GCS to store the data in. |
205+
206+
**NOTE** _One and only one_ of `--local`, `--firestore`, `--cloud_storage`
207+
and `--secret_manager` must be specified

auth/abstract_datastore.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from __future__ import annotations
1515

1616
from typing import Any, Dict, List, Optional
17+
from auth import decorators
1718

1819

1920
class AbstractDatastore(object):
@@ -29,7 +30,23 @@ class AbstractDatastore(object):
2930
All unimplemented functions raise a NotImplementedError() rather than
3031
simply 'pass'.
3132
"""
32-
def get_document(self, key: Optional[str]=None) -> Dict[str, Any]:
33+
@decorators.lazy_property
34+
def project(self) -> str:
35+
return self._project
36+
37+
@project.setter
38+
def project(self, project: str) -> None:
39+
self._project = project
40+
41+
@decorators.lazy_property
42+
def email(self) -> str:
43+
return self._email
44+
45+
@email.setter
46+
def email(self, email: str) -> None:
47+
self._email = email
48+
49+
def get_document(self, key: Optional[str] = None) -> Dict[str, Any]:
3350
"""Fetches a document (could be anything, 'type' identifies the root.)
3451
3552
Fetch a document
@@ -48,9 +65,7 @@ def store_document(self, id: str,
4865
document: Dict[str, Any]) -> None:
4966
"""Stores a document.
5067
51-
Store a document in Firestore. They're all stored by Type
52-
(DCM/DBM/SA360/ADH) and each one within the type is keyed by the
53-
appropriate report id.
68+
Store a document in the datastore.
5469
5570
Arguments:
5671
id (str): report id
@@ -61,7 +76,7 @@ def store_document(self, id: str,
6176
def update_document(self, id: str, new_data: Dict[str, Any]) -> None:
6277
"""Updates a document.
6378
64-
Update a document in Firestore. If the document is not already there, it
79+
Update a document in the datastore. If the document is not already there, it
6580
will be created as a net-new document. If it is, it will be updated.
6681
6782
Args:
@@ -70,10 +85,10 @@ def update_document(self, id: str, new_data: Dict[str, Any]) -> None:
7085
"""
7186
raise NotImplementedError('Must be implemented by child class.')
7287

73-
def delete_document(self, id: str, key: Optional[str]=None) -> None:
88+
def delete_document(self, id: str, key: Optional[str] = None) -> None:
7489
"""Deletes a document.
7590
76-
This removes a document or partial document from the Firestore. If a key is
91+
This removes a document or partial document from the datastore. If a key is
7792
supplied, then just that key is removed from the document. If no key is
7893
given, the entire document will be removed from the collection.
7994
@@ -83,15 +98,10 @@ def delete_document(self, id: str, key: Optional[str]=None) -> None:
8398
"""
8499
raise NotImplementedError('Must be implemented by child class.')
85100

86-
def list_documents(self, key: str=None) -> List[str]:
101+
def list_documents(self, key: str = None) -> List[str]:
87102
"""Lists documents in a collection.
88103
89-
List all the documents in the collection 'type'. If a key is give, list
90-
all the sub-documents of that key. For example:
91-
92-
list_documents(Type.SA360_RPT) will show { '_reports', report_1, ... }
93-
list_documents(Type.SA360_RPT, '_reports') will return
94-
{ 'holiday_2020', 'sa360_hourly_depleted', ...}
104+
List all the documents.
95105
96106
Args:
97107
key (str, optional): the sub-key. Defaults to None.
@@ -104,7 +114,7 @@ def list_documents(self, key: str=None) -> List[str]:
104114
def get_all_documents(self) -> List[Dict[str, Any]]:
105115
"""Fetches all documents
106116
107-
Fetches all documents of a given Type.
117+
Fetches all documents.
108118
109119
Returns:
110120
runners (List[Dict[str, Any]]): contents of all documents

auth/cli/key_upload.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import base64
16+
import json
17+
import logging
18+
import os
19+
from contextlib import suppress
20+
from datetime import datetime
21+
22+
import gcsfs
23+
from absl import app, flags
24+
from auth.credentials_helpers import encode_key
25+
26+
27+
FLAGS = flags.FLAGS
28+
flags.DEFINE_string('project', None, 'GCP Project.')
29+
flags.DEFINE_string('email', None, 'Report owner/user email.')
30+
flags.DEFINE_string('key', None, 'Key to create/update')
31+
flags.DEFINE_string('file', None, 'File containing json data')
32+
flags.DEFINE_string('bucket', None, 'GCS Bucket for the datastore.')
33+
flags.DEFINE_bool('encode_key', False, 'Encode the key (for tokens).')
34+
flags.DEFINE_bool('local', False, 'Local storage.')
35+
flags.DEFINE_bool('firestore', False, 'Send to Firestore.')
36+
flags.DEFINE_bool('secret_manager', False, 'Send to Secret Manager.')
37+
flags.DEFINE_bool('cloud_storage', False, 'Send to GCS.')
38+
flags.mark_flags_as_required(['file', 'key'])
39+
flags.mark_bool_flags_as_mutual_exclusive(
40+
['local', 'firestore', 'secret_manager', 'cloud_storage'], required=True)
41+
42+
43+
def upload(**args) -> None:
44+
"""Uploads data to firestore.
45+
46+
Args:
47+
key (str): the data key.
48+
file (str): the file containing the data.
49+
encode_key (bool): should the key be encoded (eg is it an email).
50+
local_store (bool): local storage (True) or Firestore (False).
51+
"""
52+
_project = args.get('project')
53+
_key = args.get('key')
54+
55+
if file := args.get('file'):
56+
if file.startswith('gs://'):
57+
with gcsfs.GCSFileSystem(project=_project).open(file, 'r') as data_file:
58+
src_data = json.loads(data_file.read())
59+
else:
60+
# Assume locally stored token file
61+
with open(file, 'r') as data_file:
62+
src_data = json.loads(data_file.read())
63+
64+
if args.get('encode_key'):
65+
key = encode_key(_key)
66+
67+
else:
68+
key = _key
69+
70+
src_data['email'] = _key
71+
72+
if args.get('local_store'):
73+
from auth.datastore.local_datastore import LocalDatastore
74+
f = LocalDatastore()
75+
76+
if args.get('firestore'):
77+
from auth.datastore.firestore import Firestore
78+
f = Firestore()
79+
80+
if args.get('secret_manager'):
81+
from auth.datastore.secret_manager import SecretManager
82+
f = SecretManager(project=_project, email=args.get('email'))
83+
84+
if args.get('cloud_storage'):
85+
from auth.datastore.cloud_storage import CloudStorage
86+
f = CloudStorage(project=_project,
87+
email=args.get('email'),
88+
bucket=args.get('bucket'))
89+
90+
f.update_document(id=key, new_data=src_data)
91+
92+
93+
def main(unused_argv):
94+
upload(**FLAGS.flag_values_dict())
95+
96+
97+
if __name__ == '__main__':
98+
with suppress(SystemExit):
99+
app.run(main)

auth/cli/key_upload_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Copyright 2021 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
import unittest
17+
from unittest import mock
18+
19+
from auth import local_datastore
20+
from classes.report_type import Type
21+
from cli import key_upload
22+
23+
from copy import deepcopy
24+
from typing import Any, Callable, Dict, Mapping
25+
26+
CLASS_UNDER_TEST = 'cli.key_upload'
27+
28+
29+
class KeyUploadTest(unittest.TestCase):
30+
def setUp(self) -> None:
31+
self.valid_source = {'test_root': {'a': 'A', 'b': 'B'}, 'email': 'key'}
32+
self.open = mock.mock_open(read_data=json.dumps(self.valid_source))
33+
self.mock_datastore = mock.MagicMock()
34+
self.mock_datastore.update_document.return_value = None
35+
36+
def test_good_unencoded(self):
37+
with mock.patch(f'{CLASS_UNDER_TEST}.open', self.open):
38+
with mock.patch.object(local_datastore.LocalDatastore,
39+
'update_document',
40+
return_value=None) as mock_method:
41+
event = {
42+
'key': 'key',
43+
'file': 'test.json',
44+
'encode_key': False,
45+
'local_store': True,
46+
}
47+
_ = key_upload.upload(**event)
48+
self.open.assert_called_with('test.json', 'r')
49+
self.open().read.assert_called()
50+
mock_method.assert_called()
51+
mock_method.assert_called_with(type=Type._ADMIN, id='key',
52+
new_data=self.valid_source)
53+
54+
def test_good_encoded(self):
55+
with mock.patch(f'{CLASS_UNDER_TEST}.open', self.open):
56+
with mock.patch.object(local_datastore.LocalDatastore,
57+
'update_document',
58+
return_value=None) as mock_method:
59+
event = {
60+
'key': 'key',
61+
'file': 'test.json',
62+
'encode_key': True,
63+
'local_store': True,
64+
}
65+
_ = key_upload.upload(**event)
66+
self.open.assert_called_with('test.json', 'r')
67+
self.open().read.assert_called()
68+
mock_method.assert_called()
69+
expected = deepcopy(self.valid_source)
70+
71+
mock_method.assert_called_with(type=Type._ADMIN, id='a2V5',
72+
new_data=expected)
73+

0 commit comments

Comments
 (0)