1
0
Fork 0

feat: add support for admin user to become / stop being root

This commit is contained in:
Lance Edgar 2024-08-05 14:21:54 -05:00
parent a2ba88ca8f
commit fc339ba81b
9 changed files with 335 additions and 22 deletions

View file

@ -138,9 +138,13 @@ class WuttaSecurityPolicy:
return self.session_helper.forget(request, **kw) return self.session_helper.forget(request, **kw)
def permits(self, request, context, permission): def permits(self, request, context, permission):
# nb. root user can do anything
if getattr(request, 'is_root', False):
return True
config = request.registry.settings['wutta_config'] config = request.registry.settings['wutta_config']
app = config.get_app() app = config.get_app()
auth = app.get_auth_handler() auth = app.get_auth_handler()
user = self.identity(request) user = self.identity(request)
return auth.has_permission(self.db_session, user, permission) return auth.has_permission(self.db_session, user, permission)

View file

@ -63,6 +63,16 @@ def new_request(event):
Reference to the app :term:`config object`. Reference to the app :term:`config object`.
.. method:: request.get_referrer(default=None)
Request method to get the "canonical" HTTP referrer value.
This has logic to check for referrer in the request params,
user session etc.
:param default: Optional default URL if none is found in
request params/session. If no default is specified,
the ``'home'`` route is used.
.. attribute:: request.use_oruga .. attribute:: request.use_oruga
Flag indicating whether the frontend should be displayed using Flag indicating whether the frontend should be displayed using
@ -75,6 +85,19 @@ def new_request(event):
request.wutta_config = config request.wutta_config = config
def get_referrer(default=None):
if request.params.get('referrer'):
return request.params['referrer']
if request.session.get('referrer'):
return request.session.pop('referrer')
referrer = getattr(request, 'referrer', None)
if (not referrer or referrer == request.current_route_url()
or not referrer.startswith(request.host_url)):
referrer = default or request.route_url('home')
return referrer
request.get_referrer = get_referrer
def use_oruga(request): def use_oruga(request):
spec = config.get('wuttaweb.oruga_detector.spec') spec = config.get('wuttaweb.oruga_detector.spec')
if spec: if spec:
@ -104,22 +127,44 @@ def new_request_set_user(event, db_session=None):
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance
(if logged in), or ``None``. (if logged in), or ``None``.
:param db_session: Optional :term:`db session` to use, instead of .. attribute:: request.is_admin
:class:`wuttaweb.db.Session`. Probably only useful for tests.
Flag indicating whether current user is a member of the
Administrator role.
.. attribute:: request.is_root
Flag indicating whether user is currently elevated to root
privileges. This is only possible if :attr:`request.is_admin`
is also true.
""" """
request = event.request request = event.request
config = request.registry.settings['wutta_config'] config = request.registry.settings['wutta_config']
app = config.get_app() app = config.get_app()
model = app.model
def user(request): def user(request):
uuid = request.authenticated_userid uuid = request.authenticated_userid
if uuid: if uuid:
session = db_session or Session() session = db_session or Session()
model = app.model
return session.get(model.User, uuid) return session.get(model.User, uuid)
request.set_property(user, reify=True) request.set_property(user, reify=True)
def is_admin(request):
auth = app.get_auth_handler()
return auth.user_is_admin(request.user)
request.set_property(is_admin, reify=True)
def is_root(request):
if request.is_admin:
if request.session.get('is_root', False):
return True
return False
request.set_property(is_root, reify=True)
def before_render(event): def before_render(event):
""" """

View file

