Skip to content

Commit

Permalink
[feature] Added setting control enabled commands openwisp#683
Browse files Browse the repository at this point in the history
Added OPENWISP_CONTROLLER_ORGANIZATION_ENABLED_COMMANDS.

Closes openwisp#683
  • Loading branch information
pandafy committed Aug 18, 2022
1 parent 0bf687c commit 5fe4b86
Show file tree
Hide file tree
Showing 13 changed files with 196 additions and 20 deletions.
34 changes: 33 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2807,9 +2807,41 @@ Allows to specify backend URL for API requests, if the frontend is hosted separa
| **default**: | ``[]`` |
+--------------+----------+

Allows to specify a `list` of tuples for adding commands as described in
Allows to specify a ``list`` of tuples for adding commands as described in
`'How to define custom commands" <#how-to-define-new-options-in-the-commands-menu>`_ section.

OPENWISP_CONTROLLER_ORGANIZATION_ENABLED_COMMANDS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+--------------+------------------------------------------------+
| **type**: | ``dict`` |
+--------------+------------------------------------------------+
| **default**: | .. code-block:: python |
| | |
| | { |
| | # By default all commands are allowed |
| | '__all__': '*', |
| | } |
| | |
+--------------+------------------------------------------------+

This setting controls the command types that are enabled on the system
By default, all command types are enabled to all the organizations,
but it's possible to disable a specific command for a specific organization
as shown in the following example:

.. code-block:: python
OPENWISP_CONTROLLER_ORGANIZATION_ENABLED_COMMANDS = {
'__all__': '*',
# Organization UUID: # Tuple of enabled commands
'7448a190-6e65-42bf-b8ea-bb6603e593a5': ('reboot', 'change_password'),
}
In the example above, the organization with UUID ``7448a190-6e65-42bf-b8ea-bb6603e593a5``
will allow to send only commands of type ``reboot`` and ``change_password``,
while all the other organizations will have all command types enabled.

``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
12 changes: 11 additions & 1 deletion openwisp_controller/config/static/config/js/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,17 @@

