users: Add component for managing users and groups

- Handle groups needed by an app.
- Handle reserved usernames for an app.
- Updated documentation
- Updated unit tests

Tests performed:
  - Reserved usernames: ez-ipupd, ejabberd, Debian-minetest, mldonkey,
    monkeysphere, mumble-server, privoxy, quasselcore, radicale, debian-tor,
    debian-transmission
  - Reserved usernames checks should work in the following forms:
    - Create user
    - Update user
    - First boot user creation
  - Full list of available groups should appear in following cases:
    - Create user form
    - Update user form
  - Full list of groups should get created in Django DB during:
    - Update user form display
    - First boot form save
  - When updating the last admin user, the 'admin' group choice is checked
    and disabled.
  - Following groups show up (sorted by group name):
    - bit-torrent: Download files using BitTorrent applications
    - git-access: Read-write access to Git repositories
    - i2p: Manage I2P application
    - wiki: View and edit wiki applications
    - minidlna: Media streaming server
    - ed2k: Download files using eDonkey applications
    - freedombox-share: Access to the private shares
    - web-search: Search the web
    - syncthing: Administer Syncthing application
    - feed-reader: Read and subscribe to news feeds
    - admin: Access to all services and system settings
  - Directory validation form checks for write permissions for following apps:
    - deluge with debian-deluged user
    - transmission with debian-transmission user
  - Sharing app should show all the groups in add/edit share forms
  - The following apps should get added to share group during setup:
    debian-transmission
    debian-deluged
  - Unit tests pass
  - Functional tests for users and groups pass
  - Test that an app (example syncthing) provides the necessary
    permissions to users in that group (but not in admin group).

Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
[sunil: Fix i18n of group descriptions]
[sunil: Update developer documentation]
[sunil: Separate out cosmetic changes]
[sunil: Fix component ID for mumble]
[sunil: sharing: Remove unneeded dependency on users app]
[sunil: Implement better API for getting groups in component]
[sunil: Fix incorrect regression change ttrss app]
[sunil: Make iterating over gourps more readable]
[sunil: Improve tests, drop single use fixtures]
[sunil: Simplify test_view.py fixture]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Tested-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Joseph Nuthalapati 2020-03-20 17:16:46 +05:30 committed by James Valleroy
parent e6e67f7d53
commit e04ae48637
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
30 changed files with 344 additions and 166 deletions

View File

@ -163,11 +163,19 @@ with the FreedomBox framework in ``__init.py__``.
.. code-block:: python3
group = ('bit-torrent', 'Download files using BitTorrent applications')
from plinth.modules.users.components import UsersAndGroups
def init():
class TransmissionApp(app_module.App):
...
register_group(group)
def __init__(self):
...
groups = { 'bit-torrent': _('Download files using BitTorrent applications') }
users_and_groups = UsersAndGroups('users-and-groups-transmission',
groups=groups)
self.add(users_and_groups)
Then in the Apache configuration snippet, we can mandate that only users of this
group (and, of course, admin users) should be allowed to access our app. In the

View File

@ -11,7 +11,8 @@ from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import add_user_to_share_group, register_group
from plinth.modules.users import add_user_to_share_group
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -27,12 +28,10 @@ _description = [
'change it immediately after enabling this service.')
]
group = ('bit-torrent', _('Download files using BitTorrent applications'))
reserved_usernames = ['debian-deluged']
app = None
SYSTEM_USER = 'debian-deluged'
class DelugeApp(app_module.App):
"""FreedomBox app for Deluge."""
@ -42,6 +41,11 @@ class DelugeApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {
'bit-torrent': _('Download files using BitTorrent applications')
}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('Deluge'), icon_filename='deluge',
short_description=_('BitTorrent Web Client'),
@ -59,7 +63,7 @@ class DelugeApp(app_module.App):
url='/deluge', icon=info.icon_filename,
clients=info.clients,
login_required=True,
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-deluge', info.name,
@ -78,12 +82,16 @@ class DelugeApp(app_module.App):
listen_ports=[(8112, 'tcp4')])
self.add(daemon_web)
users_and_groups = UsersAndGroups('users-and-groups-deluge',
reserved_usernames=[SYSTEM_USER],
groups=groups)
self.add(users_and_groups)
def init():
"""Initialize the Deluge module."""
global app
app = DelugeApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():
@ -94,5 +102,5 @@ def setup(helper, old_version=None):
"""Install and configure the module."""
helper.install(managed_packages)
helper.call('post', actions.superuser_run, 'deluge', ['setup'])
add_user_to_share_group(reserved_usernames[0])
add_user_to_share_group(SYSTEM_USER)
helper.call('post', app.enable)