@ -314,8 +314,29 @@
<%def name="render_user_menu()"> <%def name="render_user_menu()">
% if request.user: % if request.user:
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">${request.user}</a> <a class="navbar-link ${'has-background-danger has-text-white' if request.is_root else ''}">${request.user}</a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
% if request.is_root:
${h.form(url('stop_root'), ref='stopBeingRootForm')}
## TODO
## ${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="stopBeingRoot()"
class="navbar-item has-background-danger has-text-white">
Stop being root
</a>
${h.end_form()}
% elif request.is_admin:
${h.form(url('become_root'), ref='startBeingRootForm')}
## TODO
## ${h.csrf_token(request)}
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
<a @click="startBeingRoot()"
class="navbar-item has-background-danger has-text-white">
Become root
</a>
${h.end_form()}
% endif
${h.link_to("Change Password", url('change_password'), class_='navbar-item')} ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
${h.link_to("Logout", url('logout'), class_='navbar-item')} ${h.link_to("Logout", url('logout'), class_='navbar-item')}
</div> </div>
@ -359,6 +380,18 @@
const key = 'menu_' + hash + '_shown' const key = 'menu_' + hash + '_shown'
this[key] = !this[key] this[key] = !this[key]
}, },
% if request.is_admin:
startBeingRoot() {
this.$refs.startBeingRootForm.submit()
},
stopBeingRoot() {
this.$refs.stopBeingRootForm.submit()
},
% endif
}, },
} }

View file

@ -198,6 +198,48 @@ class AuthView(View):
if auth.check_user_password(user, value): if auth.check_user_password(user, value):
node.raise_invalid("New password must be different from old password.") node.raise_invalid("New password must be different from old password.")
def become_root(self):
"""
Elevate the current request to 'root' for full system access.
This is only allowed if current (authenticated) user is a
member of the Administrator role. Also note that GET is not
allowed for this view, only POST.
See also :meth:`stop_root()`.
"""
if self.request.method != 'POST':
raise self.forbidden()
if not self.request.is_admin:
raise self.forbidden()
self.request.session['is_root'] = True
self.request.session.flash("You have been elevated to 'root' and now have full system access")
url = self.request.get_referrer()
return self.redirect(url)
def stop_root(self):
"""
Lower the current request from 'root' back to normal access.
Also note that GET is not allowed for this view, only POST.
See also :meth:`become_root()`.
"""
if self.request.method != 'POST':
raise self.forbidden()
if not self.request.is_admin:
raise self.forbidden()
self.request.session['is_root'] = False
self.request.session.flash("Your normal system access has been restored")
url = self.request.get_referrer()
return self.redirect(url)
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._auth_defaults(config) cls._auth_defaults(config)
@ -222,6 +264,18 @@ class AuthView(View):
route_name='change_password', route_name='change_password',
renderer='/auth/change_password.mako') renderer='/auth/change_password.mako')
# become root
config.add_route('become_root', '/root/yes',
request_method='POST')
config.add_view(cls, attr='become_root',
route_name='become_root')
# stop root
config.add_route('stop_root', '/root/no',
request_method='POST')
config.add_view(cls, attr='stop_root',
route_name='stop_root')
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -55,6 +55,14 @@ class View:
self.config = self.request.wutta_config self.config = self.request.wutta_config
self.app = self.config.get_app() self.app = self.config.get_app()
def forbidden(self):
"""
Convenience method, to raise a HTTP 403 Forbidden exception::
raise self.forbidden()
"""
return httpexceptions.HTTPForbidden()
def make_form(self, **kwargs): def make_form(self, **kwargs):
""" """
Make and return a new :class:`~wuttaweb.forms.base.Form` Make and return a new :class:`~wuttaweb.forms.base.Form`

View file

@ -137,3 +137,9 @@ class TestWuttaSecurityPolicy(TestCase):
self.user.roles.append(role) self.user.roles.append(role)
self.session.commit() self.session.commit()
self.assertTrue(self.policy.permits(self.request, None, 'baz.edit')) self.assertTrue(self.policy.permits(self.request, None, 'baz.edit'))
# now let's try another perm - we won't grant it, but will
# confirm user is denied access unless they become root
self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
self.request.is_root = True
self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm'))

View file