var bindLoadUi = function () {
$('.jsoneditor-raw:not([name*="__prefix__"])').each(function (i, el) {
$.getJSON($(el).data('schema-url'), function (schemas) {
// Add query parameters defined in the widget
var url, queryString = '?',
queryParams = $(el).data('query-params');
if (queryParams !== undefined) {
var queryKeys = Object.keys(queryParams);
for (var j = 0; j < queryKeys.length; ++j) {
queryString += '&' + queryKeys[j] + '=' + $('#' + queryParams[queryKeys[j]]).val();
}
}
url = $(el).data('schema-url') + queryString;
$.getJSON(url, function (schemas) {
django._schemas[$(el).data('schema-url')] = schemas;
var field = $(el),
schema = field.attr("data-schema"),
Expand Down
12 changes: 7 additions & 5 deletions openwisp_controller/connection/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import swapper
from django import forms
from django.contrib import admin
from django.http import JsonResponse
from django.http import HttpResponseForbidden, JsonResponse
from django.urls import path, resolve
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _
Expand All @@ -14,7 +14,6 @@

from ..admin import MultitenantAdminMixin
from ..config.admin import DeviceAdmin
from .commands import COMMANDS
from .schema import schema
from .widgets import CommandSchemaWidget, CredentialsSchemaWidget

Expand Down Expand Up @@ -159,9 +158,12 @@ def get_urls(self):
]

def schema_view(self, request):
result = {}
for key, value in COMMANDS.items():
result.update({key: value['schema']})
organization_id = request.GET.get('organization_id')
if not request.user.is_superuser and (
not organization_id or not request.user.is_manager(organization_id)
):
return HttpResponseForbidden()
result = self.model.get_org_schema(organization_id=organization_id)
return JsonResponse(result)


Expand Down
13 changes: 11 additions & 2 deletions openwisp_controller/connection/api/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
Device = load_model('config', 'Device')


class CommandSerializer(serializers.ModelSerializer):
class ValidatedDeviceFieldSerializer(ValidatedModelSerializer):
def validate(self, data):
# Add "device_id" to the data for validation
data['device_id'] = self.context['device_id']
instance = self.instance or self.Meta.model(**data)
instance.full_clean()
return data


class CommandSerializer(ValidatedDeviceFieldSerializer):
input = serializers.JSONField(allow_null=True)
device = serializers.PrimaryKeyRelatedField(
read_only=True, pk_field=serializers.UUIDField(format='hex_verbose')
Expand Down Expand Up @@ -59,7 +68,7 @@ class Meta:


class DeviceConnectionSerializer(
FilterSerializerByOrgManaged, ValidatedModelSerializer
FilterSerializerByOrgManaged, ValidatedDeviceFieldSerializer
):
class Meta:
model = DeviceConnection
Expand Down
8 changes: 5 additions & 3 deletions openwisp_controller/connection/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,18 @@ def assert_parent_exists(self):
def get_parent_queryset(self):
return Device.objects.filter(pk=self.kwargs['id'])

def get_serializer_context(self):
context = super().get_serializer_context()
context['device_id'] = self.kwargs['id']
return context


class CommandListCreateView(BaseCommandView, ListCreateAPIView):
pagination_class = ListViewPagination

def get_queryset(self):
return super().get_queryset().filter(device_id=self.kwargs['id'])

def perform_create(self, serializer):
serializer.save(device_id=self.kwargs['id'])


class CommandDetailsView(BaseCommandView, RetrieveAPIView):
def get_object(self):
Expand Down
27 changes: 27 additions & 0 deletions openwisp_controller/connection/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from ..commands import (
COMMAND_CHOICES,
DEFAULT_COMMANDS,
ORGANIZATION_COMMAND_SCHEMA,
ORGANIZATION_ENABLED_COMMANDS,
get_command_callable,
get_command_schema,
)
Expand Down Expand Up @@ -397,6 +399,18 @@ class Meta:
abstract = True
ordering = ('created',)

@classmethod
def get_org_choices(self, organization_id=None):
return ORGANIZATION_ENABLED_COMMANDS.get(
str(organization_id), ORGANIZATION_ENABLED_COMMANDS.get('__all__')
)

@classmethod
def get_org_schema(self, organization_id=None):
return ORGANIZATION_COMMAND_SCHEMA.get(
organization_id, ORGANIZATION_COMMAND_SCHEMA.get('__all__')
)

def __str__(self):
command = self.input['command'] if self.is_custom else self.get_type_display()
limit = 32
Expand All @@ -417,6 +431,19 @@ def full_clean(self, *args, **kwargs):
return super().full_clean(*args, **kwargs)

def clean(self):
if self.type not in self.get_org_choices(
organization_id=self.device.organization_id
):
raise ValidationError(
{
'input': _(
(
'"{command}" command is not available '
'for this organization'
).format(command=self.type)
)
}
)
try:
jsonschema.Draft4Validator(self._schema).validate(self.input)
except SchemaError as e:
Expand Down
12 changes: 11 additions & 1 deletion openwisp_controller/connection/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.utils.translation import gettext_lazy as _
from jsonschema import Draft4Validator

from .settings import USER_COMMANDS
from .settings import ORGANIZATION_ENABLED_COMMANDS, USER_COMMANDS

DEFAULT_COMMANDS = OrderedDict(
(
Expand Down Expand Up @@ -137,3 +137,13 @@ def _unregister_command_choice(command):
# Add USER_COMMANDS
for command_name, command_config in USER_COMMANDS:
register_command(command_name, command_config)

for org, commands in ORGANIZATION_ENABLED_COMMANDS.items():
if commands == '*':
ORGANIZATION_ENABLED_COMMANDS[org] = tuple(COMMANDS.keys())

ORGANIZATION_COMMAND_SCHEMA = {}
for org_id, commands in ORGANIZATION_ENABLED_COMMANDS.items():
ORGANIZATION_COMMAND_SCHEMA[org_id] = OrderedDict()
for command in commands:
ORGANIZATION_COMMAND_SCHEMA[org_id][command] = COMMANDS[command]['schema']
3 changes: 3 additions & 0 deletions openwisp_controller/connection/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@
# this may get overridden by openwisp-monitoring
UPDATE_CONFIG_MODEL = getattr(settings, 'OPENWISP_UPDATE_CONFIG_MODEL', 'config.Device')
USER_COMMANDS = getattr(settings, 'OPENWISP_CONTROLLER_USER_COMMANDS', [])
ORGANIZATION_ENABLED_COMMANDS = getattr(
settings, 'OPENWISP_CONTROLLER_ORGANIZATION_ENABLED_COMMANDS', {'__all__': '*'}
)
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ li.commands {
#ow-command-overlay-close > img{
height: 20px;
}
.ow-device-command-option-container {
min-width: 160px;
}

/* Styles for confirmation dialog element */
#ow-command-confirm-dialog {
Expand Down
34 changes: 29 additions & 5 deletions openwisp_controller/connection/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from django.urls import reverse
from swapper import load_model

from openwisp_controller.connection.commands import ORGANIZATION_ENABLED_COMMANDS

from ... import settings as module_settings
from ...config.tests.test_admin import TestAdmin as TestConfigAdmin
from ...tests import _get_updated_templates_settings
Expand Down Expand Up @@ -180,11 +182,33 @@ def test_commands_schema_view(self):
url = reverse(
f'admin:{Command._meta.app_label}_{Command._meta.model_name}_schema'
)
response = self.client.get(url)
result = json.loads(response.content)
self.assertIn('custom', result)
self.assertIn('change_password', result)
self.assertIn('reboot', result)
org = self._get_org()
org_admin = self._create_administrator([org])
with patch.dict(ORGANIZATION_ENABLED_COMMANDS, {str(org.id): ('reboot',)}):
with self.subTest('Test superuser request without organization_id'):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
result = json.loads(response.content)
self.assertIn('custom', result)
self.assertIn('change_password', result)
self.assertIn('reboot', result)

with self.subTest('Test superuser request with organization_id'):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
result = json.loads(response.content)
self.assertIn('reboot', result)

self.client.logout()
self.client.force_login(org_admin)
with self.subTest('Test org admin request without organization_id'):
response = self.client.get(url)
self.assertEqual(response.status_code, 403)

with self.subTest('Test org admin request with organization_id'):
response = self.client.get(url, {'organization_id': str(org.id)})
self.assertEqual(response.status_code, 200)
self.assertIn('reboot', result)

@patch.object(
module_settings,
Expand Down
22 changes: 22 additions & 0 deletions openwisp_controller/connection/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .. import settings as app_settings
from ..api.views import ListViewPagination
from ..commands import ORGANIZATION_ENABLED_COMMANDS
from .utils import CreateCommandMixin, CreateConnectionsMixin

Command = load_model('connection', 'Command')
Expand Down Expand Up @@ -285,6 +286,27 @@ def test_non_superuser(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)

def test_non_existent_command(self):
url = self._get_path('device_command_list', self.device_id)
with patch.dict(
ORGANIZATION_ENABLED_COMMANDS,
{str(self.device_conn.device.organization_id): ('reboot',)},
):
payload = {
'type': 'custom',
'input': {'command': 'echo test'},
}
response = self.client.post(
url,
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(response.status_code, 400)
self.assertIn(
'"custom" command is not available for this organization',
response.data['input'][0],
)


class TestConnectionApi(
TestAdminMixin, AuthenticationMixin, TestCase, CreateConnectionsMixin
Expand Down
35 changes: 33 additions & 2 deletions openwisp_controller/connection/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import paramiko
from django.contrib.auth.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase, TransactionTestCase
from django.test import TestCase, TransactionTestCase, tag
from django.utils import timezone
from django.utils.module_loading import import_string
from swapper import load_model
Expand All @@ -13,7 +13,12 @@

from .. import settings as app_settings
from ..apps import _TASK_NAME
from ..commands import register_command, unregister_command
from ..commands import (
COMMANDS,
ORGANIZATION_ENABLED_COMMANDS,
register_command,
unregister_command,
)
from ..signals import is_working_changed
from ..tasks import update_config
from .utils import CreateConnectionsMixin
Expand Down Expand Up @@ -476,6 +481,29 @@ def test_command_validation(self):
self.assertIn('input', e.message_dict)
self.assertEqual(e.message_dict['input'], ["[] is not of type 'object'"])

with self.subTest('Test executing command not available for org'):
org_id = dc.device.organization_id
with mock.patch.dict(
ORGANIZATION_ENABLED_COMMANDS, {str(org_id): ('reboot',)}
):
with self.assertRaises(ValidationError) as context_manager:
command.full_clean()
exception = context_manager.exception
self.assertIn('input', exception.message_dict)
self.assertEqual(
exception.message_dict['input'],
[
'"change_password" command is not available'
' for this organization'
],
)

@tag('skip_prod')
def test_enabled_command(self):
self.assertEqual(
ORGANIZATION_ENABLED_COMMANDS['__all__'], tuple(COMMANDS.keys())
)

def test_custom_command(self):
command = Command(input='test', type='change_password')
with self.assertRaises(TypeError) as context_manager:
Expand Down Expand Up @@ -616,6 +644,9 @@ def test_execute_change_password(self, connect_mocked):
self.assertEqual(list(command.arguments), ['********'])

@mock.patch(_connect_path)
@mock.patch.dict(
ORGANIZATION_ENABLED_COMMANDS, {'__all__': ('callable_ping', 'path_ping')}
)
def test_execute_user_registered_command(self, connect_mocked):
@mock.patch(_exec_command_path)
def _command_assertions(destination_address, mocked_exec_command):
Expand Down
1 change: 1 addition & 0 deletions openwisp_controller/connection/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class CommandSchemaWidget(BaseJsonSchemaWidget):
extra_attrs = {
'data-schema-selector': '#id_command_set-0-type',
'data-show-errors': 'never',
'data-query-params': '{"organization_id": "id_organization"}',
}

@property
Expand Down

0 comments on commit 5fe4b86

Please sign in to comment.