View File

@ -5,18 +5,18 @@ Forms for Deluge app.
from django.utils.translation import ugettext_lazy as _
from plinth.modules.deluge import reserved_usernames
from plinth.modules.storage.forms import (DirectorySelectForm,
DirectoryValidator)
from . import SYSTEM_USER
class DelugeForm(DirectorySelectForm):
"""Deluge configuration form"""
def __init__(self, *args, **kw):
validator = DirectoryValidator(username=reserved_usernames[0],
validator = DirectoryValidator(username=SYSTEM_USER,
check_creatable=True)
super(DelugeForm, self).__init__(
title=_('Download directory'),
default='/var/lib/deluged/Downloads', validator=validator, *args,
**kw)
super(DelugeForm, self).__init__(title=_('Download directory'),
default='/var/lib/deluged/Downloads',
validator=validator, *args, **kw)

View File

@ -11,6 +11,7 @@ from plinth import cfg, menu
from plinth.modules.names.components import DomainType
from plinth.signals import domain_added
from plinth.utils import format_lazy
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup # noqa, pylint: disable=unused-import
@ -38,8 +39,6 @@ _description = [
'name, they will get a response with your current IP address.')
]
reserved_usernames = ['ez-ipupd']
app = None
@ -67,6 +66,10 @@ class DynamicDNSApp(app_module.App):
can_have_certificate=True)
self.add(domain_type)
users_and_groups = UsersAndGroups('users-and-groups-dynamicdns',
reserved_usernames=['ez-ipupd'])
self.add(users_and_groups)
def init():
"""Initialize the module."""

View File

@ -21,6 +21,7 @@ from plinth.modules.letsencrypt.components import LetsEncrypt
from plinth.signals import (domain_added, post_hostname_change,
pre_hostname_change)
from plinth.utils import format_lazy
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -45,8 +46,6 @@ _description = [
jsxc_url=reverse_lazy('jsxc:index'))
]
reserved_usernames = ['ejabberd']
port_forwarding_info = [
('TCP', 5222),
('TCP', 5269),
@ -110,6 +109,10 @@ class EjabberdApp(app_module.App):
(5269, 'tcp6'), (5443, 'tcp4'), (5443, 'tcp6')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-ejabberd',
reserved_usernames=['ejabberd'])
self.add(users_and_groups)
def init():
"""Initialize the ejabberd module"""

View File

@ -14,7 +14,7 @@ from plinth import frontpage, menu
from plinth.errors import ActionError
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from .forms import is_repo_url
from .manifest import (GIT_REPO_PATH, # noqa, pylint: disable=unused-import
@ -36,8 +36,6 @@ _description = [
'<a href="https://git-scm.com/docs/gittutorial">Git tutorial</a>.')
]
group = ('git-access', _('Read-write access to Git repositories'))
app = None
@ -50,6 +48,8 @@ class GitwebApp(app_module.App):
"""Create components for the app."""
super().__init__()
groups = {'git-access': _('Read-write access to Git repositories')}
self.repos = []
info = app_module.Info(app_id=self.app_id, version=version,
@ -69,7 +69,7 @@ class GitwebApp(app_module.App):
icon=info.icon_filename, url='/gitweb/',
clients=info.clients,
login_required=True,
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-gitweb', info.name,
@ -84,6 +84,10 @@ class GitwebApp(app_module.App):
'gitweb-freedombox-auth')
self.add(self.auth_webserver)
users_and_groups = UsersAndGroups('users-and-groups-gitweb',
groups=groups)
self.add(users_and_groups)
def set_shortcut_login_required(self, login_required):
"""Change the login_required property of shortcut."""
shortcut = self.remove('shortcut-gitweb')
@ -163,7 +167,6 @@ def init():
"""Initialize the module."""
global app
app = GitwebApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup':

View File