@ -18,40 +18,78 @@ class TestNewRequest(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig() self.config = WuttaConfig()
self.request = self.make_request()
self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
})
def tearDown(self):
testing.tearDown()
def make_request(self): def make_request(self):
request = testing.DummyRequest() request = testing.DummyRequest()
request.registry.settings = {'wutta_config': self.config} # request.registry.settings = {'wutta_config': self.config}
return request return request
def test_wutta_config(self): def test_wutta_config(self):
request = self.make_request() event = MagicMock(request=self.request)
event = MagicMock(request=request)
# request gets a new attr # request gets a new attr
self.assertFalse(hasattr(request, 'wutta_config')) self.assertFalse(hasattr(self.request, 'wutta_config'))
subscribers.new_request(event) subscribers.new_request(event)
self.assertTrue(hasattr(request, 'wutta_config')) self.assertTrue(hasattr(self.request, 'wutta_config'))
self.assertIs(request.wutta_config, self.config) self.assertIs(self.request.wutta_config, self.config)
def test_use_oruga_default(self): def test_use_oruga_default(self):
request = self.make_request() event = MagicMock(request=self.request)
event = MagicMock(request=request)
# request gets a new attr, false by default # request gets a new attr, false by default
self.assertFalse(hasattr(request, 'use_oruga')) self.assertFalse(hasattr(self.request, 'use_oruga'))
subscribers.new_request(event) subscribers.new_request(event)
self.assertFalse(request.use_oruga) self.assertFalse(self.request.use_oruga)
def test_use_oruga_custom(self): def test_use_oruga_custom(self):
self.config.setdefault('wuttaweb.oruga_detector.spec', 'tests.test_subscribers:custom_oruga_detector') self.config.setdefault('wuttaweb.oruga_detector.spec', 'tests.test_subscribers:custom_oruga_detector')
request = self.make_request() event = MagicMock(request=self.request)
event = MagicMock(request=request)
# request gets a new attr, which should be true # request gets a new attr, which should be true
self.assertFalse(hasattr(request, 'use_oruga')) self.assertFalse(hasattr(self.request, 'use_oruga'))
subscribers.new_request(event) subscribers.new_request(event)
self.assertTrue(request.use_oruga) self.assertTrue(self.request.use_oruga)
def test_get_referrer(self):
event = MagicMock(request=self.request)
def home(request):
pass
self.pyramid_config.add_route('home', '/')
self.pyramid_config.add_view(home, route_name='home')
self.assertFalse(hasattr(self.request, 'get_referrer'))
subscribers.new_request(event)
self.assertTrue(hasattr(self.request, 'get_referrer'))
# default if no referrer, is home route
url = self.request.get_referrer()
self.assertEqual(url, self.request.route_url('home'))
# can specify another default
url = self.request.get_referrer(default='https://wuttaproject.org')
self.assertEqual(url, 'https://wuttaproject.org')
# or referrer can come from user session
self.request.session['referrer'] = 'https://rattailproject.org'
self.assertIn('referrer', self.request.session)
url = self.request.get_referrer()
self.assertEqual(url, 'https://rattailproject.org')
# nb. referrer should also have been removed from user session
self.assertNotIn('referrer', self.request.session)
# or referrer can come from request params
self.request.params['referrer'] = 'https://kernel.org'
url = self.request.get_referrer()
self.assertEqual(url, 'https://kernel.org')
def custom_oruga_detector(request): def custom_oruga_detector(request):
@ -97,6 +135,81 @@ class TestNewRequestSetUser(TestCase):
subscribers.new_request_set_user(event, db_session=self.session) subscribers.new_request_set_user(event, db_session=self.session)
self.assertIs(self.request.user, self.user) self.assertIs(self.request.user, self.user)
def test_is_admin(self):
event = MagicMock(request=self.request)
# anonymous user
self.assertFalse(hasattr(self.request, 'user'))
self.assertFalse(hasattr(self.request, 'is_admin'))
subscribers.new_request_set_user(event, db_session=self.session)
self.assertIsNone(self.request.user)
self.assertFalse(self.request.is_admin)
# reset
del self.request.is_admin
# authenticated user, but still not an admin
self.request.user = self.user
subscribers.new_request_set_user(event, db_session=self.session)
self.assertIs(self.request.user, self.user)
self.assertFalse(self.request.is_admin)
# reset
del self.request.is_admin
# but if we make them an admin, it changes
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
self.user.roles.append(admin)
self.session.commit()
subscribers.new_request_set_user(event, db_session=self.session)
self.assertIs(self.request.user, self.user)
self.assertTrue(self.request.is_admin)
def test_is_root(self):
event = MagicMock(request=self.request)
# anonymous user
self.assertFalse(hasattr(self.request, 'user'))
self.assertFalse(hasattr(self.request, 'is_root'))
subscribers.new_request_set_user(event, db_session=self.session)
self.assertIsNone(self.request.user)
self.assertFalse(self.request.is_root)
# reset
del self.request.is_admin
del self.request.is_root
# authenticated user, but still not an admin
self.request.user = self.user
subscribers.new_request_set_user(event, db_session=self.session)
self.assertIs(self.request.user, self.user)
self.assertFalse(self.request.is_root)
# reset
del self.request.is_admin
del self.request.is_root
# even if we make them an admin, still not yet root
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
self.user.roles.append(admin)
self.session.commit()
subscribers.new_request_set_user(event, db_session=self.session)
self.assertIs(self.request.user, self.user)
self.assertTrue(self.request.is_admin)
self.assertFalse(self.request.is_root)
# reset
del self.request.is_admin
del self.request.is_root
# root status flag lives in user session
self.request.session['is_root'] = True
subscribers.new_request_set_user(event, db_session=self.session)
self.assertTrue(self.request.is_admin)
self.assertTrue(self.request.is_root)
class TestBeforeRender(TestCase): class TestBeforeRender(TestCase):

