diff --git a/Dockerfile b/Dockerfile index 2d5e1778..8b851634 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,9 @@ COPY --from=backend-build /usr/local/bin/uwsgi /usr/local/bin/uwsgi # Stage 3.2 - Copy source code WORKDIR /app +COPY ./bin/wait_for_db.sh /wait_for_db.sh COPY ./bin/docker_start.sh /start.sh +COPY ./bin/setup_configuration.sh /setup_configuration.sh RUN mkdir /app/log /app/config # copy frontend build statics diff --git a/bin/docker_start.sh b/bin/docker_start.sh index dca77e91..3aa32180 100755 --- a/bin/docker_start.sh +++ b/bin/docker_start.sh @@ -15,10 +15,8 @@ uwsgi_threads=${UWSGI_THREADS:-2} mountpoint=${SUBPATH:-/} -until pg_isready; do - >&2 echo "Waiting for database connection..." - sleep 1 -done +# wait for required services +${SCRIPTPATH}/wait_for_db.sh >&2 echo "Database is up." diff --git a/bin/setup_configuration.sh b/bin/setup_configuration.sh new file mode 100755 index 00000000..716b22e8 --- /dev/null +++ b/bin/setup_configuration.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# setup initial configuration using environment variables +# Run this script from the root of the repository + +#set -e +${SCRIPTPATH}/wait_for_db.sh + +src/manage.py migrate +src/manage.py setup_configuration --no-selftest diff --git a/bin/wait_for_db.sh b/bin/wait_for_db.sh new file mode 100755 index 00000000..89e15e6a --- /dev/null +++ b/bin/wait_for_db.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +set -e + +# Wait for the database container +# See: https://docs.docker.com/compose/startup-order/ +export PGHOST=${DB_HOST:-db} +export PGPORT=${DB_PORT:-5432} + +until pg_isready; do + >&2 echo "Waiting for database connection..." + sleep 1 +done + +>&2 echo "Database is up." diff --git a/docker-compose.config.yml b/docker-compose.config.yml new file mode 100644 index 00000000..f78558cd --- /dev/null +++ b/docker-compose.config.yml @@ -0,0 +1,94 @@ +version: '3' + +services: + # OBJECTTYPES API + db_objecttypes: + image: postgres:12-alpine + environment: + - POSTGRES_HOST_AUTH_METHOD=trust + volumes: + - ./docker-init-db-objecttypes.sql:/docker-entrypoint-initdb.d/init_db.sql + command: postgres -c max_connections=300 -c log_min_messages=LOG + + objecttypes: + build: ../objecttypes + environment: &objecttypes-env + - DJANGO_SETTINGS_MODULE=objecttypes.conf.docker + - SECRET_KEY=${SECRET_KEY:-fgv=c0hz&tl*8*3m3893@m+1pstrvidc9e^5@fpspmg%cy$15d} + - DB_HOST=db_objecttypes + - IS_HTTPS=no + - ALLOWED_HOSTS=* + - TWO_FACTOR_FORCE_OTP_ADMIN=no + - TWO_FACTOR_PATCH_ADMIN=no + # setup_configuration env vars + - OBJECTTYPES_DOMAIN=objecttypes:8000 + - OBJECTTYPES_ORGANIZATION=ObjectTypes + - OBJECTS_OBJECTTYPES_TOKEN=objects-random-string + - OBJECTS_OBJECTTYPES_PERSON=Objects + - OBJECTS_OBJECTTYPES_EMAIL=objects@objects.local + - DEMO_CONFIG_ENABLE=yes + - DEMO_TOKEN=demo-random-string + - DEMO_PERSON=Demo + - DEMO_EMAIL=demo@demo.local + ports: + - 8001:8000 + depends_on: + objecttypes-init: + condition: service_completed_successfully + + objecttypes-init: + build: ../objecttypes + environment: *objecttypes-env + command: /setup_configuration.sh + depends_on: + - db_objecttypes + + # OBJECTS API + db: + # NOTE: No persistance storage configured. + # See: https://hub.docker.com/_/postgres/ + image: postgis/postgis:12-2.5 + environment: + - POSTGRES_USER=objects + - POSTGRES_PASSWORD=objects + + objects: + build: . + environment: &objects-env + - DJANGO_SETTINGS_MODULE=objects.conf.docker + - SECRET_KEY=${SECRET_KEY:-1(@f(-6s_u(5fd&1sg^uvu2s(c-9sapw)1era8q&)g)h@cwxxg} + - DB_HOST=db + - IS_HTTPS=no + - OBJECTS_SUPERUSER_USERNAME=admin + - OBJECTS_SUPERUSER_PASSWORD=admin + - OBJECTS_SUPERUSER_EMAIL=admin@localhost + - ALLOWED_HOSTS=* + - TWO_FACTOR_FORCE_OTP_ADMIN=no + - TWO_FACTOR_PATCH_ADMIN=no + - NOTIFICATIONS_DISABLED=yes + # setup_configuration env vars + - OBJECTS_DOMAIN=objects:8000 + - OBJECTS_ORGANIZATION=Objects + - OBJECTTYPES_API_ROOT=http://objecttypes:8000/api/v2/ + - OBJECTS_OBJECTTYPES_TOKEN=objects-random-string + - DEMO_CONFIG_ENABLE=yes + - DEMO_TOKEN=demo-random-string + - DEMO_PERSON=Demo + - DEMO_EMAIL=demo@demo.local + ports: + - 8000:8000 + depends_on: + objects-init: + condition: service_completed_successfully + volumes: + - media:/app/media # Shared media volume to get access to saved OAS files + + objects-init: + build: . + environment: *objects-env + command: /setup_configuration.sh + depends_on: + - db + +volumes: + media: diff --git a/docker-compose.yml b/docker-compose.yml index db1a1f51..11e98255 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,19 +11,36 @@ services: web: build: . - environment: + environment: &app-env - DJANGO_SETTINGS_MODULE=objects.conf.docker - SECRET_KEY=${SECRET_KEY:-1(@f(-6s_u(5fd&1sg^uvu2s(c-9sapw)1era8q&)g)h@cwxxg} - OBJECTS_SUPERUSER_USERNAME=admin - OBJECTS_SUPERUSER_PASSWORD=admin - OBJECTS_SUPERUSER_EMAIL=admin@localhost - ALLOWED_HOSTS=* + # setup_configuration env vars + - OBJECTS_DOMAIN=web:8000 + - OBJECTS_ORGANIZATION=Objects + - OBJECTTYPES_API_ROOT=https://objecttypes.example.com/api/v2/ + - OBJECTS_OBJECTTYPES_TOKEN=some-random-string + - DEMO_CONFIG_ENABLE=yes + - DEMO_TOKEN=demo-random-string + - DEMO_PERSON=Demo + - DEMO_EMAIL=demo@demo.local ports: - 8000:8000 depends_on: - - db + web-init: + condition: service_completed_successfully volumes: - media:/app/media # Shared media volume to get access to saved OAS files + web-init: + build: . + environment: *app-env + command: /setup_configuration.sh + depends_on: + - db + volumes: media: diff --git a/docker-init-db-objecttypes.sql b/docker-init-db-objecttypes.sql new file mode 100644 index 00000000..7e5eb4df --- /dev/null +++ b/docker-init-db-objecttypes.sql @@ -0,0 +1,3 @@ +CREATE USER objecttypes; +CREATE DATABASE objecttypes; +GRANT ALL PRIVILEGES ON DATABASE objecttypes TO objecttypes; diff --git a/docs/admin/authorization.rst b/docs/admin/authorization.rst index eb5fe55f..58033413 100644 --- a/docs/admin/authorization.rst +++ b/docs/admin/authorization.rst @@ -88,3 +88,19 @@ fields you can submit the form. Now the client who has this token can access the objects with the "Boom" object type. If you want to know how to use Objects API you can follow :ref:`api_usage` + + +Superuser permissions +---------------------- + +It's possible to set superuser permissions in Objects API. A client with such permissions +is able to request objects for all objecttypes. + +In the admin page of the Objects API go to the Token authorizations" resource and click on +a token, which should have superuser permissions. Check "is superuser" field. Now this token +has read and write permissions for all objects. + +.. warning:: + + Tokens with superuser permissions are not recommended for production. They should be used + only for test and development purposes. diff --git a/requirements/base.in b/requirements/base.in index 82102590..e7210871 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -6,6 +6,7 @@ pytz # handle timezones python-dotenv # environment variables for secrets python-decouple # processing of envvar configs jsonschema +furl # Framework libraries django~=3.2 @@ -16,6 +17,7 @@ django-rosetta maykin-django-two-factor-auth maykin-django-two-factor-auth[phonenumbers] mozilla-django-oidc-db +git+https://github.com/maykinmedia/django-setup-configuration.git@feature/1-config-command # API libraries djangorestframework diff --git a/requirements/base.txt b/requirements/base.txt index feaa7bd3..272306b1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -54,6 +54,7 @@ django==3.2.23 # django-rest-framework-condition # django-rosetta # django-sendfile2 + # django-setup-configuration # django-simple-certmanager # django-solo # djangorestframework @@ -101,6 +102,8 @@ django-rosetta==0.9.8 # via -r requirements/base.in django-sendfile2==0.7.0 # via django-privates +django-setup-configuration @ git+https://github.com/maykinmedia/django-setup-configuration.git@feature/1-config-command + # via -r requirements/base.in django-simple-certmanager==1.4.1 # via zgw-consumers django-solo==2.2.0 @@ -132,6 +135,8 @@ face==20.1.1 # via glom faker==8.1.0 # via zgw-consumers +furl==2.1.3 + # via -r requirements/base.in gemma-zds-client==1.0.1 # via # vng-api-common @@ -170,6 +175,8 @@ mozilla-django-oidc==4.0.0 # via mozilla-django-oidc-db mozilla-django-oidc-db==0.14.1 # via -r requirements/base.in +orderedmultidict==1.0.1 + # via furl oyaml==1.0 # via vng-api-common packaging==23.2 @@ -235,8 +242,10 @@ sentry-sdk==1.0.0 six==1.15.0 # via # django-markup + # furl # isodate # jsonschema + # orderedmultidict # python-dateutil # qrcode # requests-mock diff --git a/requirements/ci.txt b/requirements/ci.txt index 0f320a93..15463dd9 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -73,6 +73,7 @@ django==3.2.23 # django-rest-framework-condition # django-rosetta # django-sendfile2 + # django-setup-configuration # django-simple-certmanager # django-solo # djangorestframework @@ -142,6 +143,8 @@ django-sendfile2==0.7.0 # via # -r requirements/base.txt # django-privates +django-setup-configuration @ git+https://github.com/maykinmedia/django-setup-configuration.git@feature/1-config-command + # via -r requirements/base.txt django-simple-certmanager==1.4.1 # via # -r requirements/base.txt @@ -193,6 +196,8 @@ faker==8.1.0 # zgw-consumers freezegun==1.1.0 # via -r requirements/test-tools.in +furl==2.1.3 + # via -r requirements/base.txt gemma-zds-client==1.0.1 # via # -r requirements/base.txt @@ -253,6 +258,10 @@ mozilla-django-oidc==4.0.0 # mozilla-django-oidc-db mozilla-django-oidc-db==0.14.1 # via -r requirements/base.txt +orderedmultidict==1.0.1 + # via + # -r requirements/base.txt + # furl oyaml==1.0 # via # -r requirements/base.txt @@ -348,8 +357,10 @@ six==1.15.0 # via # -r requirements/base.txt # django-markup + # furl # isodate # jsonschema + # orderedmultidict # python-dateutil # qrcode # requests-mock diff --git a/requirements/dev.txt b/requirements/dev.txt index 1f2e664a..bdd28c51 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -97,6 +97,7 @@ django==3.2.23 # django-rest-framework-condition # django-rosetta # django-sendfile2 + # django-setup-configuration # django-simple-certmanager # django-solo # djangorestframework @@ -170,6 +171,8 @@ django-sendfile2==0.7.0 # via # -r requirements/ci.txt # django-privates +django-setup-configuration @ git+https://github.com/maykinmedia/django-setup-configuration.git@feature/1-config-command + # via -r requirements/ci.txt django-simple-certmanager==1.4.1 # via # -r requirements/ci.txt @@ -229,6 +232,8 @@ flake8==7.0.0 # via -r requirements/dev.in freezegun==1.1.0 # via -r requirements/ci.txt +furl==2.1.3 + # via -r requirements/ci.txt gemma-zds-client==1.0.1 # via # -r requirements/ci.txt @@ -300,6 +305,10 @@ mozilla-django-oidc-db==0.14.1 # via -r requirements/ci.txt mypy-extensions==0.4.3 # via black +orderedmultidict==1.0.1 + # via + # -r requirements/ci.txt + # furl oyaml==1.0 # via # -r requirements/ci.txt @@ -417,8 +426,10 @@ six==1.15.0 # via # -r requirements/ci.txt # django-markup + # furl # isodate # jsonschema + # orderedmultidict # python-dateutil # qrcode # requests-mock diff --git a/src/objects/api/fields.py b/src/objects/api/fields.py index 3cd1b28f..c6240c41 100644 --- a/src/objects/api/fields.py +++ b/src/objects/api/fields.py @@ -1,8 +1,10 @@ -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.encoding import smart_text from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from vng_api_common.utils import get_uuid_from_path +from zgw_consumers.models import Service from objects.core.models import ObjectRecord @@ -42,7 +44,23 @@ def to_internal_value(self, data): try: return self.get_queryset().get_by_url(data) except ObjectDoesNotExist: - self.fail("does_not_exist", value=smart_text(data)) + # if service is configured, but objec_type is missing + # let's try to create an ObjectType + service = Service.get_service(data) + if not service: + self.fail("does_not_exist", value=smart_text(data)) + + uuid = get_uuid_from_path(data) + object_type = self.get_queryset().model(service=service, uuid=uuid) + + try: + object_type.clean() + except ValidationError: + self.fail("does_not_exist", value=smart_text(data)) + + object_type.save() + return object_type + except (TypeError, ValueError): self.fail("invalid") diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index d77e8afb..b5eaed55 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -82,6 +82,7 @@ "vng_api_common.notifications", "simple_certmanager", "zgw_consumers", + "django_setup_configuration", # 2fa apps "django_otp", "django_otp.plugins.otp_static", @@ -90,6 +91,7 @@ # Project applications. "objects.accounts", "objects.api", + "objects.config", "objects.core", "objects.token", "objects.utils", @@ -437,3 +439,39 @@ OIDC_AUTHENTICATE_CLASS = "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" MOZILLA_DJANGO_OIDC_DB_CACHE = "oidc" MOZILLA_DJANGO_OIDC_DB_CACHE_TIMEOUT = 5 * 60 + +# +# Django setup configuration +# +SETUP_CONFIGURATION_STEPS = [ + "objects.config.site.SiteConfigurationStep", + "objects.config.objecttypes.ObjecttypesStep", + "objects.config.demo.DemoUserStep", +] + + +# +# Objecttypes settings +# + +# setup_configuration command +# sites config +SITES_CONFIG_ENABLE = config("SITES_CONFIG_ENABLE", default=True) +OBJECTS_DOMAIN = config("OBJECTS_DOMAIN", "") +OBJECTS_ORGANIZATION = config("OBJECTS_ORGANIZATION", "") +# objecttypes config +OBJECTS_OBJECTTYPES_CONFIG_ENABLE = config( + "OBJECTS_OBJECTTYPES_CONFIG_ENABLE", default=True +) +OBJECTTYPES_API_ROOT = config("OBJECTTYPES_API_ROOT", "") +if OBJECTTYPES_API_ROOT and not OBJECTTYPES_API_ROOT.endswith("/"): + OBJECTTYPES_API_ROOT = f"{OBJECTTYPES_API_ROOT.strip()}/" +OBJECTTYPES_API_OAS = config( + "OBJECTTYPES_API_OAS", default=f"{OBJECTTYPES_API_ROOT}schema/openapi.yaml" +) +OBJECTS_OBJECTTYPES_TOKEN = config("OBJECTS_OBJECTTYPES_TOKEN", "") +# Demo User Configuration +DEMO_CONFIG_ENABLE = config("DEMO_CONFIG_ENABLE", default=DEBUG) +DEMO_TOKEN = config("DEMO_TOKEN", "") +DEMO_PERSON = config("DEMO_PERSON", "") +DEMO_EMAIL = config("DEMO_EMAIL", "") diff --git a/src/objects/config/__init__.py b/src/objects/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/config/demo.py b/src/objects/config/demo.py new file mode 100644 index 00000000..47643edc --- /dev/null +++ b/src/objects/config/demo.py @@ -0,0 +1,63 @@ +from django.conf import settings +from django.urls import reverse + +import requests +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.token.models import TokenAuth +from objects.utils import build_absolute_url + + +class DemoUserStep(BaseConfigurationStep): + """ + Create demo user to request Objects API + + **NOTE** For now demo user has all permissions. + """ + + verbose_name = "Demo User Configuration" + required_settings = [ + "DEMO_TOKEN", + "DEMO_PERSON", + "DEMO_EMAIL", + ] + enable_setting = "DEMO_CONFIG_ENABLE" + + def is_configured(self) -> bool: + return TokenAuth.objects.filter(token=settings.DEMO_TOKEN).exists() + + def configure(self): + token_auth, created = TokenAuth.objects.get_or_create( + token=settings.DEMO_TOKEN, + defaults={ + "contact_person": settings.DEMO_PERSON, + "email": settings.DEMO_EMAIL, + "is_superuser": True, + }, + ) + if ( + token_auth.contact_person != settings.DEMO_PERSON + or token_auth.email != settings.DEMO_EMAIL + ): + token_auth.contact_person = settings.DEMO_PERSON + token_auth.email = settings.DEMO_EMAIL + token_auth.save(update_fields=["contact_person", "email"]) + + def test_configuration(self): + endpoint = reverse("v2:object-list") + full_url = build_absolute_url(endpoint, request=None) + + try: + response = requests.get( + full_url, + headers={ + "Authorization": f"Token {settings.DEMO_TOKEN}", + "Accept": "application/json", + }, + ) + response.raise_for_status() + except requests.RequestException as exc: + raise SelfTestFailed( + "Could not list objects for the configured token" + ) from exc diff --git a/src/objects/config/management/__init__.py b/src/objects/config/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/config/management/commands/__init__.py b/src/objects/config/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/config/management/commands/create_test_object.py b/src/objects/config/management/commands/create_test_object.py new file mode 100644 index 00000000..b5004cd0 --- /dev/null +++ b/src/objects/config/management/commands/create_test_object.py @@ -0,0 +1,103 @@ +from django.conf import settings +from django.core.management import BaseCommand, CommandError +from django.urls import reverse + +import requests + + +class Command(BaseCommand): + help = "Create a test object as an integration test for Objects and Objecttypes API" + + def add_arguments(self, parser): + super().add_arguments(parser) + + parser.add_argument( + "--keep-data", + dest="keep_data", + action="store_true", + help="The component name to define urlconf and schema info", + ) + + def handle(self, **options): + # For test purposes the same DEMO_TOKEN is used for both Objects API and Objecttypes API + if not settings.DEMO_TOKEN: + raise CommandError("DEMO_TOKEN env var should be configured") + + req = requests.session() + req.headers.update( + { + "Authorization": f"Token {settings.DEMO_TOKEN}", + "Accept": "application/json", + "Content-Type": "application/json", + "Content-Crs": "EPSG:4326", + } + ) + req.hooks = {"response": [lambda r, *args, **kwargs: r.raise_for_status()]} + + # 1. create an Object Type + objecttype_data = { + "name": "boom", + "namePlural": "bomen", + "description": "tree type description", + } + objecttypes_list_url = "http://localhost:8001/api/v2/objecttypes" + response = req.post(objecttypes_list_url, json=objecttype_data) + + objecttype_url = response.json()["url"] + objecttype_uuid = response.json()["uuid"] + self.stdout.write(f"Demo objecttype {objecttype_url} was created") + + # 2. Create an Object Type version + objectversion_data = { + "status": "draft", + "jsonSchema": { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": { + "diameter": {"type": "integer", "description": "size in cm."} + }, + }, + } + objectversion_list_url = ( + f"http://localhost:8001/api/v2/objecttypes/{objecttype_uuid}/versions" + ) + response = req.post(objectversion_list_url, json=objectversion_data) + + objectversion_url = response.json()["url"] + objectversion_version = response.json()["version"] + self.stdout.write(f"Demo objectversion {objectversion_url} was created") + + # 3. Create an Object + object_data = { + "type": objecttype_url.replace("localhost:8001", "objecttypes:8000"), + "record": { + "typeVersion": objectversion_version, + "data": {"diameter": 30}, + "startAt": "2024-01-01", + }, + } + object_list_url = "http://localhost:8000/api/v2/objects" + response = req.post(object_list_url, json=object_data) + + object_url = response.json()["url"] + self.stdout.write(f"Demo object {object_url} was created") + + if options["keep_data"]: + self.stdout.write("Demo objecttype and object are remained in DB") + return + + # 4. Delete an Object + req.delete(object_url) + self.stdout.write(f"Demo object {object_url} was deleted") + + # 5. Delete an Object Version + req.delete(objectversion_url) + self.stdout.write(f"Demo objectversion {objectversion_url} was deleted") + + # 6. Delete an Object Type + req.delete(objecttype_url) + self.stdout.write(f"Demo objecttype {objecttype_url} was deleted") + + self.stdout.write(self.style.SUCCESS("Demo process is finished")) diff --git a/src/objects/config/objecttypes.py b/src/objects/config/objecttypes.py new file mode 100644 index 00000000..480ff67e --- /dev/null +++ b/src/objects/config/objecttypes.py @@ -0,0 +1,58 @@ +from django.conf import settings + +import requests +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed +from zds_client.client import ClientError +from zgw_consumers.constants import APITypes, AuthTypes +from zgw_consumers.models import Service + + +class ObjecttypesStep(BaseConfigurationStep): + """ + Configure credentials for Objects API to request Objecttypes API + + Normal mode doesn't change the token after its initial creation. + If the token is changed, run this command with 'overwrite' flag + """ + + verbose_name = "Objecttypes Configuration" + required_settings = [ + "OBJECTTYPES_API_ROOT", + "OBJECTS_OBJECTTYPES_TOKEN", + ] + enable_setting = "OBJECTS_OBJECTTYPES_CONFIG_ENABLE" + + def is_configured(self) -> bool: + return Service.objects.filter(api_root=settings.OBJECTTYPES_API_ROOT).exists() + + def configure(self) -> None: + service, created = Service.objects.update_or_create( + api_root=settings.OBJECTTYPES_API_ROOT, + defaults={ + "label": "Objecttypes API", + "api_type": APITypes.orc, + "oas": settings.OBJECTTYPES_API_OAS, + "auth_type": AuthTypes.api_key, + "header_key": "Authorization", + "header_value": f"Token {settings.OBJECTS_OBJECTTYPES_TOKEN}", + }, + ) + if not created: + service.oas = settings.OBJECTTYPES_API_OAS + service.header_value = f"Token {settings.OBJECTS_OBJECTTYPES_TOKEN}" + service.save(update_fields=["oas", "header_value"]) + + def test_configuration(self) -> None: + """ + This check depends on the configuration in Objecttypes + """ + client = Service.objects.get( + api_root=settings.OBJECTTYPES_API_ROOT + ).build_client() + try: + client.list("objecttype") + except (requests.RequestException, ClientError) as exc: + raise SelfTestFailed( + "Could not Could not retrieve list of objecttypes from Objecttypes API." + ) from exc diff --git a/src/objects/config/site.py b/src/objects/config/site.py new file mode 100644 index 00000000..af20fb0e --- /dev/null +++ b/src/objects/config/site.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.urls import reverse + +import requests +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.utils import build_absolute_url + + +class SiteConfigurationStep(BaseConfigurationStep): + """ + Configure the application site/domain. + """ + + verbose_name = "Site Configuration" + required_settings = ["OBJECTS_DOMAIN", "OBJECTS_ORGANIZATION"] + enable_setting = "SITES_CONFIG_ENABLE" + + def is_configured(self) -> bool: + site = Site.objects.get_current() + return site.domain == settings.OBJECTS_DOMAIN + + def configure(self): + site = Site.objects.get_current() + site.domain = settings.OBJECTS_DOMAIN + site.name = f"Objects {settings.OBJECTS_ORGANIZATION}".strip() + site.save() + + def test_configuration(self): + full_url = build_absolute_url(reverse("home")) + try: + response = requests.get(full_url) + response.raise_for_status() + except requests.RequestException as exc: + raise SelfTestFailed(f"Could not access home page at '{full_url}'") from exc diff --git a/src/objects/core/query.py b/src/objects/core/query.py index 84514c6e..bd1116fe 100644 --- a/src/objects/core/query.py +++ b/src/objects/core/query.py @@ -30,6 +30,10 @@ class ObjectRecordQuerySet(models.QuerySet): def filter_for_token(self, token): if not token: return self.none() + + if token.is_superuser: + return self.all() + allowed_object_types = token.permissions.values("object_type") return self.filter( object__object_type__in=models.Subquery(allowed_object_types) diff --git a/src/objects/tests/commands/__init__.py b/src/objects/tests/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/tests/commands/test_setup_configuration.py b/src/objects/tests/commands/test_setup_configuration.py new file mode 100644 index 00000000..5a0d06e6 --- /dev/null +++ b/src/objects/tests/commands/test_setup_configuration.py @@ -0,0 +1,117 @@ +from io import StringIO + +from django.contrib.sites.models import Site +from django.core.management import CommandError, call_command +from django.test import TestCase, override_settings +from django.urls import reverse + +import requests_mock +from rest_framework import status +from zgw_consumers.models import Service + +from objects.config.demo import DemoUserStep +from objects.config.objecttypes import ObjecttypesStep +from objects.config.site import SiteConfigurationStep + +from ..utils import mock_service_oas_get + + +@override_settings( + OBJECTS_DOMAIN="objects.example.com", + OBJECTS_ORGANIZATION="ACME", + OBJECTTYPES_API_ROOT="https://objecttypes.example.com/api/v2/", + OBJECTTYPES_API_OAS="https://objecttypes.example.com/api/v2/schema/openapi.yaml", + OBJECTS_OBJECTTYPES_TOKEN="some-random-string", + DEMO_CONFIG_ENABLE=True, + DEMO_TOKEN="demo-random-string", + DEMO_PERSON="Demo", + DEMO_EMAIL="demo@demo.local", +) +class SetupConfigurationTests(TestCase): + def setUp(self): + super().setUp() + + self.addCleanup(Site.objects.clear_cache) + + @requests_mock.Mocker() + def test_setup_configuration(self, m): + stdout = StringIO() + # mocks + m.get("http://objects.example.com/", status_code=200) + m.get("http://objects.example.com/api/v2/objects", json=[]) + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + m.get("https://objecttypes.example.com/api/v2/objecttypes", json={}) + + call_command("setup_configuration", stdout=stdout) + + with self.subTest("Command output"): + command_output = stdout.getvalue().splitlines() + expected_output = [ + f"Configuration will be set up with following steps: [{SiteConfigurationStep()}, " + f"{ObjecttypesStep()}, {DemoUserStep()}]", + f"Configuring {SiteConfigurationStep()}...", + f"{SiteConfigurationStep()} is successfully configured", + f"Configuring {ObjecttypesStep()}...", + f"{ObjecttypesStep()} is successfully configured", + f"Configuring {DemoUserStep()}...", + f"{DemoUserStep()} is successfully configured", + "Instance configuration completed.", + ] + + self.assertEqual(command_output, expected_output) + + with self.subTest("Site configured correctly"): + site = Site.objects.get_current() + self.assertEqual(site.domain, "objects.example.com") + self.assertEqual(site.name, "Objects ACME") + + with self.subTest("Objects can query Objecttypes API"): + client = Service.get_client("https://objecttypes.example.com/api/v2/") + self.assertIsNotNone(client) + + client.list("objecttype") + + list_call = m.last_request + self.assertEqual( + list_call.url, "https://objecttypes.example.com/api/v2/objecttypes" + ) + self.assertIn("Authorization", list_call.headers) + self.assertEqual( + list_call.headers["Authorization"], "Token some-random-string" + ) + + with self.subTest("Demo user configured correctly"): + response = self.client.get( + reverse("v2:object-list"), + HTTP_AUTHORIZATION="Token demo-random-string", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @requests_mock.Mocker() + def test_setup_configuration_selftest_fails(self, m): + m.get("http://objects.example.com/", status_code=500) + m.get("http://objects.example.com/api/v2/objects", status_code=200) + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + m.get("https://objecttypes.example.com/api/v2/objecttypes", json={}) + + with self.assertRaisesMessage( + CommandError, + "Configuration test failed with errors: " + "Site Configuration: Could not access home page at 'http://objects.example.com/'", + ): + call_command("setup_configuration") + + @requests_mock.Mocker() + def test_setup_configuration_without_selftest(self, m): + stdout = StringIO() + + call_command("setup_configuration", no_selftest=True, stdout=stdout) + command_output = stdout.getvalue() + + self.assertEqual(len(m.request_history), 0) + self.assertTrue("Selftest is skipped" in command_output) diff --git a/src/objects/tests/config/__init__.py b/src/objects/tests/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/tests/config/test_demo_configuration.py b/src/objects/tests/config/test_demo_configuration.py new file mode 100644 index 00000000..5d9a2cce --- /dev/null +++ b/src/objects/tests/config/test_demo_configuration.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +from django.test import TestCase, override_settings + +import requests +import requests_mock +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.config.demo import DemoUserStep +from objects.token.models import TokenAuth + + +@override_settings( + DEMO_TOKEN="demo-random-string", DEMO_PERSON="Demo", DEMO_EMAIL="demo@demo.local" +) +class DemoConfigurationTests(TestCase): + def test_configure(self): + configuration = DemoUserStep() + + configuration.configure() + + token_auth = TokenAuth.objects.get() + self.assertEqual(token_auth.token, "demo-random-string") + self.assertTrue(token_auth.is_superuser) + self.assertEqual(token_auth.contact_person, "Demo") + self.assertEqual(token_auth.email, "demo@demo.local") + + @requests_mock.Mocker() + @patch( + "objects.config.demo.build_absolute_url", + return_value="http://testserver/objects", + ) + def test_configuration_check_ok(self, m, *mocks): + configuration = DemoUserStep() + configuration.configure() + m.get("http://testserver/objects", json=[]) + + configuration.test_configuration() + + self.assertEqual(m.last_request.url, "http://testserver/objects") + self.assertEqual(m.last_request.method, "GET") + + @requests_mock.Mocker() + @patch( + "objects.config.demo.build_absolute_url", + return_value="http://testserver/objects", + ) + def test_configuration_check_failures(self, m, *mocks): + configuration = DemoUserStep() + configuration.configure() + + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + with self.subTest(mock=mock_config): + m.get("http://testserver/objects", **mock_config) + + with self.assertRaises(SelfTestFailed): + configuration.test_configuration() + + def test_is_configured(self): + configuration = DemoUserStep() + + self.assertFalse(configuration.is_configured()) + + configuration.configure() + + self.assertTrue(configuration.is_configured()) diff --git a/src/objects/tests/config/test_objecttypes_configuration.py b/src/objects/tests/config/test_objecttypes_configuration.py new file mode 100644 index 00000000..95fee463 --- /dev/null +++ b/src/objects/tests/config/test_objecttypes_configuration.py @@ -0,0 +1,82 @@ +from unittest.mock import patch + +from django.test import TestCase, override_settings + +import requests +import requests_mock +from django_setup_configuration.exceptions import SelfTestFailed +from zgw_consumers.constants import AuthTypes +from zgw_consumers.models import Service + +from objects.config.objecttypes import ObjecttypesStep + +from ..utils import mock_service_oas_get + + +@override_settings( + OBJECTTYPES_API_ROOT="https://objecttypes.example.com/api/v2/", + OBJECTTYPES_API_OAS="https://objecttypes.example.com/api/v2/schema/openapi.yaml", + OBJECTS_OBJECTTYPES_TOKEN="some-random-string", +) +class ObjecttypesConfigurationTests(TestCase): + def test_configure(self): + configuration = ObjecttypesStep() + + configuration.configure() + + service = Service.objects.get( + api_root="https://objecttypes.example.com/api/v2/" + ) + self.assertEqual( + service.oas, "https://objecttypes.example.com/api/v2/schema/openapi.yaml" + ) + self.assertEqual(service.auth_type, AuthTypes.api_key) + self.assertEqual(service.header_key, "Authorization") + self.assertEqual(service.header_value, "Token some-random-string") + + @requests_mock.Mocker() + def test_selftest_ok(self, m): + configuration = ObjecttypesStep() + configuration.configure() + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + m.get("https://objecttypes.example.com/api/v2/objecttypes", json={}) + + configuration.test_configuration() + + self.assertEqual( + m.last_request.url, "https://objecttypes.example.com/api/v2/objecttypes" + ) + + @requests_mock.Mocker() + def test_selftest_fail(self, m): + configuration = ObjecttypesStep() + configuration.configure() + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + with self.subTest(mock=mock_config): + m.get( + "https://objecttypes.example.com/api/v2/objecttypes", **mock_config + ) + + with self.assertRaises(SelfTestFailed): + configuration.test_configuration() + + def test_is_configured(self): + configuration = ObjecttypesStep() + self.assertFalse(configuration.is_configured()) + + configuration.configure() + + self.assertTrue(configuration.is_configured()) diff --git a/src/objects/tests/config/test_site_configuration.py b/src/objects/tests/config/test_site_configuration.py new file mode 100644 index 00000000..b6e47ecd --- /dev/null +++ b/src/objects/tests/config/test_site_configuration.py @@ -0,0 +1,66 @@ +from django.contrib.sites.models import Site +from django.test import TestCase, override_settings + +import requests +import requests_mock +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.config.site import SiteConfigurationStep + + +@override_settings( + OBJECTS_DOMAIN="localhost:8000", + OBJECTS_ORGANIZATION="ACME", +) +class SiteConfigurationTests(TestCase): + def setUp(self): + super().setUp() + + self.addCleanup(Site.objects.clear_cache) + + def test_set_domain(self): + configuration = SiteConfigurationStep() + configuration.configure() + + site = Site.objects.get_current() + self.assertEqual(site.domain, "localhost:8000") + self.assertEqual(site.name, "Objects ACME") + + @requests_mock.Mocker() + def test_configuration_check_ok(self, m): + m.get("http://localhost:8000/", status_code=200) + configuration = SiteConfigurationStep() + configuration.configure() + + configuration.test_configuration() + + self.assertEqual(m.last_request.url, "http://localhost:8000/") + self.assertEqual(m.last_request.method, "GET") + + @requests_mock.Mocker() + def test_configuration_check_failures(self, m): + configuration = SiteConfigurationStep() + configuration.configure() + + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + with self.subTest(mock=mock_config): + m.get("http://localhost:8000/", **mock_config) + + with self.assertRaises(SelfTestFailed): + configuration.test_configuration() + + def test_is_configured(self): + configuration = SiteConfigurationStep() + + self.assertFalse(configuration.is_configured()) + + configuration.configure() + + self.assertTrue(configuration.is_configured()) diff --git a/src/objects/tests/v2/test_auth.py b/src/objects/tests/v2/test_auth.py index 4cca5325..819bb877 100644 --- a/src/objects/tests/v2/test_auth.py +++ b/src/objects/tests/v2/test_auth.py @@ -1,18 +1,22 @@ from django.contrib.gis.geos import Point +import requests_mock from rest_framework import status from rest_framework.test import APITestCase +from objects.core.models import ObjectType from objects.core.tests.factories import ( ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, + ServiceFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory, TokenAuthFactory from objects.utils.test import TokenAuthMixin from ..constants import GEO_WRITE_KWARGS, POLYGON_AMSTERDAM_CENTRUM +from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse, reverse_lazy OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" @@ -292,3 +296,189 @@ def test_search_objects_limited_to_object_permission(self): data[0]["url"], f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", ) + + +class SuperUserTests(TokenAuthMixin, APITestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.token_auth.is_superuser = True + cls.token_auth.save() + + def test_retrieve_superuser(self): + object = ObjectFactory.create() + ObjectRecordFactory.create(object=object) + url = reverse("object-detail", args=[object.uuid]) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_superuser(self): + ObjectRecordFactory.create_batch(2) + url = reverse_lazy("object-list") + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.json()["results"]), 2) + + def test_search_superuser(self): + ObjectRecordFactory.create_batch(2, geometry=Point(4.905289, 52.369918)) + url = reverse("object-search") + + response = self.client.post( + url, + { + "geometry": { + "within": { + "type": "Polygon", + "coordinates": [POLYGON_AMSTERDAM_CENTRUM], + } + }, + }, + **GEO_WRITE_KWARGS, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + self.assertEqual(len(data), 2) + + def test_history_superuser(self): + object = ObjectFactory.create() + ObjectRecordFactory.create(object=object) + url = reverse("object-history", args=[object.uuid]) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @requests_mock.Mocker() + def test_create_superuser(self, m): + object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + url = reverse("object-list") + data = { + "type": f"{object_type.url}", + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + }, + } + # mocks + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{object_type.url}/versions/1", + json=mock_objecttype_version(object_type.url), + ) + + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_superuser_no_service(self): + url = reverse("object-list") + data = { + "type": f"{OBJECT_TYPES_API}objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2", + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + }, + } + + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @requests_mock.Mocker() + def test_create_superuser_no_object_type(self, m): + objecttype_url = ( + f"{OBJECT_TYPES_API}objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2" + ) + service = ServiceFactory.create(api_root=OBJECT_TYPES_API) + url = reverse("object-list") + data = { + "type": objecttype_url, + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + }, + } + # mocks + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get(objecttype_url, json=mock_objecttype(objecttype_url)) + m.get( + f"{objecttype_url}/versions/1", + json=mock_objecttype_version(objecttype_url), + ) + + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # check created object type + object_type = ObjectType.objects.get() + self.assertEqual(object_type.service, service) + self.assertEqual(object_type.url, objecttype_url) + + @requests_mock.Mocker() + def test_update_superuser(self, m): + object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + record = ObjectRecordFactory.create(object__object_type=object_type, version=1) + url = reverse("object-detail", args=[record.object.uuid]) + data = { + "type": f"{object_type.url}", + "record": { + "typeVersion": record.version, + "data": record.data, + "startAt": record.start_at, + }, + } + # mocks + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{object_type.url}/versions/1", + json=mock_objecttype_version(object_type.url), + ) + + response = self.client.put(url, data=data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @requests_mock.Mocker() + def test_patch_superuser(self, m): + object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + record = ObjectRecordFactory.create( + object__object_type=object_type, version=1, data__name="old" + ) + url = reverse("object-detail", args=[record.object.uuid]) + # mocks + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{object_type.url}/versions/1", + json=mock_objecttype_version(object_type.url), + ) + + response = self.client.patch( + url, + data={ + "record": { + **record.data, + **{"name": "new"}, + "startAt": "2020-01-01", + }, + }, + **GEO_WRITE_KWARGS, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_destroy_superuser(self): + record = ObjectRecordFactory.create(data__name="old") + url = reverse("object-detail", args=[record.object.uuid]) + + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/src/objects/token/admin.py b/src/objects/token/admin.py index 47faf319..9b1a0d32 100644 --- a/src/objects/token/admin.py +++ b/src/objects/token/admin.py @@ -115,6 +115,7 @@ class TokenAuthAdmin(admin.ModelAdmin): "organization", "administration", "application", + "is_superuser", ) readonly_fields = ("token",) inlines = [PermissionInline] diff --git a/src/objects/token/migrations/0010_tokenauth_is_superuser.py b/src/objects/token/migrations/0010_tokenauth_is_superuser.py new file mode 100644 index 00000000..b535e5ad --- /dev/null +++ b/src/objects/token/migrations/0010_tokenauth_is_superuser.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.23 on 2024-03-08 13:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("token", "0009_alter_permission_fields"), + ] + + operations = [ + migrations.AddField( + model_name="tokenauth", + name="is_superuser", + field=models.BooleanField( + default=False, + help_text="Designates whether the user has access to all objects.", + verbose_name="is superuser", + ), + ), + ] diff --git a/src/objects/token/models.py b/src/objects/token/models.py index e086fb45..2856c5f3 100644 --- a/src/objects/token/models.py +++ b/src/objects/token/models.py @@ -46,6 +46,11 @@ class TokenAuth(models.Model): blank=True, help_text=_("Administration which has access to the API"), ) + is_superuser = models.BooleanField( + _("is superuser"), + default=False, + help_text=_("Designates whether the user has access to all objects."), + ) object_types = models.ManyToManyField( "core.ObjectType", diff --git a/src/objects/token/permissions.py b/src/objects/token/permissions.py index 2d05c505..ab2d553d 100644 --- a/src/objects/token/permissions.py +++ b/src/objects/token/permissions.py @@ -14,6 +14,9 @@ def has_permission(self, request, view): if not request.auth: return False + if request.auth.is_superuser: + return True + # detail actions are processed in has_object_permission method if view.action != "create": return True @@ -39,6 +42,9 @@ def has_object_permission(self, request, view, obj): if bypass_permissions(request): return True + if request.auth.is_superuser: + return True + object_permission = request.auth.get_permission_for_object_type( obj.object.object_type ) diff --git a/src/objects/urls.py b/src/objects/urls.py index 4413dcb8..d1b47345 100644 --- a/src/objects/urls.py +++ b/src/objects/urls.py @@ -43,6 +43,7 @@ template_name="index.html", extra_context={"version": api_settings.DEFAULT_VERSION}, ), + name="home", ), path("ref/", include("vng_api_common.urls")), path("ref/", include("vng_api_common.notifications.urls")), diff --git a/src/objects/utils/__init__.py b/src/objects/utils/__init__.py index e69de29b..ab878fd7 100644 --- a/src/objects/utils/__init__.py +++ b/src/objects/utils/__init__.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.http import HttpRequest + +from furl import furl + + +def get_domain() -> str: + """ + Obtain the domain/netloc according to settings or configuration. + """ + from django.contrib.sites.models import Site + + if settings.OBJECTS_DOMAIN: + return settings.OBJECTS_DOMAIN + + return Site.objects.get_current().domain + + +def build_absolute_url(path: str, request: HttpRequest | None = None) -> str: + if request is not None: + return request.build_absolute_uri(path) + + domain = get_domain() + _furl = furl( + scheme="https" if settings.IS_HTTPS else "http", + netloc=domain, + path=path, + ) + return _furl.url diff --git a/src/objects/utils/serializers.py b/src/objects/utils/serializers.py index c7c2aaa1..8a69ab3f 100644 --- a/src/objects/utils/serializers.py +++ b/src/objects/utils/serializers.py @@ -148,6 +148,9 @@ def get_allowed_fields(self, instance) -> list: if not request: return ALL_FIELDS + if request.auth.is_superuser: + return ALL_FIELDS + # use prefetch_related for DB optimization if getattr(instance.object.object_type, "token_permissions", None): permission = instance.object.object_type.token_permissions[0]