Store IP-based org-given roles in the user document.

This is a two-stage approach that happens when a new token is verified
with Blender ID and stored in our local MongoDB:

  - Given the remote IP address of the HTTP request, compute and store the
    org roles in the token document.
  - Recompute the user's roles based on their own roles, regular org roles,
    and the roles stored in non-expired token documents.

This happens once per hour, since that's how long we store tokens in our
database.
This commit is contained in:
2018-01-24 18:19:26 +01:00
parent 270bb21646
commit fd3e795824
7 changed files with 228 additions and 32 deletions

View File

@@ -10,9 +10,11 @@ import logging
import requests
from bson import tz_util
from rauth import OAuth2Session
from flask import Blueprint, request, current_app, jsonify, session
from flask import Blueprint, request, jsonify, session
from requests.adapters import HTTPAdapter
from pillar import current_app
from pillar.api import service
from pillar.api.utils import authentication
from pillar.api.utils.authentication import find_user_in_db, upsert_user
@@ -79,7 +81,13 @@ def validate_create_user(blender_id_user_id, token, oauth_subclient_id):
db_id, status = upsert_user(db_user)
# Store the token in MongoDB.
authentication.store_token(db_id, token, token_expiry, oauth_subclient_id)
ip_based_roles = current_app.org_manager.roles_for_request()
authentication.store_token(db_id, token, token_expiry, oauth_subclient_id,
org_roles=ip_based_roles)
if current_app.org_manager is not None:
roles = current_app.org_manager.refresh_roles(db_id)
db_user['roles'] = list(roles)
return db_user, status

View File