@ -12,7 +12,7 @@ from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.i2p.resources import FAVORITES
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -35,8 +35,6 @@ _description = [
'configuration process.')
]
group = ('i2p', _('Manage I2P application'))
port_forwarding_info = [
('TCP', 4444),
('TCP', 4445),
@ -60,6 +58,9 @@ class I2PApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {'i2p': _('Manage I2P application')}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('I2P'), icon_filename='i2p',
short_description=_('Anonymity Network'),
@ -77,7 +78,7 @@ class I2PApp(app_module.App):
icon=info.icon_filename, url='/i2p/',
clients=info.clients,
login_required=True,
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-i2p-web', info.name,
@ -97,12 +98,15 @@ class I2PApp(app_module.App):
listen_ports=[(7657, 'tcp6')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-i2p',
groups=groups)
self.add(users_and_groups)
def init():
"""Initialize the module."""
global app
app = I2PApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -11,7 +11,7 @@ from plinth import app as app_module
from plinth import cfg, frontpage, menu
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import format_lazy
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -36,8 +36,6 @@ _description = [
users_url=reverse_lazy('users:index'))
]
group = ('wiki', _('View and edit wiki applications'))
app = None
@ -49,6 +47,7 @@ class IkiwikiApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
info = app_module.Info(app_id=self.app_id, version=version,
name=_('ikiwiki'), icon_filename='ikiwiki',
short_description=_('Wiki and Blog'),
@ -71,6 +70,11 @@ class IkiwikiApp(app_module.App):
urls=['https://{host}/ikiwiki'])
self.add(webserver)
groups = {'wiki': _('View and edit wiki applications')}
users_and_groups = UsersAndGroups('users-and-groups-ikiwiki',
groups=groups)
self.add(users_and_groups)
def add_shortcut(self, site, title):
"""Add an ikiwiki shortcut to frontpage."""
shortcut = frontpage.Shortcut('shortcut-ikiwiki-' + site, title,
@ -101,7 +105,6 @@ def init():
"""Initialize the ikiwiki module."""
global app
app = IkiwikiApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -12,6 +12,7 @@ from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.firewall.components import Firewall
from plinth.utils import format_lazy
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -43,8 +44,6 @@ _description = [
port_forwarding_info = [('UDP', 30000)]
reserved_usernames = ['Debian-minetest']
CONFIG_FILE = '/etc/minetest/minetest.conf'
AUG_PATH = '/files' + CONFIG_FILE + '/.anon'
@ -87,6 +86,11 @@ class MinetestApp(app_module.App):
listen_ports=[(30000, 'udp4')])
self.add(daemon)
users_and_groups = UsersAndGroups(
'users-and-groups-minetest',
reserved_usernames=['Debian-minetest'])
self.add(users_and_groups)
def init():
"""Initialize the module."""

View File

@ -9,7 +9,7 @@ from plinth import actions, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa
@ -30,8 +30,6 @@ _description = [
'such as PS3 and Xbox 360) or applications such as totem and Kodi.')
]
group = ('minidlna', _('Media streaming server'))
app = None
@ -42,6 +40,9 @@ class MiniDLNAApp(app_module.App):
def __init__(self):
"""Initialize the app components"""
super().__init__()
groups = {'minidlna': _('Media streaming server')}
info = app_module.Info(app_id=self.app_id, version=version,
name='minidlna', icon_filename='minidlna',
short_description=_('Simple Media Server'),
@ -60,16 +61,12 @@ class MiniDLNAApp(app_module.App):
is_external=False)
webserver = Webserver('webserver-minidlna', 'minidlna-freedombox',
urls=['http://localhost:8200/'])
shortcut = frontpage.Shortcut(
'shortcut-minidlna',
info.name,
short_description=info.short_description,
description=info.description,
icon=info.icon_filename,
url='/_minidlna/',
login_required=True,
allowed_groups=[group[0]],
)
shortcut = frontpage.Shortcut('shortcut-minidlna', info.name,
short_description=info.short_description,
description=info.description,
icon=info.icon_filename,
url='/_minidlna/', login_required=True,
allowed_groups=list(groups))
daemon = Daemon('daemon-minidlna', managed_services[0])
self.add(menu_item)
@ -78,12 +75,15 @@ class MiniDLNAApp(app_module.App):
self.add(shortcut)
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-minidlna',
groups=groups)
self.add(users_and_groups)
def init():
"""Initialize the module."""
global app
app = MiniDLNAApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -11,7 +11,7 @@ from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import format_lazy
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -35,10 +35,6 @@ _description = [
'directory.'), box_name=cfg.box_name)
]
reserved_usernames = ['mldonkey']
group = ('ed2k', _('Download files using eDonkey applications'))
app = None
@ -50,6 +46,9 @@ class MLDonkeyApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {'ed2k': _('Download files using eDonkey applications')}
info = app_module.Info(
app_id=self.app_id, version=version, name=_('MLDonkey'),
icon_filename='mldonkey',
@ -66,7 +65,7 @@ class MLDonkeyApp(app_module.App):
'shortcut-mldonkey', info.name,
short_description=info.short_description, icon=info.icon_filename,
url='/mldonkey/', login_required=True, clients=info.clients,
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcuts)
firewall = Firewall('firewall-mldonkey', info.name,
@ -81,12 +80,16 @@ class MLDonkeyApp(app_module.App):
listen_ports=[(4080, 'tcp4')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-mldonkey',
reserved_usernames=['mldonkey'],
groups=groups)
self.add(users_and_groups)
def init():
"""Initialize the MLDonkey module."""
global app
app = MLDonkeyApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -7,6 +7,7 @@ from django.utils.translation import ugettext_lazy as _
from plinth import app as app_module
from plinth import menu
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup # noqa, pylint: disable=unused-import
@ -33,8 +34,6 @@ _description = [
'website</a>.')
]
reserved_usernames = ['monkeysphere']
app = None
@ -57,6 +56,10 @@ class MonkeysphereApp(app_module.App):
advanced=True)
self.add(menu_item)
users_and_groups = UsersAndGroups('users-and-groups-monkeysphere',
reserved_usernames=['monkeysphere'])
self.add(users_and_groups)
def init():
"""Initialize the monkeysphere module."""

