Skip to content
This repository has been archived by the owner on Jan 19, 2022. It is now read-only.

Commit

Permalink
Merge 52eb4bb into 6862bf6
Browse files Browse the repository at this point in the history
  • Loading branch information
djmitche committed Mar 25, 2015
2 parents 6862bf6 + 52eb4bb commit 62d94d2
Show file tree
Hide file tree
Showing 18 changed files with 400 additions and 45 deletions.
6 changes: 5 additions & 1 deletion docs/development/@relengapi/app.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Flask App

The RelengAPI Flask App is mostly a normal Flask App, but has a few additional attributes that may be of use:

.. py:class:: Flask
.. py:class:: flask.Flask
.. py:attribute:: relengapi_blueprints
Expand All @@ -18,3 +18,7 @@ The RelengAPI Flask App is mostly a normal Flask App, but has a few additional a
.. py:attribute:: aws
See :doc:`aws`

.. py:attribute:: authz
See :doc:`auth`
18 changes: 18 additions & 0 deletions docs/development/@relengapi/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,21 @@ The Permission class
Return True if the current user can perform all of the given permissions
See :py:meth:`Permission.can`.

Out-of-band Authorization Access
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

For cases where you need information about a user outside of a request context for that user, use ``app.authz``.

The Flask application has an ``authz`` attribute that is a subclass of this class:

.. py:class:: relengapi.lib.auth.base.BaseAuthz
.. py:method:: get_user_permissions(email)
:param email: user's email
:raises: NotImplementedError
:returns: set of permissions or None

Get the given user's permissions, or None if the user is not available.

14 changes: 14 additions & 0 deletions docs/usage/@relengapi/tokenauth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ Tokens are opaque strings (actually JSON Web Tokens) which are provided in the A
Each token permits a limited set of permissions, specified when the token is issued.

Issuing Tokens Via UI
---------------------

If you have adequate permissions, the API home page will display a link to manage tokens.
On this page, you can issue, examine, and revoke user and permanent tokens, depending on your permissions.

User Tokens and User Permissions
--------------------------------

User tokens which grant permissions that the user no longer posesses are automatically disabled.
In the UI, they are indicated with a "(DISABLED)" tag.
Such tokens are not usable for authentication
If the user's permissions change back (for example, if the user was misconfigured temporarily), the token will be re-enabled.

Managing Tokens
---------------

Expand Down
24 changes: 16 additions & 8 deletions relengapi/blueprints/tokenauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
from relengapi.blueprints.tokenauth import tables
from relengapi.blueprints.tokenauth import tokenstr
from relengapi.blueprints.tokenauth import types
from relengapi.blueprints.tokenauth import usermonitor
from relengapi.lib import angular
from relengapi.lib import api
from relengapi.lib import auth
from relengapi.util import tz
from werkzeug.exceptions import BadRequest
from werkzeug.exceptions import Forbidden
Expand Down Expand Up @@ -78,6 +78,9 @@ def user_to_jsontoken(user):
if 'prm' in cl:
attrs['permissions'] = cl['prm']

# we never load disabled users, so this one isn't disabled
attrs['disabled'] = False

if user.token_data:
td = user.token_data
attrs['id'] = td.id
Expand Down Expand Up @@ -172,7 +175,8 @@ def issue_prm(body, requested_permissions):
token_row = tables.Token(
typ='prm',
description=body.description,
permissions=requested_permissions)
permissions=requested_permissions,
disabled=False)
session.add(token_row)
session.commit()

Expand Down Expand Up @@ -208,7 +212,8 @@ def issue_tmp(body, requested_permissions):
not_before=tz.utcfromtimestamp(nbf),
expires=body.expires,
permissions=perm_strs,
metadata=body.metadata)
metadata=body.metadata,
disabled=False)


@token_issuer('usr')
Expand All @@ -223,7 +228,8 @@ def issue_usr(body, requested_permissions):
typ='usr',
user=email,
description=body.description,
permissions=requested_permissions)
permissions=requested_permissions,
disabled=False)
session.add(token_row)
session.commit()

Expand Down Expand Up @@ -258,6 +264,10 @@ def issue_token(body):
if getattr(body, attr) is wsme.Unset:
raise BadRequest("missing %s" % attr)

# prohibit silly requests
if body.disabled:
raise BadRequest("can't issue disabled tokens")

# All types have permissions, so handle those here -- ensure the request is
# for a subset of the permissions the user can perform
requested_permissions = [p.get(a) for a in body.permissions]
Expand Down Expand Up @@ -332,10 +342,8 @@ def revoke_token(token_id):
return None, 204


# enable the loader to get a look at each incoming request
auth.request_loader(loader.token_loader)