View file

@ -4,11 +4,12 @@ from unittest import TestCase
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pyramid import testing from pyramid import testing
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound, HTTPForbidden
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import auth as mod from wuttaweb.views import auth as mod
from wuttaweb.auth import WuttaSecurityPolicy from wuttaweb.auth import WuttaSecurityPolicy
from wuttaweb.subscribers import new_request
class TestAuthView(TestCase): class TestAuthView(TestCase):
@ -19,7 +20,9 @@ class TestAuthView(TestCase):
}) })
self.request = testing.DummyRequest(wutta_config=self.config, user=None) self.request = testing.DummyRequest(wutta_config=self.config, user=None)
self.pyramid_config = testing.setUp(request=self.request) self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
})
self.app = self.config.get_app() self.app = self.config.get_app()
auth = self.app.get_auth_handler() auth = self.app.get_auth_handler()
@ -142,3 +145,46 @@ class TestAuthView(TestCase):
self.assertIn('form', context) self.assertIn('form', context)
dform = context['form'].get_deform() dform = context['form'].get_deform()
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.") self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
def test_become_root(self):
event = MagicMock(request=self.request)
new_request(event) # add request.get_referrer()
view = mod.AuthView(self.request)
# GET not allowed
self.request.method = 'GET'
self.assertRaises(HTTPForbidden, view.become_root)
# non-admin users also not allowed
self.request.method = 'POST'
self.request.is_admin = False
self.assertRaises(HTTPForbidden, view.become_root)
# but admin users can become root
self.request.is_admin = True
self.assertNotIn('is_root', self.request.session)
redirect = view.become_root()
self.assertIsInstance(redirect, HTTPFound)
self.assertTrue(self.request.session['is_root'])
def test_stop_root(self):
event = MagicMock(request=self.request)
new_request(event) # add request.get_referrer()
view = mod.AuthView(self.request)
# GET not allowed
self.request.method = 'GET'
self.assertRaises(HTTPForbidden, view.stop_root)
# non-admin users also not allowed
self.request.method = 'POST'
self.request.is_admin = False
self.assertRaises(HTTPForbidden, view.stop_root)
# but admin users can stop being root
# (nb. there is no check whether user is currently root)
self.request.is_admin = True
self.assertNotIn('is_root', self.request.session)
redirect = view.stop_root()
self.assertIsInstance(redirect, HTTPFound)
self.assertFalse(self.request.session['is_root'])

View file

@ -3,7 +3,7 @@
from unittest import TestCase from unittest import TestCase
from pyramid import testing from pyramid import testing
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound, HTTPForbidden
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import base from wuttaweb.views import base
@ -23,6 +23,10 @@ class TestView(TestCase):
self.assertIs(self.view.config, self.config) self.assertIs(self.view.config, self.config)
self.assertIs(self.view.app, self.app) self.assertIs(self.view.app, self.app)
def test_forbidden(self):
error = self.view.forbidden()
self.assertIsInstance(error, HTTPForbidden)
def test_make_form(self): def test_make_form(self):
form = self.view.make_form() form = self.view.make_form()
self.assertIsInstance(form, Form) self.assertIsInstance(form, Form)