View File

@ -10,6 +10,7 @@ from plinth import app as app_module
from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.firewall.components import Firewall
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -27,8 +28,6 @@ _description = [
'from your desktop and Android devices are available.')
]
reserved_usernames = ['mumble-server']
port_forwarding_info = [
('TCP', 64738),
('UDP', 64738),
@ -73,6 +72,10 @@ class MumbleApp(app_module.App):
(64738, 'udp6')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-mumble',
reserved_usernames=['mumble-server'])
self.add(users_and_groups)
def init():
"""Initialize the Mumble module."""

View File

@ -13,6 +13,7 @@ from plinth.daemon import Daemon
from plinth.modules.apache.components import diagnose_url
from plinth.modules.firewall.components import Firewall
from plinth.utils import format_lazy
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup # noqa, pylint: disable=unused-import
@ -39,8 +40,6 @@ _description = [
box_name=_(cfg.box_name)),
]
reserved_usernames = ['privoxy']
app = None
@ -78,6 +77,10 @@ class PrivoxyApp(app_module.App):
listen_ports=[(8118, 'tcp4'), (8118, 'tcp6')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-privoxy',
reserved_usernames=['privoxy'])
self.add(users_and_groups)
def diagnose(self):
"""Run diagnostics and return the results."""
results = super().diagnose()

View File

@ -16,6 +16,7 @@ from plinth.modules import names
from plinth.modules.firewall.components import Firewall
from plinth.modules.letsencrypt.components import LetsEncrypt
from plinth.utils import format_lazy
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -43,8 +44,6 @@ _description = [
'are available.'),
]
reserved_usernames = ['quasselcore']
port_forwarding_info = [('TCP', 4242)]
app = None
@ -95,6 +94,10 @@ class QuasselApp(app_module.App):
listen_ports=[(4242, 'tcp4'), (4242, 'tcp6')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-quasselcore',
reserved_usernames=['quasselcore'])
self.add(users_and_groups)
def init():
"""Initialize the quassel module."""

View File

@ -17,6 +17,7 @@ from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Uwsgi, Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import format_lazy, Version
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -39,8 +40,6 @@ _description = [
'contacts, which must be done using a separate client.'),
]
reserved_usernames = ['radicale']
logger = logging.getLogger(__name__)
CONFIG_FILE = '/etc/radicale/config'
@ -91,6 +90,10 @@ class RadicaleApp(app_module.App):
daemon = RadicaleDaemon('daemon-radicale', managed_services[0])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-radicale',
reserved_usernames=['radicale'])
self.add(users_and_groups)
class RadicaleWebserver(Webserver):
"""Webserver enable/disable behavior specific for radicale."""

View File