@bp.record
def init_blueprint(state):
tokenstr.init_app(state.app)
loader.init_app(state.app)
usermonitor.init_app(state.app)
7 changes: 6 additions & 1 deletion relengapi/blueprints/tokenauth/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def from_str(self, token_str):


token_loader = TokenLoader()
auth.request_loader(token_loader)


@token_loader.type_function('prm')
Expand Down Expand Up @@ -107,9 +108,13 @@ def tmp_loader(claims):
def usr_loader(claims):
token_id = tokenstr.jti2id(claims['jti'])
token_data = tables.Token.query.filter_by(id=token_id).first()
if token_data:
if token_data and not token_data.disabled:
assert token_data.typ == 'usr'
return TokenUser(claims,
permissions=token_data.permissions,
token_data=token_data,
authenticated_email=token_data.user)


def init_app(app):
pass
3 changes: 3 additions & 0 deletions relengapi/blueprints/tokenauth/static/tokens.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ <h3>Active Tokens</h3>
<div class="row">
<div class="col-sm-10">
<small>
<span ng-if="token.disabled" class="text-danger">
(DISABLED)
</span>
<span ng-if="token.typ == 'prm'" class="text-warning">
(permanent)
</span>
Expand Down
4 changes: 3 additions & 1 deletion relengapi/blueprints/tokenauth/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ def __init__(self, permissions=None, **kwargs):
typ = sa.Column(sa.String(4), nullable=False)
description = sa.Column(sa.Text, nullable=False)
user = sa.Column(sa.Text, nullable=True)
disabled = sa.Column(sa.Boolean, nullable=False)
_permissions = sa.Column(sa.Text, nullable=False)

def to_jsontoken(self):
tok = types.JsonToken(id=self.id, typ=self.typ, description=self.description,
permissions=[str(a) for a in self.permissions])
permissions=[str(a) for a in self.permissions],
disabled=self.disabled)
if self.user:
tok.user = self.user
return tok
Expand Down
13 changes: 13 additions & 0 deletions relengapi/blueprints/tokenauth/test_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from nose.tools import eq_
from relengapi import p
from relengapi.blueprints.tokenauth import loader
from relengapi.blueprints.tokenauth import tables
from relengapi.blueprints.tokenauth.test_tokenauth import test_context
from relengapi.blueprints.tokenauth.util import FakeSerializer
from relengapi.blueprints.tokenauth.util import insert_prm
Expand Down Expand Up @@ -130,3 +131,15 @@ def test_usr_loader(app):
with app.app_context():
eq_(loader.usr_loader({'typ': 'usr', 'jti': 't2'}).permissions,
set([p.test_tokenauth.zig]))


@test_context.specialize(db_setup=insert_usr)
def test_usr_loader_disabled(app):
with app.app_context():
# disable the token
session = app.db.session('relengapi')
tok = tables.Token.query.first()
tok.disabled = True
session.commit()
# no TokenUser results
eq_(loader.usr_loader({'typ': 'usr', 'jti': 't2'}), None)
19 changes: 17 additions & 2 deletions relengapi/blueprints/tokenauth/test_tokenauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,14 @@ def assert_prm_token(data, **attrs):
attrs['typ'] = 'prm'
attrs['id'] = id = token.id
attrs['token'] = FakeSerializer.prm(id)
attrs['disabled'] = False
_eq_token(token, attrs)


def assert_tmp_token(data, **attrs):
token = _get_token(data)
attrs['typ'] = 'tmp'
attrs['disabled'] = False
nbf = JAN_2014
exp = calendar.timegm(token.expires.utctimetuple())
attrs['token'] = FakeSerializer.tmp(
Expand All @@ -119,6 +121,7 @@ def assert_usr_token(data, **attrs):
attrs['typ'] = 'usr'
attrs['id'] = id = token.id
attrs['token'] = FakeSerializer.usr(id)
attrs['disabled'] = False
_eq_token(token, attrs)


Expand Down Expand Up @@ -336,6 +339,15 @@ def test_issue_tmp_token_no_metadata(client):
eq_(client.post_json('/tokenauth/tokens', request).status_code, 400)


@test_context.specialize(user=userperms([p.base.tokens.usr.issue, p.test_tokenauth.zig]))
def test_issue_usr_disabled(client):
"""An issue request for a disabled token is rejected."""
request = {'permissions': ['test_tokenauth.zig'],
'disabled': True,
'description': 'More Zig', 'typ': 'usr'}
eq_(client.post_json('/tokenauth/tokens', request).status_code, 400)


@test_context.specialize(user=userperms([]))
def test_issue_usr_token_forbidden(client):
"""Issuing a temporary token requires base.tokens.prm.issue"""
Expand Down Expand Up @@ -398,7 +410,8 @@ def test_get_prm_token_success(client):
"""Getting an existing permanent token returns its id, permissions, and description."""
eq_(json.loads(client.get('/tokenauth/tokens/1').data),
{'result': {'id': 1, 'description': 'Zig only', 'typ': 'prm',
'permissions': ['test_tokenauth.zig']}})
'permissions': ['test_tokenauth.zig'],
'disabled': False}})


