diff --git a/.github/linters/.flake8 b/.github/linters/.flake8 deleted file mode 100644 index 55e13d2..0000000 --- a/.github/linters/.flake8 +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -max-line-length = 160 -extend-ignore = E203 diff --git a/.github/linters/.isort.cfg b/.github/linters/.isort.cfg deleted file mode 100644 index a9a8d64..0000000 --- a/.github/linters/.isort.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[settings] -profile = black - -; vertical hanging indent mode also used in black configuration -multi_line_output = 3 - -; necessary because black expect the trailing comma -include_trailing_comma = true diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json deleted file mode 100644 index a474b30..0000000 --- a/.github/linters/.jscpd.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "threshold": 10 -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b580b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + pull_request: + +# This ensures that previous jobs for the workflow are canceled when the ref is +# updated. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + run-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + # Full git history is needed to get a proper list of changed files within `super-linter` + fetch-depth: 0 + + - name: Lint Code Base + uses: github/super-linter/slim@v4 + env: + DEFAULT_BRANCH: dev + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUPPRESS_POSSUM: true + LINTER_RULES_PATH: / + VALIDATE_ALL_CODEBASE: false + VALIDATE_DOCKERFILE: false + VALIDATE_JSCPD: true + FILTER_REGEX_EXCLUDE: (.*/)?(configuration/.*) + + test: + runs-on: ubuntu-latest + name: Runs plugin tests + needs: run-lint + steps: + - id: git-checkout + name: Checkout + uses: actions/checkout@v3 + + - id: docker-test + name: Test the image + run: ./test.sh snapshot diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml deleted file mode 100644 index 49aa1ee..0000000 --- a/.github/workflows/super-linter.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- -# This workflow executes several linters on changed files based on languages used in your code base whenever -# you push a code or open a pull request. -# -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/github/super-linter -name: Lint Code Base - -on: - push: - branches: ["dev"] - pull_request: - branches: ["dev"] -jobs: - run-lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - # Full git history is needed to get a proper list of changed files within `super-linter` - fetch-depth: 0 - - - name: Lint Code Base - uses: github/super-linter/slim@v4 - env: - VALIDATE_ALL_CODEBASE: false - DEFAULT_BRANCH: "dev" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000..338acfe --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,4 @@ +{ + "threshold": 10, + "ignore": ["**/tests/**"] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9f31f9d..9ebc05c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,8 @@ repos: - id: debug-statements - id: end-of-file-fixer - id: name-tests-test + args: + - "--django" - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/PyCQA/isort diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72b0fcd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +ARG NETBOX_VARIANT=v3.4 + +FROM netboxcommunity/netbox:${NETBOX_VARIANT} + +RUN mkdir -pv /plugins/netbox-acls +COPY . /plugins/netbox-acls + +RUN /opt/netbox/venv/bin/python3 /plugins/netbox-acls/setup.py develop +RUN cp -rf /plugins/netbox-acls/netbox_acls/ /opt/netbox/venv/lib/python3.10/site-packages/netbox_acls diff --git a/MANIFEST.in b/MANIFEST.in index beb736b..35c0df2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ include README.md include LICENSE recursive-include netbox_acls/templates * -recursive-include netbox_acls/static * diff --git a/configuration/configuration.py b/configuration/configuration.py new file mode 100644 index 0000000..1ebd734 --- /dev/null +++ b/configuration/configuration.py @@ -0,0 +1,99 @@ +#### +## We recommend to not edit this file. +## Create separate files to overwrite the settings. +## See `extra.py` as an example. +#### + +from os import environ +from os.path import abspath, dirname + +# For reference see https://netbox.readthedocs.io/en/stable/configuration/ +# Based on https://github.com/netbox-community/netbox/blob/master/netbox/netbox/configuration.example.py + +# Read secret from file +def _read_secret(secret_name, default=None): + try: + f = open("/run/secrets/" + secret_name, encoding="utf-8") + except OSError: + return default + else: + with f: + return f.readline().strip() + + +_BASE_DIR = dirname(dirname(abspath(__file__))) + +######################### +# # +# Required settings # +# # +######################### + +# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write +# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name. +# +# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] +ALLOWED_HOSTS = environ.get("ALLOWED_HOSTS", "*").split(" ") + +# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: +# https://docs.djangoproject.com/en/stable/ref/settings/#databases +DATABASE = { + "NAME": environ.get("DB_NAME", "netbox"), # Database name + "USER": environ.get("DB_USER", ""), # PostgreSQL username + "PASSWORD": _read_secret("db_password", environ.get("DB_PASSWORD", "")), + # PostgreSQL password + "HOST": environ.get("DB_HOST", "localhost"), # Database server + "PORT": environ.get("DB_PORT", ""), # Database port (leave blank for default) + "OPTIONS": {"sslmode": environ.get("DB_SSLMODE", "prefer")}, + # Database connection SSLMODE + "CONN_MAX_AGE": int(environ.get("DB_CONN_MAX_AGE", "300")), + # Max database connection age + "DISABLE_SERVER_SIDE_CURSORS": environ.get( + "DB_DISABLE_SERVER_SIDE_CURSORS", + "False", + ).lower() + == "true", + # Disable the use of server-side cursors transaction pooling +} + +# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate +# configuration exists for each. Full connection details are required in both sections, and it is strongly recommended +# to use two separate database IDs. +REDIS = { + "tasks": { + "HOST": environ.get("REDIS_HOST", "localhost"), + "PORT": int(environ.get("REDIS_PORT", 6379)), + "PASSWORD": _read_secret("redis_password", environ.get("REDIS_PASSWORD", "")), + "DATABASE": int(environ.get("REDIS_DATABASE", 0)), + "SSL": environ.get("REDIS_SSL", "False").lower() == "true", + "INSECURE_SKIP_TLS_VERIFY": environ.get( + "REDIS_INSECURE_SKIP_TLS_VERIFY", + "False", + ).lower() + == "true", + }, + "caching": { + "HOST": environ.get("REDIS_CACHE_HOST", environ.get("REDIS_HOST", "localhost")), + "PORT": int(environ.get("REDIS_CACHE_PORT", environ.get("REDIS_PORT", 6379))), + "PASSWORD": _read_secret( + "redis_cache_password", + environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")), + ), + "DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)), + "SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() + == "true", + "INSECURE_SKIP_TLS_VERIFY": environ.get( + "REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY", + environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"), + ).lower() + == "true", + }, +} + +# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file. +# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and +# symbols. NetBox will not run without this defined. For more information, see +# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY +SECRET_KEY = _read_secret("secret_key", environ.get("SECRET_KEY", "")) + +DEVELOPER = True diff --git a/configuration/logging.py b/configuration/logging.py new file mode 100644 index 0000000..86914ae --- /dev/null +++ b/configuration/logging.py @@ -0,0 +1,11 @@ +# Remove first comment(#) on each line to implement this working logging example. +# Add LOGLEVEL environment variable to netbox if you use this example & want a different log level. +from os import environ + +# Set LOGLEVEL in netbox.env or docker-compose.overide.yml to override a logging level of INFO. +LOGLEVEL = environ.get("LOGLEVEL", "INFO") + +LOGGING = { + "version": 1, + "disable_existing_loggers": True, +} diff --git a/configuration/plugins.py b/configuration/plugins.py new file mode 100644 index 0000000..eb7c714 --- /dev/null +++ b/configuration/plugins.py @@ -0,0 +1,13 @@ +# Add your plugins and plugin settings here. +# Of course uncomment this file out. + +# To learn how to build images with your required plugins +# See https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins + +PLUGINS = [ + "netbox_acls", +] + +PLUGINS_CONFIG = { # type: ignore + "netbox_acls": {}, +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0b78731 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.4' + +services: + netbox: + build: + dockerfile: Dockerfile + context: . + args: + NETBOX_VARIANT: ${NETBOX_VARIANT} + depends_on: + - postgres + - redis + env_file: env/netbox.env + volumes: + - ./configuration:/etc/netbox/config:z,ro + + # postgres + postgres: + image: postgres:14-alpine + env_file: env/postgres.env + + # redis + redis: + image: redis:6-alpine + command: + - sh + - -c # this is to evaluate the $REDIS_PASSWORD from the env + - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose + env_file: env/redis.env diff --git a/env/netbox.env b/env/netbox.env new file mode 100644 index 0000000..ee71174 --- /dev/null +++ b/env/netbox.env @@ -0,0 +1,23 @@ +ALLOWED_HOSTS=* +CORS_ORIGIN_ALLOW_ALL=true +DB_HOST=postgres +DB_NAME=netbox +DB_PASSWORD=J5brHrAXFLQSif0K +DB_USER=netbox +DEBUG=true +ENFORCE_GLOBAL_UNIQUE=true +LOGIN_REQUIRED=false +GRAPHQL_ENABLED=true +MAX_PAGE_SIZE=1000 +MEDIA_ROOT=/opt/netbox/netbox/media +REDIS_DATABASE=0 +REDIS_HOST=redis +REDIS_INSECURE_SKIP_TLS_VERIFY=false +REDIS_PASSWORD=H733Kdjndks81 +SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj +SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567 +SUPERUSER_EMAIL=admin@example.com +SUPERUSER_NAME=admin +SUPERUSER_PASSWORD=admin +STARTUP_SCRIPTS=false +WEBHOOKS_ENABLED=true diff --git a/env/postgres.env b/env/postgres.env new file mode 100644 index 0000000..bb7b53c --- /dev/null +++ b/env/postgres.env @@ -0,0 +1,3 @@ +POSTGRES_DB=netbox +POSTGRES_PASSWORD=J5brHrAXFLQSif0K +POSTGRES_USER=netbox diff --git a/env/redis.env b/env/redis.env new file mode 100644 index 0000000..44a1987 --- /dev/null +++ b/env/redis.env @@ -0,0 +1 @@ +REDIS_PASSWORD=H733Kdjndks81 diff --git a/netbox_acls/migrations/0003_netbox_acls.py b/netbox_acls/migrations/0003_netbox_acls.py index ccfcfbe..562e0f3 100644 --- a/netbox_acls/migrations/0003_netbox_acls.py +++ b/netbox_acls/migrations/0003_netbox_acls.py @@ -15,28 +15,36 @@ class Migration(migrations.Migration): model_name="accesslist", name="custom_field_data", field=models.JSONField( - blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder + blank=True, + default=dict, + encoder=utilities.json.CustomFieldJSONEncoder, ), ), migrations.AlterField( model_name="aclextendedrule", name="custom_field_data", field=models.JSONField( - blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder + blank=True, + default=dict, + encoder=utilities.json.CustomFieldJSONEncoder, ), ), migrations.AlterField( model_name="aclinterfaceassignment", name="custom_field_data", field=models.JSONField( - blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder + blank=True, + default=dict, + encoder=utilities.json.CustomFieldJSONEncoder, ), ), migrations.AlterField( model_name="aclstandardrule", name="custom_field_data", field=models.JSONField( - blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder + blank=True, + default=dict, + encoder=utilities.json.CustomFieldJSONEncoder, ), ), ] diff --git a/netbox_acls/tests/__init__.py b/netbox_acls/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_acls/tests/test_api.py b/netbox_acls/tests/test_api.py new file mode 100644 index 0000000..f01a687 --- /dev/null +++ b/netbox_acls/tests/test_api.py @@ -0,0 +1,100 @@ +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from netbox_acls.choices import * +from netbox_acls.models import * +from rest_framework import status +from utilities.testing import APITestCase, APIViewTestCases + + +class AppTest(APITestCase): + def test_root(self): + url = reverse("plugins-api:netbox_acls-api:api-root") + response = self.client.get(f"{url}?format=api", **self.header) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class ACLTestCase( + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.CreateObjectViewTestCase, + APIViewTestCases.UpdateObjectViewTestCase, + APIViewTestCases.DeleteObjectViewTestCase, +): + """Test the AccessList Test""" + + model = AccessList + view_namespace = "plugins-api:netbox_acls" + brief_fields = ["display", "id", "name", "url"] + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name="Site 1", slug="site-1") + manufacturer = Manufacturer.objects.create( + name="Manufacturer 1", + slug="manufacturer-1", + ) + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, + model="Device Type 1", + ) + devicerole = DeviceRole.objects.create( + name="Device Role 1", + slug="device-role-1", + ) + device = Device.objects.create( + name="Device 1", + site=site, + device_type=devicetype, + device_role=devicerole, + ) + + access_lists = ( + AccessList( + name="testacl1", + assigned_object_type=ContentType.objects.get_for_model(Device), + assigned_object_id=device.id, + type=ACLTypeChoices.TYPE_STANDARD, + default_action=ACLActionChoices.ACTION_DENY, + ), + AccessList( + name="testacl2", + assigned_object_type=ContentType.objects.get_for_model(Device), + assigned_object_id=device.id, + type=ACLTypeChoices.TYPE_STANDARD, + default_action=ACLActionChoices.ACTION_DENY, + ), + AccessList( + name="testacl3", + assigned_object_type=ContentType.objects.get_for_model(Device), + assigned_object_id=device.id, + type=ACLTypeChoices.TYPE_STANDARD, + default_action=ACLActionChoices.ACTION_DENY, + ), + ) + AccessList.objects.bulk_create(access_lists) + + cls.create_data = [ + { + "name": "testacl4", + "assigned_object_type": "dcim.device", + "assigned_object_id": device.id, + "type": ACLTypeChoices.TYPE_STANDARD, + "default_action": ACLActionChoices.ACTION_DENY, + }, + { + "name": "testacl5", + "assigned_object_type": "dcim.device", + "assigned_object_id": device.id, + "type": ACLTypeChoices.TYPE_EXTENDED, + "default_action": ACLActionChoices.ACTION_DENY, + }, + { + "name": "testacl6", + "assigned_object_type": "dcim.device", + "assigned_object_id": device.id, + "type": ACLTypeChoices.TYPE_STANDARD, + "default_action": ACLActionChoices.ACTION_DENY, + }, + ] diff --git a/netbox_acls/urls.py b/netbox_acls/urls.py index c5f07a1..5c70579 100644 --- a/netbox_acls/urls.py +++ b/netbox_acls/urls.py @@ -5,7 +5,7 @@ Map Views to URLs. from django.urls import include, path from utilities.urls import get_model_urls -from . import models, views +from . import views urlpatterns = ( # Access Lists diff --git a/setup.py b/setup.py index 1b55f67..50d4055 100644 --- a/setup.py +++ b/setup.py @@ -3,12 +3,13 @@ import os.path from setuptools import find_packages, setup -with open("README.md", encoding="utf-8") as fh: +here = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(here, "README.md"), encoding="utf-8") as fh: long_description = fh.read() def read(rel_path): - here = os.path.abspath(os.path.dirname(__file__)) with codecs.open(os.path.join(here, rel_path), "r") as fp: return fp.read() diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..5fc2e06 --- /dev/null +++ b/test.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Runs the NetBox plugin unit tests +# Usage: +# ./test.sh latest +# ./test.sh v2.9.7 +# ./test.sh develop-2.10 + +# exit when a command exits with an exit code != 0 +set -e + +# NETBOX_VARIANT is used by `Dockerfile` to determine the tag +NETBOX_VARIANT="${1-latest}" + +# The docker compose command to use +doco="docker compose --file docker-compose.yml" + +test_netbox_unit_tests() { + echo "⏱ Running NetBox Unit Tests" + $doco run --rm netbox python manage.py makemigrations netbox_acls --check + $doco run --rm netbox python manage.py test netbox_acls +} + +test_cleanup() { + echo "💣 Cleaning Up" + $doco down -v + $doco rm -fsv + docker image rm docker.io/library/netbox-acls-netbox || echo '' +} + +export NETBOX_VARIANT=${NETBOX_VARIANT} + +echo "🐳🐳🐳 Start testing '${NETBOX_VARIANT}'" + +# Make sure the cleanup script is executed +trap test_cleanup EXIT ERR +test_netbox_unit_tests + +echo "🐳🐳🐳 Done testing '${NETBOX_VARIANT}'"