@ -17,7 +17,7 @@ from plinth import app as app_module
from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import format_lazy
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -44,8 +44,6 @@ _description = [
'own private space.'),
]
group = ('freedombox-share', _('Access to the private shares'))
app = None
@ -57,6 +55,9 @@ class SambaApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {'freedombox-share': _('Access to the private shares')}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('Samba'), icon_filename='samba',
short_description=_('File Sharing'),
@ -74,7 +75,7 @@ class SambaApp(app_module.App):
short_description=info.short_description, icon=info.icon_filename,
description=info.description,
configure_url=reverse_lazy('samba:index'), clients=info.clients,
login_required=True, allowed_groups=[group[0]])
login_required=True, allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-samba', info.name, ports=['samba'])
@ -92,12 +93,15 @@ class SambaApp(app_module.App):
self.add(daemon_nmbd)
users_and_groups = UsersAndGroups('users-and-groups-samba',
groups=groups)
self.add(users_and_groups)
def init():
"""Initialize the module."""
global app
app = SambaApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -12,7 +12,7 @@ from plinth import app as app_module
from plinth import frontpage, menu
from plinth.modules.apache.components import Uwsgi, Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from .manifest import (PUBLIC_ACCESS_SETTING_FILE, # noqa, pylint: disable=unused-import
backup, clients)
@ -28,8 +28,6 @@ _description = [
'It stores no cookies by default.')
]
group = ('web-search', _('Search the web'))
manual_page = 'Searx'
app = None
@ -43,6 +41,9 @@ class SearxApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {'web-search': _('Search the web')}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('Searx'), icon_filename='searx',
short_description=_('Web Search'),
@ -60,7 +61,7 @@ class SearxApp(app_module.App):
short_description=info.short_description, icon=info.icon_filename,
url='/searx/', clients=info.clients,
login_required=(not is_public_access_enabled()),
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-searx', info.name,
@ -78,6 +79,10 @@ class SearxApp(app_module.App):
uwsgi = Uwsgi('uwsgi-searx', 'searx')
self.add(uwsgi)
users_and_groups = UsersAndGroups('users-and-groups-searx',
groups=groups)
self.add(users_and_groups)
def set_shortcut_login_required(self, login_required):
"""Change the login_required property of shortcut."""
shortcut = self.remove('shortcut-searx')
@ -101,7 +106,6 @@ def init():
"""Initialize the module."""
global app
app = SearxApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from plinth.modules import sharing
from plinth.modules.users.forms import get_group_choices
from plinth.modules.users.components import UsersAndGroups
class AddShareForm(forms.Form):
@ -29,6 +29,7 @@ class AddShareForm(forms.Form):
'Make files in this folder available to anyone with the link.'))
groups = forms.MultipleChoiceField(
choices=UsersAndGroups.get_group_choices,
widget=forms.CheckboxSelectMultiple, required=False,
label=_('User groups that can read the files in the share'),
help_text=_(
@ -38,7 +39,6 @@ class AddShareForm(forms.Form):
def __init__(self, *args, **kwargs):
"""Initialize the form with extra request argument."""
super().__init__(*args, **kwargs)
self.fields['groups'].choices = get_group_choices()
self.fields['name'].widget.attrs.update({'autofocus': 'autofocus'})
def clean_name(self):

View File

@ -11,7 +11,7 @@ from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import format_lazy
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -37,8 +37,6 @@ _description = [
'users belonging to the "admin" group.'), box_name=_(cfg.box_name)),
]
group = ('syncthing', _('Administer Syncthing application'))
app = None
@ -50,6 +48,9 @@ class SyncthingApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
self.groups = {'syncthing': _('Administer Syncthing application')}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('Syncthing'), icon_filename='syncthing',
short_description=_('File Synchronization'),
@ -67,7 +68,7 @@ class SyncthingApp(app_module.App):
icon=info.icon_filename,
url='/syncthing/', clients=info.clients,
login_required=True,
allowed_groups=[group[0]])
allowed_groups=list(self.groups))
self.add(shortcut)
firewall = Firewall('firewall-syncthing-web', info.name,
@ -85,12 +86,15 @@ class SyncthingApp(app_module.App):
daemon = Daemon('daemon-syncthing', managed_services[0])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-syncthing',
groups=self.groups)
self.add(users_and_groups)
def init():
"""Initialize the module."""
global app
app = SyncthingApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -15,6 +15,7 @@ from plinth.modules.apache.components import diagnose_url
from plinth.modules.firewall.components import Firewall
from plinth.modules.names.components import DomainType
from plinth.signals import domain_added, domain_removed
from plinth.modules.users.components import UsersAndGroups
from . import utils
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -38,8 +39,6 @@ _description = [
'Tor Browser</a>.')
]
reserved_usernames = ['debian-tor']
app = None
@ -82,6 +81,10 @@ class TorApp(app_module.App):
(9040, 'tcp6'), (9053, 'udp4'), (9053, 'udp6')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-tor',
reserved_usernames=['debian-tor'])
self.add(users_and_groups)
def diagnose(self):
"""Run diagnostics and return the results."""
results = super().diagnose()

View File