@@ -354,7 +354,16 @@ tokens_schema = {
'is_subclient_token': {
'type': 'boolean',
'required': False,
}
},
# Roles this user gets while this token is valid.
'org_roles': {
'type': 'list',
'default': [],
'schema': {
'type': 'string',
},
},
}
files_schema = {

View File

@@ -4,11 +4,13 @@ Assumes role names that are given to users by organization membership
start with the string "org-".
"""
import datetime
import logging
import typing
import attr
import bson
import flask
import werkzeug.exceptions as wz_exceptions
from pillar import attrs_extra, current_app
@@ -244,8 +246,11 @@ class OrgManager:
for uid in members:
self.refresh_roles(uid)
def refresh_roles(self, user_id: bson.ObjectId):
"""Refreshes the user's roles to own roles + organizations' roles."""
def refresh_roles(self, user_id: bson.ObjectId) -> typing.Set[str]:
"""Refreshes the user's roles to own roles + organizations' roles.
:returns: the applied set of roles.
"""
assert isinstance(user_id, bson.ObjectId)
@@ -254,30 +259,41 @@ class OrgManager:
self._log.info('Refreshing roles for user %s', user_id)
org_coll = current_app.db('organizations')
tokens_coll = current_app.db('tokens')
# Aggregate all org-given roles for this user.
query = org_coll.aggregate([
{'$match': {'members': user_id}},
{'$project': {'org_roles': 1}},
{'$unwind': {'path': '$org_roles'}},
{'$group': {
'_id': None,
'org_roles': {'$addToSet': '$org_roles'},
}}])
def aggr_roles(coll, match: dict) -> typing.Set[str]:
query = coll.aggregate([
{'$match': match},
{'$project': {'org_roles': 1}},
{'$unwind': {'path': '$org_roles'}},
{'$group': {
'_id': None,
'org_roles': {'$addToSet': '$org_roles'},
}}])
# If the user has no organizations at all, the query will have no results.
try:
org_roles_doc = query.next()
except StopIteration:
org_roles = set()
else:
org_roles = set(org_roles_doc['org_roles'])
# If the user has no organizations/tokens at all, the query will have no results.
try:
org_roles_doc = query.next()
except StopIteration:
return set()
return set(org_roles_doc['org_roles'])
# Join all organization-given roles and roles from the tokens collection.
org_roles = aggr_roles(org_coll, {'members': user_id})
self._log.debug('Organization-given roles for user %s: %s', user_id, org_roles)
now = datetime.datetime.now(bson.tz_util.utc)
token_roles = aggr_roles(tokens_coll, {
'user': user_id,
'expire_time': {"$gt": now},
})
self._log.debug('Token-given roles for user %s: %s', user_id, token_roles)
org_roles.update(token_roles)
users_coll = current_app.db('users')
user_doc = users_coll.find_one(user_id, projection={'roles': 1})
if not user_doc:
self._log.warning('Trying refresh roles of non-existing user %s, ignoring', user_id)
return
return set()
all_user_roles = set(user_doc.get('roles') or [])
existing_org_roles = {role for role in all_user_roles
@@ -291,6 +307,8 @@ class OrgManager:
if revoke_roles:
do_badger('revoke', roles=revoke_roles, user_id=user_id)
return all_user_roles.union(grant_roles) - revoke_roles
def user_is_admin(self, org_id: bson.ObjectId) -> bool:
"""Returns whether the currently logged in user is the admin of the organization."""
@@ -389,14 +407,37 @@ class OrgManager:
from . import ip_ranges
org_coll = current_app.db('organizations')
try:
q = ip_ranges.query(remote_addr)
except ValueError as ex:
self._log.warning('Invalid remote address %s, ignoring IP-based roles: %s',
remote_addr, ex)
return set()
orgs = org_coll.find(
{'ip_ranges': ip_ranges.query(remote_addr)},
{'ip_ranges': q},
projection={'org_roles': True},
)
return set(role
for org in orgs
for role in org.get('org_roles', []))
def roles_for_request(self) -> typing.Set[str]:
"""Find roles for user via the request's remote IP address."""
try:
remote_addr = flask.request.access_route[0]
except IndexError:
return set()
if not remote_addr:
return set()
roles = self.roles_for_ip_address(remote_addr)
self._log.debug('Roles for IP address %s: %s', remote_addr, roles)
return roles
def setup_app(app):
from . import patch, hooks

View File

@@ -55,7 +55,7 @@ def force_cli_user():
g.current_user = CLI_USER
def find_user_in_db(user_info: dict, provider='blender-id'):
def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
"""Find the user in our database, creating/updating the returned document where needed.
First, search for the user using its id from the provider, then try to look the user up via the
@@ -222,7 +222,8 @@ def hash_auth_token(token: str) -> str:
return base64.b64encode(digest).decode('ascii')
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False):
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
org_roles: typing.Set[str]=frozenset()):
"""Stores an authentication token.
:returns: the token document from MongoDB
@@ -237,6 +238,8 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False):
}
if oauth_subclient_id:
token_data['is_subclient_token'] = True
if org_roles:
token_data['org_roles'] = sorted(org_roles)
r, _, _, status = current_app.post_internal('tokens', token_data)

View File

@@ -67,6 +67,9 @@ class UserClass(flask_login.UserMixin):
def __repr__(self):
return f'UserClass(user_id={self.user_id})'
def __str__(self):
return f'{self.__class__.__name__}(id={self.user_id}, email={self.email!r}'
def __getitem__(self, item):
"""Compatibility layer with old dict-based g.current_user object."""

View File

@@ -477,13 +477,15 @@ class AbstractPillarTest(TestMinimal):
return urlencode(jsonified_params)
def client_request(self, method, path, qs=None, expected_status=200, auth_token=None, json=None,
data=None, headers=None, files=None, content_type=None, etag=None):
data=None, headers=None, files=None, content_type=None, etag=None,
environ_overrides=None):
"""Performs a HTTP request to the server."""
from pillar.api.utils import dumps
import json as mod_json
headers = headers or {}
environ_overrides = environ_overrides or {}
if auth_token is not None:
headers['Authorization'] = self.make_header(auth_token)
@@ -506,7 +508,8 @@ class AbstractPillarTest(TestMinimal):
resp = self.client.open(path=path, method=method, data=data, headers=headers,
content_type=content_type,
query_string=self.join_url_params(qs))
query_string=self.join_url_params(qs),
environ_overrides=environ_overrides)
self.assertEqual(expected_status, resp.status_code,
'Expected status %i but got %i. Response: %s' % (
expected_status, resp.status_code, resp.data