@test_context.specialize(user=userperms([]), db_setup=insert_usr)
Expand Down Expand Up @@ -470,7 +483,8 @@ def test_query_prm_token_exists(client):
eq_(res.status_code, 200)
eq_(json.loads(res.data),
{'result': {'id': 1, 'description': 'Zig only', 'typ': 'prm',
'permissions': ['test_tokenauth.zig']}})
'permissions': ['test_tokenauth.zig'],
'disabled': False}})


@test_context.specialize(user=userperms([]), db_setup=insert_usr)
Expand Down Expand Up @@ -536,6 +550,7 @@ def test_query_tmp_token(client):
'expires': '3000-01-01T00:00:00+00:00',
'metadata': {},
'permissions': ['test_tokenauth.zag'],
'disabled': False,
}})


Expand Down
125 changes: 125 additions & 0 deletions relengapi/blueprints/tokenauth/test_usermonitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import contextlib
import mock

from nose.tools import eq_
from relengapi import p
from relengapi.blueprints.tokenauth import tables
from relengapi.blueprints.tokenauth import usermonitor
from relengapi.blueprints.tokenauth.util import insert_usr
from relengapi.lib.testing.context import TestContext

p.test_usermonitor.a.doc("A")
A = p.test_usermonitor.a
p.test_usermonitor.b.doc("B")
B = p.test_usermonitor.b
p.test_usermonitor.c.doc("B")
C = p.test_usermonitor.c


test_context = TestContext(databases=['relengapi'])


@contextlib.contextmanager
def mocked_perms(permissions):
with mock.patch('relengapi.lib.auth.static_authz.'
'StaticAuthz.get_user_permissions') as gup:
def side_effect(user):
if user in permissions:
return set(permissions[user])
gup.side_effect = side_effect
yield


def assert_disabled(app, js):
eq_(tables.Token.query.filter_by(id=2).first().disabled, True)
js.log_message.assert_called_with("Disabling token 2 for user me@me.com")


def assert_enabled(app):
eq_(tables.Token.query.filter_by(id=2).first().disabled, False)


def assert_reenabled(app, js):
assert_enabled(app)
js.log_message.assert_called_with("Re-enabling token 2 for user me@me.com")


@test_context
def test_monitor_users_disable_reduced_perms(app):
"""If the user's permissions are a subset of those for the token, disable"""
with app.app_context():
insert_usr(app, permissions=[A, B])
with mocked_perms({'me@me.com': [A]}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_disabled(app, js)


@test_context
def test_monitor_users_disable_changed_perms(app):
"""If the user's permissions are a, b and the token's are b, c, disable"""
with app.app_context():
insert_usr(app, permissions=[B, C])
with mocked_perms({'me@me.com': [A, B]}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_disabled(app, js)


@test_context
def test_monitor_users_disable_user_gone(app):
"""If the user is gone, disable"""
with app.app_context():
insert_usr(app, permissions=[A, B])
with mocked_perms({}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_disabled(app, js)


@test_context
def test_monitor_users_disable_user_gone_no_token_perms(app):
"""If the user is gone, disable even a token with no permissions."""
with app.app_context():
insert_usr(app, permissions=[])
with mocked_perms({}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_disabled(app, js)


@test_context
def test_monitor_users_same_permissions(app):
"""If the user's permissions match the token's, leave it enabled"""
with app.app_context():
insert_usr(app, permissions=[A, B])
with mocked_perms({'me@me.com': [A, B]}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_enabled(app)


@test_context
def test_monitor_users_ample_permissions(app):
"""If the user's permissions exceed the token's, leave it enabled"""
with app.app_context():
insert_usr(app, permissions=[A, B])
with mocked_perms({'me@me.com': [A, B, C]}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_enabled(app)


@test_context
def test_monitor_users_reenable(app):
"""If the token is disabled and can be re-enabled, it is"""
with app.app_context():
insert_usr(app, permissions=[A, B], disabled=True)
with mocked_perms({'me@me.com': [A, B, C]}):
js = mock.Mock()
usermonitor.monitor_users(js)
assert_reenabled(app, js)
Loading

0 comments on commit 62d94d2

Please sign in to comment.