@ -13,7 +13,8 @@ from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import add_user_to_share_group, register_group
from plinth.modules.users import add_user_to_share_group
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -29,12 +30,10 @@ _description = [
'BitTorrent is not anonymous.'),
]
reserved_usernames = ['debian-transmission']
group = ('bit-torrent', _('Download files using BitTorrent applications'))
app = None
SYSTEM_USER = 'debian-transmission'
class TransmissionApp(app_module.App):
"""FreedomBox app for Transmission."""
@ -44,6 +43,10 @@ class TransmissionApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {
'bit-torrent': _('Download files using BitTorrent applications')
}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('Transmission'),
icon_filename='transmission',
@ -61,7 +64,7 @@ class TransmissionApp(app_module.App):
'shortcut-transmission', info.name,
short_description=info.short_description, icon=info.icon_filename,
url='/transmission', clients=info.clients, login_required=True,
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-transmission', info.name,
@ -76,12 +79,16 @@ class TransmissionApp(app_module.App):
listen_ports=[(9091, 'tcp4')])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-transmission',
reserved_usernames=[SYSTEM_USER],
groups=groups)
self.add(users_and_groups)
def init():
"""Initialize the Transmission module."""
global app
app = TransmissionApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():
@ -99,5 +106,5 @@ def setup(helper, old_version=None):
helper.call('post', actions.superuser_run, 'transmission',
['merge-configuration'],
input=json.dumps(new_configuration).encode())
add_user_to_share_group(reserved_usernames[0], managed_services[0])
add_user_to_share_group(SYSTEM_USER, managed_services[0])
helper.call('post', app.enable)

View File

@ -5,18 +5,19 @@ FreedomBox app for configuring Transmission.
from django.utils.translation import ugettext_lazy as _
from plinth.modules.transmission import reserved_usernames
from plinth.modules.storage.forms import (DirectorySelectForm,
DirectoryValidator)
from . import SYSTEM_USER
class TransmissionForm(DirectorySelectForm):
"""Transmission configuration form"""
def __init__(self, *args, **kw):
validator = DirectoryValidator(
username=reserved_usernames[0], check_creatable=True)
super(TransmissionForm, self).__init__(
title=_('Download directory'),
default='/var/lib/transmission-daemon/downloads',
validator=validator, *args, **kw)
validator = DirectoryValidator(username=SYSTEM_USER,
check_creatable=True)
super(TransmissionForm,
self).__init__(title=_('Download directory'),
default='/var/lib/transmission-daemon/downloads',
validator=validator, *args, **kw)

View File

@ -12,7 +12,7 @@ from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import register_group
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import Version, format_lazy
from .manifest import backup, clients # noqa, pylint: disable=unused-import
@ -39,8 +39,6 @@ _description = [
'/tt-rss-app</a> for connecting.'))
]
group = ('feed-reader', _('Read and subscribe to news feeds'))
app = None
@ -52,6 +50,9 @@ class TTRSSApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
groups = {'feed-reader': _('Read and subscribe to news feeds')}
info = app_module.Info(app_id=self.app_id, version=version,
name=_('Tiny Tiny RSS'), icon_filename='ttrss',
short_description=_('News Feed Reader'),
@ -69,7 +70,7 @@ class TTRSSApp(app_module.App):
icon=info.icon_filename, url='/tt-rss',
clients=info.clients,
login_required=True,
allowed_groups=[group[0]])
allowed_groups=list(groups))
self.add(shortcut)
firewall = Firewall('firewall-ttrss', info.name,
@ -83,6 +84,10 @@ class TTRSSApp(app_module.App):
daemon = Daemon('daemon-ttrss', managed_services[0])
self.add(daemon)
users_and_groups = UsersAndGroups('users-and-groups-ttrss',
groups=groups)
self.add(users_and_groups)
def enable(self):
"""Enable components and API access."""
super().enable()
@ -93,7 +98,6 @@ def init():
"""Initialize the module."""
global app
app = TTRSSApp()
register_group(group)
setup_helper = globals()['setup_helper']
if setup_helper.get_state() != 'needs-setup' and app.is_enabled():

View File

@ -14,6 +14,8 @@ from plinth import cfg, menu
from plinth.daemon import Daemon
from plinth.utils import format_lazy
from .components import UsersAndGroups
version = 3
is_essential = True
@ -45,9 +47,6 @@ _description = [
box_name=_(cfg.box_name))
]
# All FreedomBox user groups
groups = dict()
app = None
@ -75,6 +74,12 @@ class UsersApp(app_module.App):
listen_ports=[(389, 'tcp4'), (389, 'tcp6')])
self.add(daemon)
# Add the admin group
groups = {'admin': _('Access to all services and system settings')}
users_and_groups = UsersAndGroups('users-and-groups-admin',
groups=groups)
self.add(users_and_groups)
def diagnose(self):
"""Run diagnostics and return the results."""
results = super().diagnose()
@ -129,10 +134,6 @@ def remove_group(group):
actions.superuser_run('users', options=['remove-group', group])
def register_group(group):
groups[group[0]] = group[1]
def get_last_admin_user():
"""If there is only one admin user return its name else return None."""
output = actions.superuser_run('users', ['get-group-users', 'admin'])

