blob: b6126f53a173d63fe19e31b7dc9f4cf19699a176 [file] [log] [blame]
Joe Gregorio9da2ad82011-09-11 14:04:44 -04001# Copyright 2011 Google Inc. All Rights Reserved.
2
3"""Multi-credential file store with lock support.
4
5This module implements a JSON credential store where multiple
6credentials can be stored in one file. That file supports locking
7both in a single process and across processes.
8
9The credential themselves are keyed off of:
10* client_id
11* user_agent
12* scope
13
14The format of the stored data is like so:
15{
16 'file_version': 1,
17 'data': [
18 {
19 'key': {
20 'clientId': '<client id>',
21 'userAgent': '<user agent>',
22 'scope': '<scope>'
23 },
Joe Gregorio562b7312011-09-15 09:06:38 -040024 'credential': {
25 # JSON serialized Credentials.
26 }
Joe Gregorio9da2ad82011-09-11 14:04:44 -040027 }
28 ]
29}
30"""
31
32__author__ = 'jbeda@google.com (Joe Beda)'
33
34import base64
35import fcntl
36import logging
37import os
Joe Gregorio9da2ad82011-09-11 14:04:44 -040038import threading
39
Joe Gregorio549230c2012-01-11 10:38:05 -050040from anyjson import simplejson
Joe Gregorio9da2ad82011-09-11 14:04:44 -040041from client import Storage as BaseStorage
Joe Gregorio562b7312011-09-15 09:06:38 -040042from client import Credentials
Joe Gregorio9da2ad82011-09-11 14:04:44 -040043
44logger = logging.getLogger(__name__)
45
46# A dict from 'filename'->_MultiStore instances
47_multistores = {}
48_multistores_lock = threading.Lock()
49
50
51class Error(Exception):
52 """Base error for this module."""
53 pass
54
55
56class NewerCredentialStoreError(Error):
57 """The credential store is a newer version that supported."""
58 pass
59
60
61def get_credential_storage(filename, client_id, user_agent, scope,
62 warn_on_readonly=True):
63 """Get a Storage instance for a credential.
64
65 Args:
66 filename: The JSON file storing a set of credentials
67 client_id: The client_id for the credential
68 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -040069 scope: string or list of strings, Scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -040070 warn_on_readonly: if True, log a warning if the store is readonly
71
72 Returns:
73 An object derived from client.Storage for getting/setting the
74 credential.
75 """
76 filename = os.path.realpath(os.path.expanduser(filename))
77 _multistores_lock.acquire()
78 try:
79 multistore = _multistores.setdefault(
80 filename, _MultiStore(filename, warn_on_readonly))
81 finally:
82 _multistores_lock.release()
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -040083 if type(scope) is list:
84 scope = ' '.join(scope)
Joe Gregorio9da2ad82011-09-11 14:04:44 -040085 return multistore._get_storage(client_id, user_agent, scope)
86
87
88class _MultiStore(object):
89 """A file backed store for multiple credentials."""
90
91 def __init__(self, filename, warn_on_readonly=True):
92 """Initialize the class.
93
94 This will create the file if necessary.
95 """
96 self._filename = filename
97 self._thread_lock = threading.Lock()
98 self._file_handle = None
99 self._read_only = False
100 self._warn_on_readonly = warn_on_readonly
101
102 self._create_file_if_needed()
103
104 # Cache of deserialized store. This is only valid after the
105 # _MultiStore is locked or _refresh_data_cache is called. This is
106 # of the form of:
107 #
108 # (client_id, user_agent, scope) -> OAuth2Credential
109 #
110 # If this is None, then the store hasn't been read yet.
111 self._data = None
112
113 class _Storage(BaseStorage):
114 """A Storage object that knows how to read/write a single credential."""
115
116 def __init__(self, multistore, client_id, user_agent, scope):
117 self._multistore = multistore
118 self._client_id = client_id
119 self._user_agent = user_agent
120 self._scope = scope
121
122 def acquire_lock(self):
123 """Acquires any lock necessary to access this Storage.
124
125 This lock is not reentrant.
126 """
127 self._multistore._lock()
128
129 def release_lock(self):
130 """Release the Storage lock.
131
132 Trying to release a lock that isn't held will result in a
133 RuntimeError.
134 """
135 self._multistore._unlock()
136
137 def locked_get(self):
138 """Retrieve credential.
139
140 The Storage lock must be held when this is called.
141
142 Returns:
143 oauth2client.client.Credentials
144 """
145 credential = self._multistore._get_credential(
146 self._client_id, self._user_agent, self._scope)
147 if credential:
148 credential.set_store(self)
149 return credential
150
151 def locked_put(self, credentials):
152 """Write a credential.
153
154 The Storage lock must be held when this is called.
155
156 Args:
157 credentials: Credentials, the credentials to store.
158 """
159 self._multistore._update_credential(credentials, self._scope)
160
161 def _create_file_if_needed(self):
162 """Create an empty file if necessary.
163
164 This method will not initialize the file. Instead it implements a
165 simple version of "touch" to ensure the file has been created.
166 """
167 if not os.path.exists(self._filename):
168 old_umask = os.umask(0177)
169 try:
170 open(self._filename, 'a+').close()
171 finally:
172 os.umask(old_umask)
173
174 def _lock(self):
175 """Lock the entire multistore."""
176 self._thread_lock.acquire()
177 # Check to see if the file is writeable.
178 if os.access(self._filename, os.W_OK):
179 self._file_handle = open(self._filename, 'r+')
180 fcntl.lockf(self._file_handle.fileno(), fcntl.LOCK_EX)
181 else:
182 # Cannot open in read/write mode. Open only in read mode.
183 self._file_handle = open(self._filename, 'r')
184 self._read_only = True
185 if self._warn_on_readonly:
186 logger.warn('The credentials file (%s) is not writable. Opening in '
187 'read-only mode. Any refreshed credentials will only be '
188 'valid for this run.' % self._filename)
189 if os.path.getsize(self._filename) == 0:
190 logger.debug('Initializing empty multistore file')
191 # The multistore is empty so write out an empty file.
192 self._data = {}
193 self._write()
194 elif not self._read_only or self._data is None:
195 # Only refresh the data if we are read/write or we haven't
196 # cached the data yet. If we are readonly, we assume is isn't
197 # changing out from under us and that we only have to read it
198 # once. This prevents us from whacking any new access keys that
199 # we have cached in memory but were unable to write out.
200 self._refresh_data_cache()
201
202 def _unlock(self):
203 """Release the lock on the multistore."""
204 if not self._read_only:
205 fcntl.lockf(self._file_handle.fileno(), fcntl.LOCK_UN)
206 self._file_handle.close()
207 self._thread_lock.release()
208
209 def _locked_json_read(self):
210 """Get the raw content of the multistore file.
211
212 The multistore must be locked when this is called.
213
214 Returns:
215 The contents of the multistore decoded as JSON.
216 """
217 assert self._thread_lock.locked()
218 self._file_handle.seek(0)
219 return simplejson.load(self._file_handle)
220
221 def _locked_json_write(self, data):
222 """Write a JSON serializable data structure to the multistore.
223
224 The multistore must be locked when this is called.
225
226 Args:
227 data: The data to be serialized and written.
228 """
229 assert self._thread_lock.locked()
230 if self._read_only:
231 return
232 self._file_handle.seek(0)
233 simplejson.dump(data, self._file_handle, sort_keys=True, indent=2)
234 self._file_handle.truncate()
235
236 def _refresh_data_cache(self):
237 """Refresh the contents of the multistore.
238
239 The multistore must be locked when this is called.
240
241 Raises:
242 NewerCredentialStoreError: Raised when a newer client has written the
243 store.
244 """
245 self._data = {}
246 try:
247 raw_data = self._locked_json_read()
248 except Exception:
249 logger.warn('Credential data store could not be loaded. '
250 'Will ignore and overwrite.')
251 return
252
253 version = 0
254 try:
255 version = raw_data['file_version']
256 except Exception:
257 logger.warn('Missing version for credential data store. It may be '
258 'corrupt or an old version. Overwriting.')
259 if version > 1:
260 raise NewerCredentialStoreError(
261 'Credential file has file_version of %d. '
262 'Only file_version of 1 is supported.' % version)
263
264 credentials = []
265 try:
266 credentials = raw_data['data']
267 except (TypeError, KeyError):
268 pass
269
270 for cred_entry in credentials:
271 try:
272 (key, credential) = self._decode_credential_from_json(cred_entry)
273 self._data[key] = credential
274 except:
275 # If something goes wrong loading a credential, just ignore it
276 logger.info('Error decoding credential, skipping', exc_info=True)
277
278 def _decode_credential_from_json(self, cred_entry):
279 """Load a credential from our JSON serialization.
280
281 Args:
282 cred_entry: A dict entry from the data member of our format
283
284 Returns:
285 (key, cred) where the key is the key tuple and the cred is the
286 OAuth2Credential object.
287 """
288 raw_key = cred_entry['key']
289 client_id = raw_key['clientId']
290 user_agent = raw_key['userAgent']
291 scope = raw_key['scope']
292 key = (client_id, user_agent, scope)
Joe Gregorio562b7312011-09-15 09:06:38 -0400293 credential = None
294 credential = Credentials.new_from_json(simplejson.dumps(cred_entry['credential']))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400295 return (key, credential)
296
297 def _write(self):
298 """Write the cached data back out.
299
300 The multistore must be locked.
301 """
302 raw_data = {'file_version': 1}
303 raw_creds = []
304 raw_data['data'] = raw_creds
305 for (cred_key, cred) in self._data.items():
306 raw_key = {
307 'clientId': cred_key[0],
308 'userAgent': cred_key[1],
309 'scope': cred_key[2]
310 }
Joe Gregorio562b7312011-09-15 09:06:38 -0400311 raw_cred = simplejson.loads(cred.to_json())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400312 raw_creds.append({'key': raw_key, 'credential': raw_cred})
313 self._locked_json_write(raw_data)
314
315 def _get_credential(self, client_id, user_agent, scope):
316 """Get a credential from the multistore.
317
318 The multistore must be locked.
319
320 Args:
321 client_id: The client_id for the credential
322 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400323 scope: A string for the scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400324
325 Returns:
326 The credential specified or None if not present
327 """
328 key = (client_id, user_agent, scope)
Joe Gregorio562b7312011-09-15 09:06:38 -0400329
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400330 return self._data.get(key, None)
331
332 def _update_credential(self, cred, scope):
333 """Update a credential and write the multistore.
334
335 This must be called when the multistore is locked.
336
337 Args:
338 cred: The OAuth2Credential to update/set
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400339 scope: The scope(s) that this credential covers
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400340 """
341 key = (cred.client_id, cred.user_agent, scope)
342 self._data[key] = cred
343 self._write()
344
345 def _get_storage(self, client_id, user_agent, scope):
346 """Get a Storage object to get/set a credential.
347
348 This Storage is a 'view' into the multistore.
349
350 Args:
351 client_id: The client_id for the credential
352 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400353 scope: A string for the scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400354
355 Returns:
356 A Storage object that can be used to get/set this cred
357 """
358 return self._Storage(self, client_id, user_agent, scope)