Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(283)

Unified Diff: appengine/components/components/auth/ui/rest_api.py

Issue 2550143003: auth_service: Add '/memberships/list' and '/memberships/check' API. (Closed)
Patch Set: add tests and more docs Created 4 years ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: appengine/components/components/auth/ui/rest_api.py
diff --git a/appengine/components/components/auth/ui/rest_api.py b/appengine/components/components/auth/ui/rest_api.py
index f1d377dd32826ccbf5c4de35a87999b5c547299d..6d7321641a081a02022178798eb2572f0abc6dda 100644
--- a/appengine/components/components/auth/ui/rest_api.py
+++ b/appengine/components/components/auth/ui/rest_api.py
@@ -52,6 +52,8 @@ def get_rest_api_routes():
webapp2.Route(
'/auth/api/v1/ip_whitelists/<name:%s>' % ip_whitelist_re,
IPWhitelistHandler),
+ webapp2.Route('/auth/api/v1/memberships/list', MembershipsListHandler),
+ webapp2.Route('/auth/api/v1/memberships/check', MembershipsCheckHandler),
webapp2.Route('/auth/api/v1/server/certificates', CertificatesHandler),
webapp2.Route('/auth/api/v1/server/info', ServerInfoHandler),
webapp2.Route('/auth/api/v1/server/oauth_config', OAuthConfigHandler),
@@ -911,6 +913,293 @@ class IPWhitelistHandler(EntityHandlerBase):
entity.key.delete()
+class PerIdentityBatchHandler(handler.ApiHandler):
+ """A class with the POST handler being the batch version of the GET handler.
+
+ GET handler accepts 'identity=...' query parameter.
+ POST handler accepts {'per_identity': {<ident>: <parameters>}} dict.
+
+ Subclasses must override 'collect_get_params', 'validate_params' and
+ 'execute_batch'.
+ """
+
+ # POST here is not state-modifying, no need for XSRF token.
+ xsrf_token_enforce_on = ()
+
+ def collect_get_params(self):
+ """Examines GET query and returns a dict with them.
+
+ The format of the dict must match what is supposed to be passed as a value
+ in 'per_identity' dict in POST body. So POST body is essentially
+ a collection of GET queries to be executed as a batch.
+ """
+ raise NotImplementedError()
+
+ def validate_params(self, params):
+ """Takes a dict with some single query parameters and validates it.
+
+ Raises ValueError if parameters are invalid.
+ """
+ raise NotImplementedError()
+
+ def execute_batch(self, queries):
+ """Takes a dict {Identity => params dict} and returns {Identity => results}.
+
+ Parameters are already validated at this point.
+ """
+ raise NotImplementedError()
+
+ @api.require(acl.has_access)
+ def post(self):
+ self.send_response(self._handle_batch(self.parse_body()))
+
+ @api.require(acl.has_access)
+ def get(self):
+ ident = self.request.get('identity')
+ if not ident:
+ self.abort_with_error(400, text='"identity" query parameter is required')
+ try:
+ model.Identity.from_bytes(ident)
+ except ValueError as e:
+ self.abort_with_error(400, text='Invalid "identity" - %s' % e)
+
+ # Make the "batch" call with the single request.
+ resp = self._handle_batch({
+ 'per_identity': {
+ ident: self.collect_get_params(),
+ },
+ })
+
+ # Extract back singular response.
+ per_ident = resp.get('per_identity')
+ assert ident in per_ident, resp
+ self.send_response(per_ident[ident])
+
+ def _handle_batch(self, body):
+ # 'per_identity' is a dict {identity => request parameters},
+ if not isinstance(body, dict):
+ self.abort_with_error(400, text='The body must be a dict')
+ per_identity = body.get('per_identity', None)
+ if not isinstance(per_identity, dict) or not per_identity:
+ self.abort_with_error(400, text='"per_identity" must be a non-empty dict')
+
+ # Validate individual queries.
+ queries = {}
+ for ident_str, params in per_identity.iteritems():
+ try:
+ ident = model.Identity.from_bytes(ident_str)
+ except ValueError as e:
+ self.abort_with_error(
+ 400, text='Not a valid identity %r - %s' % (ident_str, e))
+ # Make sure 'ident' serializes back to 'ident_str'. This is important,
+ # since we use it as key in the response, and callers most likely will be
+ # searching for exact same value they pass in the query.
+ assert ident.to_bytes() == ident_str, (ident, ident_str)
+ if params is None:
+ params = {}
+ try:
+ if not isinstance(params, dict):
+ raise ValueError('parameters must be specified as a dict')
+ self.validate_params(params)
+ except ValueError as e:
+ self.abort_with_error(400, text='When querying %s: %s' % (ident_str, e))
+ queries[ident] = params
+
+ return {
+ 'per_identity': {
+ ident.to_bytes(): res
+ for ident, res in self.execute_batch(queries).iteritems()
+ },
+ }
+
+
+class MembershipsListHandler(PerIdentityBatchHandler):
+ """Lists all groups a user belongs to."""
+
+ # This is visible in the UI.
+ api_doc = [
+ {
+ 'verb': 'GET',
+ 'params': 'identity=...',
+
+ 'doc':
+ 'Returns a list of groups an identity belongs to (including all '
+ 'transitive relations) as a list of memberships.',
+
+ 'response_type': {
+ 'name': 'Membership list',
+ 'doc': 'Represents a list of groups some identity is a member of.',
+ 'example': {
+ 'memberships': [
+ {'group': 'Group name'},
+ {'group': 'Another group name'},
+ ],
+ },
+ },
+ },
+
+ {
+ 'verb': 'POST',
+
+ 'doc':
+ 'A batch version of the membership listing call. Executes multiple '
+ 'queries for multiple identities in parallel.',
+
+ 'request_type': {
+ 'name': 'Batch listing request',
+ 'doc':
+ 'A request to query a list of groups of multiple identities in '
+ 'parallel. Per-identity dict values are options for membership '
+ 'listing (there are currently none, so pass null or {}).',
+ 'example': {
+ 'per_identity': {
+ 'user:someone@example.com': None,
+ 'user:someone_else@example.com': None,
+ },
+ },
+ },
+
+ 'response_type': {
+ 'name': 'Batch listing response',
+ 'doc':
+ 'For each identity specifies a list of groups it is a member of '
+ '(in the same format as non-batched version).',
+ 'example': {
+ 'per_identity': {
+ 'user:someone@example.com': {
+ 'memberships': [
+ {'group': 'Group name'},
+ {'group': 'Another group name'},
+ ],
+ },
+ 'user:someone_else@example.com': {
+ 'memberships': [
+ {'group': 'Group name'},
+ {'group': 'Another group name'},
+ ],
+ },
+ },
+ },
+ },
+ },
+ ]
+
+ def collect_get_params(self):
+ # No parameters for now.
+ return {}
+
+ def validate_params(self, params):
+ # No parameters for now.
+ pass
+
+ def execute_batch(self, queries):
+ # Since we currently have all groups data in memory, doing queries truly
+ # in parallel will only hurt, since we have only one CPU and there's no IO.
+ auth_db = api.get_request_cache().auth_db
+ resp = {}
+ for ident in queries:
+ resp[ident] = {
+ 'memberships': [
+ {'group': g} for g in sorted(auth_db.fetch_groups_with_member(ident))
+ ],
+ }
+ return resp
+
+
+class MembershipsCheckHandler(PerIdentityBatchHandler):
+ """Checks whether an identity belongs to any of given groups."""
+
+ # This is visible in the UI.
+ api_doc = [
+ {
+ 'verb': 'GET',
+ 'params': 'identity=...&groups=...',
+
+ 'doc':
+ 'Checks whether a user belongs to any of given groups (provided via '
+ '"groups" query parameter that can be specified multiple times).',
+
+ 'response_type': {
+ 'name': 'Check response',
+ 'doc':
+ 'Indicates whether the identity is a member of any of the groups '
+ 'specified in the request.',
+ 'example': {
+ 'is_member': True,
+ }
+ },
+ },
+
+ {
+ 'verb': 'POST',
+
+ 'doc':
+ 'A batch version of the membership check call. Executes multiple '
+ 'checks for multiple identities in parallel.',
+
+ 'request_type': {
+ 'name': 'Batch check request',
+ 'doc':
+ 'Represents a request to check memberships of multiple identities in '
+ 'parallel.',
+ 'example': {
+ 'per_identity': {
+ 'user:someone@example.com': {
+ 'groups': ['Group A', 'Group B'],
+ },
+ 'user:someone_else@example.com': {
+ 'groups': ['Group C', 'Group D'],
+ },
+ },
+ },
+ },
+
+ 'response_type': {
+ 'name': 'Batch check response',
+ 'doc':
+ 'For each queried identity specifies whether it is a member of any '
+ 'of the groups specified in the request for this identity.',
+ 'example': {
+ 'per_identity': {
+ 'user:someone@example.com': {
+ 'is_member': True,
+ },
+ 'user:someone_else@example.com': {
+ 'is_member': False,
+ },
+ },
+ },
+ }
+ },
+ ]
+
+ def collect_get_params(self):
+ groups = self.request.GET.getall('groups')
+ if not groups:
+ self.abort_with_error(400, text='"groups" query parameter is required')
+ return {'groups': groups}
+
+ def validate_params(self, params):
+ groups = params.get('groups')
+ if not isinstance(groups, list) or not groups:
+ raise ValueError('must specify a non-empty list of groups to check')
+ for g in groups:
+ if not isinstance(g, basestring):
+ raise ValueError('not a group name %r' % (g,))
+
+ def execute_batch(self, queries):
+ # Since we currently have all groups data in memory, doing queries truly
+ # in parallel will only hurt, since we have only one CPU and there's no IO.
+ auth_db = api.get_request_cache().auth_db
+ resp = {}
+ for iden, p in queries.iteritems():
+ assert isinstance(p['groups'], list)
+ resp[iden] = {
+ 'is_member': any(auth_db.is_group_member(g, iden) for g in p['groups']),
+ }
+ return resp
+
+
class ServerInfoHandler(handler.ApiHandler):
"""Returns information about the service (app version, service account name).
« no previous file with comments | « appengine/components/components/auth/api.py ('k') | appengine/components/components/auth/ui/rest_api_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698