View File

@ -0,0 +1,56 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
App component to manage users and groups.
"""
import itertools
from plinth import app
class UsersAndGroups(app.FollowerComponent):
"""Component to manage users and groups of an app."""
# Class variable to hold a list of user groups for apps
_all_components = set()
def __init__(self, component_id, reserved_usernames=[], groups={}):
"""Store reserved_usernames and groups of the app.
'reserved_usernames' is a list of operating system user names that the
app uses. It is not permitted to create a FreedomBox user with one of
these names.
'groups' is a dictionary of the following format: {"group_name": "A
localized string describing what permissions are offered to the users
of this group"}.
"""
super().__init__(component_id)
self.reserved_usernames = reserved_usernames
self.groups = groups
self._all_components.add(self)
@classmethod
def get_groups(cls):
"""Return a set of all groups."""
all_groups = itertools.chain(*(component.groups.keys()
for component in cls._all_components))
return set(all_groups)
@classmethod
def get_group_choices(cls):
"""Return list of groups that can be used as form choices."""
all_groups = itertools.chain(*(component.groups.items()
for component in cls._all_components))
choices = [(group, f'{description} ({group})')
for group, description in set(all_groups)]
return sorted(choices, key=lambda g: g[0])
@classmethod
def is_username_reserved(cls, username):
"""Returns whether the given username is reserved or not."""
return any((username in component.reserved_usernames
for component in cls._all_components))

View File

@ -3,7 +3,6 @@
import pwd
import re
import plinth.forms
from django import forms
from django.contrib import auth, messages
from django.contrib.auth.forms import SetPasswordForm, UserCreationForm
@ -13,23 +12,17 @@ from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy
from plinth import actions, module_loader
import plinth.forms
from plinth import actions
from plinth.errors import ActionError
from plinth.modules import first_boot, users
from plinth.modules import first_boot
from plinth.modules.security import set_restricted_access
from plinth.translation import set_language
from plinth.utils import is_user_admin
from . import get_last_admin_user
def get_group_choices():
"""Return localized group description and group name in one string."""
admin_group = ('admin', _('Access to all services and system settings'))
users.register_group(admin_group)
choices = [(k, ('{} ({})'.format(users.groups[k], k)))
for k in users.groups]
return sorted(choices, key=lambda g: g[0])
from .components import UsersAndGroups
class ValidNewUsernameCheckMixin(object):
@ -40,8 +33,8 @@ class ValidNewUsernameCheckMixin(object):
username = self.cleaned_data['username']
if self.instance.username != username and \
not self.is_valid_new_username():
raise ValidationError(
_('Username is taken or is reserved.'), code='invalid')
raise ValidationError(_('Username is taken or is reserved.'),
code='invalid')
return username
@ -52,10 +45,8 @@ class ValidNewUsernameCheckMixin(object):
if username.lower() in existing_users:
return False
for module_name, module in module_loader.loaded_modules.items():
for reserved_username in getattr(module, 'reserved_usernames', []):
if username.lower() == reserved_username.lower():
return False
if UsersAndGroups.is_username_reserved(username.lower()):
return False
return True
@ -88,10 +79,9 @@ class CreateUserForm(ValidNewUsernameCheckMixin,
"""
username = USERNAME_FIELD
groups = forms.MultipleChoiceField(
choices=get_group_choices(), label=ugettext_lazy('Permissions'),
required=False, widget=forms.CheckboxSelectMultiple,
help_text=ugettext_lazy(
'Select which services should be available to the new '
choices=UsersAndGroups.get_group_choices,
label=ugettext_lazy('Permissions'), required=False,
widget=forms.CheckboxSelectMultiple, help_text=ugettext_lazy(
'user. The user will be able to log in to services that '
'support single sign-on through LDAP, if they are in the '
'appropriate group.<br /><br />Users in the admin group '
@ -109,7 +99,6 @@ class CreateUserForm(ValidNewUsernameCheckMixin,
"""Initialize the form with extra request argument."""
self.request = request
super(CreateUserForm, self).__init__(*args, **kwargs)
self.fields['groups'].choices = get_group_choices()
self.fields['username'].widget.attrs.update({
'autofocus': 'autofocus',
'autocapitalize': 'none',
@ -175,7 +164,7 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
def __init__(self, request, username, *args, **kwargs):
"""Initialize the form with extra request argument."""
group_choices = dict(get_group_choices())
group_choices = dict(UsersAndGroups.get_group_choices())
for group in group_choices:
Group.objects.get_or_create(name=group)
@ -313,9 +302,8 @@ class UserChangePasswordForm(SetPasswordForm):
"""Initialize the form with extra request argument."""
self.request = request
super(UserChangePasswordForm, self).__init__(*args, **kwargs)
self.fields['new_password1'].widget.attrs.update({
'autofocus': 'autofocus'
})
self.fields['new_password1'].widget.attrs.update(
{'autofocus': 'autofocus'})
def save(self, commit=True):
"""Save the user model and change LDAP password as well."""
@ -366,7 +354,7 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm):
_('Failed to add new user to admin group.'))
# Create initial Django groups
for group_choice in get_group_choices():
for group_choice in UsersAndGroups.get_group_choices():
auth.models.Group.objects.get_or_create(name=group_choice[0])
admin_group = auth.models.Group.objects.get(name='admin')

View File

@ -0,0 +1,67 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Tests for the UsersAndGroups app component.
"""
import pytest
from ..components import UsersAndGroups
@pytest.fixture(autouse=True)
def fixture_empty_components():
"""Remove all components from the global list before every test."""
UsersAndGroups._all_components = set()
def test_create_users_and_groups_component():
"""Test initialization of users and groups component."""
component = UsersAndGroups('simple-component')
assert component.groups == {}
assert component.reserved_usernames == []
assert len(component._all_components) == 1
assert component in component._all_components
groups = {'test-group1', 'Test description'}
component = UsersAndGroups('another-component', groups=groups,
reserved_usernames=['test-user1'])
assert component.groups == groups
assert component.reserved_usernames == ['test-user1']
assert len(component._all_components) == 2
assert component in component._all_components
def test_get_groups():
"""Test getting all the groups.
Test that:
1. Group names are unique
2. All components have the same global set of groups
"""
UsersAndGroups('component-with-no-groups')
UsersAndGroups('component-with-one-group',
groups={'group1': 'description1'})
UsersAndGroups('component-with-groups', groups={
'group1': 'description1',
'group2': 'description2'
})
assert UsersAndGroups.get_groups() == {'group1', 'group2'}
assert UsersAndGroups.get_group_choices() == [
('group1', 'description1 (group1)'),
('group2', 'description2 (group2)')
]
def test_check_username_reservation():
"""Test username reservations by multiple components."""
UsersAndGroups('complex-component',
reserved_usernames=['username1', 'username2'],
groups={'somegroup', 'some description'})
assert not UsersAndGroups.is_username_reserved('something')
assert UsersAndGroups.is_username_reserved('username1')
assert not UsersAndGroups.is_username_reserved('username3')
UsersAndGroups('temp-component', reserved_usernames=['username3'])
assert UsersAndGroups.is_username_reserved('username3')

View File

@ -1,18 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test module to exercise group registration.
It is recommended to run this module with root privileges in a virtual machine.
"""
from plinth.modules import users
def test_register_group():
"""Test for multi addition of same group"""
users.groups = dict() # reset groups
group = ('TestGroup', 'Group for testing')
users.register_group(group)
users.register_group(group)
assert len(users.groups) == 1
return users.groups

View File

@ -13,6 +13,8 @@ from django.core.exceptions import PermissionDenied
from plinth import module_loader
from plinth.modules.users import views
from ..components import UsersAndGroups
# For all tests, plinth.urls instead of urls configured for testing, and
# django database
pytestmark = [pytest.mark.urls('plinth.urls'), pytest.mark.django_db]
@ -43,15 +45,16 @@ def action_run(action, options, **kwargs):
@pytest.fixture(autouse=True)
def module_patch():
"""Patch users module."""
loaded_modules = [('minetest',
Mock(reserved_usernames=['Debian-minetest']))]
pwd_users = [Mock(pw_name='root'), Mock(pw_name='plinth')]
with patch('pwd.getpwall', return_value=pwd_users),\
patch('plinth.actions.superuser_run', side_effect=action_run),\
patch('plinth.module_loader.loaded_modules.items',
return_value=loaded_modules):
UsersAndGroups._all_components = set()
UsersAndGroups('test-users-and-groups',
groups={'admin': 'The admin group'})
UsersAndGroups('users-and-groups-minetest',
reserved_usernames=['debian-minetest'])
with patch('pwd.getpwall', return_value=pwd_users),\
patch('plinth.actions.superuser_run', side_effect=action_run):
yield