diff --git a/.github/labeler.yml b/.github/labeler.yml index 46e2cc589..3875a4235 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,6 +3,9 @@ api: - magpie/api/**/* +cli: + - magpie/cli/**/* + # label 'ci' all automation-related steps and files ci: - .* # all '.<>' files diff --git a/.gitignore b/.gitignore index 10d853fb4..886060918 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ magpie/include magpie/lib magpie/local magpie/ui/swagger-ui/magpie-rest-api.json +magpie_create_users*.txt +magpie_delete_users*.txt requirements-all.txt gunicorn.app.wsgiapp error_log.txt diff --git a/.pylintrc b/.pylintrc index 7fc8a75ee..f6a2a125a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -76,13 +76,16 @@ disable=C0111,missing-docstring, R0915,too-many-statements, R1710,inconsistent-return-statements, R1721,unnecessary-comprehension, + R1725,super-with-arguments, W0108,unnecessary-lambda, + W0143,comparison-with-callable, W0232,no-init, W0235,useless-super-delegation, W0613,unused-argument, W0640,cell-var-from-loop, W0703,broad-except, W0706,try-except-raise, + W0707,raise-missing-from, W1508,invalid-envvar-default # note: following errors are managed via isort tool, ignore false positives @@ -93,7 +96,10 @@ disable=C0111,missing-docstring, # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member +enable= + c-extension-no-member + W0122 + W0123 [REPORTS] @@ -130,7 +136,10 @@ max-nested-blocks=5 # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. -never-returning-functions=sys.exit +never-returning-functions= + sys.exit + magpie.utils.raise_log + magpie.api.exception.raise_http [TYPECHECK] @@ -365,7 +374,7 @@ function-naming-style=snake_case #function-rgx= # Good variable names which should always be accepted, separated by a comma. -good-names=i,j,k,v,ex,x,y,z,f,h,db,kw,ns,_ +good-names=i,j,k,v,ex,x,y,z,f,fd,h,db,kw,ns,qs,_ # Include a hint for the correct naming format with invalid-name. include-naming-hint=no @@ -461,7 +470,7 @@ valid-metaclass-classmethod-first-arg=cls # List of modules that can be imported at any level, not just the top level # one. -allow-any-import-level= +allow-any-import-level=magpie # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no diff --git a/.travis.yml b/.travis.yml index 71a583228..82d76b391 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,25 +5,21 @@ dist: xenial os: - linux python: - - "3.6" - - "3.5" - "2.7" + - "3.5" + - "3.6" + - "3.7" + - "3.8" cache: - pip - - directories: - - $HOME/conda - - $HOME/downloads # includes PR when base branch = master if: branch = master OR tag IS present env: global: - - CONDA_HOME=$HOME/conda - - DOWNLOAD_CACHE=$HOME/downloads - secure: lzPPXuRTlqLHl2MCAUuy7ROrewSwPFGGKqbADo7npQ4jqVMAb/TQ5PTzIi4IAIvsv817dNj5WSAd39hsp1zSlE1R2jLtbxGEVqwcjTqSv9VStGascRQnCW1hcoSKYqESweEhtdIcH+uXpV8qnDsUWB6YIIP2VjgWPMfSPZPynFKC0IqZm2sFsqDEJJHr3QM5iN3i3Z9GrEIrBEEpoaf0yak5u8LjqoGSaY8ISPGWipBJYn7i/r/+sZpirFXbZRJmI4ljsaHi0tysNWdANOIf7S2+Dy/oGNn/3Br0OMJ5snFe/n6Uf4GcmPGDc1c86oN0FpGcoNSA+lCUPMASghOGwVqBhMjX+qAZZfzEbCGtr2j/MhcpETMO3S9oqelw4CcbZRXxP2Y20L+KGNOCSt3SC4/HdKj5MWRQGUnIZbT9C2Tpn2wTffpVRoKvhik/6UU8/KT12LSwDhjjrfqLgCEFLqH1nBL2Mopkj/eOwQbZ66nZmthrAF7/c+vaB38LWoqBIZi/fOwo6+kt947d1bW6UX6Z1KnCNLN2KO064Hjn+SHb1gAes0JIXIZE7af1Gti2dKmUL4a5eoYyU5vCLlp2nq5yxEtTUdJKZ1UVnkDqRtSaT0F3FgLypbFqi+qZ6AtygxKqplMjeAGhvVSqZTPqRupP1ac/KBBCvMzG8pkfXWs= jobs: - TEST_TARGET=test-local START_TARGET= - TEST_TARGET=test-remote START_TARGET=start - - TEST_TARGET=coverage START_TARGET= addons: postgresql: "9.6" services: @@ -37,18 +33,20 @@ notifications: email: false # === pipeline === before_install: + # obtain details about environment - python -V - uname -a - lsb_release -a - - export PYTHON_VERSION=${TRAVIS_PYTHON_VERSION} - - export CONDA_ENV=magpie-${TRAVIS_PYTHON_VERSION} - - export CONDA_PREFIX=$HOME/conda/envs/magpie-${TRAVIS_PYTHON_VERSION} - - export PATH=${CONDA_HOME}/bin:$PATH - hash -r - - env - - make conda-base + # fake conda paths to employ preinstalled virtualenv python + - export CONDA_ENV_NAME=python${TRAVIS_PYTHON_VERSION} + - export CONDA_ENVS_DIR=${HOME}/virtualenv + - export CONDA_PREFIX=${CONDA_ENVS_DIR}/${CONDA_ENV_NAME} + - export CONDA_ENV_PATH=${CONDA_PREFIX} + - env | sort + - make info - make install-sys - #==== magpie env and constants === + # load magpie env and constants - mkdir -p ./env - cp -f ./ci/magpie.env ./env/magpie.env install: @@ -59,38 +57,39 @@ before_script: - psql -c 'create database magpie;' -U postgres - echo ${CONDA_PREFIX} - echo ${CONDA_ENV} - - export PYTHON_VERSION=${TRAVIS_PYTHON_VERSION} - - export CONDA_ENV=magpie-${TRAVIS_PYTHON_VERSION} - - export CONDA_PREFIX=${HOME}/conda/envs/magpie-${TRAVIS_PYTHON_VERSION} - - export PATH=${CONDA_HOME}/bin:${PATH} + - source ./env/magpie.env - hash -r - - env + - env | sort stages: - check # run linting checks and don't bother with the rest if invalid - test # use default stage to run job matrix variations - smoke-test # try running the built/packaged docker image jobs: + allow_failures: + # leave versions while they remains easy to maintain + - python: "2.7" # deprecated support (January 2020) + - python: "3.5" # deprecated support (September 2020) include: # use stages to quick fail faster tests # these are extra to default 'test' stage with auto-matrix/env extension - stage: check name: "Linter Checks" - python: "3.6" + python: "3.7" os: linux script: make check - stage: check name: "Documentation Check" # verify that build works - python: "3.6" + python: "3.7" os: linux script: make docs - stage: test name: "Coverage" - python: "3.6" + python: "3.7" os: linux script: make coverage - stage: smoke-test name: "Smoke Test" - python: "3.6" + python: "3.7" os: linux script: make test-docker script: @@ -101,4 +100,4 @@ script: after_success: # coverage report - bash <(curl -s https://codecov.io/bash) || echo "Codecov did not collect coverage reports" - - source ${CONDA_PREFIX}/bin/activate ${CONDA_ENV} && python-codacy-coverage -r reports/coverage.xml + - source ${VIRTUAL_ENV}/bin/activate && python-codacy-coverage -r reports/coverage.xml diff --git a/HISTORY.rst b/CHANGES.rst similarity index 83% rename from HISTORY.rst rename to CHANGES.rst index c1b30d63e..c415c3b02 100644 --- a/HISTORY.rst +++ b/CHANGES.rst @@ -1,13 +1,100 @@ +.. explicit references must be used in this file (not references.rst) to ensure they are directly rendered on Github .. :changelog: -History -======= +Changes +******* `Unreleased `_ (latest) ------------------------------------------------------------------------------------ * Nothing yet. +`2.0.0 `_ (2020-07-31) +------------------------------------------------------------------------------------ + +Features / Changes +~~~~~~~~~~~~~~~~~~~~~ +* Add ``/ui`` route redirect to frontpage when UI is enabled. +* Add ``/json`` route information into generated Swagger API documentation. +* Add tag description into generated Swagger API documentation. +* Add more usage details to start `Magpie` web application in documentation. +* Add database migration for new ``discoverable`` column of groups. +* Allow logged user to update its own information both via API and UI + (relates to `#170 `_). +* Allow logged user of any access-level to register by itself to ``discoverable`` groups. +* Change some UI CSS for certain pages to improve table readability. +* Add UI page to render error details from API responses (instead of default server-side HTML error rendering). +* Add ``MAGPIE_UI_THEME`` with new default *blue* theme and legacy *green* theme (with few improvements). +* Add more validation and inputs parameters to update ``Group`` information. +* Add UI input fields to allow administrator to update group description and group discoverability. +* Allow combined configuration files (``providers``, ``permissions``, ``users`` and ``groups`` sections) with + resolution of inter-references between them. File can be specified with ``MAGPIE_CONFIG_PATH`` environment variable + or ``magpie.config_path`` setting (example in ``configs``). +* Add configurable ``User`` creation parameters upon `Magpie` application startup through configuration files + (fixes `#47 `_ and + `#204 `_). +* Add disabled checkboxes for UI rendering of non-editable items to avoid user doing operations that will always be + blocked by corresponding API validation (relates to `#164 `_). +* Add more tests to validate forbidden operations such as update or delete of reserved ``User`` and ``Group`` details. +* Add active version tag at bottom of UI pages (same version as returned by API ``/version`` route). +* Enforce configuration parameters ``MAGPIE_SECRET``, ``MAGPIE_ADMIN_USER`` and ``MAGPIE_ADMIN_PASSWORD`` by explicitly + defined values (either by environment variable or INI settings) to avoid using defaults for security purposes. +* Change CLI helper ``create_users`` to ``batch_update_users`` to better represent provided functionalities. +* Change CLI helper ``register_default_users`` to ``register_defaults`` to avoid confusion on groups also created. +* Extend CLI ``batch_update_users`` functionality with additional options and corresponding tests. +* Move all CLI helpers under ``magpie.cli`` and provide more details about them in documentation. +* Allow unspecified ``group_name`` during user creation request to employ ``MAGPIE_ANONYMOUS_GROUP`` by default + (i.e.: created user will have no other apparent group membership since it is always attributed for public access). +* Change all ``PUT`` requests to ``PATCH`` to better reflect their actual behaviour according to RESTful best practices + (partial field updates instead of complete resource replacement and conflict responses on duplicate identifiers). +* Add support of ``Accept`` header and ``format`` query parameter for all API responses, for content-types variations + in either plain text, HTML, XML or JSON (default), and include applicable values in schemas for Swagger generation. +* Add support of new response content-type as XML (must request using ``Accept`` header or ``format`` query parameter). +* Add documentation details about different types of ``Permission``, interaction between various `Magpie` models, + glossary and other general improvements (relates to `#332 `_ and + `#341 `_). +* Add alternative response format for service and service-type paths using ``flatten`` query parameter to obtain a flat + list of services instead of nested dictionaries (fixes `#345 `_). +* Change pre-existing ``list`` query parameter of user-scoped views to ``flatten`` response format to match new query + of service-scoped views. +* Add ``filtered`` query parameter for user-scoped resources permission listing when request user is an administrator. +* Obsolete all API routes using ``inherited_permission`` format (deprecated since ``0.7.4``) in favor of equivalent + ``permissions?inherited=true`` query parameter modifier. +* Replace ``inherit`` query parameter wherever applicable by ``inherited`` to match documentation names, but preserve + backward compatibility support of old name. +* Add ``MAGPIE_PASSWORD_MIN_LENGTH`` setting with corresponding validation of field during ``User`` creation and update. +* Avoid returning ``Service`` entries where user, group or both (according to request path and query options) does not + actually have any permission set either directly on them or onto one of their respective children ``Resource``. This + avoids unnecessarily exposing all ``Service`` for which the user cannot (or should not) be interacting with anyway. +* Add ``TWITCHER_HOST`` as alternative configuration parameter to define the service public URL, to have a similar + naming convention as other use cases covered by ``MAGPIE_HOST`` and ``PHOENIX_HOST``. +* Modify ``PHOENIX_PUSH`` to be *disabled* by default to be consistent across all locations where corresponding + feature is referenced (startup registration, CLI utility, API requests and UI checkbox option) and because this + option is an advanced extension not to be considered as default behavior. +* Python 2.7 and Python 3.5 marked for deprecation (they remain in CI, but are not required to pass), as both + reached their EOL as of January/September 2020. + +Bug Fixes +~~~~~~~~~~~~~~~~~~~~~ +* Fix invalid API documentation of request body for ``POST /users/{user_name}/groups``. +* Fix `#164 `_ (forbid *special* users and groups update and delete). +* Fix `#84 `_ and + `#171 `_ with additional input validation. +* Fix `#194 `_ to render API error responses according to content-type. +* Fix `#337 `_ documentation mismatch with previously denied request + users since they are now allowed to run these requests with new user-scoped functionalities + (`#340 `_). +* Fix bug introduced in `0.9.4 `_ + (`4a23a49 `_) where some + API routes would not return the `Allowed Permissions` for children ``Resource`` under ``Service`` + (only ``Service`` permissions would be filled), or when requesting ``Resource`` details directly. +* Fix input check to avoid situations where updating ``Resource`` name could cause involuntary duplicate errors. +* Fix minor HTML issues in mako templates. +* Fix invalid generation of default ``postgres.env`` file from ``magpie.env.example``. + File ``postgres.env.example`` will now be correctly employed as documented. +* Make environment variable ``PHOENIX_PUSH`` refer to ``phoenix.push`` instead of ``magpie.phoenix_push`` to employ + same naming schema as all other variables. + `1.11.0 `_ (2020-06-19) ------------------------------------------------------------------------------------ @@ -213,14 +300,14 @@ Bug Fixes Bug Fixes ~~~~~~~~~~~~~~~~~~~~~ -* Fix a bug in ows_parser_factory that caused query parameters for wps services to be case sensitive. +* Fix a bug in ``ows_parser_factory`` that caused query parameters for wps services to be case sensitive. `1.6.1 `_ (2019-10-01) ------------------------------------------------------------------------------------ Bug Fixes ~~~~~~~~~~~~~~~~~~~~~ -* Fix migration script for project-api service type. +* Fix migration script for ``project-api`` service type. `1.6.0 `_ (2019-09-20) ------------------------------------------------------------------------------------ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 34d1b8b41..851edc884 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -78,7 +78,7 @@ Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the - feature to the list in `history`_ (under relevant category of section `Unreleased`). + feature to the list in `changes`_ (under relevant category of section `Unreleased`). 3. The tests should work for the specified version of Python for this project. @@ -93,6 +93,6 @@ To run a subset of tests:: .. References for this page .. _new issue: https://github.com/Ouranosinc/Magpie/issues/new -.. _history: HISTORY.rst +.. _changes: CHANGES.rst .. _installation: docs/installation.rst .. _utilities: docs/utilities.rst diff --git a/Dockerfile b/Dockerfile index f074d5b6b..4e542361e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN chmod 0644 $CRON_DIR/magpie-cron # install dependencies COPY magpie/__init__.py magpie/__meta__.py $MAGPIE_DIR/magpie/ -COPY requirements* setup.py README.rst HISTORY.rst $MAGPIE_DIR/ +COPY requirements* setup.py README.rst CHANGES.rst $MAGPIE_DIR/ RUN apk update \ && apk add \ bash \ diff --git a/Dockerfile.adapter b/Dockerfile.adapter index 6dcc29bbc..0ca5ca20f 100644 --- a/Dockerfile.adapter +++ b/Dockerfile.adapter @@ -10,7 +10,7 @@ LABEL Vendor="CRIM" ENV MAGPIE_DIR=/opt/local/src/magpie COPY magpie/__init__.py magpie/__meta__.py $MAGPIE_DIR/magpie/ -COPY requirements* setup.py README.rst HISTORY.rst $MAGPIE_DIR/ +COPY requirements* setup.py README.rst CHANGES.rst $MAGPIE_DIR/ # install dependencies used by Magpie RUN apk update \ @@ -23,7 +23,7 @@ RUN apk update \ supervisor \ gcc \ libffi-dev \ - python-dev \ + python3-dev \ musl-dev \ postgresql-dev \ && pip install --no-cache-dir --upgrade pip setuptools \ diff --git a/Makefile b/Makefile index f16b8a7ea..8a94dc4bf 100644 --- a/Makefile +++ b/Makefile @@ -19,35 +19,57 @@ MAKEFILE_NAME := $(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)) # Application APP_ROOT := $(abspath $(lastword $(MAKEFILE_NAME))/..) APP_NAME := magpie -APP_VERSION ?= 1.11.0 +APP_VERSION ?= 2.0.0 APP_INI ?= $(APP_ROOT)/config/$(APP_NAME).ini +# guess OS (Linux, Darwin,...) +OS_NAME := $(shell uname -s 2>/dev/null || echo "unknown") +CPU_ARCH := $(shell uname -m 2>/dev/null || uname -p 2>/dev/null || echo "unknown") + # conda -CONDA_ENV ?= $(APP_NAME) +CONDA_ENV_NAME ?= $(APP_NAME) CONDA_HOME ?= $(HOME)/.conda CONDA_ENVS_DIR ?= $(CONDA_HOME)/envs -CONDA_ENV_PATH := $(CONDA_ENVS_DIR)/$(CONDA_ENV) -CONDA_BIN := $(CONDA_HOME)/bin/conda +CONDA_ENV_PATH := $(CONDA_ENVS_DIR)/$(CONDA_ENV_NAME) +# allow pre-installed conda in Windows bash-like shell +ifeq ($(findstring MINGW,$(OS_NAME)),MINGW) + CONDA_BIN_DIR ?= $(CONDA_HOME)/Scripts +else + CONDA_BIN_DIR ?= $(CONDA_HOME)/bin +endif +CONDA_BIN := $(CONDA_BIN_DIR)/conda CONDA_ENV_REAL_TARGET_PATH := $(realpath $(CONDA_ENV_PATH)) CONDA_ENV_REAL_ACTIVE_PATH := $(realpath ${CONDA_PREFIX}) -ifeq ("$(CONDA_ENV_REAL_ACTIVE_PATH)","$(CONDA_ENV_REAL_TARGET_PATH)") - CONDA_CMD := + +# environment already active - use it directly +ifneq ("$(CONDA_ENV_REAL_ACTIVE_PATH)", "") CONDA_ENV_MODE := [using active environment] -else - CONDA_CMD := source "$(CONDA_HOME)/bin/activate" "$(CONDA_ENV)"; + CONDA_ENV_NAME := $(notdir $(CONDA_ENV_REAL_ACTIVE_PATH)) + CONDA_CMD := +endif +# environment not active but it exists - activate and use it +ifneq ($(CONDA_ENV_REAL_TARGET_PATH), "") + CONDA_ENV_NAME := $(notdir $(CONDA_ENV_REAL_TARGET_PATH)) +endif +# environment not active and not found - create, activate and use it +ifeq ("$(CONDA_ENV_NAME)", "") + CONDA_ENV_NAME := $(APP_NAME) +endif +# update paths for environment activation +ifeq ("$(CONDA_ENV_REAL_ACTIVE_PATH)", "") CONDA_ENV_MODE := [will activate environment] + CONDA_CMD := source "$(CONDA_BIN_DIR)/activate" "$(CONDA_ENV_NAME)"; endif -PYTHON_VERSION ?= `python -c 'import platform; print(platform.python_version())'` - DOWNLOAD_CACHE ?= $(APP_ROOT)/downloads REPORTS_DIR ?= $(APP_ROOT)/reports +PYTHON_VERSION ?= `python -c 'import platform; print(platform.python_version())'` +PIP_XARGS ?= --use-feature=2020-resolver # choose conda installer depending on your OS CONDA_URL = https://repo.continuum.io/miniconda -OS_NAME := $(shell uname -s || echo "unknown") -ifeq ("$(OS_NAME)","Linux") +ifeq ("$(OS_NAME)", "Linux") FN := Miniconda3-latest-Linux-x86_64.sh -else ifeq ("$(OS_NAME)","Darwin") +else ifeq ("$(OS_NAME)", "Darwin") FN := Miniconda3-latest-MacOSX-x86_64.sh else FN := unknown @@ -98,19 +120,23 @@ version: ## display current version .PHONY: info info: ## display make information - @echo "Informations about your make execution:" - @echo " OS_NAME $(OS_NAME)" - @echo " CPU_ARCH $(CPU_ARCH)" - @echo " Conda Home $(CONDA_HOME)" - @echo " Conda Environment $(CONDA_ENV)" - @echo " Conda Prefix $(CONDA_ENV_PATH)" - @echo " Conda Binary $(CONDA_BIN)" - @echo " Conda Actication $(CONDA_ENV_MODE)" - @echo " Conda Command $(CONDA_CMD)" - @echo " APP_NAME $(APP_NAME)" - @echo " APP_ROOT $(APP_ROOT)" - @echo " DOWNLOAD_CACHE $(DOWNLOAD_CACHE)" - @echo " DOCKER_REPO $(DOCKER_REPO)" + @echo "Information about your make execution:" + @echo " OS Name $(OS_NAME)" + @echo " CPU Architecture $(CPU_ARCH)" + @echo " Conda Home $(CONDA_HOME)" + @echo " Conda Prefix $(CONDA_ENV_PATH)" + @echo " Conda Env Name $(CONDA_ENV_NAME)" + @echo " Conda Env Path $(CONDA_ENV_REAL_ACTIVE_PATH)" + @echo " Conda Binary $(CONDA_BIN)" + @echo " Conda Activation $(CONDA_ENV_MODE)" + @echo " Conda Command $(CONDA_CMD)" + @echo " Application Root $(APP_ROOT)" + @echo " Application Name $(APP_NAME)" + @echo " Application Version $(APP_VERSION)" + @echo " Download Cache $(DOWNLOAD_CACHE)" + @echo " Test Reports $(REPORTS_DIR)" + @echo " Docker Tag (magpie) $(MAGPIE_DOCKER_TAG)" + @echo " Docker Tag (twitcher) $(TWITCHER_DOCKER_TAG)" ## --- Cleanup targets --- ## @@ -134,7 +160,6 @@ clean-build: ## remove build artifacts .PHONY: clean-docs clean-docs: ## remove doc artifacts @echo "Cleaning doc artifacts..." - @"$(MAKE)" -C "$(APP_ROOT)/docs" clean || true @-find "$(APP_ROOT)/docs/" -type f -name "$(APP_NAME)*.rst" -delete @-rm -f "$(APP_ROOT)/docs/modules.rst" @-rm -f "$(APP_ROOT)/docs/api.json" @@ -165,10 +190,20 @@ clean-docker: docker-clean ## alias for 'docker-clean' target ## --- Database targets --- ## .PHONY: migrate -migrate: install conda-env ## run postgres database migration with alembic +migrate: database-migration ## alias to 'database-migration' + +.PHONY: database-migration +database-migration: conda-env ## run postgres database migration with alembic + @bash -c '$(CONDA_CMD) test -f "$(CONDA_ENV_PATH)/bin/alembic" || "$(MAKE)" -C install' @echo "Running database migration..." @bash -c '$(CONDA_CMD) alembic -c "$(APP_INI)" upgrade head' +.PHONY: database-revision +database-revision: conda-env ## retrieve current database revision + @bash -c '$(CONDA_CMD) test -f "$(CONDA_ENV_PATH)/bin/alembic" || "$(MAKE)" -C install' + @echo "Fetching database revision..." + @bash -c '$(CONDA_CMD) alembic -c "$(APP_INI)" current' + ## --- Documentation targets --- ## DOC_LOCATION := $(APP_ROOT)/docs/_build/html/index.html @@ -188,7 +223,7 @@ docs-show: $(DOC_LOCATION) ## display HTML webpage of generated documentation (b @-test -f "$(DOC_LOCATION)" || $(MAKE) -C "$(APP_ROOT)" docs $(BROWSER) "$(DOC_LOCATION)" -## --- Versionning targets --- ## +## --- Versioning targets --- ## # Bumpversion 'dry' config # if 'dry' is specified as target, any bumpversion call using 'BUMP_XARGS' will not apply changes @@ -207,7 +242,7 @@ endif bump: ## bump version using VERSION specified as user input @-echo "Updating package version ..." @[ "${VERSION}" ] || ( echo ">> 'VERSION' is not set"; exit 1 ) - @-bash -c '$(CONDA_CMD) test -f "$(CONDA_ENV_PATH)/bin/bump2version" || pip install bump2version' + @-bash -c '$(CONDA_CMD) test -f "$(CONDA_ENV_PATH)/bin/bump2version" || pip install $(PIP_XARGS) bump2version' @-bash -c '$(CONDA_CMD) bump2version $(BUMP_XARGS) --new-version "${VERSION}" patch;' ## --- Installation targets --- ## @@ -225,31 +260,38 @@ install: install-all ## alias for 'install-all' target .PHONY: install-all install-all: install-sys install-pkg install-dev install-docs ## install every dependency and package definition +# note: don't use PIP_XARGS for install system package as it could be upgrade of pip that doesn't yet have those options .PHONY: install-sys install-sys: clean conda-env ## install system dependencies and required installers/runners @echo "Installing system dependencies..." @bash -c '$(CONDA_CMD) pip install --upgrade -r "$(APP_ROOT)/requirements-sys.txt"' - @bash -c '$(CONDA_CMD) pip install gunicorn' + @bash -c '$(CONDA_CMD) pip install $(PIP_XARGS) gunicorn' .PHONY: install-pkg install-pkg: install-sys ## install the package to the active Python's site-packages @echo "Installing Magpie..." + @bash -c '$(CONDA_CMD) python setup.py install_egg_info' + @bash -c '$(CONDA_CMD) pip install $(PIP_XARGS) --upgrade -e "$(APP_ROOT)" --no-cache' # TODO: remove when merged # --- ensure fix is applied @bash -c '$(CONDA_CMD) \ - pip install --force-reinstall "https://github.com/fmigneault/authomatic/archive/httplib-port.zip#egg=Authomatic"' + pip install $(PIP_XARGS) --force-reinstall \ + "https://github.com/fmigneault/authomatic/archive/httplib-port.zip#egg=Authomatic"' # --- - @bash -c '$(CONDA_CMD) python setup.py install_egg_info' - @bash -c '$(CONDA_CMD) pip install --upgrade -e "$(APP_ROOT)" --no-cache' + +.PHONY: install-req +install-req: conda-env ## install package base requirements without installing main package + @bash -c '$(CONDA_CMD) pip install $(PIP_XARGS) -r "$(APP_ROOT)/requirements.txt"' + @echo "Successfully installed base requirements." .PHONY: install-docs install-docs: conda-env ## install package requirements for documentation generation - @bash -c '$(CONDA_CMD) pip install -r "$(APP_ROOT)/requirements-docs.txt"' + @bash -c '$(CONDA_CMD) pip install $(PIP_XARGS) -r "$(APP_ROOT)/requirements-doc.txt"' @echo "Successfully installed docs requirements." .PHONY: install-dev install-dev: conda-env ## install package requirements for development and testing - @bash -c '$(CONDA_CMD) pip install -r "$(APP_ROOT)/requirements-dev.txt"' + @bash -c '$(CONDA_CMD) pip install $(PIP_XARGS) -r "$(APP_ROOT)/requirements-dev.txt"' @echo "Successfully installed dev requirements." ## --- Launchers targets --- ## @@ -318,7 +360,7 @@ docker-clean: ## remove any leftover images from docker target operations docker rmi $(docker images -f "reference=$(MAGPIE_DOCKER_REPO)" -q) docker-compose $(DOCKER_TEST_COMPOSES) down -## --- Statoc code check targets --- +## --- Static code check targets --- .PHONY: mkdir-reports mkdir-reports: @@ -370,6 +412,10 @@ check-doc8: mkdir-reports install-dev ## run PEP8 documentation style checks # FIXME: move parameters to setup.cfg when implemented (https://github.com/myint/docformatter/issues/10) # NOTE: docformatter only reports files with errors on stderr, redirect trace stderr & stdout to file with tee +# NOTE: +# Don't employ '--wrap-descriptions 120' since they *enforce* that length and rearranges format if any word can fit +# within remaining space, which often cause big diffs of ugly formatting for no important reason. Instead only check +# general formatting operations, and let other linter capture docstrings going over 120 (what we really care about). .PHONY: check-docf check-docf: mkdir-reports install-dev ## run PEP8 code documentation format checks @echo "Checking PEP8 doc formatting problems..." @@ -377,7 +423,7 @@ check-docf: mkdir-reports install-dev ## run PEP8 code documentation format chec @bash -c '$(CONDA_CMD) \ docformatter \ --pre-summary-newline \ - --wrap-descriptions 120 \ + --wrap-descriptions 0 \ --wrap-summaries 120 \ --make-summary-multi-line \ --check \ @@ -428,7 +474,7 @@ fix-docf: install-dev ## fix some PEP8 code documentation style problems automat @bash -c '$(CONDA_CMD) \ docformatter \ --pre-summary-newline \ - --wrap-descriptions 120 \ + --wrap-descriptions 0 \ --wrap-summaries 120 \ --make-summary-multi-line \ --in-place \ @@ -446,14 +492,19 @@ test-all: install-dev install ## run all tests combinations @echo "Running tests..." @bash -c '$(CONDA_CMD) pytest tests -vv --junitxml "$(APP_ROOT)/tests/results.xml"' +.PHONY: test-cli +test-cli: install-dev install ## run only CLI tests with the environment Python + @echo "Running local tests..." + @bash -c '$(CONDA_CMD) pytest tests -vv -m "cli" --junitxml "$(APP_ROOT)/tests/results.xml"' + # note: use 'not remote' instead of 'local' to capture other low-level tests like 'utils' unittests .PHONY: test-local -test-local: install-dev install ## run only local tests with the default Python +test-local: install-dev install ## run only local tests with the environment Python @echo "Running local tests..." @bash -c '$(CONDA_CMD) pytest tests -vv -m "not remote" --junitxml "$(APP_ROOT)/tests/results.xml"' .PHONY: test-remote -test-remote: install-dev install ## run only remote tests with the default Python +test-remote: install-dev install ## run only remote tests with the environment Python @echo "Running remote tests..." @bash -c '$(CONDA_CMD) pytest tests -vv -m "remote" --junitxml "$(APP_ROOT)/tests/results.xml"' @@ -509,4 +560,4 @@ conda_config: conda-base ## update conda package configuration conda-env: conda-base ## create conda environment if missing and required @test -d "$(CONDA_ENV_PATH)" || \ (echo "Creating conda environment at '$(CONDA_ENV_PATH)'..." && \ - "$(CONDA_HOME)/bin/conda" create -y -n "$(CONDA_ENV)" python=$(PYTHON_VERSION)) + "$(CONDA_HOME)/bin/conda" create -y -n "$(CONDA_ENV_NAME)" python=$(PYTHON_VERSION)) diff --git a/README.rst b/README.rst index b988408dc..9d1c13d86 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,5 @@ +.. explicit references must be used in this file (not references.rst) to ensure they are directly rendered on Github + ====================================== Magpie: A RestFul AuthN/AuthZ service ====================================== @@ -5,7 +7,7 @@ Magpie (the smart-bird) *a very smart bird who knows everything about you.* Magpie is service for AuthN/AuthZ accessible via a `REST API`_ implemented with the `Pyramid`_ web framework. -It allows you to manage User/Group/Service/Resource/Permission with a `Postgres`_ database. +It allows you to manage User/Group/Service/Resource/Permission with a `PostgreSQL`_ database. Behind the scene, it uses `Ziggurat-Foundations`_ and `Authomatic`_. @@ -27,13 +29,13 @@ Behind the scene, it uses `Ziggurat-Foundations`_ and `Authomatic`_. :alt: Requires Python 2.7, 3.5+ :target: https://www.python.org/getit -.. |commits-since| image:: https://img.shields.io/github/commits-since/Ouranosinc/Magpie/1.11.0.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/Ouranosinc/Magpie/2.0.0.svg :alt: Commits since latest release - :target: https://github.com/Ouranosinc/Magpie/compare/1.11.0...master + :target: https://github.com/Ouranosinc/Magpie/compare/2.0.0...master -.. |version| image:: https://img.shields.io/badge/tag-1.11.0-blue.svg?style=flat +.. |version| image:: https://img.shields.io/badge/tag-2.0.0-blue.svg?style=flat :alt: Latest Tag - :target: https://github.com/Ouranosinc/Magpie/tree/1.11.0 + :target: https://github.com/Ouranosinc/Magpie/tree/2.0.0 .. |dependencies| image:: https://pyup.io/repos/github/Ouranosinc/Magpie/shield.svg :alt: Dependencies Status @@ -43,9 +45,9 @@ Behind the scene, it uses `Ziggurat-Foundations`_ and `Authomatic`_. :alt: Travis-CI Build Status (master branch) :target: https://travis-ci.com/Ouranosinc/Magpie -.. |travis_tagged| image:: https://img.shields.io/travis/com/Ouranosinc/Magpie/1.11.0.svg?label=1.11.0 +.. |travis_tagged| image:: https://img.shields.io/travis/com/Ouranosinc/Magpie/2.0.0.svg?label=2.0.0 :alt: Travis-CI Build Status (latest tag) - :target: https://github.com/Ouranosinc/Magpie/tree/1.11.0 + :target: https://github.com/Ouranosinc/Magpie/tree/2.0.0 .. |readthedocs| image:: https://img.shields.io/readthedocs/pavics-magpie :alt: Readthedocs Build Status (master branch) @@ -69,9 +71,9 @@ Behind the scene, it uses `Ziggurat-Foundations`_ and `Authomatic`_. .. end-badges - +-------------- Documentation -============= +-------------- The REST API documentation is auto-generated and served under ``{MAGPIE_URL}/api/`` using Swagger-UI with tag ``latest``. @@ -81,8 +83,9 @@ The REST API documentation is auto-generated and served under ``{MAGPIE_URL}/api .. _readthedocs: https://pavics-magpie.readthedocs.io .. _docs: https://github.com/Ouranosinc/Magpie/tree/master/docs +---------------------------- Configuration and Usage -======================= +---------------------------- | Multiple configuration options exist for ``Magpie`` application. | Please refer to `configuration`_ for details. @@ -91,16 +94,17 @@ Configuration and Usage .. _configuration: ./docs/configuration.rst .. _usage: ./docs/usage.rst - +-------------- Change History -============== +-------------- -Addressed features, changes and bug fixes per version tag are available in `history`_. +Addressed features, changes and bug fixes per version tag are available in `changes`_. -.. _history: HISTORY.rst +.. _changes: CHANGES.rst +-------------- Docker Images -============= +-------------- Following most recent variants are available: @@ -114,8 +118,8 @@ Following most recent variants are available: * - Magpie - Twitcher |br| (with integrated ``MagpieAdapter``) - * - pavics/magpie:1.11.0 - - pavics/twitcher:magpie-1.11.0 + * - pavics/magpie:2.0.0 + - pavics/twitcher:magpie-2.0.0 * - pavics/magpie:latest - pavics/twitcher:magpie-latest @@ -126,9 +130,10 @@ Following most recent variants are available: - `Twitcher`_ image with integrated ``MagpieAdapter`` are only available for Magpie ``>=1.0.0`` .. REST API redoc reference is auto-generated by sphinx from magpie cornice-swagger definitions +.. These reference must be left direct (not included) to allow pretty rendering on Github .. _REST API: https://pavics-magpie.readthedocs.io/en/latest/api.html .. _Authomatic: https://authomatic.github.io/authomatic/ -.. _Postgres: https://www.postgresql.org/ +.. _PostgreSQL: https://www.postgresql.org/ .. _Pyramid: https://docs.pylonsproject.org/projects/pyramid/ .. _Ziggurat-Foundations: https://github.com/ergo/ziggurat_foundations .. _Magpie Docker Images: https://hub.docker.com/r/pavics/magpie/tags diff --git a/SECURITY.rst b/SECURITY.rst index e3822c0f1..970cd0713 100644 --- a/SECURITY.rst +++ b/SECURITY.rst @@ -1,14 +1,15 @@ +*************** Security Policy -=============== +*************** Supported Versions -~~~~~~~~~~~~~~~~~~ +===================== This project supports only the latest tagged version as it is constantly evolving. Make sure you are using the latest available version to receive latest vulnerability patches if any. Reporting a Vulnerability -~~~~~~~~~~~~~~~~~~~~~~~~~ +========================== Please submit a new issue with the tag `security` and detail it so we can reproduce it and work on it. diff --git a/ci/magpie.env b/ci/magpie.env index 7b0a5d940..7abf0b80e 100644 --- a/ci/magpie.env +++ b/ci/magpie.env @@ -4,7 +4,7 @@ MAGPIE_POSTGRES_PASSWORD=qwerty MAGPIE_POSTGRES_HOST=localhost MAGPIE_POSTGRES_PORT=5432 MAGPIE_POSTGRES_DB=magpie -MAGPIE_URL=localhost/magpie +MAGPIE_URL=http://localhost/magpie MAGPIE_SECRET=magpie MAGPIE_ADMIN_GROUP=administrators MAGPIE_ADMIN_USER=admin @@ -13,8 +13,9 @@ MAGPIE_LOG_LEVEL=INFO MAGPIE_LOG_REQUEST=false MAGPIE_LOG_EXCEPTION=false MAGPIE_TEST_REMOTE_SERVER_URL=http://localhost:2001 -MAGPIE_TEST_ADMIN_USERNAME=admin -MAGPIE_TEST_ADMIN_PASSWORD=qwerty +MAGPIE_TEST_ADMIN_USERNAME=unittest-admin +# auto-gen password +MAGPIE_TEST_ADMIN_PASSWORD= # point to sources "magpie/config" dir in travis MAGPIE_CONFIG_DIR=$TRAVIS_BUILD_DIR/config PHOENIX_PUSH=false diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 000000000..736fe575d --- /dev/null +++ b/config/config.yml @@ -0,0 +1,33 @@ +## Combined configuration definition to setup objects at Magpie startup +## See documentation 'Configuration' +# All following sections are optional + +# all providers are defined first +providers: + # see full format details in 'providers.cfg' + +# permissions are loaded after providers +permissions: + # see full format details in 'permissions.cfg' + # for each definition: + # can refer to users and groups that will be created dynamically as needed with corresponding information + # plain user or group name not matched against below entry will create it with defaults for other fields + +# extended definitions for users to create +# default values will be used for missing optional fields +# not providing an entry referenced somewhere else (e.g.: in permissions) +# will default to only using the user name and defaults for every other field +user: + - username: username # required if entry provided + password: # optional + email: # optional + group: # optional (either one of the groups, or plain string to generate it with defaults) + +# extended definitions for groups to create +# default values will be used for missing optional fields +# not providing an entry referenced somewhere else (e.g.: in permissions or users) +# will default to only using the group name and defaults for every other field +groups: + - name: # required if entry provided + description: # optional + discoverable: True # optional diff --git a/config/magpie.ini b/config/magpie.ini index 2bab60c62..ab5a16bfa 100644 --- a/config/magpie.ini +++ b/config/magpie.ini @@ -24,13 +24,15 @@ pyramid.includes = ziggurat_foundations.ext.pyramid.sign_in ziggurat_foundations.ext.pyramid.get_user -# magpie +## magpie +# see 'Configuration' section of documentation for all options magpie.port = 2001 magpie.url = http://localhost:2001 magpie.max_restart = 5 # This secret should be the same in Twitcher ! -magpie.secret = seekrit +magpie.secret = magpie.push_phoenix = true +magpie.config_path = # caching settings refer to the Performance section in the documentation cache.regions = adapter, acl diff --git a/config/providers.cfg b/config/providers.cfg index 052e28c4f..36d8bfbad 100644 --- a/config/providers.cfg +++ b/config/providers.cfg @@ -14,7 +14,7 @@ # type: service type to use for creation, must be one of the known Magpie service types # (see: magpie.services.SERVICE_TYPE_DICT) # sync_type: service synchronization type, must be one of the known Magpie service sync-types (often equals to 'type') -# (see: magpie.helpers.SYNC_SERVICES_TYPES) +# (see: magpie.cli.SYNC_SERVICES_TYPES) # # Default behaviour: # - create missing service with specified parameters. diff --git a/docs/.gitignore b/docs/.gitignore index 0a9122b45..7eeb37dc4 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,4 +2,4 @@ api.json magpie*.rst modules.rst redirects.* -./autoapi/ +autoapi/ diff --git a/docs/Makefile b/docs/Makefile index 391380c80..617f03303 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,7 +9,11 @@ BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +$(error "The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD " \ + "environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can " \ + "add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from " \ + "http://sphinx-doc.org/" \ +) endif # Internal variables. diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore index d5a737218..d531abb1d 100644 --- a/docs/_static/.gitignore +++ b/docs/_static/.gitignore @@ -1,3 +1,4 @@ # make this directory available as it is referenced by some extensions *.* !.gitignore +!custom.css diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 000000000..514fef1cd --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,6 @@ +div.body p, div.body dd, div.body li, div.body blockquote { + -moz-hyphens: none; + -ms-hyphens: none; + -webkit-hyphens: none; + hyphens: none; +} diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 000000000..e69de29bb diff --git a/docs/changes.rst b/docs/changes.rst new file mode 100644 index 000000000..d9e113ec6 --- /dev/null +++ b/docs/changes.rst @@ -0,0 +1 @@ +.. include:: ../CHANGES.rst diff --git a/docs/conf.py b/docs/conf.py index c531c47fe..4a0b498b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,10 +15,10 @@ # pylint: disable=C0103,invalid-name +import json import os import re import sys -import json # If extensions (or modules to document with autodoc) are in another # directory, add these directories to sys.path here. If the directory is @@ -94,7 +94,7 @@ def doc_redirect_include(file_path): redoc = [{ "name": __meta__.__title__, - "page": "api", # rendered under '{root}/api.html' + "page": "api", # rendered under "{root}/api.html" "spec": api_spec_file, "embed": True, "opts": { @@ -138,7 +138,7 @@ def doc_redirect_include(file_path): # General information about the project. project = __meta__.__title__ -copyright = u"2017, {}".format(__meta__.__author__) # pylint: disable=W0622,redefined-builtin +copyright = "2017, {}".format(__meta__.__author__) # pylint: disable=W0622,redefined-builtin # The version info for the project you're documenting, acts as replacement # for |version| and |release|, also used in various other places throughout @@ -161,7 +161,7 @@ def doc_redirect_include(file_path): # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ["_build"] +exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -198,7 +198,9 @@ def doc_redirect_include(file_path): # Theme options are theme-specific and customize the look and feel of a # theme further. For a list of options available for each theme, see the # documentation. -# html_theme_options = {} +html_theme_options = { + "navigation_depth": 3, # TOC, RTD theme +} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -224,15 +226,16 @@ def doc_redirect_include(file_path): # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +html_css_files = ["custom.css"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# If not "", a "Last updated on:" timestamp is inserted at every page bottom, # using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = "%Y-%m-%d" # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. @@ -297,7 +300,7 @@ def doc_redirect_include(file_path): # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ("index", "{}.tex".format(__meta__.__package__), doc_title, __meta__.__author__, "manual"), + (master_doc, "{}.tex".format(__meta__.__package__), doc_title, __meta__.__author__, "manual"), ] # The name of an image file (relative to this directory) to place at @@ -326,7 +329,7 @@ def doc_redirect_include(file_path): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ("index", __meta__.__package__, doc_title, [__meta__.__author__], 1) + (master_doc, __meta__.__package__, doc_title, [__meta__.__author__], 1) ] # If true, show URL addresses after external links. @@ -339,7 +342,7 @@ def doc_redirect_include(file_path): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ("index", __meta__.__package__, + (master_doc, __meta__.__package__, doc_title, __meta__.__author__, __meta__.__package__, diff --git a/docs/configuration.rst b/docs/configuration.rst index f9df6476b..fef34a350 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,10 +1,11 @@ +.. include:: references.rst .. _configuration_link: Configuration ============= At startup, `Magpie` application will load multiple configuration files to define various behaviours or setup -operations. These are defined though the following configuration settings presented below. +operations. These are defined though the configuration settings presented below. All generic `Magpie` configuration settings can be defined through either the `magpie.ini`_ file or environment variables. Values defined in `magpie.ini`_ are expected to follow the @@ -27,26 +28,6 @@ is used by default in each tagged Docker image. If you want to provide different overridden in the Docker image using a volume mount parameter, or by specifying an alternative path through the environment variable ``MAGPIE_INI_FILE_PATH``. -File: providers.cfg -~~~~~~~~~~~~~~~~~~~ - -This configuration file allows automatically registering service definitions in `Magpie` at startup. When the -application starts, it will look for corresponding services and add them to the database as required. It will also -look for mismatches between the service name and URL with the corresponding entry in the database to update it to -the desired URL. See ``MAGPIE_PROVIDERS_CONFIG_PATH`` below to setup alternate references to this type of configuration. -Please refer to the heading of sample file `providers.cfg`_ for specific format and parameter details. - -File: permissions.cfg -~~~~~~~~~~~~~~~~~~~~~~ - -This configuration file allows automatically registering or cleaning permission definitions in `Magpie` at startup. -Each specified permission update operation is applied for the corresponding user or group onto the specific service -or resource. This file is processed after `providers.cfg`_ in order to allow permissions to be applied on freshly -registered services. Furthermore, sub-resources are automatically created if they can be resolved with provided -parameters of the corresponding permission entry. See ``MAGPIE_PERMISSIONS_CONFIG_PATH`` below to setup alternate -references to this type of configuration. Please refer to the heading of sample file `permissions.cfg`_ for specific -format details as well as specific behaviour of each parameter according to encountered use cases. - File: magpie.env ~~~~~~~~~~~~~~~~~~~ @@ -76,13 +57,94 @@ case. .. _postgres.env.example: https://github.com/Ouranosinc/Magpie/tree/master/env/postgres.env.example +File: providers.cfg +~~~~~~~~~~~~~~~~~~~ + +This configuration file allows automatically registering :term:`Service` definitions in `Magpie` at startup. When the +application starts, it will look for corresponding services and add them to the database as required. It will also +look for mismatches between the :term:`Service` name and URL with the corresponding entry in the database to update it +to the desired URL. See ``MAGPIE_PROVIDERS_CONFIG_PATH`` setting below to setup alternate references to this type of +configuration. Please refer to the comment header of sample file `providers.cfg`_ for specific format and parameter +details. + +File: permissions.cfg +~~~~~~~~~~~~~~~~~~~~~~ + +This configuration file allows automatically registering or cleaning :term:`Permission` definitions in `Magpie` at +startup. Each specified update operation is applied for the corresponding :term:`User` or :term:`Group` onto the +specific :term:`Service` or :term:`Resource`. This file is processed after `providers.cfg`_ in order to allow +permissions to be applied on freshly registered services. Furthermore, sub-resources are automatically created if they +can be iteratively resolved with provided parameters of the corresponding permission entry (resources should be defined +using tree-path in this case, see format in :func:`magpie.api.management.resources.resources_utils.get_resource_path`). + +See ``MAGPIE_PERMISSIONS_CONFIG_PATH`` setting below to setup alternate references to this type of configuration. +Please refer to the comment header of sample file `permissions.cfg`_ for specific format details as well as specific +behaviour of each parameter according to encountered use cases. + +Combined Configuration File +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Since contents of all different configurations files (`providers.cfg`_, `permissions.cfg`_) reside under distinct +top-level objects (of same name), it is actually possible to use an unique file to define everything. For example, +one could define a combined configuration as follows. + +.. code-block:: YAML + + # inside 'config.yml' + + providers: + some-service: + url: http://${HOSTNAME}:8000 + title: Some Service + public: true + c4i: false + type: api + + groups: + - name: my-group + description: My Custom Group + discoverable: false + + users: + - username: my-user + group: my-group # will reference above group + + permissions: + - service: api + resource: /resource/user-resource # will create both resources respecting children relationship + permission: read + user: my-user # will reference above user + action: create + - service: api + resource: /groups + permission: read + group: my-group # will reference above group + action: create + + +For backward compatibility reasons, `Magpie` will first look for separate files to load each section individually. +To enforce using a combined file as above, either provide ``MAGPIE_CONFIG_PATH = /config.yml`` or ensure that each +specific environment variable ``MAGPIE_PROVIDERS_CONFIG_PATH`` and ``MAGPIE_PERMISSIONS_CONFIG_PATH`` point to the same +actual file. For all of these variables, ``magpie.[variable_name]`` formatted settings are also supported through +definitions within ``magpie.ini``. + +When loading configurations from a combined file, the order of resolution of each section is the same as when loading +definitions from multiple files, meaning that ``providers`` are first registered, followed by individual +``permissions``, with the dynamic creation of any missing ``user`` or ``group`` during this process. If an explicit +``user`` or ``group`` definition can be found under the relevant sections, additional parameters are employed for their +creation. Otherwise defaults are assumed and only the specified user or group name are employed. Please refer to files +`providers.cfg`_ and `permissions.cfg`_ for further details about specific formatting and behaviour of each available +field. + Settings and Constants ---------------------- Environment variables can be used to define all following configurations (unless mentioned otherwise with -``[constant]`` keyword next to the parameter name). -These values will be used by `Magpie` on startup unless prior definition is found within `magpie.ini`_. -All variables (i.e.: non-``constant`` parameters) can also be specified by their ``magpie.[variable_name]`` +``[constant]`` keyword next to the parameter name). Most values are parsed as plain strings, unless they refer to an +activable setting (e.g.: ``True`` or ``False``), or when specified with more specific ``[]`` notation. + +Configuration variables will be used by `Magpie` on startup unless prior definition is found within `magpie.ini`_. +All variables (i.e.: non-``[constant]`` parameters) can also be specified by their ``magpie.[variable_name]`` setting counterpart as described at the start of the `Configuration`_ section. Loading Settings @@ -90,57 +152,79 @@ Loading Settings These settings can be used to specify where to find other settings through custom configuration files. -- | ``MAGPIE_MODULE_DIR`` [constant] - | Path to the top level `Magpie` module (ie: source code). +- ``MAGPIE_MODULE_DIR`` [constant] + + Path to the top level `Magpie` module (ie: source code). + +- ``MAGPIE_ROOT`` [constant] -- | ``MAGPIE_ROOT`` [constant] - | Path to the containing directory of `Magpie`. This corresponds to the directory where the repository was cloned - or where the package was installed. + Path to the containing directory of `Magpie`. This corresponds to the directory where the repository was cloned + or where the package was installed. - | ``MAGPIE_CONFIG_DIR`` - | Configuration directory where to look for ``providers.cfg`` and ``permissions.cfg`` files. | (Default: ``${MAGPIE_ROOT}/config``) + Configuration directory where to look for ``providers.cfg`` and ``permissions.cfg`` files. + - | ``MAGPIE_PROVIDERS_CONFIG_PATH`` - | Path where to find ``providers.cfg`` file. Can also be a directory path, where all contained ``.cfg`` files will - be considered as `providers` files and will be loaded sequentially. - | **Note**: - | If a directory path is specified, the order of loaded configuration files is not guaranteed - (depending on OS implementation). - | Please refer to `providers.cfg`_ for specific format details and loading methodology according to arguments. | (Default: ``${MAGPIE_CONFIG_DIR}/providers.cfg``) + Path where to find ``providers.cfg`` file. Can also be a directory path, where all contained ``.cfg`` files will + be considered as `providers` files and will be loaded sequentially. + + .. note:: + If a directory path is specified, the order of loaded configuration files is not guaranteed + (depending on OS implementation). + Please refer to `providers.cfg`_ for specific format details and loading methodology according to arguments. + - | ``MAGPIE_PERMISSIONS_CONFIG_PATH`` - | Path where to find ``permissions.cfg`` file. Can also be a directory path, where all contained ``.cfg`` files will - be considered as `permissions` files and will be loaded sequentially. - | **Note**: - | If a directory path is specified, the order of loaded configuration files is not guaranteed + | (default: ``${MAGPIE_CONFIG_DIR}/permissions.cfg``) + + Path where to find ``permissions.cfg`` file. Can also be a directory path, where all contained ``.cfg`` files will + be considered as `permissions` files and will be loaded sequentially. + + .. note:: + If a directory path is specified, the order of loaded configuration files is not guaranteed (depending on OS implementation). Therefore, cross-file references to services or resources should be avoided to ensure that, for example, any parent resource dependency won't be missing because it was specified in a second file loaded after the first. Corresponding references can be duplicated across files and these conflicts will be correctly handled according to configuration loading methodology. - | Please refer to `permissions.cfg`_ for specific format details and loading methodology according to arguments. - | (default: ``${MAGPIE_CONFIG_DIR}/permissions.cfg``) + Please refer to `permissions.cfg`_ for specific format details and loading methodology according to arguments. + +- ``MAGPIE_CONFIG_PATH`` + + Path where to find a combined YAML configuration file which can include ``providers``, ``permissions``, ``users`` + and ``groups`` sections to sequentially process registration or removal of items at `Magpie` startup. + + .. note:: + When provided, all other combinations of ``MAGPIE_CONFIG_DIR``, ``MAGPIE_PERMISSIONS_CONFIG_PATH`` and + ``MAGPIE_PROVIDERS_CONFIG_PATH`` are effectively ignored in favour of definitions in this file. + See `Combined Configuration File`_ for further details and example. + +- ``MAGPIE_INI_FILE_PATH`` + + Specifies where to find the initialization file to run `Magpie` application. -- | ``MAGPIE_INI_FILE_PATH`` - | Specifies where to find the initialization file to run `Magpie` application. - | **Note**: - | This variable ignores the setting/env-var resolution order since settings cannot be defined without + .. note:: + This variable ignores the setting/env-var resolution order since settings cannot be defined without firstly loading the file referenced by its value. - | ``MAGPIE_ENV_DIR`` - | Directory path where to look for ``.env`` files. This variable can be useful to load specific test environment - configurations or to specify a local path while the actual `Magpie` code is located in a Python `site-packages` - directory (``.env`` files are not installed to avoid hard-to-resolve settings loaded from an install location). - | (Default: ``${MAGPIE_ROOT}/env``) + | (Default: ``"${MAGPIE_ROOT}/env"``) + + Directory path where to look for ``.env`` files. This variable can be useful to load specific test environment + configurations or to specify a local path while the actual `Magpie` code is located in a Python `site-packages` + directory (``.env`` files are not installed to avoid hard-to-resolve settings loaded from an install location). - | ``MAGPIE_ENV_FILE`` - | File path to ``magpie.env`` file with additional environment variables to configure the application. - | (Default: ``${MAGPIE_ENV_DIR}/magpie.env``) + | (Default: ``"${MAGPIE_ENV_DIR}/magpie.env"``) + + File path to ``magpie.env`` file with additional environment variables to configure the application. - | ``MAGPIE_POSTGRES_ENV_FILE`` - | File path to ``postgres.env`` file with additional environment variables to configure the `postgres` connection. - | (Default: ``${MAGPIE_ENV_DIR}/postgres.env``) + | (Default: ``"${MAGPIE_ENV_DIR}/postgres.env"``) + + File path to ``postgres.env`` file with additional environment variables to configure the `postgres` connection. .. _magpie.ini: https://github.com/Ouranosinc/Magpie/tree/master/config/magpie.ini @@ -155,64 +239,91 @@ the `Loading Settings`_. All ``magpie.[variable_name]`` counterpart definitions at the start of the `Configuration`_ section. - | ``MAGPIE_URL`` - | Full hostname URL to use so that `Magpie` can resolve his own running instance location. - | **Note:** - | If the value is not set, `Magpie` will attempt to retrieve this critical information through other variables such + | (Default: ``"http://localhost:2001"``) + + Full hostname URL to use so that `Magpie` can resolve his own running instance location. + + .. note:: + If the value is not set, `Magpie` will attempt to retrieve this critical information through other variables such as ``MAGPIE_HOST``, ``MAGPIE_PORT``, ``MAGPIE_SCHEME`` and ``HOSTNAME``. Modifying any of these variables partially is permitted but will force `Magpie` to attempt building the full URL as best as possible from the individual parts. The result of these parts (potential using corresponding defaults) will have the following format: ``"${MAGPIE_SCHEME}//:${MAGPIE_HOST}:${MAGPIE_PORT}"``. - | (Default: ``"http://localhost:2001"``) - | ``MAGPIE_SCHEME`` - | Protocol scheme URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. | (Default: ``"http"``) + Protocol scheme URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. + - | ``MAGPIE_HOST`` - | Domain host URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. | (Default: ``"localhost"``) -- | ``MAGPIE_PORT`` - | Port URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. + Domain host URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. + +- | ``MAGPIE_PORT`` [:class:`int`] | (Default: ``2001``) + Port URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. + - | ``MAGPIE_SECRET`` - | Port URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. | (Default: ``2001``) + Port URL part of `Magpie` application to rebuild the full ``MAGPIE_URL``. + - | ``MAGPIE_CRON_LOG`` - | Path that the ``cron`` operation should use for logging. | (Default: ``"~/magpie-cron.log"``) + Path that the ``cron`` operation should use for logging. + - | ``MAGPIE_LOG_LEVEL`` - | Logging level of operations. `Magpie` will first use the complete logging configuration found in - `magpie.ini`_ in order to define logging formatters and handler referencing to the ``logger_magpie`` - section. If this configuration fail to retrieve an explicit logging level, this variable is used instead to - prepare a basic logger, after checking if a corresponding ``magpie.log_level`` setting was instead specified. | (Default: ``INFO``) -- | ``MAGPIE_LOG_LEVEL`` - | Specifies whether `Magpie` logging should also enforce printing the details to the console when using *helpers*. - Otherwise, the configured logging methodology in `magpie.ini`_ is used (which can also define a - console handler). + Logging level of operations. `Magpie` will first use the complete logging configuration found in + `magpie.ini`_ in order to define logging formatters and handler referencing to the ``logger_magpie`` section. + If this configuration fail to retrieve an explicit logging level, this configuration variable is used instead to + prepare a basic logger, after checking if a corresponding ``magpie.log_level`` setting was instead specified. + + .. warning:: + When setting ``DEBUG`` level or lower, `Magpie` will potentially dump some sensitive information in logs such + as access tokens. It is important to avoid this setting for production systems. + +- | ``MAGPIE_LOG_PRINT`` | (Default: ``False``) + Specifies whether `Magpie` logging should also **enforce** printing the details to the console when using + `CLI helpers `_. + Otherwise, the configured logging methodology in `magpie.ini`_ is used (which can also define a console handler). + - | ``MAGPIE_LOG_REQUEST`` - | Specifies whether `Magpie` should log incoming request details. - | **Note:** - | This can make `Magpie` quite verbose if large quantity of requests are accomplished. | (Default: ``True``) + Specifies whether `Magpie` should log incoming request details. + + .. note:: + This can make `Magpie` quite verbose if large quantity of requests are accomplished. + - | ``MAGPIE_LOG_EXCEPTION`` - | Specifies whether `Magpie` should log a raised exception during a process execution. | (Default: ``True``) + Specifies whether `Magpie` should log a raised exception during a process execution. + - | ``MAGPIE_UI_ENABLED`` - | Specifies whether `Magpie` graphical user interface should be available with the started instance. If disabled, - all routes that normally refer to the UI will return ``404``, except the frontpage that will return a simple JSON - description as it is normally the default entrypoint of the application. | (Default: ``True``) + Specifies whether `Magpie` graphical user interface should be available with the started instance. If disabled, + all routes that normally refer to the UI will return ``404``, except the frontpage that will return a simple JSON + description as it is normally the default entrypoint of the application. + +- | ``MAGPIE_UI_THEME`` + | (Default: ``"blue"``) + + Specifies the adjustable theme to apply `Magpie` UI pages. This theme consist principally of the applied color for + generic interface items, but could be extended at a later date. The value must be one of the CSS file names located + within the `themes`_ subdirectory. + + +.. _themes: https://github.com/Ouranosinc/Magpie/tree/master/magpie/ui/home/static/themes + Security Settings ~~~~~~~~~~~~~~~~~~~~~ @@ -221,111 +332,204 @@ Following configuration parameters are used to define specific values that are r Again, the `Loading Settings`_ will be processed beforehand and all ``magpie.[variable_name]`` setting definitions remain available as described at the start of the `Configuration`_ section. -- | ``MAGPIE_SECRET`` - | Secret value employed to encrypt user authentication tokens. - | **Important Note:** - | Changing this value at a later time will cause previously created user tokens to be invalidated. - It is **strongly** recommended to change this value before proceeding to user accounts and permissions creation - in your `Magpie` instance. - | (Default: ``"seekrit"``) +- ``MAGPIE_SECRET`` + .. no default since explicit value is now required + + Secret value employed to encrypt user authentication tokens. + + .. warning:: + Changing this value at a later time will cause previously created user tokens from passwords to be invalidated. + This value **MUST** be defined before starting the application in order to move on to user accounts and permissions + creation in your `Magpie` instance. The application will quit with an error if this value cannot be found. + + .. versionchanged:: 2.0.0 + Prior to this version, a default value was employed if this setting not provided. Later `Magpie` version now + require an explicit definition of this parameter to avoid weak default configuration making the protected system + prone to easier breaches. This also avoids incorrect initial setup of special :term:`User`s with that temporary + weak secret that would need recreation to regenerate passwords. - | ``MAGPIE_COOKIE_NAME`` - | Identifier of the cookie that will be used for reading and writing in the requests from login and for - user authentication operations. | (Default: ``"auth_tkt"``) -- | ``MAGPIE_COOKIE_EXPIRE`` - | Lifetime duration of the cookies. Tokens become invalid after this duration is elapsed. - | (Default: ``None`` [infinite]) - -- | ``MAGPIE_ADMIN_USER`` - | Name of the default 'administrator' generated by the application. - | **Note:** - | This user is required for initial launch of the application to avoid being 'looked out' as routes for creating new - users require administrative permissions and access rights. It should be used as a first login method to setup other - accounts. It will also be used by other `Magpie` internal operations such as service synchronization and setup - during the application startup. If this user is missing, it is automatically re-created on following start. - | (Default: ``"admin"``) - -- | ``MAGPIE_ADMIN_PASSWORD`` - | Password of the default 'administrator' generated by the application. - | (Default: ``"qwerty"``) + Identifier of the cookie that will be used for reading and writing in the requests from login and for + user authentication operations. + +- | ``MAGPIE_COOKIE_EXPIRE`` [:class:`int`] + | (Default: ``None``) + + Lifetime duration in seconds of the cookies. Tokens become invalid after this duration is elapsed. + + When no value is provided, the cookies will have an infinite duration (never expire). + When a valid integer value is provided, their reissue time (how long until a new token is regenerated) is a factor + of 10 from this expiration time. For example, tokens are reissued after 360 seconds if their expiration is 3600. + +- ``MAGPIE_ADMIN_USER`` + .. no default since explicit value is now required + + Name of the default 'administrator' generated by the application. + + This :term:`User` is required for initial launch of the application to avoid being 'locked out' as routes for creating + new users require administrative access rights. It should be used as a first login method to setup other accounts. + It is afterwards recommended to employ other user accounts with ``MAGPIE_ADMIN_GROUP`` membership to accomplish + administrative management operations. + + If this :term:`User` is missing, it is automatically recreated on following start. The best way to invalidate its + credentials is therefore to completely remove its entry from the database so it gets regenerated from updated + configuration values. Note also that modifying this value without actually updating the user entry in the database + could cause other operations to fail drastically since this special user will be employed by other `Magpie` internal + operations such as :ref:`Service Synchronization` and setup during the application startup. + + .. versionchanged:: 2.0.0 + Prior to this version, a default value was employed if this setting was not provided. Later `Magpie` version now + require an explicit definition of this parameter to avoid weak default configuration making the protected system + prone to easier breaches. This value **MUST** be defined before starting the application in order to resume to any + other operation in your `Magpie` instance. The application will quit with an error if this value cannot be found. + It is recommended that the developer configures every new instance with server-specific and strong credentials. + +- ``MAGPIE_ADMIN_PASSWORD`` + .. no default since explicit value is now required + + Password of the default 'administrator' :term:`User` generated by the application (see ``MAGPIE_ADMIN_USER`` details). + + .. versionchanged:: 2.0.0 + Prior to this version, a default value was employed if this setting was not provided. Later `Magpie` version now + require an explicit definition of this parameter to avoid weak default configuration making the protected system + prone to easier breaches. This value **MUST** be defined before starting the application in order to resume to any + other operation in your `Magpie` instance. The application will quit with an error if this value cannot be found. + It is recommended that the developer configures every new instance with server-specific and strong credentials. - | ``MAGPIE_ADMIN_EMAIL`` - | Email of the default 'administrator' generated by the application. | (Default: ``"${MAGPIE_ADMIN_USER}@mail.com"``) + Email of the default 'administrator' generated by the application. + - | ``MAGPIE_ADMIN_GROUP`` - | Group name of the default 'administrator' generated by the application. - | **Note:** - | To simplify configuration of future administrators of the application, all their inherited permissions are shared - through this group instead of setting individual permissions on each user. It is recommended to keep defining such - higher level permissions on this group to ease the management process of granted access to all their members. | (Default: ``"administrators"``) -- | ``MAGPIE_ADMIN_PERMISSION`` - | Name of the permission used to represent highest administration privilege in the application. - | Except for some public routes, most API and UI paths will require the user to have this permission (either with - direct permission or by inherited group permission) to be granted access to view and edit content. - The group defined by ``MAGPIE_ADMIN_GROUP`` automatically gets granted this permission. - | (Default: ``"admin"``) + Name of the default 'administrator' :term:`Group` generated by the application. + + .. note:: + To simplify configuration of future administrators of the application, all their :ref:`Inherited Permissions` are + shared through this :term:`Group` instead of setting individual permissions on each :term:`User`. It is recommended + to keep defining such higher level permissions on this :term:`Group` to ease the management process of granted + access to all their members, or in other words, to allow multiple administrators to manage `Magpie` resources with + their respective accounts. + +- | ``MAGPIE_ADMIN_PERMISSION`` [constant] + | (Value: ``"admin"``) + + Name of the :term:`Permission` used to represent highest administration privilege in the application. It is one of + the special :term:`Access Permissions` known by the application (see also :ref:`Route Access` section). + +- | ``MAGPIE_LOGGED_PERMISSION`` [constant] + | (Value: ``"MAGPIE_LOGGED_USER"``) + + .. versionadded:: 2.0.0 + + Defines a special condition of :term:`Access Permissions` related to the :term:`Logged User` session and the + targeted :term:`User` by the request. See details in :ref:`Route Access` for when it applies. + +- | ``MAGPIE_LOGGED_USER`` [constant] + | (Value: ``"current"``) + + Keyword used to define route resolution using the currently logged in user. This value allows, for example, + retrieving the user details of the logged user with ``GET /users/${MAGPIE_LOGGED_USER}`` instead of having to + find explicitly the ``GET /users/`` variant. User resolution is done using the authentication cookie + found in the request. If no cookie can be found, it defaults to the ``MAGPIE_ANONYMOUS_USER`` value. + + .. note:: + Because the :term:`Logged User` executing the request with this keyword is effectively the authenticated user, + the behaviour of some specific paths can be slightly different than their literal ``user_name`` counterpart. + For example, :term:`User` details will be accessible to the :term:`Logged User` (he can view his own information) + but this same user will receive a forbidden response if using is ID in the path if he doesn't have required + privileges. + + .. versionchanged:: 2.0.0 + Even without administrative access rights, the :term:`Logged User` is allowed to obtain some additional details + about the targeted :term:`User` of the request path if it corresponds to itself. See ``MAGPIE_LOGGED_PERMISSION`` + and :ref:`Route Access` for further details. - | ``MAGPIE_ANONYMOUS_USER`` - | Name of the default user that represents a non logged-in user (ie: invalid or no authentication token provided). - | This user is used to manage "public" access to service and resources. | (Default: ``"anonymous"``) + Name of the default :term:`User` that represents non logged-in user (ie: invalid or no :term:`Authentication` + token provided). This :term:`User` is used to manage :term:`Public` access to :term:`Service` and :term:`Resource`. + - | ``MAGPIE_ANONYMOUS_PASSWORD`` [constant] - | Password of the default unauthenticated user. - | This value is not modifiable directly and is available only for preparation of the default user on startup. | (Default: ``${MAGPIE_ANONYMOUS_USER}``) + Password of the default unauthenticated :term:`User`. + This value is not modifiable directly and is available only for preparation of the default user on startup. + - | ``MAGPIE_ANONYMOUS_EMAIL`` - | Email of the default unauthenticated user. + | Email of the default unauthenticated :term:`User`. | (Default: ``"${MAGPIE_ANONYMOUS_USER}@mail.com"``) - | ``MAGPIE_ANONYMOUS_GROUP`` [constant] - | This parameter is preserved for backward compatibility of migration scripts and external libraries. - | All users are automatically member of this group to inherit "public" permissions to services and resources. - | **Important Note:** - | To set "public" permissions, one should always set them on this group instead of directly on - ``MAGPIE_ANONYMOUS_USER`` as setting them directly on this user will cause only him to be granted access to the - targeted resource. In this situation, all *other* users would "lose" public permissions after they authenticate - themselves in `Magpie` as they would not be recognized as ``MAGPIE_ANONYMOUS_USER`` anymore. | (Default: ``${MAGPIE_ANONYMOUS_USER}``) + Special :term:`Group` name that defines :ref:`Public Access` functionalities. All users are automatically member of + this :term:`Public` :term:`Group` to obtain :ref:`Inherited Permissions`. + + This parameter is enforced to be equal to ``MAGPIE_ANONYMOUS_USER``. It is preserved for backward compatibility of + migration scripts and external libraries that specifically refer to this parameter. + + .. versionchanged:: + The :term:`Group` generated by this configuration cannot be modified to remove :term:`User` memberships or change + other metadata associated to it. + + .. warning:: + To set :term:`Public` permissions, one should always set them on this :term:`Group` instead of directly on + ``MAGPIE_ANONYMOUS_USER`` as setting them directly on that :term:`User` will cause unexpected behaviours. + See :ref:`Public Access` section for full explanation. + - | ``MAGPIE_EDITOR_GROUP`` - | *Unused for the moment.* | (Default: ``"editors"``) + *Unused for the moment.* + - | ``MAGPIE_USERS_GROUP`` - | Name of the default group created to associate all users registered in the application. - | New users are created with this group. | (Default: ``"users"``) -- | ``MAGPIE_USER_NAME_MAX_LENGTH`` - | Maximum length to consider as a valid user name. User name specified during creation will be forbidden if longer. - | **Note:** - | This value should not be greater then the token length used to identify a user to preserve some utility behaviour. + Name of a generic :term:`Group` created to associate registered :term:`User` memberships in the application. + + .. versionchanged:: 2.0.0 + New :term:`User` are **NOT** automatically added to this :term:`Group` anymore. This :term:`Group` remains + available for testing and backward compatibility reasons, but doesn't have any special connotation and can be + modified just as any other normal :term:`Group`. + + Prior versions of `Magpie` were adding every new :term:`User` to that :term:`Group` which made it no different + than the behaviour fulfilled by ``MAGPIE_ANONYMOUS_GROUP`` which they are also member of. Since + ``MAGPIE_USERS_GROUP`` has no special meaning and is modifiable at any time (e.g.: users could be removed from it), + it could not even be employed to ensure provision of permissions applied to all users (its original purpose), which + is also equivalent to functionalities provided with :term:`Public` permissions inherited by + ``MAGPIE_ANONYMOUS_GROUP`` that is more specifically handled by `Magpie` for this purpose. + +- | ``MAGPIE_USER_NAME_MAX_LENGTH`` [:class:`int`] | (Default: ``64``) -- | ``MAGPIE_LOGGED_USER`` - | Keyword used to define route resolution using the currently logged in user. This value allows, for example, - retrieving the user details of the logged user with ``GET /users/${MAGPIE_LOGGED_USER}`` instead of having to - find explicitly the ``GET /users/`` variant. User resolution is done using the authentication cookie - found in the request. If no cookie can be found, it defaults to the ``MAGPIE_ANONYMOUS_USER`` value. - | **Note:** - | Because the user executing the request with this keyword is effectively the authenticated user, the behaviour of - some specific paths can be slightly different than their literal user-id counterpart. For example, user details - will be accessible to the logged user (he can view his own information) but this same user will receive an - unauthorized response if using is ID in the path if he doesn't have administrator privilege. - | (Default: ``"current"``) - -- | ``MAGPIE_DEFAULT_PROVIDER`` - | Name of the provider used for local login. This represents the identifier that will be set to define who to - differentiate between a local sign-in procedure and a dispatched one to one of the known `External Providers`_. - | *The default is the value of the internal package used to manage user permissions.* - | (Default: ``"ziggurat"``) + Maximum length to consider a :term:`User` name as valid. + The name specified during creation will be forbidden if longer. + + .. warning:: + This value should not be greater than the token length used to identify a :term:`User` to preserve internal + functionalities. + +- | ``MAGPIE_PASSWORD_MIN_LENGTH`` + | (Default: ``12``) + + .. versionadded:: 2.0.0 + Minimum length of the password for :term:`User` creation or update. + + .. note:: + Because of backward-compatibility requirements, passwords are not enforced this condition during login procedure + as shorter passwords could have been used and not yet updated for older accounts. + +- | ``MAGPIE_DEFAULT_PROVIDER`` [constant] + | (Value: ``"ziggurat"``) + + Name of the :term:`Provider` used for login. This represents the identifier that is set to define how to + differentiate between a local sign-in procedure and a dispatched one some known `Authentication Providers`_. Phoenix Settings ~~~~~~~~~~~~~~~~~~~~~ @@ -333,31 +537,34 @@ Phoenix Settings Following settings provide some integration support for `Phoenix`_ in order to synchronize its service definitions with `Magpie` services. -| **Note:** -| Support of `Phoenix`_ is fairly minimal. -| Please submit an issue if you use it and some unexpected behaviour is encountered. +.. warning:: + Support of `Phoenix`_ is fairly minimal. It is preserved for historical and backward compatibility but is + not actively tested. Please submit an `issue`_ if you use it and some unexpected behaviour is encountered. - | ``PHOENIX_USER`` - | Name of the user to use for authentication in `Phoenix`_. | (Default: ``"phoenix"``) + Name of the user to use for :term:`Authentication` in `Phoenix`_. + - | ``PHOENIX_PASSWORD`` - | Password of the user to use for authentication in `Phoenix`_. | (Default: ``"qwerty"``) + Password of the user to use for :term:`Authentication` in `Phoenix`_. + - | ``PHOENIX_HOST`` - | Hostname to use for `Phoenix`_ connection for authentication and service synchronization. | (Default: ``${HOSTNAME}"``) -- | ``PHOENIX_PORT`` - | Port to use for `Phoenix`_ connection for authentication and service synchronization. + Hostname to use for `Phoenix`_ connection to accomplish :term:`Authentication` and :ref:`Service Synchronization`. + +- | ``PHOENIX_PORT`` [:class:`int`] | (Default: ``8443``) + Port to use for `Phoenix`_ connection to accomplish :term:`Authentication` and :ref:`Service Synchronization`. + - | ``PHOENIX_PUSH`` - | Whether to push new service synchronization settings to the referenced `Phoenix`_ connection. | (Default: ``True``) -.. _Phoenix: https://github.com/bird-house/pyramid-phoenix + Whether to push new :ref:`Service Synchronization` settings to the referenced `Phoenix`_ connection. Twitcher Settings @@ -366,97 +573,152 @@ Twitcher Settings Following settings define parameters required by `Twitcher`_ (OWS Security Proxy) in order to interact with `Magpie` services. + - | ``TWITCHER_PROTECTED_PATH`` - | HTTP path used to define the protected (public) base path of services registered in `Magpie` that will be served by - an existing `Twitcher`_ proxy application after Access Control List (ACL) verification of the authenticated user. - | **Note:** - | Using this parameter to define `Twitcher`_'s path assumes that it resides under the same server domain as the - `Magpie` instance being configured (ie: hostname is inferred from resolved ``MAGPIE_URL`` or equivalent settings). | (Default: ``"/ows/proxy"``) + HTTP path used to define the protected (public) base path of services registered in `Magpie` that will be served + by an existing `Twitcher`_ proxy application after :term:`Access Control List` (ACL) verification of the + :term:`Logged User`. + + .. note:: + Using this parameter to define `Twitcher`_'s path assumes that it resides under the same server domain as the + `Magpie` instance being configured (ie: hostname is inferred from resolved value amongst ``MAGPIE_URL``, + ``MAGPIE_HOST``, ``TWITCHER_HOST`` and ``HOSTNAME`` settings or environment variables). + + .. warning:: + Path is intended to be employed with `Twitcher`_ residing side-by-side with `Magpie`. Therefore, prefix + ``/twitcher`` is added unless already explicitly provided. To employ another path without prefix, consider + instead providing it with the full URL using ``TWITCHER_PROTECTED_URL`` parameter. + +- | ``TWITCHER_HOST`` + | (Default: None) + + .. versionadded:: 2.0.0 + + Specifies the explicit hostname to employ in combination with ``TWITCHER_PROTECTED_PATH`` to form the complete base + service protected URL. Ignored if ``TWITCHER_PROTECTED_URL`` was provided directly. + + .. note:: + The resulting URL will take the form ``https://{TWITCHER_HOST}[/twitcher]{TWITCHER_PROTECTED_PATH}`` to imitate + the resolution of ``TWITCHER_PROTECTED_URL`` considering provided ``TWITCHER_PROTECTED_PATH``. + - | ``TWITCHER_PROTECTED_URL`` - | Defines the protected (public) full base URL of services registered in `Magpie`. This setting is mainly to allow - specifying an alternative domain where a remote `Twitcher`_ instance could reside. - | **Note:** - | `Twitcher`_ instance will still need to have access to `Magpie`'s database in order to allow service resolution - with `magpie.adapter.magpieservice.MagpieServiceStore`. - | (Default: ``None``, ie: uses ``TWITCHER_PROTECTED_PATH``) + | (Default: *see note*) -.. _Twitcher: https://github.com/bird-house/twitcher + Defines the protected (public) full base URL of services registered in `Magpie`. This setting is mainly to allow + specifying an alternative domain where a remote `Twitcher`_ instance could reside. + + .. note:: + When not provided, attempts to infer the value by combining the environment variable ``HOSTNAME`` or + ``TWITCHER_HOSTNAME``, and an optional ``/twitcher`` prefix (as needed to match incoming request) and the + value provided by ``TWITCHER_PROTECTED_PATH``. + + +Please note that although `Twitcher`_ URL references are needed to configure interactive parameters with `Magpie`, the +employed `Twitcher`_ instance will also need to have access to `Magpie`'s database in order to allow proper +:term:`Service` resolution with `magpie.adapter.magpieservice.MagpieServiceStore`. Appropriate database credentials +must therefore be shared between the two services, as well as ``MAGPIE_SECRET`` value in order for successful +completion of the handshake during :term:`Authentication` procedure of the request :term:`User` token. Postgres Settings ~~~~~~~~~~~~~~~~~~~~~ -Following settings define parameters required to define the `Postgres`_ database connection employed by `Magpie` as +Following settings define parameters required to define the `PostgreSQL`_ database connection employed by `Magpie` as well as some other database-related operation settings. Settings defined by ``magpie.[variable_name]`` definitions are available as described at the start of the `Configuration`_ section, as well as some special cases where additional configuration names are supported where mentioned. - | ``MAGPIE_DB_MIGRATION`` - | Run database migration on startup in order to bring it up to date using `Alembic`_. | (Default: ``True``) + Run database migration on startup in order to bring it up to date using `Alembic`_. + - | ``MAGPIE_DB_MIGRATION_ATTEMPTS`` - | Number of attempts to re-run database migration on startup in cased it failed (eg: due to connection error). | (Default: ``5``) + Number of attempts to re-run database migration on startup in case it failed (eg: due to connection error). + - | ``MAGPIE_DB_URL`` - | Full database connection URL formatted as ``://:@:/``. - | Please refer to `SQLAlchemy Engine`_'s documentation for supported database implementations and their corresponding - configuration. Only `Postgres`_ has been extensively tested with `Magpie`, but other variants should be applicable. - | (Default: infer ``postgresql`` database connection URL formed using below ``MAGPIE_POSTGRES_<>`` parameters if the - value was not explicitly provided) + | (Default: *see note*) + + Full database connection URL formatted as ``://:@:/``. + + Please refer to `SQLAlchemy Engine`_'s documentation for supported database implementations and their corresponding + configuration. + + .. warning:: + Only `PostgreSQL`_ has been extensively tested with `Magpie`, but other variants *could* be applicable. + + .. note:: + By default, ``postgresql`` database connection URL is inferred by combining al below ``MAGPIE_POSTGRES_<>`` + parameters if the value was not explicitly provided. - | ``MAGPIE_POSTGRES_USERNAME`` - | Database connection username to retrieve `Magpie` data stored in `Postgres`_. - | On top of ``MAGPIE_POSTGRES_USERNAME``, environment variable ``POSTGRES_USERNAME`` and setting ``postgres.username`` - are also supported. For backward compatibility, all above variants with ``user`` instead of ``username`` - (with corresponding lower/upper case) are also verified for potential configuration if no prior parameter was - matched. The lookup order of each name variant is as they were presented, while also keeping the setting name - priority over an equivalent environment variable name. | (Default: ``"magpie"``) + Database connection username to retrieve `Magpie` data stored in `PostgreSQL`_. + + .. versionchanged:: 1.9.0 + On top of ``MAGPIE_POSTGRES_USERNAME``, environment variable ``POSTGRES_USERNAME`` and setting + ``postgres.username`` are all supported interchangeably. For backward compatibility, all above variants with + ``user`` instead of ``username`` (with corresponding lower/upper case) are also verified for potential + configuration if no prior parameter was matched. The lookup order of each name variant is as they were presented, + while also keeping the setting name priority over an equivalent environment variable name. + - | ``MAGPIE_POSTGRES_PASSWORD`` - | Database connection password to retrieve `Magpie` data stored in `Postgres`_. - | Environment variable ``POSTGRES_PASSWORD`` and setting ``postgres.password`` are also supported if not previously - identified by their `Magpie`-prefixed variants. | (Default: ``"qwerty"``) -- | ``MAGPIE_POSTGRES_HOST`` - | Database connection host location to retrieve `Magpie` data stored in `Postgres`_. - | Environment variable ``POSTGRES_HOST`` and setting ``postgres.host`` are also supported if not previously + Database connection password to retrieve `Magpie` data stored in `PostgreSQL`_. + + .. versionchanged:: 1.9.0 + Environment variable ``POSTGRES_PASSWORD`` and setting ``postgres.password`` are also supported if not previously identified by their `Magpie`-prefixed variants. + +- | ``MAGPIE_POSTGRES_HOST`` | (Default: ``"postgres"``) -- | ``MAGPIE_POSTGRES_PORT`` - | Database connection port to retrieve `Magpie` data stored in `Postgres`_. - | Environment variable ``POSTGRES_PORT`` and setting ``postgres.port`` are also supported if not previously + Database connection host location to retrieve `Magpie` data stored in `PostgreSQL`_. + + .. versionchanged:: 1.9.0 + Environment variable ``POSTGRES_HOST`` and setting ``postgres.host`` are also supported if not previously identified by their `Magpie`-prefixed variants. + +- | ``MAGPIE_POSTGRES_PORT`` [:class:`int`] | (Default: ``5432``) + Database connection port to retrieve `Magpie` data stored in `PostgreSQL`_. + + .. versionchanged:: 1.9.0 + Environment variable ``POSTGRES_PORT`` and setting ``postgres.port`` are also supported if not previously + identified by their `Magpie`-prefixed variants. + - | ``MAGPIE_POSTGRES_DB`` - | Name of the database located at the specified connection to retrieve `Magpie` data stored in `Postgres`_. - | Environment variable ``POSTGRES_DB`` and setting ``postgres.db``, as well as the same variants with ``database`` - instead of ``db``, are also supported if not previously identified by their `Magpie`-prefixed variants. | (Default: ``"magpie"``) -.. _Postgres: https://www.postgresql.org/ -.. _Alembic: https://alembic.sqlalchemy.org/ + Name of the database located at the specified connection to retrieve `Magpie` data stored in `PostgreSQL`_. + + .. versionchanged:: 1.9.0 + Environment variable ``POSTGRES_DB`` and setting ``postgres.db``, as well as the same variants with ``database`` + instead of ``db``, are also supported if not previously identified by their `Magpie`-prefixed variants. + .. _SQLAlchemy Engine: https://docs.sqlalchemy.org/en/13/core/engines.html -External Providers ----------------------- +Authentication Providers +--------------------------- -In order to perform authentication in `Magpie`, multiple external providers are supported. By default, the 'local' -provider is ``ziggurat`` which corresponds to the package used to manage users, groups, permissions, etc. internally. -Supported external providers are presented in the table below, although more could be added later on. +In order to perform :term:`Authentication` in `Magpie`, multiple :term:`Providers` are supported. By default, +the :term:`Internal Provider` named ``ziggurat``, which corresponds to the package used to manage all `Magpie` elements +internally, is employed. Supported :term:`External Providers` are presented in the table below, although more could be +added later on. To signin using one of these :term:`Providers`, the corresponding identifier must be provided within +the signin request contents. Each as different configuration parameters as defined in `MagpieSecurity`_ and use various protocols amongst -``OpenID``, ``ESGF``-flavored ``OpenID`` and ``OAuth2``. Further external providers can be defined using this module's -dictionary configuration style following parameter specification of `Authomatic`_ package used for managing this -authentication procedure. +``OpenID``, ``ESGF``-flavored ``OpenID`` and ``OAuth2``. Further :term:`External Providers` can be defined using this +module's dictionary configuration style following parameter specification of `Authomatic`_ package used for managing +this :term:`Authentication` procedure. +--------------------------------+-----------------------------------------------------------------------+ | Category | Provider | @@ -483,13 +745,11 @@ authentication procedure. | :sup:`(2)` formerly identified as *British Atmospheric Data Centre* (`BADC`_) | :sup:`(3)` formerly identified as *Program for Climate Model Diagnosis & Intercomparison* (`PCMDI`_) -| **Note:** -| Please note that due to the constantly changing nature of multiple of these external providers (APIs and moved - Websites), rarely used authentication bridges by the developers could break without prior notice. If this is the - case and you use one of the broken connectors, summit a new - `issue `_. +.. note:: + Please note that due to the constantly changing nature of multiple of these external providers (APIs and moved + Websites), rarely used authentication bridges by the developers could break without prior notice. If this is the + case and you use one of the broken connectors, summit a new `issue`_. -.. _Authomatic: https://authomatic.github.io/authomatic/ .. _OpenID: https://openid.net/ .. _ESGF: https://esgf.llnl.gov/ .. _DKRZ: https://esgf-data.dkrz.de diff --git a/docs/glossary.rst b/docs/glossary.rst new file mode 100644 index 000000000..95d8afaee --- /dev/null +++ b/docs/glossary.rst @@ -0,0 +1,144 @@ +.. _glossary: +.. include:: references.rst + +************ +Glossary +************ + +.. glossary:: + :sorted: + + Access Permission + Required :term:`Group` membership to obtain sufficient privileges in order to be permitted the execution of + a given request. Requests under different scopes require variable access levels depending on context. + + ACL + Access Control List. + + Set of :term:`User` and :term:`Group` scopes, provided session :term:`Authentication` elements, that either + grants or denies access to the applicable :term:`User` to the targeted HTTP request. + + Allowed Permissions + Set of applicable :term:`Permission` values onto an element. See :ref:`Allowed Permissions` section. + + Applied Permissions + An active :term:`Permission` for a given :term:`User` or :term:`Group` depending on context. + See also :ref:`Applied Permissions` details. + + Authentication + Process of identifying one-self using credentials in order to login into `Magpie`, or retrieving connected + session :term:`User` during an HTTP request using supported methods. + + Authorization + Process of allowing or denying access to a :term:`Resource` or :term:`Service` according to :term:`Logged User` + identified through :term:`Authentication` methods. This process typically falls into the hands of a + :term:`Proxy` application. + + Context User + Specific :term:`User` that is being targeted by a request from specified value for the ``{user_name}`` request + path variable. The contextual :term:`User` of the request *could* correspond to the :term:`Logged User` if the + reference resolves to itself, but this is not necessarily the case. See further details and examples provided + in section :ref:`Route Access`. + + Cookies + Set of :term:`Authentication` identifiers primarily employed by `Magpie` HTTP requests to determine the + :term:`Logged User`. + + Direct Permissions + Describes a :term:`Permission` that is given to a :term:`User` explicitly, rather than one of its :term:`Group` + memberships. See also :ref:`Direct Permissions` details. + + Discoverable Group + :term:`Group` that has property ``discoverable=True``, making it publicly viewable to any-level user. + Otherwise, groups can be listed or accessed only by administrators. + + Effective Permissions + A :term:`Permission` that has been completely resolved according to all applicable contexts, that indicates + the final granted or denied result. See also :ref:`Effective Permissions` section. + + External Providers + Set of all known user-identity :term:`Provider` defined externally to `Magpie`. Each of these :term:`Provider` + require specific connection methodologies, as configured in :mod:`magpie.security`. + See also :ref:`Authentication Providers` section for details. + + Group + Entity on which :term:`Permission` over a :term:`Service` or :term:`Resource` can be applied. Any :term:`User` + can be set as a member of any number of :term:`Group`, making it inherit all applicable set of + :term:`Permission`. + + Immediate Permissions + Describes a :term:`Permission` that originates directly and only from a :term:`Service`. + This is referenced in only a few use-cases, notably for :ref:`Finding User Permissions`. + + Inherited Permissions + Describes a :term:`Permission` that includes both :term:`User` and :term:`Group` contexts simultaneously. + See :ref:`Inherited Permissions` details. + + Internal Providers + Represents all the :term:`Provider` that are known for *local* (instead of *external*) + :term:`Authentication` to the referenced `Magpie` instance. The credentials for login as locally searched + fo rather than dispatched to an external user-identity. For the moment, this consists uniquely of + :py:data:`magpie.constants.MAGPIE_DEFAULT_PROVIDER` constant. + + Logged User + More specific use-case of :term:`Request User` that simultaneously corresponds to the active request session + :term:`User` as well at the referenced :term:`Context User` from the path variable. This :term:`User` can be + automatically retrieved in applicable requests using in the request path the special constant value defined by + :py:data:`magpie.constants.MAGPIE_LOGGED_USER`, or using its literal :term:`User` name. + When not logged in, this :term:`User` is considered to be equivalent to explicitly requesting + :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER`. Otherwise, it is whoever the + :term:`Authentication` mechanism identifies with token extracted from request :term:`Cookies`. + + Permission + Element that defines which rules are applicable for a given combination of :term:`User` and/or :term:`Group` + against one or many :term:`Service` and/or :term:`Resource`, depending of the many contexts for which they + can apply. Applicable values are generally defined by enum :py:class:`magpie.permissions.Permission`. + + .. note:: + See `permissions`_ chapter for more exhaustive details, including contextual comparisons for all other + *Permission*-related terms presented here. + + Provider + Corresponds to the reference user-identity to employ in order to attempt :term:`Authentication`. + See also :term:`Internal Providers`, :term:`External Providers` and section :ref:`Authentication Providers`. + + Proxy + Sibling service (typically `Twitcher `_) that employs `Magpie` as access management of :term:`User`, + :term:`Group`, :term:`Service` and :term:`Resource` to obtain applicable sets of :term:`Permission`. + Provided these, it acts as policy enforcement point (PEP). + + Public + Refers to a :term:`Permission` applied on a :term:`Service` or :term:`Resource` to special elements in order + to make them available to anyone including even unauthenticated sessions. See also :ref:`Public Access` section + for implementation details to achieve this result. + + Request User + Active request session :term:`User` that can be retrieved by calling ``request.user`` with resolution of + :term:`Authentication` headers within the request (:term:`User` is ``None`` if unauthenticated, + i.e.: :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER`). This is not the same as the :term:`Context User` + extracted from ``{user_name}`` path variable, except for the special case covered by :term:`Logged User`'s + definition. The request :term:`User` could send request that work on another :term:`Context User` than itself + if sufficient :term:`Access Permission` is granted. See also :ref:`Route Access` for further details. + + Resource + Entity on which :term:`User` and :term:`Group` can be associated to applicable :term:`Permission` respectively + for the contextual :term:`Service` under which it resides. This element can represent relatively *anything*. + The interpretation of each :term:`Resource` depends on the context of the :term:`Service` they relate to. + Implemented by sub-classes of :py:class:`magpie.models.Resource`. + + Service + Top-level specialized :term:`Resource` that defines which children :term:`Resource` elements are applicable to + it (if any), how its hierarchy of :term:`Resource` should behave against incoming HTTP request details, and how + to parse any set of :term:`Permission` applied on them against respective request elements. Also defines URL + connexion details pointing to the actual service on which access control are applicable. Each type of + :term:`Service` defines different combination of functionalities. Implemented by sub-classes of + :py:class:`magpie.models.ServiceInterface`. + + User + Unitary entity containing details about the user allowing it to log into `Magpie` and that can have other + relationships applied to it such as :term:`Permission` and :term:`Group` that extend his specific access rights + to :term:`Service` and :term:`Resource` elements. Implemented by :py:class:`magpie.models.User`. + + +.. _permissions: ./docs/permissions.rst + diff --git a/docs/history.rst b/docs/history.rst deleted file mode 100644 index 250649964..000000000 --- a/docs/history.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../HISTORY.rst diff --git a/docs/index.rst b/docs/index.rst index 284676b3b..348ad0d2f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,36 +1,11 @@ -Magpie Documentation -====================================== - .. include:: ../README.rst - -Package Information -=================== - -.. toctree:: - :maxdepth: 2 - - usage - installation - configuration - utilities - performance - contributing - authors - history - security - - -Source Code -=================== - -.. toctree:: - :maxdepth: 2 +.. include:: toc.rst +---------------------------- Indices and Tables -================== +---------------------------- * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/installation.rst b/docs/installation.rst index 94da0c6cb..b37936e96 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,5 +1,9 @@ +.. _installation: +.. include:: references.rst + +============= Installation -============ +============= At the command line:: @@ -22,4 +26,20 @@ If you want the full setup for development (including dependencies for test exec make install-dev -You can run the Magpie container with a docker-compose.yml for a local setup (see docker-compose.yml.example) +You can run the Magpie container with a ``docker-compose.yml`` for a local setup (see `docker-compose.yml.example`_) + +.. _`docker-compose.yml.example`: https://github.com/Ouranosinc/Magpie/tree/master/docker-compose.yml.example + + +Backward Compatibility +-------------------------- + +`Magpie` remains available for following obsolete and backward compatible versions. + +- Python 2.7 (end of life on January 1st, 2020) +- Python 3.5 (end of life on September 13th, 2020) + +Older versions than ones listed above are unsupported. These oldest versions remain tested in `Travis-CI` and deployment +procedure for traceability, but are not guaranteed to work, nor provide all functional or security features and will not +be actively maintained. If you identify a easy fix for such an older version, please submit an `issue`_ to be considered +for integration. It is greatly recommended to upgrade your Python version to receive all applicable security patches. diff --git a/docs/performance.rst b/docs/performance.rst index 55120e530..7daa0a5d7 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -1,9 +1,12 @@ -=========== +.. _performance: +.. include:: references.rst + +************* Performance -=========== +************* Requesting permissions for a specific user and service can be demanding if a lot of -requests are done rapidly. PostgreSQL and sqlalchemy are usually fast enough, but +requests are done rapidly. `PostgreSQL`_ and `SQLAlchemy`_ are usually fast enough, but when more than a couple requests per second are needed, some solutions are possible to improve the performance of these requests. diff --git a/docs/permissions.rst b/docs/permissions.rst new file mode 100644 index 000000000..a832c41a0 --- /dev/null +++ b/docs/permissions.rst @@ -0,0 +1,355 @@ +.. _permissions: +.. include:: references.rst + +=========== +Permissions +=========== + +Types of Permissions +----------------------- + +Across the documentation and the code, term :term:`Permission` is often employed interchangeably to represent different +more subtle contextual functionalities. This is mostly an abuse of language, but is preserved regardless in order to +maintain backward compatibility of features and API response content with older systems that could employ `Magpie`. +Therefore, care must be taken to consider under which context this term is observed to ensure correct interpretation +of observed results. + +More specifically, following distinction can be considered between different kind of :term:`Permission` used by +`Magpie`: + +.. _`allowed permissions`: + +- **Allowed Permissions**: + Represents the set of valid :term:`Permission` values that *could* be applied to a given :term:`Service` or + :term:`Resource`, depending on their type's implementation. Every allowed :term:`Permission` is one entry from + :py:data:`magpie.permissions.Permission`, and their set define the *schema* that will pass validation if applied + to corresponding items of :py:mod:`magpie.services`, but they are not necessarily an active nor existing entry + in the database (i.e.: `Applied Permissions`_). In general, these values are obtained from requests scoped under + paths ``/service`` and ``/resources``. + +.. _`applied permissions`: + +- **Applied Permissions**: + Represents an active "rule" which defines a combination of ``(User|Group, Service|Resource, Permission)``. + These entries are validated during requests against the appropriate `Allowed Permissions`_ of the targeted item + (:term:`Service` or :term:`Resource`) to create the "rule" under (for) the targeted :term:`User` or :term:`Group`. + When executing requests under scopes ``/users`` and ``/groups``, responses without query parameter modifiers + (see `perm_example`_) are by default `Applied Permissions`_. These are also scoped under a *single context* + at a given time (:term:`User` or :term:`Group`), depending on the request path being executed. They determine + which access rights will be granted or denied for the respective :term:`User` or :term`Group`. + +.. _`direct permissions`: + +- **Direct Permissions**: + Explicitly represents :term:`Applied Permissions` "rules" in the case of :term:`User` context, which is, when + :term:`Group` membership are **NOT** considered (i.e.: :term:`Inherited Permissions`). Since calling ``/users`` + scoped requests can lead to all different :term:`Permission` variations presented here with different contextual + information, this term is employed to specifically indicate the situations of the default behaviour of the routes + without query parameters. + +.. _`immediate permissions`: + +- **Immediate Permissions**: + Represents a "rule" combination that was explicitly applied to a :term:`Service`. Rules applied to children + :term:`Resource` are **NOT** considered :term:`Immediate Permissions` (they are simply `Applied Permissions`_ + without any special connotation). Note that `Immediate Permissions`_ are still `Applied Permissions`_. + They are a special subset of `Applied Permissions`_ matching how :term:`Service` are a specialized implementation + of :term:`Resource` (see: :class:`magpie.models.Service`). This kind of :term:`Permissions` is notably referred + to by requests for `Finding User Permissions`_ as they provide useful and unique properties. + +.. _`inherited permissions`: + +- **Inherited Permissions**: + Represents the combined set of `Applied Permissions`_ from the :term:`User` context and every one of its + :term:`Group` membership contexts. When requesting a :term:`Group`'s permissions, only "rules" explicitly set on + the given group are returned. The same concept applies when *only* requesting :term:`User` permissions. Providing + applicable :term:`User`-scoped requests with ``inherited=true`` query parameter will return the *merged* set of + `Applied Permissions`_ for that :term:`User` and all his :term:`Group` membership simultaneously. + See `perm_example`_ for complete comparison. + + .. versionchanged:: 2.0.0 + Prior to this version, ``inherit`` (without ``ed``) was employed as query parameter name. This often lead to + confusion between expected and returned results due to mistakenly employed adjective. Because they are referred + to as :term:`Inherited Permissions` in the documentation and naturally from a linguistic standpoint, query + ``inherited`` (with ``ed``) is now the *official* parameter. The older variant remains supported and equivalent. + +.. _`effective permissions`: + +- **Effective Permissions**: + Represents all `Inherited Permissions`_ of the :term:`User` and all its :term:`Group` membership, as well as the + extensive resolution of the :term:`Service` and every children :term:`Resource` in its hierarchy for the requested + :term:`Resource` scope. Effective permissions automatically imply ``inherited=True``, and can be obtained from + :term:`User`-scoped requests with ``effective=true`` query parameter wherever supported. See `perm_example`_ for + complete comparison. + +.. _`access permissions`: + +- **Access Permissions**: + Represents the required level of :term:`Permission` needed to access `Magpie` API routes to request details. These + can be referred to as "roles", but are inferred by :term:`Group` memberships of the :term:`Logged User` attempting + to complete the request. It is the only kind of :term:`Permission` which the values are not retrieved from the + enum :class:`magpie.permissions.Permission`, but rather from a combination of special :term:`Group` and + :ref:`Configuration` constants. See `Route Access`_ for more details. + +.. following are potential but not implemented / unused: + ownership permissions: + user/group that owns the service/resource + defined with the id saved directly under that Resource (see Resource.owner_[user|group]_id) + role permission: + (user|group, permission) relationship, within separate tables in database + maybe could be combined used with group permissions and 'access permissions' do, but there + is still a need to check view access dynamic group with them, might require some GroupFactory? + + +Route Access +------------- + +Most of the HTTP routes require by default administrative privileges (i.e.: ``MAGPIE_ADMIN_PERMISSION`` or equivalent +inferred from ``MAGPIE_ADMIN_GROUP`` membership for the :term:`Logged User`). Exceptions to this are notably requests +with :term:`User`-scoped routes under ``/users/{user_name}`` which allow retrieval of :term:`Public` :term:`Resource` +details (e.g.: obtaining information about what ``MAGPIE_ANONYMOUS_GROUP`` members have access to), and informative +API routes that are granted :ref:`Public Access` to anyone such as the `Magpie REST API`_ documentation served under +a running `Magpie` instance or the instance's version route. + +.. versionchanged:: 2.0.0 + + Some routes under ``/users/{user_name}`` are also granted more *contextual access* than the default admin-only + access requirement to allow self-referencing user operations. Using a combination of view configurations with + :py:data:`magpie.constants.MAGPIE_LOGGED_PERMISSION` and + :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION`, the permitted functionalities are controlled according to + the actual procedure being executed. In other words, if the :term:`Request User` corresponds to the path variable + :term:`Context User`, access *could* also granted to allow that individual to obtain or update its own details. + In this situation, allowed routes are controlled on a per-request basis with for the respective contextual + operations accomplished by each request. For example, :term:`Logged User` could be granted access to update its + account details, but won't be able to grant itself more permissions on a given :term:`Service` or :term:`Resource`. + +Typically, request `Access Permissions`_ fall into one of the following category for all API endpoints. Higher +permissions in the table typically imply higher access conditions. + +.. list-table:: + :header-rows: 1 + + * - View Permission + - Request Requirement + * - :py:data:`magpie.constants.MAGPIE_ADMIN_PERMISSION` + - :term:`Logged User` must be a member of :term:`Group` configured by + :py:data:`magpie.constants.MAGPIE_ADMIN_GROUP`. + * - :py:data:`magpie.constants.MAGPIE_LOGGED_PERMISSION` + - :term:`Logged User` must at the very least refer to itself in the request path variable and **MUST** + be authenticated with an active session. + * - :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION` + - :term:`Request User` must refer to itself as :term:`Context User`, but **CAN** be authenticated or not. + * - :py:data:`pyramid.security.Authenticated` + - :term:`Logged User` must at the very least be :term:`Authenticated`, but **CAN** refer to any other + :term:`Context User` or even none at all. + * - :py:data:`pyramid.security.NO_PERMISSION_REQUIRED` + - Anyone can access the endpoint (i.e.: :ref:`Public Access`), including unauthenticated + :term:`Request User` session. + +When targeting specific :term:`User`-scoped routes, the following (simplified) operations are applied to determine if +access should be granted to execute the request: + +1. :term:`Logged User` has administrative-level `Access Permissions`_ (always granted access). +2. :term:`Context User` corresponds exactly to same :term:`Request User` identified from the path variable. +3. :term:`Context User` in path variable is the special keyword :py:data:`magpie.constants.MAGPIE_LOGGED_USER`. +4. :term:`Context User` in path variable is special user :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER`. + +For the first matched of the above steps, the condition is compared to the specific request requirement. +Access is granted or denied according to met or insufficient condition result. + +Every time a :term:`User`-scoped request is executed, the targeted :term:`Context User` is resolved accordingly to +either the explicit ``{user_name}}`` value provided, or the auto-resolved :py:data:`magpie.constants.MAGPIE_LOGGED_USER` +value that implicitly retrieves the :term:`Request User` as the :term:`Context User`. + +.. note:: + Whenever one of the :term:`User`-scoped requests refers to specials keywords such as + :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER` or :py:data:`magpie.constants.MAGPIE_ADMIN_GROUP`, any operation + that has the intention to modify the corresponding :term:`User` or :term:`Group` are forbidden. There is therefore + some additional request-specific logic (depending on its purpose and resulting actions) for special-cases that is + not explicitly detailed in above steps. Some of these special behaviors can be observed across the various `tests`_. + +.. _tests: https://github.com/Ouranosinc/Magpie/tree/master/tests + +Finally, it is worth further detailing the small distinction between +:py:data:`magpie.constants.MAGPIE_LOGGED_PERMISSION` and :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION`, +provided that they act almost the same way. More precisely, they both work in the exact situation where +:term:`Request User` is equal to :term:`Context User`, but each for different sets of applicable values for those +:term:`User` references. + +When a route is attributed :py:data:`magpie.constants.MAGPIE_LOGGED_PERMISSION`, it means that the :term:`Request User` +must absolutely be authenticated (i.e.: not ``None``), while :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION` does +not enforce this criteria. The *contextual* permission is an extended set of the *logged* one with two exceptions, which +are when the :term:`Request User` is unauthenticated and/or when the referenced :term:`Context User` is resolved to the +unauthenticated :term:`User` defined by :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER`. + +An example where such distinction is important goes as follows. A request that requires to update :term:`User` +details typically *minimally* requires a :term:`Logged User` because it does not make sense to attempt modification +of an undefined :term:`User`. If the :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION` requirement was applied, it +would imply that unauthenticated :term:`Context User` *could* update itself, which is obviously wrong. On the other +hand, it makes sense to allow the :term:`Request User` to update its own details. In this case, the applicable view +configuration is :py:data:`magpie.constants.MAGPIE_LOGGED_PERMISSION` so that it immediately forbids the operation if +the :term:`Request User` did not accomplish prior :term:`Authentication`. As counter example, requesting details about +resources that are :term:`Public` (more details in :ref:`Public Access` for this), makes sense even when we did not +complete prior :term:`Authentication`, as they are accessible to everyone. The view configuration in this case should +employ :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION` so that :term:`Context User` referring to unauthenticated +:term:`User` will be permitted. They cannot be set to :py:data:`pyramid.security.Authenticated`, as this would enforce +the need to signin in first, while :py:data:`pyramid.security.NO_PERMISSION_REQUIRED` would fully open *all* requests +targeting for example an administrator as :term:`Context User`. It is important to distinguish in this situation between +:ref:`Access Permissions` of the view configuration and listed :ref:`Applied Permissions` on resources. + +Public Access +------------- + +In order to achieve publicly accessible :term:`Service` or :term:`Resource` functionality by any given individual, the +desired :term:`Permission` must be applied on special :term:`Group` defined with configuration setting +:py:data:`magpie.constants.MAGPIE_ANONYMOUS_GROUP`. Since every existing :term:`User` automatically gets attributed +membership to that special :term:`Group` at creation time, all applied :term:`Permission` to it are inherited by +everyone, making the corresponding :term:`Resource` effectively :term:`Public`. + +Note that it is **VERY** important to apply :term:`Permission` on the :term:`Group` defined by +:py:data:`magpie.constants.MAGPIE_ANONYMOUS_GROUP` rather than then :term:`User` defined by +:py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER` in order to achieve :term:`Public`-like access by everyone. This is +because using the :term:`User` instead of the :term:`Group` would instead make the :term:`Resource` +accessible **ONLY** while not authenticated (i.e.: when :term:`Logged User` corresponds to +:py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER`). Once a real :term:`User` would authenticate itself, they would +suddenly *lose* the :term:`Public` :term:`Permission` since :term:`Logged User` would not be the special :term:`User` +anymore. That would lead to unexpected behavior where :term:`Resource` intended to be always :term:`Public` would +contextually change access criteria depending on active :term:`Logged User` session. More precisely, this would cause +confusing situation where an unauthenticated :term:`User` would be able to see publicly accessible elements, but +wouldn't see them anymore as soon as he would authenticate itself (login). That :term:`User` would have the impression +that its access rights are lowered although they should be increased by authenticating itself. + +Special user :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER` is available only for evaluation purpose of +:term:`Public`-only :term:`Permission` applied to :term:`Service` and :term:`Resource`, but is technically not required +to execute `Magpie` application. Effectively, when the active session corresponds to unauthenticated +:term:`Logged User`, it is still allowed to call :term:`User`-scoped API request paths, which will return details about +:term:`Public` accessible items. + +.. _perm_example: +.. |perm_example| replace:: Permission Example + +Example to distinguish Applied, Inherited and Effective Permissions +-------------------------------------------------------------------------------------- + +This section intends to provide more insight on the different :ref:`Types of Permissions` using a simplified +demonstration of interaction between defined :term:`Service`, :term:`Resource`, :term:`Group`, :term:`User` and +:term:`Permission` elements. + +Let's say we have some fictive :term:`Service` that allows the following *permission scheme*, and that it implements +the default hierarchical resolution of :term:`Resource` items (i.e.: having permissions on ``resource-type-1`` also +provides the same ones for child resources ``resource-type-2``). + +.. code-block:: + + service-type [read | write] + resource-type-1 [read | write] + resource-type-2 [read | write] + +Given that scheme, let's say that existing elements are defined using the allowed types as follows: + +.. code-block:: + + service-1 (service-type) + service-2 (service-type) + resource-A (resource-type-1) + service-3 (service-type) + resource-B1 (resource-type-1) + resource-B2 (resource-type-2) + +Let's says we also got a ``example-user`` that is member of ``example-group``, and that `Applied Permissions`_ on them +are as follows: + +.. code-block:: + + (service-1, example-user, write) + (service-2, example-group, write) + (resource-A, example-user, read) + (service-3, example-user, write) + (resource-B1, example-group, read) + +For simplification purposes, we will use the names directly in following steps, but remember that requests would +normally require unique identifiers for :term:`Resource` resolution. Lets observe what happens using different query +parameters with request ``GET /users/{user_name}/resources/{resource_id}/permissions``. + +If no query parameter is specified, we obtain permissions as follows: + +.. code-block:: + + /users/example-user/resources/service-1/permissions => [write] + /users/example-user/resources/service-2/permissions => [] + /users/example-user/resources/resource-A/permissions => [read] + /users/example-user/resources/service-3/permissions => [write] + /users/example-user/resources/resource-B1/permissions => [] + /users/example-user/resources/resource-B2/permissions => [] + +Using ``inherited`` option, we obtain the following: + +.. code-block:: + + /users/example-user/resources/service-1/permissions?inherited=true => [write] + /users/example-user/resources/service-2/permissions?inherited=true => [write] (1) + /users/example-user/resources/resource-A/permissions?inherited=true => [read] + /users/example-user/resources/service-3/permissions?inherited=true => [write] + /users/example-user/resources/resource-B1/permissions?inherited=true => [read] (1) + /users/example-user/resources/resource-B2/permissions?inherited=true => [] + +As illustrated, requesting for `Inherited Permissions`_ now also returns :term:`Group`-related :term:`Permission` +:sup:`(1)` where they where not returned before with only :term:`User`-related :term:`Permission`. + +On the other hand, using ``effective`` would result in the following: + +.. code-block:: + + /users/example-user/resources/service-1/permissions?effective=true => [write] + /users/example-user/resources/service-2/permissions?effective=true => [write] (2) + /users/example-user/resources/resource-A/permissions?effective=true => [read, write] (3) + /users/example-user/resources/service-3/permissions?effective=true => [] + /users/example-user/resources/resource-B1/permissions?effective=true => [read] (2) + /users/example-user/resources/resource-B2/permissions?effective=true => [read, write] (4) + +In this case, :term:`Resource`s that had :term:`Permission` directly set on them :sup:`(2)`, whether through +:term:`User` or :term:`Group` combination, all return the exact same set of :term:`Permission`. This is because +`Effective Permissions`_ always imply `Inherited Permissions`_ (i.e.: using both query simultaneously is redundant). +The reason why we obtain these sets for cases :sup:`(2)` is also because there is no other :term:`Permission` applied +to any of their parent :term:`Service` or :term:`Resource`. Contrarily, ``resource-A`` :sup:`(3)` now additionally +receives :term:`Permission` ``read`` indirectly from its parent ``service-2`` (note: ``write`` is redundant here). +Similarly, ``resource-B2`` :sup:`(4)` which did not even have any immediate :term:`Permission` applied to it, +now receives both ``read`` and ``write`` access, respectively from its parents ``resource-B1`` and ``service-3``. This +demonstrates why, although `Effective Permissions`_ imply `Inherited Permissions`_, they do not necessarily resolve to +the same result according to the effective :term:`Resource` hierarchy and its parent-children resolution implementation. + +Using ``effective`` query tells `Magpie` to rewind the :term:`Resource` tree from the requested :term:`Resource` up to +the top-most :term:`Service` in order to accumulate all `Inherited Permissions`_ observed along the way for every +encountered element. All :term:`Permission` that is applied *higher* to the requested :term:`Resource` are considered +as if applied directly on it. Query parameter ``inherited`` limits itself only to specifically requested +:term:`Resource`, without hierarchy resolution, but still considering :term:`Group` memberships. For this reason, +``inherited`` *could* look the same to ``effective`` results if the :term:`Service` hierarchy is "flat", or if all +:term:`Permission` can be found directly on the target :term:`Resource`, but it is not guaranteed. This is further +important if the :term:`Service`'s type implementation provides custom methodology for parsing the hierarchy resolution +(see :ref:`services` for more details). + +In summary, ``effective`` tells us *"which permissions does the user have access to for this resource"*, while +``inherited`` answers *"which permissions does the user have on this resource alone"*, and without any query, we +obtain *"what are the permissions that this user explicitly has on this resource"*. + + +Finding User Permissions +---------------------------- + +One of the trickiest (and often annoying) situation when we want to figure out which :term:`Service` a :term:`User` has +any :term:`Permission` on, is where to actually start looking? Effectively, if we have a vast amount of registered +:term:`Service` each with a immense hierarchy of :term:`Resource`, doing an exhaustive search can be quite daunting, +not to mention costly in terms of request lookup and resources. + +For this purpose, there is one query parameter named ``cascade`` that can be employed with request +``GET /users/{user_name}/services``. In normal condition (without the parameter), this request responds with every +:term:`Service` where the user has :term:`Immediate Permissions` on (doesn't lookup the whole tree hierarchy). With the +added query parameter, it tells `Magpie` to recursively search the hierarchy of `Applied Permissions`_ and return all +:term:`Service` instances that possess *any* :term:`Permission` given to at least one child :term:`Resource` at *any* +level. Furthermore, the ``cascade`` query can be combined with ``inherited`` query to search for all combinations of +:term:`Inherited Permissions` instead of (by default) only for the :term:`User`'s :term:`Direct Permissions`. + +This query can be extremely useful to quickly answer *"does the user have any permission at all on this service"*, +without needing to manually execute multiple successive lookup requests with all combinations of :term:`Resource` +identifiers in the hierarchy. + diff --git a/docs/references.rst b/docs/references.rst new file mode 100644 index 000000000..485e27150 --- /dev/null +++ b/docs/references.rst @@ -0,0 +1,18 @@ +.. Listing of all useful references for the documentation +.. Don't place any 'visible/rendered' documentation here (only links), or it will appear everywhere it is included + +.. _Alembic: https://alembic.sqlalchemy.org/ +.. _Authomatic: https://authomatic.github.io/authomatic/ +.. _Gunicorn: https://gunicorn.org/ +.. _issue: https://github.com/Ouranosinc/Magpie/issues/new +.. _Magpie Docker Images: https://hub.docker.com/r/pavics/magpie/tags +.. _Magpie REST API: https://pavics-magpie.readthedocs.io/en/latest/api.html +.. _Ouranosinc/requests-magpie: https://github.com/Ouranosinc/requests-magpie +.. _Phoenix: https://github.com/bird-house/pyramid-phoenix +.. _PostgreSQL: https://www.postgresql.org/ +.. _Pyramid: https://docs.pylonsproject.org/projects/pyramid/ +.. _ReadTheDocs: https://pavics-magpie.readthedocs.io/ +.. _SQLAlchemy: https://www.sqlalchemy.org/ +.. _Twitcher: https://github.com/bird-house/twitcher +.. _Waitress: https://github.com/Pylons/waitress +.. _Ziggurat-Foundations: https://github.com/ergo/ziggurat_foundations diff --git a/docs/references_page.rst b/docs/references_page.rst new file mode 100644 index 000000000..6b29cd63c --- /dev/null +++ b/docs/references_page.rst @@ -0,0 +1,19 @@ +.. _references_page: +.. include:: references.rst + +==================== +References and Links +==================== + +- `Authomatic`_ +- `Gunicorn`_ +- `Magpie Docker Images`_ +- `Magpie REST API`_ +- `Ouranosinc/requests-magpie`_ +- `PostgreSQL`_ +- `Pyramid`_ +- `readthedocs`_ +- `SQLAlchemy`_ +- `Twitcher`_ +- `Waitress`_ +- `Ziggurat-Foundations`_ diff --git a/docs/security.rst b/docs/security.rst index 2f6043391..deb701308 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -1 +1,7 @@ .. include:: ../SECURITY.rst + +.. note:: + This is the generic security policy for code quality. For specific `Magpie` "security" details, refer instead to + `permissions`_. + +.. _permissions: permissions.rst diff --git a/docs/services.rst b/docs/services.rst new file mode 100644 index 000000000..b02480791 --- /dev/null +++ b/docs/services.rst @@ -0,0 +1,53 @@ +.. _services: +.. include:: references.rst + +=========== +Services +=========== + +This section describes :term:`Service` and underlying :term:`Resource` elements packaged with `Magpie`, as well as +their respective functionalities and intended procedural behavior. + +Basic Service and Resource details +------------------------------------ + +Each :term:`Service` is a specialization of a :term:`Resource` (see: :class:`magpie.models.Service` implementation). +Therefore, they can be listed and searched for either using ``/services`` API routes (using ``service_name``) or using +``/resources`` API routes (with their attributed ``resource_id``), whichever is more convenient for the required needs. + +On top of any :term:`Resource`'s metadata, a :term:`Service` provide specific information about its location, its +remote synchronization method (if any), any its exposed endpoint. Another important detail about the :term:`Service` +is its type. This will not only dictate its purpose, but also define the whole schema of allowed :term:`Resource` under +it (if any), as well as every one of their :ref:`Allowed Permissions`. + +The final distinction between a :term:`Service` and generic :term:`Resource` is their position in the hierarchy. Only +:term:`Service`-specialized :term:`Resource` (literally ``resource_type = "service"``) are allowed to be placed at the +top of the tree hierarchy. All generic :term:`Resource` nodes must be nested under some root :term:`Service`. Relative +references are indicated using ``parent_id`` and ``root_service_id`` in corresponding :term:`Resource` details. + + +Available Services +------------------- + +.. todo: + listing and explanation/features of every available service types, their 'effective permission' resolution, etc. + https://github.com/Ouranosinc/Magpie/issues/332 + +.. seealso:: + - :py:mod:`magpie.services` + +.. todo: + dynamic custom service definition + https://github.com/Ouranosinc/Magpie/issues/149 +.. todo: even if not implementing above, could be good to document fields or ServiceInterface for future reference + +Service Synchronization +------------------------ + +.. todo: + resource auto-sync feature for a given service and cron configuration setup for it + +.. seealso:: + + Utility ``magpie_sync_resources`` in `Magpie CLI Helpers`_ is also available to manually launch a :term:`Resource` + synchronization operation for supporting :term:`Service`-types. diff --git a/docs/toc.rst b/docs/toc.rst new file mode 100644 index 000000000..20fb21ffd --- /dev/null +++ b/docs/toc.rst @@ -0,0 +1,23 @@ +---------------------- +Table of Contents +---------------------- + +.. toctree:: + :maxdepth: 4 + + Contents + usage + installation + configuration + permissions + services + utilities + performance + OpenAPI Specification + Source Code Documentation + contributing + authors + changes + security + glossary + references_page diff --git a/docs/usage.rst b/docs/usage.rst index 3ba6af5c0..0bec86470 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,4 +1,5 @@ -.. usage_link: +.. _usage: +.. include:: references.rst ======== Usage @@ -7,21 +8,69 @@ Usage Package ~~~~~~~ -To use Magpie in a project, fist you need to install it. To do so, you can do a basic ``pip install``. -For more details or other installation variants and preparation, see `installation`_ and +To use `Magpie` in a project, first you need to install it. To do so, you can do a basic ``pip install``. +For more details or other installation variants and environment preparation, see `installation`_ and `configuration`_ procedures. -Then simply import the Python package:: +After this, you should be able to import the Python package to validate it is installed properly using:: import magpie +Web Application +~~~~~~~~~~~~~~~~~~~~~ + +In most situation, you will want to run `Magpie` as a Web Application in combination with some Web Proxy +(e.g.: `Twitcher`_) that can interrogate `Magpie` about applicable user authentication and permission authorization +from the HTTP request session. To start the application, you can simply run the following command:: + + make start + +This will first install any missing dependencies in the current environment (see `installation`_), and will after start +a basic Web Application on ``localhost:2001`` with default configurations. Please note that you **MUST** have a +`PostgreSQL`_ database connection configured prior to running `Magpie` for it to operate (refer to `Configuration`_ +for details). + +For running the application, multiple +`WSGI HTTP Servers` can be employed (e.g.: `Gunicorn`_, `Waitress`_, etc.). They usually all support as input an INI +configuration file for specific settings. `Magpie` also employs such INI file to customize its behaviour. +See `Configuration`_ for further details, and please refer to the employed `WSGI` application documentation of your +liking for their respective setup requirements. + + API ~~~~~~~ -When the application is started, the Swagger API should be available under ``/api`` path. Please refer to this -documentation to discover all provided API paths and operations supported by `Magpie`. The API allows an administrator -with sufficient access rights to modify services, resources, users and groups references via HTTP requests. +When the application is started, the Swagger API should be available under ``/api`` path. This will render the *current* +version API and applicable requests. Please refer to this documentation to discover all provided API paths and +operations supported by `Magpie` on a *running* instance (that could be older than latest code base). Alternatively, +documentation of *all* versions is available on `ReadTheDocs`_. + +The API allows an administrator-level user to modify services, resources, users and groups references via HTTP requests. +To do these kind of operations, sufficient access rights must be provided to the corresponding user (either directly or +through administrative-level group membership). + +Some API routes are accessible by *any*-level user access. These are designated by :term:`Logged User` in the +documentation. When accessing such API paths, the applicable user for which the request is accomplished uses contextual +information from authentication headers and/or cookies of the request. When no user is authenticated, a minimal subset +of paths will provide some publicly available details, such as current session user. Other routes will be more verbose +according to the applicable user permission (or is inherited group memberships). + +.. versionchanged:: 2.0.0 + + As of this version, some API paths will offer additional :term:`Logged User` operations such as self-registration + to publicly available groups. See the appropriate API version documentation for routes that could be added or + adjusted with this new functionality. Note also that a valid user account will still be required to access these + routes. + +Please refer to :ref:`permissions` for further details about applicable user access levels. + +CLI +~~~~~~~ + +After successful `installation`_ of `Magpie` package, multiple `helper utilities `_ become available +as CLI applications callable from the shell. These can be quite useful to run typical `Magpie` operations targeting +a local or remote instance. Please refer to the relevant page for further details. GUI ~~~~~~~ @@ -31,12 +80,20 @@ allows quicker editing of elements accessible through the API by providing commo fields or adjusting specific user-resource permissions. To have access to this interface, the user must have administrator permissions. +.. versionchanged:: 2.0.0 + + User-scoped views such as logged-user account details are now accessible to non-administrator level users. + These offer some basic functionalities such as registration to publicly visible groups. Users minimally require + to be logged-in (successful :term:`Authentication`) in order to access these pages. The UI pages are accessible + using the ``Account`` button from the main entrypoint of the `Magpie` UI. + Additional Utilities ~~~~~~~~~~~~~~~~~~~~ -Multiple `utilities`_ are provided either directly within `Magpie` or through external resources. +Multiple `utilities`_ are provided either directly within `Magpie` as a package or through external resources. Please refer to this section for more details. .. _configuration: configuration.rst .. _installation: installation.rst +.. _permissions: permissions.rst .. _utilities: utilities.rst diff --git a/docs/utilities.rst b/docs/utilities.rst index 9d6ee296c..56a1dc310 100644 --- a/docs/utilities.rst +++ b/docs/utilities.rst @@ -1,7 +1,10 @@ +.. _utilities: +.. include:: references.rst + Utilities ============ -.. utilities_helpers: +.. _utilities_helpers: Magpie CLI Helpers --------------------- @@ -13,18 +16,36 @@ configuration file or creating basic user accounts. Please refer to their corres Available helpers: -- ``magpie_create_users`` -- ``magpie_register_default_users`` -- ``magpie_register_providers`` -- ``magpie_run_database_migration`` -- ``magpie_sync_resources`` +.. list-table:: + :header-rows: 1 + + * - Command + - Description + * - ``magpie_batch_update_users`` + - Register or unregister users using entries provided by batch file or arguments. + * - ``magpie_register_defaults`` + - | Register default users and groups for `Magpie` internal operation. + | See `Configuration`_ for details on applicable parameters definitions. + * - ``magpie_register_providers`` + - | Register service providers from a configuration file. + | This is the same command executed at `Magpie` startup using files defined through configuration settings. + * - ``magpie_run_database_migration`` + - | Run any required database migration operation, according to detected database state and required one by + the current `Magpie` version. + | This operation is the same command that is executed at `Magpie` startup to ensure data integrity. + * - ``magpie_sync_resources`` + - | Synchronizes local and remote resources based on `Magpie` service's ``sync-type`` methodology. + | See also `magpie-con`_. + +.. _configuration: configuration.rst +.. _magpie-con: https://github.com/Ouranosinc/Magpie/tree/master/magpie-cron For convenience, a generic CLI ``magpie_helper`` is also provided which allows calling each of the other helper operations as *mode*. You can therefore do as follows. .. code-block:: console - # list of available 'helper' + # list of available 'helper' commands magpie_helper --help # arguments of the given helper magpie_helper [helper] --help @@ -34,19 +55,19 @@ For example, the two statements below are equivalent. .. code-block:: console - magpie_helper create_users [...] + magpie_helper register_providers [...] # OR - magpie_create_users [...] + magpie_register_providers [...] When using an ``conda`` environment, you should be able to directly call the ``magpie_helper`` CLI as above if you previously installed the package (see `installation`_). -Source code of these helpers can be found `here `_. +Source code of these helpers can be found `here `_. .. _installation: installation.rst -.. utilities_connection: +.. _utilities_connection: Magpie Connection --------------------- @@ -55,7 +76,7 @@ The repository `Ouranosinc/requests-magpie`_ offers basic Authentication connect similarly to how traditional ``HTTPBasicAuth`` works under the hood. Using this, you can easily plug-and-play `Magpie` in order to use it with the standard ``requests`` library by passing ``auth=MagpieAuth(<...>)``. -.. _Ouranosinc/requests-magpie: https://github.com/Ouranosinc/requests-magpie +.. _utilities_adapter: Magpie Adapter: Integration with Twitcher ---------------------------------------------- @@ -76,4 +97,3 @@ confirm that both instances were adequately configured as both require to share .. _docker-compose: https://docs.docker.com/compose/ .. _source_magpie_adapter: https://github.com/Ouranosinc/Magpie/blob/master/magpie/adapter/__init__.py#L107 -.. _Twitcher: https://github.com/bird-house/twitcher diff --git a/env/magpie.env.example b/env/magpie.env.example index 012679d97..ee1a7139b 100644 --- a/env/magpie.env.example +++ b/env/magpie.env.example @@ -18,13 +18,13 @@ MAGPIE_CONFIG_DIR=../config PHOENIX_USER=phoenix PHOENIX_PASSWORD=qwerty PHOENIX_PORT=8443 -PHOENIX_PUSH=true +PHOENIX_PUSH=false TWITCHER_PROTECTED_PATH=/ows/proxy # Magpie Tests MAGPIE_TEST_ADMIN_USERNAME=admin MAGPIE_TEST_ADMIN_PASSWORD=qwerty -MAGPIE_TEST_REMOTE_SERVER_URL=localhost:2001 +MAGPIE_TEST_REMOTE_SERVER_URL=http://localhost:2001 MAGPIE_TEST_LOCAL=true MAGPIE_TEST_REMOTE=true MAGPIE_TEST_API=true diff --git a/magpie-cron b/magpie-cron index 914f3f2a1..e769dba72 100644 --- a/magpie-cron +++ b/magpie-cron @@ -1 +1 @@ -0 * * * * /bin/bash -c "set -a ; source <($MAGPIE_ENV_DIR/*.env) ; set +a ; python -c 'from magpie.helpers.sync_resources import main; main()'" +0 * * * * /bin/bash -c "set -a ; source <($MAGPIE_ENV_DIR/*.env) ; set +a ; magpie_sync_resources" diff --git a/magpie/__init__.py b/magpie/__init__.py index 9327c145f..2c7ef57a5 100644 --- a/magpie/__init__.py +++ b/magpie/__init__.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + import sys # NOTE: @@ -9,33 +11,33 @@ def includeme(config): # import needs to be here, otherwise ImportError happens during setup.py install (modules not yet installed) + # pylint: disable=C0415 from pyramid.events import NewRequest from pyramid.tweens import EXCVIEW - from magpie.api.generic import ( # pylint: disable=C0415 - internal_server_error, - not_found_or_method_not_allowed, - unauthorized_or_forbidden, - ) - from magpie.constants import get_constant # pylint: disable=C0415 - from magpie.utils import get_logger # pylint: disable=C0415 + + from magpie.api import generic as ag + from magpie.constants import get_constant + from magpie.utils import fully_qualified_name, get_logger, log_exception_tween, log_request mod_dir = get_constant("MAGPIE_MODULE_DIR", config) logger = get_logger(__name__) logger.info("Adding MAGPIE_MODULE_DIR='%s' to path.", mod_dir) sys.path.insert(0, mod_dir) - config.add_exception_view(internal_server_error) - config.add_forbidden_view(unauthorized_or_forbidden) - config.add_notfound_view(not_found_or_method_not_allowed) - + config.add_exception_view(ag.internal_server_error) + config.add_forbidden_view(ag.unauthorized_or_forbidden) + config.add_notfound_view(RemoveSlashNotFoundViewFactory(ag.not_found_or_method_not_allowed), append_slash=True) config.set_default_permission(get_constant("MAGPIE_ADMIN_PERMISSION", config)) - tween_position = EXCVIEW + + tween_position = fully_qualified_name(ag.apply_response_format_tween) + config.add_tween(tween_position, over=EXCVIEW) if get_constant("MAGPIE_LOG_REQUEST", config): - config.add_subscriber("magpie.utils.log_request", NewRequest) + config.add_subscriber(log_request, NewRequest) if get_constant("MAGPIE_LOG_EXCEPTION", config): - config.add_tween("magpie.utils.log_exception_tween", under=tween_position) - tween_position = "magpie.utils.log_exception_tween" - config.add_tween("magpie.api.generic.validate_accept_header_tween", under=tween_position) + tween_name = fully_qualified_name(log_exception_tween) + config.add_tween(tween_name, under=tween_position) + tween_position = tween_name + config.add_tween(fully_qualified_name(ag.validate_accept_header_tween), under=tween_position) config.include("cornice") config.include("cornice_swagger") @@ -48,3 +50,28 @@ def includeme(config): config.include("magpie.ui") else: logger.warning("Magpie UI not enabled.") + + +class RemoveSlashNotFoundViewFactory(object): + """ + Utility that will try to resolve a path without appended slash if one was provided. + """ + def __init__(self, notfound_view=None): + self.notfound_view = notfound_view + + def __call__(self, request): + from pyramid.httpexceptions import HTTPMovedPermanently + from pyramid.interfaces import IRoutesMapper + path = request.path + registry = request.registry + mapper = registry.queryUtility(IRoutesMapper) + if mapper is not None and path.endswith("/"): + no_slash_path = path.rstrip("/") + no_slash_path = no_slash_path.split("/magpie", 1)[-1] + for route in mapper.get_routes(): + if route.match(no_slash_path) is not None: + query = request.query_string + if query: + no_slash_path += "?" + query + return HTTPMovedPermanently(location=no_slash_path) + return self.notfound_view(request) diff --git a/magpie/__meta__.py b/magpie/__meta__.py index 7ce7c5f05..21420887a 100644 --- a/magpie/__meta__.py +++ b/magpie/__meta__.py @@ -2,7 +2,7 @@ General meta information on the magpie package. """ -__version__ = "1.11.0" +__version__ = "2.0.0" __title__ = "Magpie" __package__ = "magpie" # pylint: disable=W0622 __author__ = "Francois-Xavier Derue, Francis Charette-Migneault" diff --git a/magpie/adapter/__init__.py b/magpie/adapter/__init__.py index 7de30d670..963c50ab3 100644 --- a/magpie/adapter/__init__.py +++ b/magpie/adapter/__init__.py @@ -17,19 +17,23 @@ from magpie.db import get_engine, get_session_factory, get_tm_session from magpie.security import get_auth_config from magpie.utils import CONTENT_TYPE_JSON, SingletonMeta, get_logger, get_magpie_url, get_settings + # twitcher available only when this module is imported from it -from twitcher.adapter.base import AdapterInterface # noqa -from twitcher.owsproxy import owsproxy_defaultconfig # noqa +from twitcher.adapter.base import AdapterInterface # noqa +from twitcher.owsproxy import owsproxy_defaultconfig # noqa if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.models import User # noqa: F401 - from magpie.typedefs import AnySettingsContainer, Str, JSON # noqa: F401 + from typing import Optional + + from pyramid.config import Configurator + from pyramid.httpexceptions import HTTPException + from pyramid.request import Request + + from magpie.models import User + from magpie.typedefs import JSON, AnySettingsContainer, Str + from twitcher.store import AccessTokenStoreInterface # noqa - from pyramid.config import Configurator # noqa: F401 - from pyramid.httpexceptions import HTTPException # noqa: F401 - from pyramid.request import Request # noqa: F401 - from typing import Optional # noqa: F401 LOGGER = get_logger("TWITCHER") @@ -112,15 +116,16 @@ def verify_user(request): headers={"Content-Type": CONTENT_TYPE_JSON, "Accept": CONTENT_TYPE_JSON}) if resp.status_code != HTTPOk.code: content = {"response": resp.json()} - return raise_http(HTTPForbidden, detail="Failed Magpie login.", content=content, nothrow=True) - authn_policy = request.registry.queryUtility(IAuthenticationPolicy) + return raise_http(HTTPForbidden, detail="Failed Magpie login.", content=content, nothrow=True) # noqa + authn_policy = request.registry.queryUtility(IAuthenticationPolicy) # noqa result = authn_policy.cookie.identify(request) if result is None: - return raise_http(HTTPForbidden, detail="Twitcher login incompatible with Magpie login.", nothrow=True) + return raise_http(HTTPForbidden, detail="Twitcher login incompatible with Magpie login.", nothrow=True) # noqa return valid_http(HTTPOk, detail="Twitcher login verified successfully with Magpie login.") -class MagpieAdapter(six.with_metaclass(SingletonMeta, AdapterInterface)): +@six.add_metaclass(SingletonMeta) +class MagpieAdapter(AdapterInterface): # pylint: disable: W0223,W0612 def __init__(self, container): @@ -160,7 +165,7 @@ def owsproxy_config(self, container): config = self.configurator_factory(container) owsproxy_defaultconfig(config) # let Twitcher configure the rest normally - def configurator_factory(self, container): # noqa: N805 + def configurator_factory(self, container): # noqa: N805, R0201 # type: (AnySettingsContainer) -> Configurator settings = get_settings(container) set_cache_regions_from_settings(settings) diff --git a/magpie/adapter/magpieowssecurity.py b/magpie/adapter/magpieowssecurity.py index 53aff0db4..bcebbc4e8 100644 --- a/magpie/adapter/magpieowssecurity.py +++ b/magpie/adapter/magpieowssecurity.py @@ -13,10 +13,11 @@ from magpie.permissions import Permission from magpie.services import service_factory from magpie.utils import CONTENT_TYPE_JSON, get_logger, get_magpie_url, get_settings + # twitcher available only when this module is imported from it -from twitcher.owsexceptions import OWSAccessForbidden # noqa -from twitcher.owssecurity import OWSSecurityInterface # noqa -from twitcher.utils import parse_service_name # noqa +from twitcher.owsexceptions import OWSAccessForbidden # noqa +from twitcher.owssecurity import OWSSecurityInterface # noqa +from twitcher.utils import parse_service_name # noqa LOGGER = get_logger("TWITCHER") @@ -36,7 +37,7 @@ def check_request(self, request): service = evaluate_call(lambda: Service.by_service_name(service_name, db_session=request.db), fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, msg_on_fail="Service query by name refused by db.") - verify_param(service, not_none=True, http_error=HTTPNotFound, msg_on_fail="Service name not found in db.") + verify_param(service, not_none=True, http_error=HTTPNotFound, msg_on_fail="Service name not found.") # return a specific type of service, ex: ServiceWPS with all the acl (loaded according to the service_type) service_specific = service_factory(service, request) diff --git a/magpie/adapter/magpieservice.py b/magpie/adapter/magpieservice.py index 0c2e1ec10..021d502fb 100644 --- a/magpie/adapter/magpieservice.py +++ b/magpie/adapter/magpieservice.py @@ -10,13 +10,15 @@ from magpie.api.schemas import ServicesAPI from magpie.models import Service as MagpieService from magpie.utils import CONTENT_TYPE_JSON, get_admin_cookies, get_logger, get_magpie_url, get_settings + # twitcher available only when this module is imported from it -from twitcher.datatype import Service # noqa -from twitcher.exceptions import ServiceNotFound # noqa -from twitcher.store import ServiceStoreInterface # noqa +from twitcher.datatype import Service # noqa +from twitcher.exceptions import ServiceNotFound # noqa +from twitcher.store import ServiceStoreInterface # noqa if TYPE_CHECKING: - from pyramid.request import Request # noqa: F401 + from pyramid.request import Request + LOGGER = get_logger("TWITCHER") diff --git a/magpie/alembic/README b/magpie/alembic/README deleted file mode 100644 index 98e4f9c44..000000000 --- a/magpie/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/magpie/alembic/README.md b/magpie/alembic/README.md new file mode 100644 index 000000000..2aa529d5f --- /dev/null +++ b/magpie/alembic/README.md @@ -0,0 +1,5 @@ +# Generic single-database configuration. + +**IMPORTANT** + +There must not be any `__init__.py` in this directory, otherwise alembic imports could fail using the wrong location. diff --git a/magpie/alembic/env.py b/magpie/alembic/env.py index 385670620..bc0cbcdb3 100644 --- a/magpie/alembic/env.py +++ b/magpie/alembic/env.py @@ -18,8 +18,8 @@ # verify if a connection is already provided config_connection = None -if 'connection' in config.attributes and isinstance(config.attributes['connection'], Connection): - config_connection = context.config.attributes['connection'] +if "connection" in config.attributes and isinstance(config.attributes["connection"], Connection): + config_connection = context.config.attributes["connection"] # add your model's MetaData object here target_metadata = MetaData(naming_convention={ @@ -57,8 +57,10 @@ def run_migrations_online(connection=None): In this scenario we need to create an Engine and associate a connection with the context. """ - - url = get_db_url() + if not config_connection: + url = get_db_url() + else: + url = config_connection.engine.url def connect(c=None): if isinstance(c, Connection) and not c.closed: @@ -68,9 +70,9 @@ def connect(c=None): return c.connect() if not database_exists(url): - db_name = get_constant('MAGPIE_POSTGRES_DB') - LOGGER.warning('Database [{}] not found, attempting creation...'.format(db_name)) - connection = create_database(url, encoding='utf8', template='template0') + db_name = get_constant("MAGPIE_POSTGRES_DB") + LOGGER.warning("Database [{}] not found, attempting creation...".format(db_name)) + connection = create_database(url, encoding="utf8", template="template0") # retry connection and run migration with connect(connection) as migrate_conn: @@ -78,7 +80,7 @@ def connect(c=None): context.configure( connection=migrate_conn, target_metadata=target_metadata, - version_table='alembic_version', + version_table="alembic_version", transaction_per_migration=True, render_as_batch=True ) diff --git a/magpie/alembic/utils.py b/magpie/alembic/utils.py deleted file mode 100644 index 96d67937a..000000000 --- a/magpie/alembic/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -from sqlalchemy.engine.reflection import Inspector - - -def has_column(context, table_name, column_name): - inspector = Inspector.from_engine(context.connection.engine) - for column in inspector.get_columns(table_name=table_name): - if column_name in column['name']: - return True - return False diff --git a/magpie/alembic/versions/03b54feffe45_ensure_anonymous_group_applied_to_users.py b/magpie/alembic/versions/03b54feffe45_ensure_anonymous_group_applied_to_users.py index dd16ad557..ac9710f59 100644 --- a/magpie/alembic/versions/03b54feffe45_ensure_anonymous_group_applied_to_users.py +++ b/magpie/alembic/versions/03b54feffe45_ensure_anonymous_group_applied_to_users.py @@ -5,18 +5,43 @@ Revises: 73b872478d87 Create Date: 2019-08-23 18:08:07.507556 """ +import os +import sys + +import sqlalchemy as sa from alembic import op from alembic.context import get_context # noqa: F401 from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.orm import sessionmaker -from ziggurat_foundations.models.services import BaseService -from ziggurat_foundations.models.services.group import GroupService -from magpie import constants, models -from magpie.helpers.register_default_users import init_anonymous +cur_file = os.path.abspath(__file__) +root_dir = os.path.dirname(cur_file) # version +root_dir = os.path.dirname(root_dir) # alembic +root_dir = os.path.dirname(root_dir) # magpie +root_dir = os.path.dirname(root_dir) # root +sys.path.insert(0, root_dir) + +from magpie.constants import get_constant # isort:skip # noqa: E402 Session = sessionmaker() +users = sa.table( + "users", + sa.column("id", sa.Integer), + sa.column("user_name", sa.String), +) +groups = sa.table( + "groups", + sa.column("id", sa.Integer), + sa.column("group_name", sa.String), + sa.column("member_count", sa.Integer) +) +users_groups = sa.table( + "users_groups", + sa.column("user_id", sa.Integer), + sa.column("group_id", sa.Integer), +) + # revision identifiers, used by Alembic. revision = "03b54feffe45" @@ -35,17 +60,23 @@ def upgrade(): context.connection.engine.dialect.supports_sane_multi_rowcount = False if isinstance(context.connection.engine.dialect, PGDialect): + all_user_ids = set(session.execute(sa.select([users.c.id]))) + # make sure group exists, then get it - init_anonymous(db_session=session) - anonym_group = GroupService.by_group_name(constants.get_constant("MAGPIE_ANONYMOUS_GROUP"), db_session=session) - - all_users = BaseService.all(models.User, db_session=session) - all_user_group_refs = BaseService.all(models.UserGroup, db_session=session) - all_user_group_tups = [(ugr.user_id, ugr.group_id) for ugr in all_user_group_refs] - for user in all_users: - if (user.id, anonym_group.id) not in all_user_group_tups: - user_group = models.UserGroup(user_id=user.id, group_id=anonym_group.id) # noqa - session.add(user_group) + anonym_name = get_constant("MAGPIE_ANONYMOUS_GROUP") + query = sa.select([groups]).where(groups.c.group_name == anonym_name) + anonym_group = session.execute(query).fetchone() + if not anonym_group: + session.execute(groups.insert().values(group_name=anonym_name, member_count=len(all_user_ids))) + anonym_group = session.execute(query).fetchone() + + query = sa.select([users_groups.c.user_id]).where(users_groups.c.group_id == anonym_group.id) + users_with_anonym = session.execute(query) + users_with_anonym = set(users_with_anonym or []) # handle if None + missing_grp_users = all_user_ids - users_with_anonym + missing_grp_users = [{"user_id": usr_id, "group_id": anonym_group.id} for usr_id in missing_grp_users] + if missing_grp_users: + session.execute(users_groups.insert(), missing_grp_users) session.commit() diff --git a/magpie/alembic/versions/2a6c63397399_separate_personal_standard_groups.py b/magpie/alembic/versions/2a6c63397399_separate_personal_standard_groups.py index 3e0f53b36..ed78e2583 100644 --- a/magpie/alembic/versions/2a6c63397399_separate_personal_standard_groups.py +++ b/magpie/alembic/versions/2a6c63397399_separate_personal_standard_groups.py @@ -5,27 +5,13 @@ Revises: 9fd4589cc82c Create Date: 2018-05-23 17:17:51.205891 """ -import os -import sys +import sqlalchemy as sa from alembic import op from alembic.context import get_context # noqa: F401 from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.orm.session import sessionmaker -from magpie import models # noqa: F401 - -cur_file = os.path.abspath(__file__) -root_dir = os.path.dirname(cur_file) # version -root_dir = os.path.dirname(root_dir) # alembic -root_dir = os.path.dirname(root_dir) # magpie -root_dir = os.path.dirname(root_dir) # root -sys.path.insert(0, root_dir) - -from magpie.helpers.register_default_users import ( # isort:skip # noqa: E402 - init_anonymous, init_admin, init_users_group -) - Session = sessionmaker() # revision identifiers, used by Alembic. @@ -42,13 +28,30 @@ OLD_USER_USERS = OLD_GROUP_USERS OLD_USER_ADMIN = OLD_GROUP_ADMIN +users = sa.table( + "users", + sa.column("id", sa.Integer), + sa.column("user_name", sa.String), +) +groups = sa.table( + "groups", + sa.column("id", sa.Integer), + sa.column("group_name", sa.String), + sa.column("member_count", sa.Integer) +) +users_groups = sa.table( + "users_groups", + sa.column("user_id", sa.Integer), + sa.column("group_id", sa.Integer), +) + def get_users_groups(db_session): """ Fetch current db users and groups. """ - all_users = db_session.query(models.User) - all_groups = db_session.query(models.Group) + all_users = db_session.execute(sa.select([users])) + all_groups = db_session.execute(sa.select([groups])) old_user_admin = [user for user in all_users if user.user_name == OLD_USER_ADMIN] old_user_users = [user for user in all_users if user.user_name == OLD_USER_USERS] old_group_admin = [group for group in all_groups if group.group_name == OLD_GROUP_ADMIN] @@ -77,11 +80,10 @@ def upgrade_migrate(old_group, old_user, new_group, new_name, db_session): old_group.group_name = new_name elif new_group is None and old_group is None: # create missing group, no group reference to modify - new_group = models.Group(group_name=new_name) # noqa - db_session.add(new_group) + db_session.execute(groups.insert().values(group_name=new_name, member_count=0)) elif new_group is not None and old_group is not None: # both groups exist, must transfer references - all_usr_grp = db_session.query(models.UserGroup) + all_usr_grp = db_session.execute(sa.select([users_groups])) for usr_grp in all_usr_grp: if usr_grp.group_id == old_group.id: # if user_id/group_id combination already exists, delete duplicate that would be generated by transfer @@ -94,7 +96,7 @@ def upgrade_migrate(old_group, old_user, new_group, new_name, db_session): # remove not required 'user-group' if old_user is not None: - for usr_grp in db_session.query(models.UserGroup): + for usr_grp in db_session.execute(sa.select([users_groups])): if usr_grp.user_id == old_user.id: db_session.delete(usr_grp) db_session.delete(old_user) @@ -109,11 +111,11 @@ def downgrade_migrate(old_group, old_user, new_group, old_name, db_session): if old_group is None: # create missing group - old_group = models.Group(group_name=old_name) # noqa + old_group = Group(group_name=old_name) # noqa db_session.add(old_group) if old_group is not None and new_group is not None: # transfer user-group references - all_usr_grp = db_session.query(models.UserGroup) + all_usr_grp = db_session.execute(sa.select(users_groups)) for usr_grp in all_usr_grp: if usr_grp.group_id == new_group.id: # if user_id/group_id combination already exists, delete duplicate that would be generated by transfer @@ -122,18 +124,18 @@ def downgrade_migrate(old_group, old_user, new_group, old_name, db_session): db_session.delete(usr_grp) # otherwise transfer back reference to old group else: - usr_grp.group_id = old_group.id + db_session.execute(users_groups.update() + .where(usr_grp.c.group_id == new_group.c.id) + .values(group_id=old_group.id)) if new_group is not None: db_session.delete(new_group) if old_user is None: email = "{}@mail.com".format(old_name) - old_user = models.User(user_name=old_name, email=email) # noqa - db_session.add(old_user) - old_user = models.User.by_user_name(old_name, db_session) - usr_grp = models.UserGroup(group_id=old_group.id, user_id=old_user.id) # noqa - db_session.add(usr_grp) + db_session.execute(users.insert().values(user_name=old_name, email=email)) + old_user = db_session.execute(sa.select(users).where(users.c.user_name == old_name)).fetchone() + db_session.execute(users_groups.insert().values(group_id=old_group.id, user_id=old_user.id)) def clean_user_groups(db_session): @@ -143,9 +145,9 @@ def clean_user_groups(db_session): Invalid user references are dropped. """ - all_users = db_session.query(models.User) - all_groups = db_session.query(models.Group) - all_usr_grp = db_session.query(models.UserGroup) + all_users = db_session.execute(sa.select([users])) + all_groups = db_session.execute(sa.select([groups])) + all_usr_grp = db_session.execute(sa.select([users_groups])) all_usr_dict = dict([(usr.id, usr.user_name) for usr in all_users]) all_grp_dict = dict([(grp.id, grp.group_name) for grp in all_groups]) @@ -171,9 +173,6 @@ def upgrade(): upgrade_migrate(old_grp_a, old_usr_a, new_grp_a, NEW_GROUP_ADMIN, session) upgrade_migrate(old_grp_u, old_usr_u, new_grp_u, NEW_GROUP_USERS, session) clean_user_groups(session) - init_admin(session) - init_anonymous(session) - init_users_group(session) session.commit() diff --git a/magpie/alembic/versions/5e7b5346c330_remove_obsolete_personal_groups.py b/magpie/alembic/versions/5e7b5346c330_remove_obsolete_personal_groups.py index 2c69d5bd3..1674402f4 100644 --- a/magpie/alembic/versions/5e7b5346c330_remove_obsolete_personal_groups.py +++ b/magpie/alembic/versions/5e7b5346c330_remove_obsolete_personal_groups.py @@ -9,12 +9,11 @@ import os import sys +import sqlalchemy as sa from alembic import op from alembic.context import get_context # noqa: F401 from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.orm import sessionmaker -from ziggurat_foundations.models.services import BaseService -from ziggurat_foundations.models.services.user import UserService cur_file = os.path.abspath(__file__) root_dir = os.path.dirname(cur_file) # version @@ -23,7 +22,7 @@ root_dir = os.path.dirname(root_dir) # root sys.path.insert(0, root_dir) -from magpie import models, constants # isort:skip # noqa: E402 +from magpie.constants import get_constant # isort:skip # noqa: E402 Session = sessionmaker() @@ -33,17 +32,48 @@ branch_labels = None depends_on = None +users = sa.table( + "users", + sa.column("user_name", sa.String), + sa.column("id", sa.String), +) +groups = sa.table( + "groups", + sa.column("group_name", sa.String), +) +users_groups = sa.table( + "users_groups", + sa.column("group_id", sa.Integer), + sa.column("user_id", sa.Integer), +) +groups_resources_permissions = sa.table( + "groups_resources_permissions", + sa.column("group_id", sa.Integer), + sa.column("resource_id", sa.Integer), + sa.column("perm_name", sa.String), +) +users_resources_permissions = sa.table( + "users_resources_permissions", + sa.column("user_id", sa.Integer), + sa.column("resource_id", sa.Integer), + sa.column("perm_name", sa.String), +) + def upgrade(): context = get_context() session = Session(bind=op.get_bind()) if isinstance(context.connection.engine.dialect, PGDialect): - all_users = BaseService.all(models.User, db_session=session) - all_groups = BaseService.all(models.Group, db_session=session) - all_user_group_refs = BaseService.all(models.UserGroup, db_session=session) - all_grp_res_perms = BaseService.all(models.GroupResourcePermission, db_session=session) - - ignore_groups = {constants.MAGPIE_ADMIN_GROUP, constants.MAGPIE_USERS_GROUP, constants.MAGPIE_ANONYMOUS_GROUP} + all_users = session.execute(sa.select([users])) + all_groups = session.execute(sa.select([groups])) + all_user_group_refs = session.execute(sa.select([users_groups])) + all_grp_res_perms = session.execute(sa.select([groups_resources_permissions])) + + ignore_groups = { + get_constant("MAGPIE_ADMIN_GROUP"), + get_constant("MAGPIE_USERS_GROUP"), + get_constant("MAGPIE_ANONYMOUS_GROUP") + } user_names = {usr.user_name for usr in all_users} # parse through 'personal' groups matching an existing user @@ -52,14 +82,15 @@ def upgrade(): if group_name in user_names and group_name not in ignore_groups: # get the real user - user = UserService.by_user_name(user_name=group_name, db_session=session) + query = sa.select([users]).where(users.c.user_name == group_name) + user = session.execute(query).fetchone() # transfer permissions from 'personal' group to user user_group_res_perm = [urp for urp in all_grp_res_perms if urp.group_id == group.id] for group_perm in user_group_res_perm: - user_perm = models.UserResourcePermission(resource_id=group_perm.resource_id, - user_id=user.id, perm_name=group_perm.perm_name) # noqa - session.add(user_perm) + session.execute(users_resources_permissions.insert().values( + user_id=user.id, resource_id=group_perm.resource_id, perm_name=group_perm.perm_name) + ) session.delete(group_perm) # delete obsolete personal group and corresponding user-group references diff --git a/magpie/alembic/versions/ae1a3c8c7860_transfer_group_users_admins_users.py b/magpie/alembic/versions/ae1a3c8c7860_transfer_group_users_admins_users.py index acfe5311c..1a09c7891 100644 --- a/magpie/alembic/versions/ae1a3c8c7860_transfer_group_users_admins_users.py +++ b/magpie/alembic/versions/ae1a3c8c7860_transfer_group_users_admins_users.py @@ -5,25 +5,13 @@ Revises: 5e7b5346c330 Create Date: 2018-05-30 15:15:33.008614 """ -import os -import sys +import sqlalchemy as sa from alembic import op from alembic.context import get_context # noqa: F401 from sqlalchemy.dialects.postgresql.base import PGDialect from sqlalchemy.orm.session import sessionmaker -from ziggurat_foundations.models.services import BaseService from ziggurat_foundations.models.services.group import GroupService -from ziggurat_foundations.models.services.user import UserService - -cur_file = os.path.abspath(__file__) -root_dir = os.path.dirname(cur_file) # version -root_dir = os.path.dirname(root_dir) # alembic -root_dir = os.path.dirname(root_dir) # magpie -root_dir = os.path.dirname(root_dir) # root -sys.path.insert(0, root_dir) - -from magpie import models # isort:skip # noqa: E402 Session = sessionmaker() @@ -39,6 +27,23 @@ branch_labels = None depends_on = None +users = sa.table( + "users", + sa.column("id", sa.Integer), + sa.column("user_name", sa.String), +) +groups = sa.table( + "groups", + sa.column("id", sa.Integer), + sa.column("group_name", sa.String), + sa.column("member_count", sa.Integer) +) +users_groups = sa.table( + "users_groups", + sa.column("user_id", sa.Integer), + sa.column("group_id", sa.Integer), +) + def upgrade(): context = get_context() @@ -50,20 +55,22 @@ def upgrade(): context.connection.engine.dialect.supports_sane_multi_rowcount = False if isinstance(context.connection.engine.dialect, PGDialect): - all_groups = session.query(models.Group) - all_user_group_refs = BaseService.all(models.UserGroup, db_session=session) + all_groups = session.execute(sa.select([groups])) + all_user_group_refs = session.execute(sa.select([users_groups])) map_groups = {OLD_GROUP_ADMIN: NEW_GROUP_ADMIN, OLD_GROUP_USERS: NEW_GROUP_USERS} for group in all_groups: if group.group_name in map_groups.keys(): new_group_name = map_groups[group.group_name] - new_group = GroupService.by_group_name(new_group_name, db_session=session) + query = sa.select([groups]).where(groups.c.group_name == new_group_name) + new_group = session.execute(query).fetchone() # create new group if missing if not new_group: - new_group = models.Group(group_name=new_group_name) # noqa - session.add(new_group) - new_group = GroupService.by_group_name(new_group_name, db_session=session) + new_group = Group(group_name=new_group_name) # noqa + session.execute(groups.insert().value(group_name=new_group_name, member_count=0)) + query = sa.select([groups]).where(groups.c.group_name == new_group_name) + new_group = session.execute(query).fetchone() old_group_perms = GroupService.resources_with_possible_perms(group, db_session=session) new_group_perms = GroupService.resources_with_possible_perms(new_group, db_session=session) @@ -81,9 +88,8 @@ def upgrade(): diff_group_users = set(old_group_users) - set(new_group_users) for user_name in diff_group_users: - user = UserService.by_user_name(user_name=user_name, db_session=session) - user_group = models.UserGroup(group_id=new_group.id, user_id=user.id) # noqa - session.add(user_group) + user = session.execute(sa.select([users]).where(users.c.user_name == user_name)).fetchone() + session.execute(users_groups.insert().values(group_id=new_group.id, user_id=user.id)) session.delete(group) for user_group in all_user_group_refs: diff --git a/magpie/alembic/versions/b739afcc91db_add_discoverable_groups.py b/magpie/alembic/versions/b739afcc91db_add_discoverable_groups.py new file mode 100644 index 000000000..936678e52 --- /dev/null +++ b/magpie/alembic/versions/b739afcc91db_add_discoverable_groups.py @@ -0,0 +1,24 @@ +""" +Add discoverable groups. + +Revision ID: b739afcc91db +Revises: 24da162a54f1 +Create Date: 2020-07-23 15:54:22.850077 +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b739afcc91db" +down_revision = "24da162a54f1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("groups", sa.Column("discoverable", sa.Boolean, nullable=False, + server_default=sa.schema.DefaultClause("0"))) + + +def downgrade(): + op.drop_column("groups", "discoverable") diff --git a/magpie/api/exception.py b/magpie/api/exception.py index fcba5b364..e6ba9f029 100644 --- a/magpie/api/exception.py +++ b/magpie/api/exception.py @@ -1,8 +1,11 @@ import json +import re from sys import exc_info from typing import TYPE_CHECKING +import colander import six +from dicttoxml import dicttoxml from pyramid.httpexceptions import ( HTTPBadRequest, HTTPError, @@ -15,33 +18,46 @@ from magpie.utils import ( CONTENT_TYPE_ANY, + CONTENT_TYPE_APP_XML, CONTENT_TYPE_HTML, CONTENT_TYPE_JSON, CONTENT_TYPE_PLAIN, - SUPPORTED_CONTENT_TYPES, + CONTENT_TYPE_TXT_XML, + SUPPORTED_ACCEPT_TYPES, + get_logger, isclass, islambda ) if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Any, Callable, List, Iterable, NoReturn, Optional, Tuple, Type, Union - from magpie.typedefs import Str, JSON, ParamsType, PyramidResponse # noqa: F401 + from typing import Any, Callable, Iterable, List, NoReturn, Optional, Tuple, Type, Union + + from magpie.typedefs import JSON, ParamsType, Str + +LOGGER = get_logger(__name__) # control variables to avoid infinite recursion in case of # major programming error to avoid application hanging RAISE_RECURSIVE_SAFEGUARD_MAX = 5 RAISE_RECURSIVE_SAFEGUARD_COUNT = 0 +# utility parameter validation regexes for 'matches' argument +PARAM_REGEX = r"^[A-Za-z0-9]+(?:[\s_\-\.][A-Za-z0-9]+)*$" # request parameters +EMAIL_REGEX = colander.EMAIL_RE +URL_REGEX = colander.URL_REGEX +INDEX_REGEX = r"^[0-9]+$" + -def verify_param( # noqa: E126 - # --- verification values --- +def verify_param( # noqa: E126 # pylint: disable=R0913,too-many-arguments + # --- verification values --- # noqa: E126 param, # type: Any param_compare=None, # type: Optional[Union[Any, List[Any]]] # --- output options on failure --- param_name=None, # type: Optional[Str] + param_content=None, # type: Optional[JSON] with_param=True, # type: bool - http_error=HTTPBadRequest, # type: HTTPError + http_error=HTTPBadRequest, # type: Type[HTTPError] http_kwargs=None, # type: Optional[ParamsType] msg_on_fail="", # type: Str content=None, # type: Optional[JSON] @@ -58,43 +74,56 @@ def verify_param( # noqa: E126 is_in=False, # type: bool is_equal=False, # type: bool is_type=False, # type: bool - ): # type: (...) -> None + matches=False, # type: bool + ): # type: (...) -> None # noqa: E123,E126 # pylint: disable=R0912,R0914 """ Evaluate various parameter combinations given the requested verification flags. Given a failing verification, - directly raises the specified ``http_error``. Invalid usage exceptions generated by this verification process are - treated as :class:`HTTPInternalServerError`. Exceptions are generated using the standard output method. + directly raises the specified :paramref:`http_error`. Invalid usage exceptions generated by this verification + process are treated as :class:`HTTPInternalServerError`. Exceptions are generated using the standard output method. :param param: parameter value to evaluate :param param_compare: - other value(s) to test `param` against, can be an iterable (single value resolved as iterable unless `None`) - to test for `None` type, use `is_none`/`not_none` flags instead + Other value(s) to test :paramref:`param` against. + Can be an iterable (single value resolved as iterable unless ``None``). + To test for ``None`` type, use :paramref:`is_none`/:paramref:`not_none` flags instead. :param param_name: name of the tested parameter returned in response if specified for debugging purposes - :param http_error: derived exception to raise on test failure (default: `HTTPBadRequest`) - :param http_kwargs: additional keyword arguments to pass to `http_error` if called in case of HTTP exception + :param param_content: + Additional JSON content to apply to generated error content on raise when :paramref:`with_param` is ``True``. + Must be JSON serializable. Provided content can override generated error parameter if matching fields. + :param with_param: + On raise, adds values of :paramref:`param`, :paramref:`param_name` and :paramref:`param_compare`, as well as + additional failing conditions metadata to the JSON response body for each of the corresponding value. + :param http_error: derived exception to raise on test failure (default: :class:`HTTPBadRequest`) + :param http_kwargs: additional keyword arguments to pass to :paramref:`http_error` called in case of HTTP exception :param msg_on_fail: message details to return in HTTP exception if flag condition failed :param content: json formatted additional content to provide in case of exception - :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_CONTENT_TYPES`) - :param not_none: test that `param` is None type - :param not_empty: test that `param` is an empty string - :param not_in: test that `param` does not exist in `param_compare` values - :param not_equal: test that `param` is not equal to `param_compare` value - :param is_true: test that `param` is `True` - :param is_false: test that `param` is `False` - :param is_none: test that `param` is None type - :param is_empty: test `param` for an empty string - :param is_in: test that `param` exists in `param_compare` values - :param is_equal: test that `param` equals `param_compare` value - :param is_type: test that `param` is of same type as specified by `param_compare` type - :param with_param: on raise, adds values of `param`, `param_name` and `param_compare` to json response if specified - :raises `HTTPError`: if tests fail, specified exception is raised (default: `HTTPBadRequest`) - :raises `HTTPInternalServerError`: for evaluation error + :param content_type: format in which to return the exception + (one of :py:data:`magpie.common.SUPPORTED_ACCEPT_TYPES`) + :param not_none: test that :paramref:`param` is not ``None`` type + :param not_empty: test that :paramref:`param` is not an empty iterable (string, list, set, etc.) + :param not_in: test that :paramref:`param` does not exist in :paramref:`param_compare` values + :param not_equal: test that :paramref:`param` is not equal to :paramref:`param_compare` value + :param is_true: test that :paramref:`param` is ``True`` + :param is_false: test that :paramref:`param` is ``False`` + :param is_none: test that :paramref:`param` is ``None`` type + :param is_empty: test `param` for an empty iterable (string, list, set, etc.) + :param is_in: test that :paramref:`param` exists in :paramref:`param_compare` values + :param is_equal: test that :paramref:`param` equals :paramref:`param_compare` value + :param is_type: test that :paramref:`param` is of same type as specified by :paramref:`param_compare` type + :param matches: test that :paramref:`param` matches the regex specified by :paramref:`param_compare` value + :raises HTTPError: if tests fail, specified exception is raised (default: :class:`HTTPBadRequest`) + :raises HTTPInternalServerError: for evaluation error :return: nothing if all tests passed """ content = {} if content is None else content + needs_compare = is_type or is_in or not_in or is_equal or not_equal or matches + needs_iterable = is_in or not_in # precondition evaluation of input parameters try: + # following TypeError/ValueError are used instead of HTTPError as they would be incorrect setup by the developer + # after validation of their conditions, we do actual validation of the parameters according to conditions if not isinstance(not_none, bool): raise TypeError("'not_none' is not a 'bool'") if not isinstance(not_empty, bool): @@ -117,75 +146,149 @@ def verify_param( # noqa: E126 raise TypeError("'is_equal' is not a 'bool'") if not isinstance(is_type, bool): raise TypeError("'is_type' is not a 'bool'") - if param_compare is None and (is_in or not_in or is_equal or not_equal): - raise TypeError("'param_compare' cannot be 'None' with specified test flags") - if is_equal or not_equal: - # allow 'different' string literals for comparison, otherwise types must match exactly - if (not (isinstance(param, six.string_types) and isinstance(param_compare, six.string_types)) - and type(param) != type(param_compare)): # noqa: E127 # pylint: disable=C0123 - raise TypeError("'param_compare' cannot be of incompatible type with specified test flags") - if not hasattr(param_compare, "__iter__") and (is_in or not_in): - param_compare = [param_compare] + if not isinstance(matches, bool): + raise TypeError("'matches' is not a 'bool'") # error if none of the flags specified if not any([not_none, not_empty, not_in, not_equal, - is_none, is_empty, is_in, is_equal, is_true, is_false, is_type]): + is_none, is_empty, is_in, is_equal, is_true, is_false, is_type, matches]): raise ValueError("no comparison flag specified for verification") + if param_compare is None and needs_compare: + raise TypeError("'param_compare' cannot be 'None' with specified test flags") + is_str_typ = param_compare in six.string_types or param_compare == six.string_types + is_cmp_typ = isinstance(param_compare, type) + if needs_compare and not needs_iterable: + # allow 'different' string literals for comparison, otherwise types between value/compare must match exactly + # with 'is_type', comparison must be made directly with compare as type instead of with instance type + is_str_cmp = isinstance(param, six.string_types) + ok_str_cmp = isinstance(param_compare, six.string_types) + eq_typ_cmp = type(param) is type(param_compare) + if is_type and not (is_str_typ or is_cmp_typ): + LOGGER.debug("[param: %s] invalid type compare with [param_compare: %s]", type(param), param_compare) + raise TypeError("'param_compare' cannot be of non-type with specified verification flags") + if not is_type and not ((is_str_cmp and ok_str_cmp) or (not is_str_cmp and eq_typ_cmp)): + # since 'param' depends of provided input by user, it should be a user-side invalid parameter + # only exception is if 'param_compare' is not value-based, then developer combined wrong flags + if is_str_typ or is_cmp_typ: + LOGGER.debug("[param: %s] invalid value compare with [param_compare: %s]", param, param_compare) + raise TypeError("'param_compare' must be value-based for specified verification flags") + # when both 'param' and 'param_compare' are values, then the types must match + # raise immediately since mismatching param types can make following checks fail uncontrollably + LOGGER.debug("[param: %s] != [param_compare: %s]", type(param), type(param_compare)) + content = apply_param_content(content, param, param_compare, param_name, with_param, param_content, + needs_compare, needs_iterable, is_type, {"is_type": False}) + raise_http(http_error, http_kwargs=http_kwargs, detail=msg_on_fail, + content=content, content_type=content_type) + if needs_iterable and (not hasattr(param_compare, "__iter__") or is_str_typ or is_cmp_typ): + LOGGER.debug("[param_compare: %s]", param_compare) + raise TypeError("'param_compare' must be an iterable of values for specified verification flags") + except HTTPException: + raise except Exception as exc: - content[u"traceback"] = repr(exc_info()) - content[u"exception"] = repr(exc) + content["traceback"] = repr(exc_info()) + content["exception"] = repr(exc) raise_http(http_error=HTTPInternalServerError, http_kwargs=http_kwargs, content=content, content_type=content_type, detail="Error occurred during parameter verification") - # evaluate requested parameter combinations + # passed this point, input condition flags are valid, evaluate requested parameter combinations + fail_conditions = {} fail_verify = False if not_none: - fail_verify = fail_verify or (param is None) + fail_conditions.update({"not_none": param is not None}) + fail_verify = fail_verify or not fail_conditions["not_none"] if is_none: - fail_verify = fail_verify or (param is not None) + fail_conditions.update({"is_none": param is None}) + fail_verify = fail_verify or not fail_conditions["is_none"] if is_true: - fail_verify = fail_verify or (param is False) + fail_conditions.update({"is_true": param is True}) + fail_verify = fail_verify or not fail_conditions["is_true"] if is_false: - fail_verify = fail_verify or (param is True) + fail_conditions.update({"is_false": param is False}) + fail_verify = fail_verify or not fail_conditions["is_false"] if not_empty: - fail_verify = fail_verify or (param == "") + fail_conditions.update({"not_empty": hasattr(param, "__len__") and len(param) > 0}) + fail_verify = fail_verify or not fail_conditions["not_empty"] if is_empty: - fail_verify = fail_verify or (param != "") + fail_conditions.update({"is_empty": hasattr(param, "__len__") and len(param) == 0}) + fail_verify = fail_verify or not fail_conditions["is_empty"] if not_in: - fail_verify = fail_verify or (param in param_compare) + fail_conditions.update({"not_in": param not in param_compare}) + fail_verify = fail_verify or not fail_conditions["not_in"] if is_in: - fail_verify = fail_verify or (param not in param_compare) + fail_conditions.update({"is_in": param in param_compare}) + fail_verify = fail_verify or not fail_conditions["is_in"] if not_equal: - fail_verify = fail_verify or (param == param_compare) + fail_conditions.update({"not_equal": param != param_compare}) + fail_verify = fail_verify or not fail_conditions["not_equal"] if is_equal: - fail_verify = fail_verify or (param != param_compare) + fail_conditions.update({"is_equal": param == param_compare}) + fail_verify = fail_verify or not fail_conditions["is_equal"] if is_type: - fail_verify = fail_verify or (not isinstance(param, param_compare)) + fail_conditions.update({"is_type": isinstance(param, param_compare)}) + fail_verify = fail_verify or not fail_conditions["is_type"] + if matches: + fail_conditions.update({"matches": bool(re.match(param_compare, param))}) + fail_verify = fail_verify or not fail_conditions["matches"] if fail_verify: - if with_param: - content[u"param"] = {u"value": str(param)} - if param_name is not None: - content[u"param"][u"name"] = str(param_name) - if param_compare is not None: - content[u"param"][u"compare"] = str(param_compare) + content = apply_param_content(content, param, param_compare, param_name, with_param, param_content, + needs_compare, needs_iterable, is_type, fail_conditions) raise_http(http_error, http_kwargs=http_kwargs, detail=msg_on_fail, content=content, content_type=content_type) +def apply_param_content(content, # type: JSON + param, # type: Any + param_compare, # type: Any + param_name, # type: Str + with_param, # type: bool + param_content, # type: Optional[JSON] + needs_compare, # type: bool + needs_iterable, # type: bool + is_type, # type: bool + fail_conditions, # type: JSON + ): # type: (...) -> JSON + """ + Formats and applies the failing parameter conditions and results to returned JSON content according to flags. + + .. seealso:: + :func:`verify_param` + """ + if with_param: + content["param"] = {} + content["param"]["conditions"] = fail_conditions + if isinstance(param, six.string_types + (int, float, bool, type(None))): + content["param"]["value"] = param + else: + content["param"]["value"] = str(param) + if param_name is not None: + content["param"]["name"] = str(param_name) + if needs_compare and param_compare is not None: + if needs_iterable or is_type: + param_compare = str if param_compare == six.string_types else param_compare + param_compare = getattr(param_compare, "__name__", str(param_compare)) + param_compare = "Type[{}]".format(param_compare) if is_type else param_compare + content["param"]["compare"] = str(param_compare) + if isinstance(param_content, dict): + content["param"].update(param_content) + return content + + def evaluate_call(call, # type: Callable[[], Any] fallback=None, # type: Optional[Callable[[], None]] - http_error=HTTPInternalServerError, # type: HTTPError + http_error=HTTPInternalServerError, # type: Type[HTTPError] http_kwargs=None, # type: Optional[ParamsType] msg_on_fail="", # type: Str content=None, # type: Optional[JSON] content_type=CONTENT_TYPE_JSON # type: Str ): # type: (...) -> Any """ - Evaluates the specified ``call`` with a wrapped HTTP exception handling. On failure, tries to call ``fallback`` if - specified, and finally raises the specified ``http_error``. Any potential error generated by ``fallback`` or - ``http_error`` themselves are treated as. + Evaluates the specified :paramref:`call` with a wrapped HTTP exception handling. On failure, tries to call. + + :paramref:`fallback` if specified, and finally raises the specified :paramref:`http_error`. + Any potential error generated by :paramref:`fallback` or :paramref:`http_error` themselves are treated as :class:`HTTPInternalServerError`. - Exceptions are generated using the standard output method formatted based on the specified ``content_type``. + + Exceptions are generated using the standard output method formatted based on specified :paramref:`content_type`. Example: normal call:: @@ -207,59 +310,57 @@ def evaluate_call(call, # type: Callable[[], Any :param http_kwargs: additional keyword arguments to pass to `http_error` if called in case of HTTP exception :param msg_on_fail: message details to return in HTTP exception if `call` failed :param content: json formatted additional content to provide in case of exception - :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_CONTENT_TYPES`) + :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_ACCEPT_TYPES`) :raises http_error: on `call` failure :raises `HTTPInternalServerError`: on `fallback` failure :return: whichever return value `call` might have if no exception occurred """ - msg_on_fail = repr(msg_on_fail) if isinstance(msg_on_fail, six.string_types) else msg_on_fail + msg_on_fail = str(msg_on_fail) if isinstance(msg_on_fail, six.string_types) else repr(msg_on_fail) + content_repr = repr(content) if content is not None else content if not islambda(call): raise_http(http_error=HTTPInternalServerError, http_kwargs=http_kwargs, detail="Input 'call' is not a lambda expression.", - content={u"call": {u"detail": msg_on_fail, u"content": repr(content)}}, - content_type=content_type) + content={"call": {"detail": msg_on_fail, "content": content_repr}}, content_type=content_type) # preemptively check fallback to avoid possible call exception without valid recovery if fallback is not None: if not islambda(fallback): raise_http(http_error=HTTPInternalServerError, http_kwargs=http_kwargs, detail="Input 'fallback' is not a lambda expression, not attempting 'call'.", - content={u"call": {u"detail": msg_on_fail, u"content": repr(content)}}, - content_type=content_type) + content={"call": {"detail": msg_on_fail, "content": content_repr}}, content_type=content_type) try: return call() except Exception as exc: - exc_call = repr(exc) + exc_call = {"exception": type(exc).__name__, "type": str(exc), + "detail": msg_on_fail, "content": content_repr} try: if fallback is not None: fallback() except Exception as exc: + exc_fallback = {"exception": type(exc).__name__, "error": str(exc)} raise_http(http_error=HTTPInternalServerError, http_kwargs=http_kwargs, detail="Exception occurred during 'fallback' called after failing 'call' exception.", - content={u"call": {u"exception": exc_call, u"detail": msg_on_fail, u"content": repr(content)}, - u"fallback": {u"exception": repr(exc)}}, - content_type=content_type) + content={"call": exc_call, "fallback": exc_fallback}, content_type=content_type) raise_http(http_error, detail=msg_on_fail, http_kwargs=http_kwargs, - content={u"call": {u"exception": exc_call, u"content": repr(content)}}, - content_type=content_type) + content={"call": exc_call}, content_type=content_type) -def valid_http(http_success=HTTPOk, # type: Optional[HTTPSuccessful] +def valid_http(http_success=HTTPOk, # type: Union[Type[HTTPSuccessful], Type[HTTPRedirection]] http_kwargs=None, # type: Optional[ParamsType] detail="", # type: Optional[Str] content=None, # type: Optional[JSON] content_type=CONTENT_TYPE_JSON, # type: Optional[Str] - ): # type: (...) -> HTTPException + ): # type: (...) -> Union[HTTPSuccessful, HTTPRedirection] """ Returns successful HTTP with standardized information formatted with content type. (see :func:`raise_http` for HTTP error calls) - :param http_success: any derived class from base `HTTPSuccessful` (default: `HTTPOk`) + :param http_success: any derived class from *valid* HTTP codes (<400) (default: `HTTPOk`) :param http_kwargs: additional keyword arguments to pass to `http_success` when called :param detail: additional message information (default: empty) :param content: json formatted content to include - :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_CONTENT_TYPES`) - :return `HTTPSuccessful`: formatted successful with additional details and HTTP code + :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_ACCEPT_TYPES`) + :returns: formatted successful response with additional details and HTTP code """ global RAISE_RECURSIVE_SAFEGUARD_COUNT # pylint: disable=W0603 @@ -269,18 +370,18 @@ def valid_http(http_success=HTTPOk, # type: Optional[HTTPSuccessful] http_code, detail, content = validate_params(http_success, [HTTPSuccessful, HTTPRedirection], detail, content, content_type) json_body = format_content_json_str(http_code, detail, content, content_type) - resp = generate_response_http_format(http_success, http_kwargs, json_body, output_type=content_type) + resp = generate_response_http_format(http_success, http_kwargs, json_body, content_type=content_type) RAISE_RECURSIVE_SAFEGUARD_COUNT = 0 # reset counter for future calls (don't accumulate for different requests) - return resp + return resp # noqa -def raise_http(http_error=HTTPInternalServerError, # type: HTTPError +def raise_http(http_error=HTTPInternalServerError, # type: Type[HTTPError] http_kwargs=None, # type: Optional[ParamsType] detail="", # type: Str content=None, # type: Optional[JSON] content_type=CONTENT_TYPE_JSON, # type: Str nothrow=False # type: bool - ): # type: (...) -> Union[HTTPException, NoReturn] + ): # type: (...) -> NoReturn """ Raises error HTTP with standardized information formatted with content type. @@ -294,7 +395,7 @@ def raise_http(http_error=HTTPInternalServerError, # type: HTTPError :param http_kwargs: additional keyword arguments to pass to `http_error` if called in case of HTTP exception :param detail: additional message information (default: empty) :param content: json formatted content to include - :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_CONTENT_TYPES`) + :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_ACCEPT_TYPES`) :param nothrow: returns the error response instead of raising it automatically, but still handles execution errors :raises HTTPError: formatted raised exception with additional details and HTTP code :returns: HTTPError formatted exception with additional details and HTTP code only if `nothrow` is `True` @@ -313,7 +414,7 @@ def raise_http(http_error=HTTPInternalServerError, # type: HTTPError content_type = CONTENT_TYPE_JSON if content_type == CONTENT_TYPE_ANY else content_type _, detail, content = validate_params(http_error, HTTPError, detail, content, content_type) json_body = format_content_json_str(http_error.code, detail, content, content_type) - resp = generate_response_http_format(http_error, http_kwargs, json_body, output_type=content_type) + resp = generate_response_http_format(http_error, http_kwargs, json_body, content_type=content_type) # reset counter for future calls (don't accumulate for different requests) # following raise is the last in the chain since it wasn't triggered by other functions @@ -323,7 +424,7 @@ def raise_http(http_error=HTTPInternalServerError, # type: HTTPError raise resp -def validate_params(http_class, # type: HTTPException +def validate_params(http_class, # type: Type[HTTPException] http_base, # type: Union[Type[HTTPException], Iterable[Type[HTTPException]]] detail, # type: Str content, # type: Optional[JSON] @@ -337,7 +438,7 @@ def validate_params(http_class, # type: HTTPException (ie: 2xx, 4xx, 5xx codes). Can be a single class of an iterable of possible requirements (any). :param detail: additional message information (default: empty) :param content: json formatted content to include - :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_CONTENT_TYPES`) + :param content_type: format in which to return the exception (one of `magpie.common.SUPPORTED_ACCEPT_TYPES`) :raise `HTTPInternalServerError`: if any parameter is of invalid expected format :returns http_code, detail, content: parameters with corrected and validated format if applicable """ @@ -345,9 +446,9 @@ def validate_params(http_class, # type: HTTPException # cannot be done within a try/except because it would always trigger with `raise_http` content = dict() if content is None else content detail = repr(detail) if not isinstance(detail, six.string_types) else detail - caller = {u"content": content, u"type": content_type, u"detail": detail, u"code": 520} # "unknown" code error + caller = {"content": content, "type": content_type, "detail": detail, "code": 520} # "unknown" code error verify_param(isclass(http_class), param_name="http_class", is_true=True, - http_error=HTTPInternalServerError, content_type=CONTENT_TYPE_JSON, content={u"caller": caller}, + http_error=HTTPInternalServerError, content_type=CONTENT_TYPE_JSON, content={"caller": caller}, msg_on_fail="Object specified is not a class, class derived from `HTTPException` is expected.") # if `http_class` derives from `http_base` (ex: `HTTPSuccessful` or `HTTPError`) it is of proper requested type # if it derives from `HTTPException`, it *could* be different than base (ex: 2xx instead of 4xx codes) @@ -359,12 +460,12 @@ def validate_params(http_class, # type: HTTPException http_code = http_class.code else: http_code = 520 - caller[u"code"] = http_code + caller["code"] = http_code verify_param(issubclass(http_class, http_base), param_name="http_base", is_true=True, - http_error=HTTPInternalServerError, content_type=CONTENT_TYPE_JSON, content={u"caller": caller}, + http_error=HTTPInternalServerError, content_type=CONTENT_TYPE_JSON, content={"caller": caller}, msg_on_fail="Invalid 'http_base' derived class specified.") - verify_param(content_type, param_name="content_type", param_compare=SUPPORTED_CONTENT_TYPES, is_in=True, - http_error=HTTPInternalServerError, content_type=CONTENT_TYPE_JSON, content={u"caller": caller}, + verify_param(content_type, param_name="content_type", param_compare=SUPPORTED_ACCEPT_TYPES, is_in=True, + http_error=HTTPInternalServerError, content_type=CONTENT_TYPE_JSON, content={"caller": caller}, msg_on_fail="Invalid 'content_type' specified for exception output.") return http_code, detail, content @@ -379,62 +480,119 @@ def format_content_json_str(http_code, detail, content, content_type): """ json_body = {} try: - content[u"code"] = http_code - content[u"detail"] = detail - content[u"type"] = content_type + content["code"] = http_code + content["detail"] = detail + content["type"] = content_type json_body = json.dumps(content) except Exception as exc: # pylint: disable=W0703 msg = "Dumping json content '{!s}' resulted in exception '{!r}'.".format(content, exc) raise_http(http_error=HTTPInternalServerError, detail=msg, content_type=CONTENT_TYPE_JSON, - content={u"traceback": repr(exc_info()), - u"exception": repr(exc), - u"caller": {u"content": repr(content), # raw string to avoid recursive json.dumps error - u"detail": detail, - u"code": http_code, - u"type": content_type}}) + content={"traceback": repr(exc_info()), + "exception": repr(exc), + "caller": {"content": repr(content), # raw string to avoid recursive json.dumps error + "detail": detail, + "code": http_code, + "type": content_type}}) return json_body -def generate_response_http_format(http_class, http_kwargs, json_content, output_type=CONTENT_TYPE_PLAIN): - # type: (Union[HTTPException, PyramidResponse], ParamsType, JSON, Optional[Str]) -> PyramidResponse +def rewrite_content_type(content, content_type): + # type: (Union[Str, JSON], Str) -> Tuple[Str, Optional[JSON]] + """ + Attempts to rewrite the ``type`` field inserted by various functions such as: + + - :func:`format_content_json_str` + - :func:`raise_http` + - :func:`valid_http` + + By applying the new value provided by :paramref:`content_type`. + + :returns: + Content with rewritten "type" (if possible) and converted to string directly insertable to a response body. + Also provides the converted JSON body if applicable (original content was literal JSON or JSON-like string). """ - Formats the HTTP response output according to desired ``output_type`` using provided HTTP code and content. + json_content = None + if isinstance(content, six.string_types): + try: + content = json.loads(content) + json_content = content + except (TypeError, json.decoder.JSONDecodeError): + pass + if isinstance(content, (list, dict)): + if "type" in content: + content["type"] = content_type + json_content = content + content = json.dumps(content) + return content, json_content + + +def generate_response_http_format(http_class, http_kwargs, content, content_type=CONTENT_TYPE_PLAIN, metadata=None): + # type: (Type[HTTPException], Optional[ParamsType], JSON, Optional[Str], Optional[JSON]) -> HTTPException + """ + Formats the HTTP response content according to desired ``content_type`` using provided HTTP code and content. :param http_class: `HTTPException` derived class to use for output (code, generic title/explanation, etc.) :param http_kwargs: additional keyword arguments to pass to `http_class` when called - :param json_content: formatted json content providing additional details for the response cause - :param output_type: one of `magpie.common.SUPPORTED_CONTENT_TYPES` (default: `magpie.common.CONTENT_TYPE_PLAIN`) - :return: `http_class` instance with requested information and output type if creation succeeds - :raises: `HTTPInternalServerError` instance details about requested information and output type if creation fails + :param content: formatted JSON content or literal string content providing additional details for the response + :param content_type: one of `magpie.common.SUPPORTED_ACCEPT_TYPES` (default: `magpie.common.CONTENT_TYPE_PLAIN`) + :param metadata: request metadata to add to the response body. (see: :func:`magpie.api.requests.get_request_info`) + :return: `http_class` instance with requested information and content type if creation succeeds + :raises: `HTTPInternalServerError` instance details about requested information and content type if creation fails """ # content body is added manually to avoid auto-format and suppression of fields by `HTTPException` - json_content = str(json_content) if not isinstance(json_content, six.string_types) else json_content + content, json_content = rewrite_content_type(content, content_type) + if isinstance(json_content, dict) and isinstance(metadata, dict): + # ensure that original JSON content has priority in fields definition over metadata + # preserve original JSON field ordering, as best as possible + json_content.update({k: v for k, v in metadata.items() if k not in json_content}) + content, json_content = rewrite_content_type(json_content, content_type) + content = str(content) if not isinstance(content, six.string_types) else content # adjust additional keyword arguments and try building the http response class with them http_kwargs = dict() if http_kwargs is None else http_kwargs + http_headers = http_kwargs.get("headers", {}) + # omit content-type and related headers that we override + for header in dict(http_headers): + if header.lower().startswith("content-"): + http_headers.pop(header, None) + try: # directly output json - if output_type == CONTENT_TYPE_JSON: - json_type = "{}; charset=UTF-8".format(CONTENT_TYPE_JSON) - http_response = http_class(body=json_content, content_type=json_type, **http_kwargs) + if content_type == CONTENT_TYPE_JSON: + content_type = "{}; charset=UTF-8".format(CONTENT_TYPE_JSON) + http_response = http_class(body=content, content_type=content_type, **http_kwargs) # otherwise json is contained within the html section - elif output_type == CONTENT_TYPE_HTML: + elif content_type == CONTENT_TYPE_HTML: + if http_class is HTTPOk: + http_class.explanation = "Operation successful." + if not http_class.explanation: + http_class.explanation = http_class.title # some don't have any defined # add preformat
 section to output as is within the  section
-            html_body = "{}

Exception Details

" \ - "
{}
" \ - .format(http_class.explanation, json_content) - http_response = http_class(body_template=html_body, content_type=CONTENT_TYPE_HTML, **http_kwargs) + html_status = "Exception" if http_class.code >= 400 else "Response" + html_header = "{}

{} Details

".format(http_class.explanation, html_status) + html_template = "
{}
" + content_type = "{}; charset=UTF-8".format(CONTENT_TYPE_HTML) + if json_content: + html_body = html_template.format(json.dumps(json_content, indent=True, ensure_ascii=False)) + else: + html_body = html_template.format(content) + html_body = html_header + html_body + http_response = http_class(body_template=html_body, content_type=content_type, **http_kwargs) + + elif content_type in [CONTENT_TYPE_APP_XML, CONTENT_TYPE_TXT_XML]: + xml_body = dicttoxml(json_content, custom_root="response") + http_response = http_class(body=xml_body, content_type=CONTENT_TYPE_TXT_XML, **http_kwargs) # default back to plain text else: - http_response = http_class(body=json_content, content_type=CONTENT_TYPE_PLAIN, **http_kwargs) + http_response = http_class(body=content, content_type=CONTENT_TYPE_PLAIN, **http_kwargs) return http_response except Exception as exc: # pylint: disable=W0703 raise_http(http_error=HTTPInternalServerError, detail="Failed to build HTTP response", - content={u"traceback": repr(exc_info()), u"exception": repr(exc), - u"caller": {u"http_kwargs": repr(http_kwargs), - u"http_class": repr(http_class), - u"output_type": str(output_type)}}) + content={"traceback": repr(exc_info()), "exception": repr(exc), + "caller": {"http_kwargs": repr(http_kwargs), + "http_class": repr(http_class), + "content_type": str(content_type)}}) diff --git a/magpie/api/generic.py b/magpie/api/generic.py index 2e126eff9..38a70c4e5 100644 --- a/magpie/api/generic.py +++ b/magpie/api/generic.py @@ -1,8 +1,9 @@ from typing import TYPE_CHECKING -from pyramid.authentication import Authenticated, IAuthenticationPolicy +from pyramid.authentication import Authenticated from pyramid.exceptions import PredicateMismatch from pyramid.httpexceptions import ( + HTTPException, HTTPForbidden, HTTPInternalServerError, HTTPMethodNotAllowed, @@ -11,27 +12,32 @@ HTTPServerError, HTTPUnauthorized ) +from pyramid.request import Request from simplejson import JSONDecodeError +from magpie.api import exception as ax from magpie.api import schemas as s -from magpie.api.exception import raise_http, verify_param +from magpie.api.requests import get_principals from magpie.utils import ( CONTENT_TYPE_ANY, + CONTENT_TYPE_HTML, CONTENT_TYPE_JSON, - SUPPORTED_CONTENT_TYPES, + FORMAT_TYPE_MAPPING, + SUPPORTED_ACCEPT_TYPES, get_header, get_logger, - get_magpie_url + is_magpie_ui_path ) if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Callable # noqa: F401 - from magpie.typedefs import Str, JSON # noqa: F401 - from pyramid.registry import Registry # noqa: F401 - from pyramid.request import Request # noqa: F401 - from pyramid.response import Response # noqa: F401 - from pyramid.httpexceptions import HTTPException # noqa: F401 + from typing import Callable, Optional, Tuple, Union + + from pyramid.registry import Registry + from pyramid.response import Response + + from magpie.typedefs import JSON, Str + LOGGER = get_logger(__name__) @@ -42,8 +48,7 @@ def internal_server_error(request): """ content = get_request_info(request, exception_details=True, default_message=s.InternalServerErrorResponseSchema.description) - return raise_http(nothrow=True, http_error=HTTPInternalServerError, detail=content[u"detail"], content=content, - content_type=get_header("Accept", request.headers, default=CONTENT_TYPE_JSON, split=";,")) + return ax.raise_http(nothrow=True, http_error=HTTPInternalServerError, detail=content["detail"], content=content) def not_found_or_method_not_allowed(request): @@ -58,17 +63,14 @@ def not_found_or_method_not_allowed(request): Without this fix, both situations return [404] regardless. """ - if (isinstance(request.exception, PredicateMismatch) - and request.method not in request.exception._safe_methods # pylint: disable=W0212 # noqa: W0212 - ): + if isinstance(request.exception, PredicateMismatch) and request.method not in ["HEAD", "GET"]: http_err = HTTPMethodNotAllowed http_msg = "" # auto-generated by HTTPMethodNotAllowed else: http_err = HTTPNotFound http_msg = s.NotFoundResponseSchema.description content = get_request_info(request, default_message=http_msg) - return raise_http(nothrow=True, http_error=http_err, detail=content[u"detail"], content=content, - content_type=get_header("Accept", request.headers, default=CONTENT_TYPE_JSON, split=";,")) + return ax.raise_http(nothrow=True, http_error=http_err, detail=content["detail"], content=content) def unauthorized_or_forbidden(request): @@ -76,87 +78,170 @@ def unauthorized_or_forbidden(request): """ Overrides the default ``HTTPForbidden`` [403] by appropriate ``HTTPUnauthorized`` [401] when applicable. - Unauthorized response is for restricted user access according to credentials and/or authorization headers. - Forbidden response is for operation refused by the underlying process operations. + Unauthorized response is for restricted user access according to missing credentials and/or authorization headers. + Forbidden response is for operation refused by the underlying process operations or due to insufficient permissions. Without this fix, both situations return [403] regardless. .. seealso:: - http://www.restapitutorial.com/httpstatuscodes.html + - http://www.restapitutorial.com/httpstatuscodes.html + + In case the request references to `Magpie UI` route, it is redirected to + :meth:`magpie.ui.home.HomeViews.error_view` for it to handle and display the error accordingly. """ - authn_policy = request.registry.queryUtility(IAuthenticationPolicy) http_err = HTTPForbidden http_msg = s.HTTPForbiddenResponseSchema.description - if authn_policy: - principals = authn_policy.effective_principals(request) - if Authenticated not in principals: - http_err = HTTPUnauthorized - http_msg = s.UnauthorizedResponseSchema.description + principals = get_principals(request) + if Authenticated not in principals: + http_err = HTTPUnauthorized + http_msg = s.UnauthorizedResponseSchema.description content = get_request_info(request, default_message=http_msg) + if is_magpie_ui_path(request): + # need to handle 401/403 immediately otherwise target view is not even called + from magpie.ui.utils import redirect_error + return redirect_error(request, code=http_err.code, content=content) + return ax.raise_http(nothrow=True, http_error=http_err, detail=content["detail"], content=content) + + +def guess_target_format(request): + # type: (Request) -> Tuple[Str, bool] + """ + Guess the best applicable response ``Content-Type`` header according to request ``Accept`` header and ``format`` + query, or defaulting to :py:data:`CONTENT_TYPE_JSON`. - return raise_http(nothrow=True, http_error=http_err, detail=content[u"detail"], content=content, - content_type=get_header("Accept", request.headers, default=CONTENT_TYPE_JSON, split=";,")) + :returns: tuple of matched MIME-type and where it was found (``True``: header, ``False``: query) + """ + content_type = FORMAT_TYPE_MAPPING.get(request.params.get("format")) + is_header = False + if not content_type: + is_header = True + content_type = get_header("accept", request.headers, default=CONTENT_TYPE_JSON, split=";,") + if content_type != CONTENT_TYPE_JSON: + # because most browsers enforce some 'visual' list of accept header, revert to JSON if detected + # explicit request set by other client (e.g.: using 'requests') will have full control over desired content + user_agent = get_header("user-agent", request.headers) + if user_agent and any(browser in user_agent for browser in ["Mozilla", "Chrome", "Safari"]): + content_type = CONTENT_TYPE_JSON + if not content_type or content_type == CONTENT_TYPE_ANY: + is_header = True + content_type = CONTENT_TYPE_JSON + return content_type, is_header def validate_accept_header_tween(handler, registry): # noqa: F811 # type: (Callable[[Request], Response], Registry) -> Callable[[Request], Response] """ - Tween that validates that the specified request ``Accept`` header (if any) is a supported one by the application. + Tween that validates that the specified request ``Accept`` header or ``format`` query (if any) is a supported one by + the application and for the given context. - :raises HTTPNotAcceptable: if `Accept` header was specified and is not supported. + :raises HTTPNotAcceptable: if desired ``Content-Type`` is not supported. """ - def validate_accept_header(request): + def validate_format(request): # type: (Request) -> Response """ - Validates the specified request according to its ``Accept`` header, ignoring UI related routes that request more - content-types than the ones supported by the application for display purposes (styles, images etc.). + Validates the specified request according to its ``Accept`` header or ``format`` query, ignoring UI related + routes that require more content-types than the ones supported by the API for displaying purposes of other + elements (styles, images, etc.). """ - # server URL could have more prefixes than only /magpie, so start by removing them using explicit URL setting - # remove any additional hostname and known /magpie prefix to get only the final magpie-specific path - magpie_url = get_magpie_url(request) - magpie_url = request.url.replace(magpie_url, "") - magpie_path = magpie_url.replace(request.host, "") - magpie_path = magpie_path.split("/magpie/", 1)[-1] # make sure we don't split a /magpie(.*) element by mistake - magpie_path = "/" + magpie_path if not magpie_path.startswith("/") else magpie_path - # ignore types defined under UI or static routes to allow rendering - if not any(magpie_path.startswith(p) for p in ("/ui", "/static")): - any_supported_header = SUPPORTED_CONTENT_TYPES + [CONTENT_TYPE_ANY] - accept = get_header("accept", request.headers, default=CONTENT_TYPE_JSON, split=";,") - verify_param(accept, is_in=True, param_compare=any_supported_header, param_name="Accept Header", - http_error=HTTPNotAcceptable, msg_on_fail=s.NotAcceptableResponseSchema.description) + if not is_magpie_ui_path(request): + accept, _ = guess_target_format(request) + http_msg = s.NotAcceptableResponseSchema.description + content = get_request_info(request, default_message=http_msg) + ax.verify_param(accept, is_in=True, param_compare=SUPPORTED_ACCEPT_TYPES, + param_name="Accept Header or Format Query", + http_error=HTTPNotAcceptable, msg_on_fail=http_msg, + content=content, content_type=CONTENT_TYPE_JSON) # enforce type to avoid recursion return handler(request) - return validate_accept_header + return validate_format -def get_request_info(request, default_message=u"undefined", exception_details=False): - # type: (Request, Str, bool) -> JSON +def apply_response_format_tween(handler, registry): # noqa: F811 + # type: (Callable[[Request], HTTPException], Registry) -> Callable[[Request], Response] """ - Obtains additional content details about the ``request`` according to available information. + Tween that obtains the request ``Accept`` header or ``format`` query (if any) to generate the response with the + desired ``Content-Type``. + + The target ``Content-Type`` is expected to have been validated by :func:`validate_accept_header_tween` beforehand + to handle not-acceptable errors. + + The tween also ensures that additional request metadata extracted from :func:`get_request_info` is applied to + the response body if not already provided by a previous operation. """ - content = { - u"route_name": str(request.upath_info), - u"request_url": str(request.url), - u"detail": default_message, - u"method": request.method - } - if hasattr(request, "exception"): + def apply_format(request): + # type: (Request) -> HTTPException + """ + Validates the specified request according to its ``Accept`` header, ignoring UI related routes that request more + content-types than the ones supported by the application for display purposes (styles, images etc.). + + Alternatively, if no ``Accept`` header is found, look for equivalent value provided via query parameter. + """ + # all magpie API routes expected to either call 'valid_http' or 'raise_http' of 'magpie.api.exception' module + # an HTTPException is always returned, and content is a JSON-like string + content_type, is_header = guess_target_format(request) + if not is_header: + # NOTE: + # enforce the accept header in case it was specified with format query, since some renderer implementations + # will afterward erroneously overwrite the 'content-type' value that we enforce when converting the response + # from the HTTPException. See: + # - https://github.com/Pylons/webob/issues/204 + # - https://github.com/Pylons/webob/issues/238 + # - https://github.com/Pylons/pyramid/issues/1344 + request.accept = content_type + resp = handler(request) # no exception when EXCVIEW tween is placed under this tween + if is_magpie_ui_path(request): + if not resp.content_type: + resp.content_type = CONTENT_TYPE_HTML + return resp + # return routes already converted (valid_http/raise_http where not used, pyramid already generated response) + if not isinstance(resp, HTTPException): + return resp + # forward any headers such as session cookies to be applied + metadata = get_request_info(request) + resp_kwargs = {"headers": resp.headers} + return ax.generate_response_http_format(type(resp), resp_kwargs, resp.text, content_type, metadata) + return apply_format + + +def get_exception_info(response, content=None, exception_details=False): + # type: (Union[HTTPException, Request, Response], Optional[JSON], bool) -> JSON + """ + Obtains additional exception content details about the :paramref:`response` according to available information. + """ + content = content or {} + if hasattr(response, "exception"): # handle error raised simply by checking for "json" property in python 3 when body is invalid has_json = False try: - has_json = hasattr(request.exception, "json") + has_json = hasattr(response.exception, "json") except JSONDecodeError: pass - if has_json and isinstance(request.exception.json, dict): - content.update(request.exception.json) - elif isinstance(request.exception, HTTPServerError) and hasattr(request.exception, "message"): - content.update({u"exception": str(request.exception.message)}) - elif isinstance(request.exception, Exception) and exception_details: - content.update({u"exception": repr(request.exception)}) + if has_json and isinstance(response.exception.json, dict): + content.update(response.exception.json) + elif isinstance(response.exception, HTTPServerError) and hasattr(response.exception, "message"): + content.update({"exception": str(response.exception.message)}) + elif isinstance(response.exception, Exception) and exception_details: + content.update({"exception": type(response.exception).__name__}) # get 'request.exc_info' or 'sys.exc_info', whichever one is available - LOGGER.error("Request exception.", exc_info=getattr(request, "exc_info", True)) - if not content[u"detail"]: - content[u"detail"] = str(request.exception) - elif hasattr(request, "matchdict"): - if request.matchdict is not None and request.matchdict != "": - content.update(request.matchdict) + LOGGER.error("Request exception.", exc_info=getattr(response, "exc_info", True)) + if not content.get("detail"): + detail = response.exception + content["detail"] = str(detail) if detail is not None else None + elif hasattr(response, "matchdict"): + if response.matchdict is not None and response.matchdict != "": + content.update(response.matchdict) + return content + + +def get_request_info(request, default_message=None, exception_details=False): + # type: (Union[Request, HTTPException], Optional[Str], bool) -> JSON + """ + Obtains additional content details about the :paramref:`request` according to available information. + """ + content = { + "path": str(request.upath_info), + "url": str(request.url), + "detail": default_message, + "method": request.method + } + content.update(get_exception_info(request, content=content, exception_details=exception_details)) return content diff --git a/magpie/api/home/home.py b/magpie/api/home/home.py index be0ba450f..9a25c0efc 100644 --- a/magpie/api/home/home.py +++ b/magpie/api/home/home.py @@ -21,9 +21,9 @@ def get_homepage(request): # noqa: W0212 """ body = deepcopy(s.InfoAPI) body.update({ - u"title": s.TitleAPI, - u"name": __meta__.__package__, - u"documentation": get_magpie_url() + s.SwaggerAPI.path + "title": s.TitleAPI, + "name": __meta__.__package__, + "documentation": get_magpie_url() + s.SwaggerAPI.path }) return ax.valid_http(http_success=HTTPOk, content=body, content_type=CONTENT_TYPE_JSON, detail=s.Version_GET_OkResponseSchema.description) @@ -41,8 +41,8 @@ def get_version(request): except Exception as exc: print_log("Failed to retrieve database revision: [{!r}]".format(exc), LOGGER, logging.WARNING) version = { - u"version": __meta__.__version__, - u"db_version": version_db + "version": __meta__.__version__, + "db_version": version_db } return ax.valid_http(http_success=HTTPOk, content=version, content_type=CONTENT_TYPE_JSON, detail=s.Version_GET_OkResponseSchema.description) diff --git a/magpie/api/login/login.py b/magpie/api/login/login.py index 63225cd3a..85ca27b90 100644 --- a/magpie/api/login/login.py +++ b/magpie/api/login/login.py @@ -1,7 +1,10 @@ +import json +from typing import TYPE_CHECKING + from authomatic.adapters import WebObAdapter from authomatic.core import Credentials, LoginResult, resolve_provider_class from authomatic.exceptions import OAuth2Error -from pyramid.authentication import Authenticated, IAuthenticationPolicy +from pyramid.authentication import Authenticated from pyramid.httpexceptions import ( HTTPBadRequest, HTTPConflict, @@ -12,7 +15,8 @@ HTTPNotFound, HTTPOk, HTTPTemporaryRedirect, - HTTPUnauthorized + HTTPUnauthorized, + HTTPUnprocessableEntity ) from pyramid.request import Request from pyramid.response import Response @@ -26,14 +30,17 @@ from magpie import models from magpie.api import exception as ax from magpie.api import generic as ag +from magpie.api import requests as ar from magpie.api import schemas as s from magpie.api.management.user.user_formats import format_user from magpie.api.management.user.user_utils import create_user -from magpie.api.requests import get_multiformat_post, get_value_multiformat_post_checked from magpie.constants import get_constant from magpie.security import authomatic_setup, get_provider_names from magpie.utils import CONTENT_TYPE_JSON, convert_response, get_logger, get_magpie_url +if TYPE_CHECKING: + from magpie.typedefs import Session, Str + LOGGER = get_logger(__name__) @@ -64,26 +71,47 @@ def process_sign_in_external(request, username, provider): def verify_provider(provider_name): - ax.verify_param(provider_name, param_name=u"provider_name", param_compare=MAGPIE_PROVIDER_KEYS, is_in=True, + # type: (Str) -> None + """:raises HTTPNotFound: if provider name is not one of known providers.""" + ax.verify_param(provider_name, param_name="provider_name", param_compare=MAGPIE_PROVIDER_KEYS, is_in=True, http_error=HTTPNotFound, msg_on_fail=s.ProviderSignin_GET_NotFoundResponseSchema.description) -@s.SigninAPI.post(schema=s.Signin_POST_RequestSchema(), tags=[s.LoginTag], response_schemas=s.Signin_POST_responses) +@s.SigninAPI.get(schema=s.Signin_GET_RequestSchema, tags=[s.SessionTag], response_schemas=s.Signin_GET_responses) +@view_config(route_name=s.SigninAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +def signin_in_param(request): + """ + Signs in a user session using query parameters. + """ + data = dict(request.params) + subreq = Request.blank(s.SigninAPI.path, base_url=request.application_url, + headers={"Content-Type": CONTENT_TYPE_JSON}, POST=json.dumps(data)) + return request.invoke_subrequest(subreq, use_tweens=True) + + +@s.SigninAPI.post(schema=s.Signin_POST_RequestSchema(), tags=[s.SessionTag], response_schemas=s.Signin_POST_responses) @view_config(route_name=s.SigninAPI.name, request_method="POST", permission=NO_PERMISSION_REQUIRED) def sign_in(request): """ Signs in a user session. """ - provider_name = get_value_multiformat_post_checked(request, "provider_name", default=MAGPIE_DEFAULT_PROVIDER) + provider_name = ar.get_value_multiformat_body_checked(request, "provider_name", default=MAGPIE_DEFAULT_PROVIDER) provider_name = provider_name.lower() - user_name = get_value_multiformat_post_checked(request, "user_name") - password = get_multiformat_post(request, "password") # no check since password is None for external login + # magpie supports login from both username or corresponding email + # therefore validate pattern combination manually after fetch otherwise email format fails patter match + user_name = ar.get_value_multiformat_body_checked(request, "user_name", pattern=None) # bad request if missing + pattern = ax.EMAIL_REGEX if "@" in user_name else ax.PARAM_REGEX + ax.verify_param(user_name, matches=True, param_compare=pattern, param_name="user_name", + http_error=HTTPUnprocessableEntity, msg_on_fail=s.UnprocessableEntityResponseSchema.description) verify_provider(provider_name) if provider_name in MAGPIE_INTERNAL_PROVIDERS.keys(): + # password can be None for external login, validate only here as needed + password = ar.get_value_multiformat_body_checked(request, "password", pattern=None) + # check manually to avoid inserting value in result body # obtain the raw path, without any '/magpie' prefix (if any), let 'application_url' handle it signin_internal_path = request.route_url("ziggurat.routes.sign_in", _app_url="") - signin_internal_data = {u"user_name": user_name, u"password": password, u"provider_name": provider_name} + signin_internal_data = {"user_name": user_name, "password": password, "provider_name": provider_name} signin_sub_request = Request.blank(signin_internal_path, base_url=request.application_url, headers={"Accept": CONTENT_TYPE_JSON}, POST=signin_internal_data) signin_response = request.invoke_subrequest(signin_sub_request, use_tweens=True) @@ -94,27 +122,39 @@ def sign_in(request): elif provider_name in MAGPIE_EXTERNAL_PROVIDERS.keys(): return ax.evaluate_call(lambda: process_sign_in_external(request, user_name, provider_name), http_error=HTTPInternalServerError, - content={u"user_name": user_name, u"provider_name": provider_name}, + content={"user_name": user_name, "provider_name": provider_name}, msg_on_fail=s.Signin_POST_External_InternalServerErrorResponseSchema.description) -# swagger responses referred in `sign_in` @view_config(context=ZigguratSignInSuccess, permission=NO_PERMISSION_REQUIRED) def login_success_ziggurat(request): + """ + Response from redirect upon successful login with valid user credentials. + + Header ``Set-Cookie`` from this response will allow creation of the response cookies. + + .. seealso:: + - :func:`sign_in` + """ # headers contains login authorization cookie return ax.valid_http(http_success=HTTPOk, http_kwargs={"headers": request.context.headers}, detail=s.Signin_POST_OkResponseSchema.description) -# swagger responses referred in `sign_in` @view_config(context=ZigguratSignInBadAuth, permission=NO_PERMISSION_REQUIRED) def login_failure(request, reason=None): + """ + Response from redirect upon login failure, either because of invalid or incorrect user credentials. + + .. seealso:: + - :func:`sign_in` + """ http_err = HTTPUnauthorized if reason is None: reason = s.Signin_POST_UnauthorizedResponseSchema.description try: - user_name = get_value_multiformat_post_checked(request, "user_name", default=None) - get_value_multiformat_post_checked(request, "password", default=None) + user_name = ar.get_value_multiformat_body_checked(request, "user_name", default=None) + ar.get_value_multiformat_body_checked(request, "password", default=None, pattern=None) except HTTPException: http_err = HTTPBadRequest reason = s.Signin_POST_BadRequestResponseSchema.description @@ -127,11 +167,12 @@ def login_failure(request, reason=None): http_err = HTTPInternalServerError reason = s.Signin_POST_Internal_InternalServerErrorResponseSchema.description content = ag.get_request_info(request, default_message=s.Signin_POST_UnauthorizedResponseSchema.description) - content.update({u"reason": str(reason)}) + content.setdefault("detail", str(reason)) ax.raise_http(http_error=http_err, content=content, detail=s.Signin_POST_UnauthorizedResponseSchema.description) def new_user_external(external_user_name, external_id, email, provider_name, db_session): + # type: (Str, Str, Str, Str, Session) -> models.User """ Create new user with an External Identity. """ @@ -141,19 +182,23 @@ def new_user_external(external_user_name, external_id, email, provider_name, db_ create_user(internal_user_name, password=None, email=email, group_name=group_name, db_session=db_session) user = UserService.by_user_name(internal_user_name, db_session=db_session) - ex_identity = models.ExternalIdentity(external_user_name=external_user_name, external_id=external_id, + ex_identity = models.ExternalIdentity(external_user_name=external_user_name, external_id=external_id, # noqa local_user_id=user.id, provider_name=provider_name) # noqa ax.evaluate_call(lambda: db_session.add(ex_identity), fallback=lambda: db_session.rollback(), http_error=HTTPConflict, msg_on_fail=s.Signin_POST_ConflictResponseSchema.description, - content={u"provider_name": str(provider_name), - u"internal_user_name": str(internal_user_name), - u"external_user_name": str(external_user_name), - u"external_id": str(external_id)}) + content={"provider_name": str(provider_name), + "internal_user_name": str(internal_user_name), + "external_user_name": str(external_user_name), + "external_id": str(external_id)}) user.external_identities.append(ex_identity) return user def login_success_external(request, external_user_name, external_id, email, provider_name): + # type: (Request, Str, Str, Str, Str) -> HTTPException + """ + Generates the login response in case of successful external provider identification. + """ # find possibly already registered user by external_id/provider user = ExternalIdentityService.user_by_external_id_and_provider(external_id, provider_name, request.db) if user is None: @@ -177,11 +222,11 @@ def login_success_external(request, external_user_name, external_id, email, prov if not header_host: homepage_route = magpie_host + ("/" if not homepage_route.startswith("/") else "") + homepage_route return ax.valid_http(http_success=HTTPFound, detail=s.ProviderSignin_GET_FoundResponseSchema.description, - content={u"homepage_route": homepage_route}, + content={"homepage_route": homepage_route}, http_kwargs={"location": homepage_route, "headers": headers}) -@s.ProviderSigninAPI.get(schema=s.ProviderSignin_GET_RequestSchema, tags=[s.LoginTag], +@s.ProviderSigninAPI.get(schema=s.ProviderSignin_GET_RequestSchema, tags=[s.SessionTag], response_schemas=s.ProviderSignin_GET_responses) @view_config(route_name=s.ProviderSigninAPI.name, permission=NO_PERMISSION_REQUIRED) def authomatic_login(request): @@ -232,7 +277,7 @@ def authomatic_login(request): # this error can happen if providing incorrectly formed authorization header except OAuth2Error as exc: LOGGER.debug("Login failure with Authorization header.") - ax.raise_http(http_error=HTTPBadRequest, content={u"reason": str(exc.message)}, + ax.raise_http(http_error=HTTPBadRequest, content={"reason": str(exc.message)}, detail=s.ProviderSignin_GET_BadRequestResponseSchema.description) # verify that the update procedure succeeded with provided token if 400 <= response.status < 500: @@ -254,7 +299,7 @@ def authomatic_login(request): return response -@s.SignoutAPI.get(tags=[s.LoginTag], response_schemas=s.Signout_GET_responses) +@s.SignoutAPI.get(tags=[s.SessionTag], response_schemas=s.Signout_GET_responses) @view_config(context=ZigguratSignOut, permission=NO_PERMISSION_REQUIRED) def sign_out(request): """ @@ -264,20 +309,19 @@ def sign_out(request): detail=s.Signout_GET_OkResponseSchema.description) -@s.SessionAPI.get(tags=[s.LoginTag], response_schemas=s.Session_GET_responses) +@s.SessionAPI.get(tags=[s.SessionTag], response_schemas=s.Session_GET_responses) @view_config(route_name=s.SessionAPI.name, permission=NO_PERMISSION_REQUIRED) def get_session(request): """ Get information about current session. """ def _get_session(req): - authn_policy = req.registry.queryUtility(IAuthenticationPolicy) - principals = authn_policy.effective_principals(req) + principals = ar.get_principals(req) if Authenticated in principals: user = request.user - json_resp = {u"authenticated": True, u"user": format_user(user)} + json_resp = {"authenticated": True, "user": format_user(user)} else: - json_resp = {u"authenticated": False} + json_resp = {"authenticated": False} return json_resp session_json = ax.evaluate_call(lambda: _get_session(request), http_error=HTTPInternalServerError, @@ -285,12 +329,12 @@ def _get_session(req): return ax.valid_http(http_success=HTTPOk, detail=s.Session_GET_OkResponseSchema.description, content=session_json) -@s.ProvidersAPI.get(tags=[s.LoginTag], response_schemas=s.Providers_GET_responses) +@s.ProvidersAPI.get(tags=[s.SessionTag], response_schemas=s.Providers_GET_responses) @view_config(route_name=s.ProvidersAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) def get_providers(request): # noqa: F811 """ Get list of login providers. """ return ax.valid_http(http_success=HTTPOk, detail=s.Providers_GET_OkResponseSchema.description, - content={u"providers": {u"internal": sorted(MAGPIE_INTERNAL_PROVIDERS.values()), - u"external": sorted(MAGPIE_EXTERNAL_PROVIDERS.values()), }}) + content={"providers": {"internal": sorted(MAGPIE_INTERNAL_PROVIDERS.values()), + "external": sorted(MAGPIE_EXTERNAL_PROVIDERS.values()), }}) diff --git a/magpie/api/management/__init__.py b/magpie/api/management/__init__.py index aa3a5a3c3..fb09062db 100644 --- a/magpie/api/management/__init__.py +++ b/magpie/api/management/__init__.py @@ -4,9 +4,10 @@ def includeme(config): - LOGGER.info("Adding management routes...") + LOGGER.info("Adding API management routes...") config.include("magpie.api.management.group") config.include("magpie.api.management.user") config.include("magpie.api.management.service") config.include("magpie.api.management.resource") + config.include("magpie.api.management.register") config.scan() diff --git a/magpie/api/management/group/group_formats.py b/magpie/api/management/group/group_formats.py index ce43a47d3..2c92895a9 100644 --- a/magpie/api/management/group/group_formats.py +++ b/magpie/api/management/group/group_formats.py @@ -1,32 +1,49 @@ from typing import TYPE_CHECKING + from pyramid.httpexceptions import HTTPInternalServerError from magpie.api.exception import evaluate_call if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import JSON, Optional # noqa: F401 + from typing import Optional + from sqlalchemy.orm.session import Session + from magpie.models import Group + from magpie.typedefs import JSON + +def format_group(group, basic_info=False, public_info=False, db_session=None): + # type: (Group, bool, bool, Optional[Session]) -> JSON + """ + Obtains the JSON formatted group definition according to field selection flags. -def format_group(group, basic_info=False, db_session=None): - # type: (Group, bool, Optional[Session]) -> JSON - def fmt_grp(grp, info): - if info: - return { - u"group_name": str(grp.group_name), - u"group_id": grp.id, - } - return { - u"group_name": str(grp.group_name), - u"description": str(grp.description), - u"member_count": grp.get_member_count(db_session), - u"group_id": grp.id, - u"user_names": [usr.user_name for usr in grp.users] - } + :param group: Group for which to provide details. + :param basic_info: + If ``True``, return only sufficient details to identify the group (useful for routes that refer to a group, + but that are not requesting it specifically), or return full details (for routes that specifically request + its information, e.g.: ``GET /groups/{grp}``). + :param public_info: + Indicate if the returned details are intended for public information (``True``) or admin-only (``False``). + Only higher level users should be provided additional details to avoid leaking potentially sensitive parameters. + :param db_session: Database connection to retrieve additional details (required when ``public_info=False``). + """ + def fmt_grp(grp, is_basic, is_public): + info = {"group_name": str(grp.group_name)} + if not is_public: + info["group_id"] = grp.id + if is_basic: + return info + info["description"] = str(grp.description) if grp.description else None + if is_public: + return info + info["discoverable"] = grp.discoverable + info["member_count"] = grp.get_member_count(db_session) + info["user_names"] = [usr.user_name for usr in grp.users] + return info return evaluate_call( - lambda: fmt_grp(group, basic_info), http_error=HTTPInternalServerError, - msg_on_fail="Failed to format group.", content={u"group": repr(group)} + lambda: fmt_grp(group, basic_info, public_info), http_error=HTTPInternalServerError, + msg_on_fail="Failed to format group.", content={"group": repr(group)} ) diff --git a/magpie/api/management/group/group_utils.py b/magpie/api/management/group/group_utils.py index ccae226fd..56080b1b1 100644 --- a/magpie/api/management/group/group_utils.py +++ b/magpie/api/management/group/group_utils.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING from pyramid.httpexceptions import ( + HTTPBadRequest, HTTPConflict, HTTPCreated, HTTPForbidden, @@ -8,6 +9,7 @@ HTTPNotFound, HTTPOk ) +from pyramid.settings import asbool from ziggurat_foundations.models.services.group import GroupService from ziggurat_foundations.models.services.group_resource_permission import GroupResourcePermissionService from ziggurat_foundations.models.services.resource import ResourceService @@ -24,10 +26,13 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from pyramid.httpexceptions import HTTPException # noqa: F401 + from typing import Iterable, List, Optional + + from pyramid.httpexceptions import HTTPException from sqlalchemy.orm.session import Session - from magpie.typedefs import Str, Iterable, List, Optional, JSON, ServiceOrResourceType # noqa: F401 - from magpie.permissions import Permission # noqa: F401 + + from magpie.permissions import Permission + from magpie.typedefs import JSON, ResourcePermissionMap, ServiceOrResourceType, Str def get_all_group_names(db_session): @@ -65,27 +70,42 @@ def get_group_resources(group, db_session): return json_response -def create_group(group_name, db_session): - # type: (Str, Session) -> HTTPException +def create_group(group_name, description, discoverable, db_session): + # type: (Str, Str, bool, Session) -> HTTPException """ Creates a group if it is permitted and not conflicting. :returns: valid HTTP response on successful operations. :raises HTTPException: error HTTP response of corresponding situation. """ + description = str(description) if description else None + discoverable = asbool(discoverable) + group_content_error = { + "group_name": str(group_name), + "description": description, + "discoverable": discoverable + } + ax.verify_param(group_name, matches=True, param_compare=ax.PARAM_REGEX, param_name="group_name", + http_error=HTTPBadRequest, content=group_content_error, + msg_on_fail=s.Groups_POST_BadRequestResponseSchema.description) + if description: + ax.verify_param(description, matches=True, param_compare=ax.PARAM_REGEX, param_name="description", + http_error=HTTPBadRequest, content=group_content_error, + msg_on_fail=s.Groups_POST_BadRequestResponseSchema.description) + group = GroupService.by_group_name(group_name, db_session=db_session) - group_content_error = {u"group_name": str(group_name)} - ax.verify_param(group, is_none=True, http_error=HTTPConflict, with_param=False, - msg_on_fail=s.Groups_POST_ConflictResponseSchema.description, content=group_content_error) - new_group = ax.evaluate_call(lambda: models.Group(group_name=group_name), # noqa - fallback=lambda: db_session.rollback(), - http_error=HTTPForbidden, content=group_content_error, - msg_on_fail=s.Groups_POST_ForbiddenCreateResponseSchema.description) + ax.verify_param(group, is_none=True, param_name="group_name", with_param=False, # don't return group as value + http_error=HTTPConflict, content=group_content_error, + msg_on_fail=s.Groups_POST_ConflictResponseSchema.description) + new_group = ax.evaluate_call( + lambda: models.Group(group_name=group_name, description=description, discoverable=discoverable), # noqa + fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, content=group_content_error, + msg_on_fail=s.Groups_POST_ForbiddenCreateResponseSchema.description) ax.evaluate_call(lambda: db_session.add(new_group), fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, content=group_content_error, msg_on_fail=s.Groups_POST_ForbiddenAddResponseSchema.description) return ax.valid_http(http_success=HTTPCreated, detail=s.Groups_POST_CreatedResponseSchema.description, - content={u"group": format_group(new_group, basic_info=True)}) + content={"group": format_group(new_group, basic_info=True)}) def create_group_resource_permission_response(group, resource, permission, db_session): @@ -98,9 +118,9 @@ def create_group_resource_permission_response(group, resource, permission, db_se """ resource_id = resource.resource_id check_valid_service_or_resource_permission(permission.value, resource, db_session) - perm_content = {u"permission_name": str(permission.value), - u"resource": format_resource(resource, basic_info=True), - u"group": format_group(group, basic_info=True)} + perm_content = {"permission_name": str(permission.value), + "resource": format_resource(resource, basic_info=True), + "group": format_group(group, basic_info=True)} existing_perm = ax.evaluate_call( lambda: GroupResourcePermissionService.get(group.id, resource_id, permission.value, db_session=db_session), fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, @@ -142,8 +162,8 @@ def get_grp_res_perm(grp, db, res_ids, res_types): fallback=lambda: db_session.rollback(), http_error=HTTPInternalServerError, msg_on_fail=s.GroupResourcesPermissions_InternalServerErrorResponseSchema.description, - content={u"group": repr(group), u"resource_ids": repr(resource_ids), - u"resource_types": repr(resource_types)}) + content={"group": repr(group), "resource_ids": repr(resource_ids), + "resource_types": repr(resource_types)}) def get_group_resource_permissions_response(group, resource, db_session): @@ -156,6 +176,7 @@ def get_group_resource_permissions_response(group, resource, db_session): """ def get_grp_res_perms(grp, res, db): if res.owner_group_id == grp.id: + # FIXME: no 'magpie.models.Resource.permissions' - ok for now because no owner handling... return models.RESOURCE_TYPE_DICT[res.type].permissions perms = db.query(models.GroupResourcePermission) \ .filter(models.GroupResourcePermission.resource_id == res.resource_id) \ @@ -166,9 +187,9 @@ def get_grp_res_perms(grp, res, db): lambda: format_permissions(get_grp_res_perms(group, resource, db_session)), http_error=HTTPInternalServerError, msg_on_fail=s.GroupResourcePermissions_InternalServerErrorResponseSchema.description, - content={u"group": repr(group), u"resource": repr(resource)}) + content={"group": repr(group), "resource": repr(resource)}) return ax.valid_http(http_success=HTTPOk, detail=s.GroupResourcePermissions_GET_OkResponseSchema.description, - content={u"permission_names": group_perm_names}) + content={"permission_names": group_perm_names}) def delete_group_resource_permission_response(group, resource, permission, db_session): @@ -181,9 +202,9 @@ def delete_group_resource_permission_response(group, resource, permission, db_se """ resource_id = resource.resource_id check_valid_service_or_resource_permission(permission.value, resource, db_session) - perm_content = {u"permission_name": permission.value, - u"resource": format_resource(resource, basic_info=True), - u"group": format_group(group, basic_info=True)} + perm_content = {"permission_name": permission.value, + "resource": format_resource(resource, basic_info=True), + "group": format_group(group, basic_info=True)} del_perm = ax.evaluate_call( lambda: GroupResourcePermissionService.get(group.id, resource_id, permission.value, db_session=db_session), fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, @@ -227,9 +248,9 @@ def get_group_services_response(group, db_session): grp_svc_json = ax.evaluate_call(lambda: get_group_services(res_perm_dict, db_session), http_error=HTTPInternalServerError, msg_on_fail=s.GroupServices_InternalServerErrorResponseSchema.description, - content={u"group": format_group(group, basic_info=True)}) + content={"group": format_group(group, basic_info=True)}) return ax.valid_http(http_success=HTTPOk, detail=s.GroupServices_GET_OkResponseSchema.description, - content={u"services": grp_svc_json}) + content={"services": grp_svc_json}) def get_group_service_permissions(group, service, db_session): @@ -248,7 +269,7 @@ def get_grp_svc_perms(grp, svc, db): return ax.evaluate_call(lambda: get_grp_svc_perms(group, service, db_session), http_error=HTTPInternalServerError, msg_on_fail="Failed to obtain group service permissions", - content={u"group": repr(group), u"service": repr(service)}) + content={"group": repr(group), "service": repr(service)}) def get_group_service_permissions_response(group, service, db_session): @@ -263,13 +284,13 @@ def get_group_service_permissions_response(group, service, db_session): lambda: format_permissions(get_group_service_permissions(group, service, db_session)), http_error=HTTPInternalServerError, msg_on_fail=s.GroupServicePermissions_GET_InternalServerErrorResponseSchema.description, - content={u"group": format_group(group, basic_info=True), u"service": format_service(service)}) + content={"group": format_group(group, basic_info=True), "service": format_service(service)}) return ax.valid_http(http_success=HTTPOk, detail=s.GroupServicePermissions_GET_OkResponseSchema.description, - content={u"permission_names": svc_perms_found}) + content={"permission_names": svc_perms_found}) def get_group_service_resources_permissions_dict(group, service, db_session): - # type: (models.Group, models.Service, Session) -> JSON + # type: (models.Group, models.Service, Session) -> ResourcePermissionMap """ Get all permissions the group has on a specific service's children resources. """ @@ -298,4 +319,4 @@ def get_group_service_resources_response(group, service, db_session): show_private_url=False, ) return ax.valid_http(http_success=HTTPOk, detail=s.GroupServiceResources_GET_OkResponseSchema.description, - content={u"service": svc_res_json}) + content={"service": svc_res_json}) diff --git a/magpie/api/management/group/group_views.py b/magpie/api/management/group/group_views.py index 90ed32dcf..82e38c012 100644 --- a/magpie/api/management/group/group_views.py +++ b/magpie/api/management/group/group_views.py @@ -1,4 +1,5 @@ from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPForbidden, HTTPInternalServerError, HTTPOk +from pyramid.settings import asbool from pyramid.view import view_config from ziggurat_foundations.models.services.group import GroupService @@ -18,7 +19,7 @@ def get_groups_view(request): """ group_names = gu.get_all_group_names(request.db) return ax.valid_http(http_success=HTTPOk, detail=s.Groups_GET_OkResponseSchema.description, - content={u"group_names": group_names}) + content={"group_names": group_names}) @s.GroupsAPI.post(schema=s.Groups_POST_RequestSchema(), tags=[s.GroupsTag], response_schemas=s.Groups_POST_responses) @@ -27,8 +28,10 @@ def create_group_view(request): """ Create a group. """ - group_name = ar.get_value_multiformat_post_checked(request, "group_name") - return gu.create_group(group_name, request.db) + group_name = ar.get_value_multiformat_body_checked(request, "group_name") + group_desc = ar.get_multiformat_body(request, "description", default="") + group_disc = asbool(ar.get_multiformat_body(request, "discoverable", default=False)) + return gu.create_group(group_name, group_desc, group_disc, request.db) @s.GroupAPI.get(tags=[s.GroupsTag], response_schemas=s.Group_GET_responses) @@ -39,29 +42,52 @@ def get_group_view(request): """ group = ar.get_group_matchdict_checked(request, group_name_key="group_name") return ax.valid_http(http_success=HTTPOk, detail=s.Group_GET_OkResponseSchema.description, - content={u"group": gf.format_group(group, db_session=request.db)}) + content={"group": gf.format_group(group, db_session=request.db)}) -@s.GroupAPI.put(schema=s.Group_PUT_RequestSchema(), tags=[s.GroupsTag], response_schemas=s.Group_PUT_responses) -@view_config(route_name=s.GroupAPI.name, request_method="PUT") +@s.GroupAPI.patch(schema=s.Group_PATCH_RequestSchema(), tags=[s.GroupsTag], response_schemas=s.Group_PATCH_responses) +@view_config(route_name=s.GroupAPI.name, request_method="PATCH") def edit_group_view(request): """ Update a group by name. """ group = ar.get_group_matchdict_checked(request, group_name_key="group_name") - new_group_name = ar.get_multiformat_post(request, "group_name") - ax.verify_param(new_group_name, not_none=True, not_empty=True, http_error=HTTPBadRequest, - msg_on_fail=s.Group_PUT_Name_BadRequestResponseSchema.description) - ax.verify_param(len(new_group_name), is_in=True, http_error=HTTPBadRequest, - param_compare=range(1, 1 + get_constant("MAGPIE_USER_NAME_MAX_LENGTH")), - msg_on_fail=s.Group_PUT_Size_BadRequestResponseSchema.description) - ax.verify_param(new_group_name, not_equal=True, http_error=HTTPBadRequest, - param_compare=group.group_name, msg_on_fail=s.Group_PUT_Same_BadRequestResponseSchema.description) - ax.verify_param(GroupService.by_group_name(new_group_name, db_session=request.db), - is_none=True, http_error=HTTPConflict, - msg_on_fail=s.Group_PUT_ConflictResponseSchema.description) - group.group_name = new_group_name - return ax.valid_http(http_success=HTTPOk, detail=s.Group_PUT_OkResponseSchema.description) + special_groups = [ + get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=request), + get_constant("MAGPIE_ADMIN_GROUP", settings_container=request), + ] + ax.verify_param(group.group_name, not_in=True, param_compare=special_groups, param_name="group_name", + http_error=HTTPForbidden, + msg_on_fail=s.Group_PATCH_ReservedKeyword_ForbiddenResponseSchema.description) + + new_group_name = ar.get_multiformat_body(request, "group_name") + new_description = ar.get_multiformat_body(request, "description") + new_discoverability = ar.get_multiformat_body(request, "discoverable") + if new_discoverability is not None: + new_discoverability = asbool(new_discoverability) + update_name = group.group_name != new_group_name and new_group_name is not None + update_desc = group.description != new_description and new_description is not None + update_disc = group.discoverable != new_discoverability and new_discoverability is not None + ax.verify_param(any([update_name, update_desc, update_disc]), is_true=True, + with_param=False, # params are not useful in response for this case + http_error=HTTPBadRequest, content={"group_name": group.group_name}, + msg_on_fail=s.Group_PATCH_None_BadRequestResponseSchema.description) + if update_name: + ax.verify_param(new_group_name, not_none=True, not_empty=True, http_error=HTTPBadRequest, + msg_on_fail=s.Group_PATCH_Name_BadRequestResponseSchema.description) + group_name_size_range = range(1, 1 + get_constant("MAGPIE_GROUP_NAME_MAX_LENGTH", settings_container=request)) + ax.verify_param(len(new_group_name), is_in=True, param_compare=group_name_size_range, + http_error=HTTPBadRequest, + msg_on_fail=s.Group_PATCH_Size_BadRequestResponseSchema.description) + ax.verify_param(GroupService.by_group_name(new_group_name, db_session=request.db), + is_none=True, http_error=HTTPConflict, with_param=False, # don't return group as value + msg_on_fail=s.Group_PATCH_ConflictResponseSchema.description) + group.group_name = new_group_name + if update_desc: + group.description = new_description + if update_disc: + group.discoverable = new_discoverability + return ax.valid_http(http_success=HTTPOk, detail=s.Group_PATCH_OkResponseSchema.description) @s.GroupAPI.delete(schema=s.Group_DELETE_RequestSchema(), tags=[s.GroupsTag], response_schemas=s.Group_DELETE_responses) @@ -71,6 +97,13 @@ def delete_group_view(request): Delete a group by name. """ group = ar.get_group_matchdict_checked(request) + special_groups = [ + get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=request), + get_constant("MAGPIE_ADMIN_GROUP", settings_container=request), + ] + ax.verify_param(group.group_name, not_in=True, param_compare=special_groups, param_name="group_name", + http_error=HTTPForbidden, + msg_on_fail=s.Group_DELETE_ReservedKeyword_ForbiddenResponseSchema.description) ax.evaluate_call(lambda: request.db.delete(group), fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, msg_on_fail=s.Group_DELETE_ForbiddenResponseSchema.description) @@ -88,7 +121,7 @@ def get_group_users_view(request): http_error=HTTPForbidden, msg_on_fail=s.GroupUsers_GET_ForbiddenResponseSchema.description) return ax.valid_http(http_success=HTTPOk, detail=s.GroupUsers_GET_OkResponseSchema.description, - content={u"user_names": sorted(user_names)}) + content={"user_names": sorted(user_names)}) @s.GroupServicesAPI.get(tags=[s.GroupsTag], response_schemas=s.GroupServices_GET_responses) @@ -121,7 +154,7 @@ def create_group_service_permission_view(request): """ group = ar.get_group_matchdict_checked(request) service = ar.get_service_matchdict_checked(request) - permission = ar.get_permission_multiformat_post_checked(request, service) + permission = ar.get_permission_multiformat_body_checked(request, service) return gu.create_group_resource_permission_response(group, service, permission, db_session=request.db) @@ -147,10 +180,10 @@ def get_group_resources_view(request): group = ar.get_group_matchdict_checked(request) grp_res_json = ax.evaluate_call(lambda: gu.get_group_resources(group, request.db), fallback=lambda: request.db.rollback(), - http_error=HTTPInternalServerError, content={u"group": repr(group)}, + http_error=HTTPInternalServerError, content={"group": repr(group)}, msg_on_fail=s.GroupResources_GET_InternalServerErrorResponseSchema.description) return ax.valid_http(http_success=HTTPOk, detail=s.GroupResources_GET_OkResponseSchema.description, - content={u"resources": grp_res_json}) + content={"resources": grp_res_json}) @s.GroupResourcePermissionsAPI.get(tags=[s.GroupsTag], response_schemas=s.GroupResourcePermissions_GET_responses) @@ -173,7 +206,7 @@ def create_group_resource_permission_view(request): """ group = ar.get_group_matchdict_checked(request) resource = ar.get_resource_matchdict_checked(request) - permission = ar.get_permission_multiformat_post_checked(request, resource) + permission = ar.get_permission_multiformat_body_checked(request, resource) return gu.create_group_resource_permission_response(group, resource, permission, db_session=request.db) diff --git a/magpie/api/management/register/__init__.py b/magpie/api/management/register/__init__.py new file mode 100644 index 000000000..8f2e0d304 --- /dev/null +++ b/magpie/api/management/register/__init__.py @@ -0,0 +1,11 @@ +from magpie.api import schemas as s +from magpie.utils import get_logger + +LOGGER = get_logger(__name__) + + +def includeme(config): + LOGGER.info("Adding API register...") + config.add_route(**s.service_api_route_info(s.RegisterGroupsAPI)) + config.add_route(**s.service_api_route_info(s.RegisterGroupAPI)) + config.scan() diff --git a/magpie/api/management/register/register_utils.py b/magpie/api/management/register/register_utils.py new file mode 100644 index 000000000..fa464577a --- /dev/null +++ b/magpie/api/management/register/register_utils.py @@ -0,0 +1,50 @@ +from typing import TYPE_CHECKING + +from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound +from ziggurat_foundations.models.services.group import GroupService + +from magpie.api import exception as ax +from magpie.api import schemas as s +from magpie.models import Group +from magpie.utils import CONTENT_TYPE_JSON + +if TYPE_CHECKING: + from typing import List + + from sqlalchemy.orm.session import Session + + from magpie.typedefs import Str + + +def get_discoverable_groups(db_session): + # type: (Session) -> List[Group] + """ + Get all existing group that are marked as publicly discoverable from the database. + """ + groups = ax.evaluate_call( + lambda: [grp for grp in GroupService.all(Group, db_session=db_session) if grp.discoverable], + http_error=HTTPForbidden, msg_on_fail=s.RegisterGroups_GET_ForbiddenResponseSchema.description) + return groups + + +def get_discoverable_group_by_name(group_name, db_session): + # type: (Str, Session) -> Group + """ + Obtains the requested discoverable group by name. + + .. note:: + For security reason, an existing group that is **NOT** discoverable will return NotFound instead of Forbidden. + Otherwise we give an indication to a potentially non-admin user that *some group* of that name exists. + + :return: found group matched by name + :raises HTTPNotFound: if the group cannot be found or if matched group name is not discoverable. + """ + public_groups = get_discoverable_groups(db_session) + found_group = ax.evaluate_call(lambda: [grp for grp in public_groups if grp.group_name == group_name], + http_error=HTTPNotFound, + msg_on_fail=s.RegisterGroup_NotFoundResponseSchema.description, + content={"group_name": group_name}) + ax.verify_param(found_group, param_name="group_name", not_empty=True, + http_error=HTTPNotFound, content_type=CONTENT_TYPE_JSON, + msg_on_fail=s.RegisterGroup_NotFoundResponseSchema.description) + return found_group[0] diff --git a/magpie/api/management/register/register_views.py b/magpie/api/management/register/register_views.py new file mode 100644 index 000000000..d1b6527e9 --- /dev/null +++ b/magpie/api/management/register/register_views.py @@ -0,0 +1,84 @@ +from typing import TYPE_CHECKING + +from pyramid.authentication import Authenticated +from pyramid.httpexceptions import HTTPConflict, HTTPCreated, HTTPForbidden, HTTPInternalServerError, HTTPOk +from pyramid.view import view_config + +from magpie import models +from magpie.api import exception as ax +from magpie.api import requests as ar +from magpie.api import schemas as s +from magpie.api.management.group import group_formats as gf +from magpie.api.management.register import register_utils as ru +from magpie.api.management.user import user_utils as uu + +if TYPE_CHECKING: + from pyramid.httpexceptions import HTTPException + from pyramid.request import Request + + +@s.RegisterGroupsAPI.get(tags=[s.GroupsTag, s.LoggedUserTag, s.RegisterTag], + response_schemas=s.RegisterGroups_GET_responses) +@view_config(route_name=s.RegisterGroupsAPI.name, request_method="GET", permission=Authenticated) +def get_discoverable_groups_view(request): + # type: (Request) -> HTTPException + """ + List all discoverable groups (publicly available to join). + """ + public_groups = ru.get_discoverable_groups(request.db) + public_group_names = ax.evaluate_call(lambda: [grp.group_name for grp in public_groups], + http_error=HTTPInternalServerError, + msg_on_fail=s.InternalServerErrorResponseSchema.description) + return ax.valid_http(http_success=HTTPOk, content={"group_names": public_group_names}, + detail=s.RegisterGroups_GET_OkResponseSchema.description) + + +@s.RegisterGroupAPI.get(tags=[s.GroupsTag, s.LoggedUserTag, s.RegisterTag], + response_schemas=s.RegisterGroup_GET_responses) +@view_config(route_name=s.RegisterGroupAPI.name, request_method="GET", permission=Authenticated) +def get_discoverable_group_info_view(request): + """ + Obtain the information of a discoverable group. + """ + group = ar.get_group_matchdict_checked(request) + public_group = ru.get_discoverable_group_by_name(group.group_name, db_session=request.db) + group_fmt = gf.format_group(public_group, public_info=True) + return ax.valid_http(http_success=HTTPOk, content={"group": group_fmt}, + detail=s.RegisterGroup_GET_OkResponseSchema.description) + + +@s.RegisterGroupAPI.post(schema=s.RegisterGroup_POST_RequestSchema, tags=[s.GroupsTag, s.LoggedUserTag, s.RegisterTag], + response_schemas=s.RegisterGroup_POST_responses) +@view_config(route_name=s.RegisterGroupAPI.name, request_method="POST", permission=Authenticated) +def join_discoverable_group_view(request): + """ + Assigns membership of the logged user to a publicly discoverable group. + """ + group = ar.get_group_matchdict_checked(request) + user = ar.get_logged_user(request) + group = ru.get_discoverable_group_by_name(group.group_name, db_session=request.db) + + ax.verify_param(user.id, param_compare=[usr.id for usr in group.users], not_in=True, with_param=False, + http_error=HTTPConflict, content={"user_name": user.user_name, "group_name": group.group_name}, + msg_on_fail=s.RegisterGroup_POST_ConflictResponseSchema.description) + ax.evaluate_call(lambda: request.db.add(models.UserGroup(group_id=group.id, user_id=user.id)), # noqa + fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, + msg_on_fail=s.RegisterGroup_POST_ForbiddenResponseSchema.description, + content={"user_name": user.user_name, "group_name": group.group_name}) + return ax.valid_http(http_success=HTTPCreated, detail=s.RegisterGroup_POST_CreatedResponseSchema.description, + content={"user_name": user.user_name, "group_name": group.group_name}) + + +@s.RegisterGroupAPI.delete(schema=s.RegisterGroup_DELETE_RequestSchema, + tags=[s.GroupsTag, s.LoggedUserTag, s.RegisterTag], + response_schemas=s.RegisterGroup_DELETE_responses) +@view_config(route_name=s.RegisterGroupAPI.name, request_method="DELETE", permission=Authenticated) +def leave_discoverable_group_view(request): + """ + Removes membership of the logged user from a previously joined discoverable group. + """ + group = ar.get_group_matchdict_checked(request) + user = ar.get_logged_user(request) + group = ru.get_discoverable_group_by_name(group.group_name, db_session=request.db) + uu.delete_user_group(user, group, request.db) + return ax.valid_http(http_success=HTTPOk, detail=s.RegisterGroup_DELETE_OkResponseSchema.description) diff --git a/magpie/api/management/resource/__init__.py b/magpie/api/management/resource/__init__.py index 4c0d1c303..2c30e2d67 100644 --- a/magpie/api/management/resource/__init__.py +++ b/magpie/api/management/resource/__init__.py @@ -5,7 +5,7 @@ def includeme(config): - LOGGER.info("Adding api resource...") + LOGGER.info("Adding API resource...") # Add all the rest api routes config.add_route(**s.service_api_route_info(s.ResourcesAPI)) config.add_route(**s.service_api_route_info(s.ResourceAPI)) diff --git a/magpie/api/management/resource/resource_formats.py b/magpie/api/management/resource/resource_formats.py index 2831db257..5b20bc8c7 100644 --- a/magpie/api/management/resource/resource_formats.py +++ b/magpie/api/management/resource/resource_formats.py @@ -1,29 +1,45 @@ +from typing import TYPE_CHECKING + from pyramid.httpexceptions import HTTPInternalServerError from ziggurat_foundations.models.services.resource import ResourceService from magpie.api.exception import evaluate_call -from magpie.models import RESOURCE_TREE_SERVICE from magpie.permissions import format_permissions from magpie.services import SERVICE_TYPE_DICT +if TYPE_CHECKING: + from typing import Iterable, Optional + + from sqlalchemy.orm.session import Session + + from magpie.models import Resource, Service + from magpie.typedefs import ( + JSON, + AnyPermissionType, + ChildrenResourceNodes, + ResourcePermissionMap, + ServiceOrResourceType + ) + def format_resource(resource, permissions=None, basic_info=False): + # type: (Resource, Optional[Iterable[AnyPermissionType]], bool) -> JSON """ - Formats the ``resource`` information into JSON. + Formats the :paramref:`resource` information into JSON. """ def fmt_res(res, perms, info): result = { - u"resource_name": str(res.resource_name), - u"resource_display_name": str(res.resource_display_name or res.resource_name), - u"resource_type": str(res.resource_type), - u"resource_id": res.resource_id + "resource_name": str(res.resource_name), + "resource_display_name": str(res.resource_display_name or res.resource_name), + "resource_type": str(res.resource_type), + "resource_id": res.resource_id } if not info: result.update({ - u"parent_id": res.parent_id, - u"root_service_id": res.root_service_id, - u"children": {}, - u"permission_names": list() if perms is None else format_permissions(perms) + "parent_id": res.parent_id, + "root_service_id": res.root_service_id, + "children": {}, + "permission_names": list() if perms is None else format_permissions(perms) }) return result @@ -31,75 +47,84 @@ def fmt_res(res, perms, info): lambda: fmt_res(resource, permissions, basic_info), http_error=HTTPInternalServerError, msg_on_fail="Failed to format resource.", - content={u"resource": repr(resource), u"permissions": repr(permissions), u"basic_info": str(basic_info)} + content={"resource": repr(resource), "permissions": repr(permissions), "basic_info": str(basic_info)} ) -def format_resource_tree(children, db_session, resources_perms_dict=None, _internal_svc_res_perm_dict=None): +def format_resource_tree(children, db_session, resources_perms_dict=None): + # type: (ChildrenResourceNodes, Session, Optional[ResourcePermissionMap]) -> JSON """ - Generates the formatted service/resource tree with all its children resources by calling :func:`format_resource` - recursively. + Generates the formatted resource tree under the provided children resources, with all of their children resources by + calling :func:`format_resource` recursively on them. - Filters resource permissions with ``resources_perms_dict`` if provided. + Apply specific resource permissions as defined by :paramref:`resources_perms_dict` if provided. :param children: service or resource for which to generate the formatted resource tree :param db_session: connection to db - :param resources_perms_dict: any pre-established user- or group-specific permissions. Only those are shown if given. - :param _internal_svc_res_perm_dict: *for this function's use only*, - avoid re-fetch of already obtained permissions for corresponding resources + :param resources_perms_dict: + Any pre-established :term:`Applied Permissions` to set to corresponding resources by ID. + When provided, these will define the :term:`User`, :term:`Group` or both (i.e.: :term:`Inherited Permissions`) + actual permissions, or even the :term:`Effective Permissions`, according to parent caller function's context. + Otherwise (``None``), defaults to extracting :term:`Allowed Permissions` for the given :term:`Resource` scoped + under the corresponding root :term:`Service`. :return: formatted resource tree """ - _internal_svc_res_perm_dict = dict() if _internal_svc_res_perm_dict is None else _internal_svc_res_perm_dict - - fmt_res_tree = {} - for child_id, child_dict in children.items(): - resource = child_dict[u"node"] - new_children = child_dict[u"children"] - perms = [] - - # case of pre-specified user/group-specific permissions - if resources_perms_dict is not None: - if resource.resource_id in resources_perms_dict.keys(): - perms = resources_perms_dict[resource.resource_id] - - # case of full fetch (permitted resource permissions) - else: - # directly access the resource if it is a service - service = None - if resource.root_service_id is None: - service = resource - service_id = resource.resource_id - # obtain corresponding top-level service resource if not already available + # optimization to avoid re-lookup of 'allowed permissions' when already fetched + # unused when parsing 'applied permissions' + __internal_svc_res_perm_dict = {} + + def recursive_fmt_res_tree(children_dict): + fmt_res_tree = {} + for child_id, child_dict in children_dict.items(): + resource = child_dict["node"] + new_children = child_dict["children"] + perms = [] + + # case of pre-specified user/group-specific permissions + if resources_perms_dict is not None: + if resource.resource_id in resources_perms_dict.keys(): + perms = resources_perms_dict[resource.resource_id] + + # case of full fetch (allowed resource permissions) else: - service_id = resource.root_service_id - if service_id not in _internal_svc_res_perm_dict: - service = ResourceService.by_resource_id(service_id, db_session=db_session) - # add to dict only if not already added - if service is not None and service_id not in _internal_svc_res_perm_dict: - _internal_svc_res_perm_dict[service_id] = { - res_type.resource_type_name: res_perms # use str key to match below 'resource_type' field - for res_type, res_perms in SERVICE_TYPE_DICT[service.type].resource_types_permissions.items() - } - perms = _internal_svc_res_perm_dict[service_id][resource.resource_type] # 'resource_type' is str here - - fmt_res_tree[child_id] = format_resource(resource, perms) - fmt_res_tree[child_id][u"children"] = format_resource_tree(new_children, db_session, - resources_perms_dict, _internal_svc_res_perm_dict) - - return fmt_res_tree - - -def get_resource_children(resource, db_session): - query = RESOURCE_TREE_SERVICE.from_parent_deeper(resource.resource_id, db_session=db_session) - tree_struct_dict = RESOURCE_TREE_SERVICE.build_subtree_strut(query) - return tree_struct_dict[u"children"] + # directly access the resource if it is a service + service = None # type: Optional[Service] + if resource.root_service_id is None: + service = resource + service_id = resource.resource_id + # obtain corresponding top-level service resource if not already available, + # get resource permissions allowed under the top service's scope + else: + service_id = resource.root_service_id + if service_id not in __internal_svc_res_perm_dict: + service = ResourceService.by_resource_id(service_id, db_session=db_session) + # add to dict only if not already added + if service is not None and service_id not in __internal_svc_res_perm_dict: + __internal_svc_res_perm_dict[service_id] = { + res_type.resource_type_name: res_perms # use str key to match below 'resource_type' field + for res_type, res_perms in SERVICE_TYPE_DICT[service.type].resource_types_permissions.items() + } + perms = __internal_svc_res_perm_dict[service_id][resource.resource_type] # 'resource_type' is str here + + fmt_res_tree[child_id] = format_resource(resource, perms) + fmt_res_tree[child_id]["children"] = recursive_fmt_res_tree(new_children) + return fmt_res_tree + + return recursive_fmt_res_tree(children) def format_resource_with_children(resource, db_session): - resource_formatted = format_resource(resource) + # type: (ServiceOrResourceType, Session) -> JSON + """ + Obtains the formatted :term:`Resource` tree with all its formatted children hierarchy. + """ + from magpie.api.management.resource import resource_utils as ru + + resource_permissions = ru.get_resource_permissions(resource, db_session=db_session) + resource_formatted = format_resource(resource, permissions=resource_permissions) - resource_formatted[u"children"] = format_resource_tree( - get_resource_children(resource, db_session), + resource_formatted["children"] = format_resource_tree( + ru.get_resource_children(resource, db_session), db_session=db_session ) return resource_formatted diff --git a/magpie/api/management/resource/resource_utils.py b/magpie/api/management/resource/resource_utils.py index 5b4b0b281..db27cf72c 100644 --- a/magpie/api/management/resource/resource_utils.py +++ b/magpie/api/management/resource/resource_utils.py @@ -19,14 +19,19 @@ from magpie.api.management.resource.resource_formats import format_resource from magpie.permissions import Permission from magpie.register import sync_services_phoenix -from magpie.services import SERVICE_TYPE_DICT +from magpie.services import SERVICE_TYPE_DICT, service_factory if TYPE_CHECKING: # pylint: disable=W0611,unused-import + from typing import List, Optional, Tuple, Type + from pyramid.httpexceptions import HTTPException + from pyramid.request import Request from sqlalchemy.orm.session import Session - from magpie.typedefs import List, Str, Optional, Tuple, Type, ServiceOrResourceType # noqa: F401 - from magpie.services import ServiceInterface # noqa: F401 + from ziggurat_foundations.models.services.resource_tree import ResourceTreeService + + from magpie.services import ServiceInterface + from magpie.typedefs import ChildrenResourceNodes, ServiceOrResourceType, Str def check_valid_service_or_resource_permission(permission_name, service_or_resource, db_session): @@ -43,9 +48,9 @@ def check_valid_service_or_resource_permission(permission_name, service_or_resou svc_res_type = service_or_resource.resource_type svc_res_name = service_or_resource.resource_name svc_res_perm = Permission.get(permission_name) - ax.verify_param(svc_res_perm, param_name=u"permission_name", param_compare=svc_res_permissions, is_in=True, + ax.verify_param(svc_res_perm, param_name="permission_name", param_compare=svc_res_permissions, is_in=True, http_error=HTTPBadRequest, - content={u"resource_type": str(svc_res_type), u"resource_name": str(svc_res_name)}, + content={"resource_type": str(svc_res_type), "resource_name": str(svc_res_name)}, msg_on_fail=s.UserResourcePermissions_POST_BadRequestResponseSchema.description) return svc_res_perm @@ -68,20 +73,63 @@ def check_valid_service_resource(parent_resource, resource_type, db_session): ax.verify_param(root_service, not_none=True, http_error=HTTPInternalServerError, msg_on_fail="Failed retrieving 'root_service' from db") ax.verify_param(root_service.resource_type, is_equal=True, http_error=HTTPInternalServerError, - param_name=u"resource_type", param_compare=models.Service.resource_type_name, + param_name="resource_type", param_compare=models.Service.resource_type_name, msg_on_fail="Invalid 'root_service' retrieved from db is not a service") ax.verify_param(SERVICE_TYPE_DICT[root_service.type].child_resource_allowed, is_equal=True, param_compare=True, http_error=HTTPBadRequest, msg_on_fail="Child resource not allowed for specified service type '{}'".format(root_service.type)) ax.verify_param(resource_type, is_in=True, http_error=HTTPBadRequest, - param_name=u"resource_type", param_compare=SERVICE_TYPE_DICT[root_service.type].resource_type_names, + param_name="resource_type", param_compare=SERVICE_TYPE_DICT[root_service.type].resource_type_names, msg_on_fail="Invalid 'resource_type' specified for service type '{}'".format(root_service.type)) return root_service +def check_unique_child_resource_name(resource_name, parent_id, error_message, db_session): + # type: (Str, int, Str, Session) -> None + """ + Verify that resource will be unique amongst other resources at the same target position. + + Verifies that the provided :paramref:`resource_name` does not already exist amongst other children resources at the + level immediately under the parent, for the specified parent resource. + + :returns: nothing if no conflict detected + :raises HTTPConflict: if the :paramref:`resource_name` conflict with another existing resource + """ + tree_struct = models.RESOURCE_TREE_SERVICE.from_parent_deeper(parent_id, limit_depth=1, db_session=db_session) + tree_struct_dict = models.RESOURCE_TREE_SERVICE.build_subtree_strut(tree_struct) + direct_children = tree_struct_dict["children"] + ax.verify_param(resource_name, param_name="resource_name", not_in=True, + param_compare=[child_dict["node"].resource_name for child_dict in direct_children.values()], + http_error=HTTPConflict, msg_on_fail=error_message) + + def crop_tree_with_permission(children, resource_id_list): + # type: (ChildrenResourceNodes, List[int]) -> Tuple[ChildrenResourceNodes, List[int]] + """ + Recursively prunes all children resources from the tree hierarchy *except* listed ones matched by ID. + + Input :paramref:`children` is expected to be a dictionary of resource nodes and children resources with their ID + as keys:: + + { + : { + "node": , + "children": { + : { + "node": , + "children": { <...> } + }, + <...> + }, + <...> + } + + :param children: full hierarchy of children resource nodes. + :param resource_id_list: resource IDs of nodes to preserve. + :return: pruned hierarchy of resource nodes. + """ for child_id, child_dict in list(children.items()): - new_children = child_dict[u"children"] + new_children = child_dict["children"] children_returned, resource_id_list = crop_tree_with_permission(new_children, resource_id_list) if child_id not in resource_id_list and not children_returned: children.pop(child_id) @@ -91,6 +139,21 @@ def crop_tree_with_permission(children, resource_id_list): def get_resource_path(resource_id, db_session): + # type: (int, Session) -> Str + """ + Obtains the full path representation of the specified resource ID from the root service it resides under using all + respective names of the intermediate resources. + + For example, the following hierarchy:: + + (id: 1) + (id: 2) + (id: 3) + + Will return the following path: ``/service-1/resource-1/resource-2``. + + This is the same representation of the ``resource`` field within startup permissions configuration file. + """ parent_resources = models.RESOURCE_TREE_SERVICE.path_upper(resource_id, db_session=db_session) parent_path = "" for parent_resource in parent_resources: @@ -105,69 +168,120 @@ def get_service_or_resource_types(service_or_resource): """ if isinstance(service_or_resource, models.Service): svc_res_type_cls = SERVICE_TYPE_DICT[service_or_resource.type] - svc_res_type_str = u"service" + svc_res_type_str = "service" elif isinstance(service_or_resource, models.Resource): svc_res_type_cls = models.RESOURCE_TYPE_DICT[service_or_resource.resource_type] - svc_res_type_str = u"resource" + svc_res_type_str = "resource" else: ax.raise_http(http_error=HTTPInternalServerError, detail="Invalid service/resource object", - content={u"service_resource": repr(type(service_or_resource))}) + content={"service_resource": repr(type(service_or_resource))}) return svc_res_type_cls, svc_res_type_str # noqa: W804 +def get_resource_children(resource, db_session, tree_service_builder=None): + # type: (ServiceOrResourceType, Session, Optional[ResourceTreeService]) -> ChildrenResourceNodes + """ + Obtains the children resource node structure of the input service or resource. + + :param resource: initial resource where to start building the tree from + :param db_session: database connection to retrieve resources + :param tree_service_builder: service that build the tree (default: :py:data:`RESOURCE_TREE_SERVICE`) + :returns: {node: Resource, children: {node_id: }} + """ + if tree_service_builder is None: + tree_service_builder = models.RESOURCE_TREE_SERVICE + query = tree_service_builder.from_parent_deeper(resource.resource_id, db_session=db_session) + tree_struct_dict = tree_service_builder.build_subtree_strut(query) + return tree_struct_dict["children"] + + def get_resource_permissions(resource, db_session): - # type: (models.Resource, Session) -> List[Permission] - ax.verify_param(resource, not_none=True, http_error=HTTPBadRequest, param_name=u"resource", + # type: (ServiceOrResourceType, Session) -> List[Permission] + """ + Obtains the applicable permissions on the service or resource, accordingly to what was provided. + + When parsing a resource, rewinds the hierarchy up to the top-most service in order to find the context under which + the resource resides, and therefore which permissions this resource is allowed to have under that service. + """ + ax.verify_param(resource, not_none=True, http_error=HTTPBadRequest, param_name="resource", msg_on_fail=s.UserResourcePermissions_GET_BadRequestResourceResponseSchema.description) # directly access the service resource if resource.root_service_id is None: - service = resource + service = resource # type: models.Service # noqa return SERVICE_TYPE_DICT[service.type].permissions # otherwise obtain root level service to infer sub-resource permissions service = ResourceService.by_resource_id(resource.root_service_id, db_session=db_session) ax.verify_param(service.resource_type, is_equal=True, http_error=HTTPBadRequest, - param_name=u"resource_type", param_compare=models.Service.resource_type_name, + param_name="resource_type", param_compare=models.Service.resource_type_name, msg_on_fail=s.UserResourcePermissions_GET_BadRequestRootServiceResponseSchema.description) service_class = SERVICE_TYPE_DICT[service.type] ax.verify_param(resource.resource_type_name, is_in=True, http_error=HTTPBadRequest, - param_name=u"resource_type", param_compare=service_class.resource_type_names, + param_name="resource_type", param_compare=service_class.resource_type_names, msg_on_fail=s.UserResourcePermissions_GET_BadRequestResourceTypeResponseSchema.description) return service_class.get_resource_permissions(resource.resource_type_name) def get_resource_root_service(resource, db_session): - # type: (models.Resource, Session) -> Optional[models.Resource] + # type: (ServiceOrResourceType, Session) -> Optional[models.Service] """ - Recursively rewinds back through the top of the resource tree up to the top-level service-resource. + Retrieves the service-specialized resource corresponding to the top-level resource in the tree hierarchy. - :param resource: initial resource where to start searching upwards the tree - :param db_session: - :return: resource-tree root service as a resource object + .. seealso:: + - :func:`get_resource_root_service_by_id` for same operation but using the resource ID + - :func:`get_resource_root_service_impl` to retrieve the explicit service's implementation """ if resource is not None: - if resource.parent_id is None: + if resource.resource_type == models.Service.resource_type_name: return resource - parent_resource = ResourceService.by_resource_id(resource.parent_id, db_session=db_session) - return get_resource_root_service(parent_resource, db_session=db_session) + return ResourceService.by_resource_id(resource.root_service_id, db_session=db_session) return None +def get_resource_root_service_by_id(resource_id, db_session): + # type: (ServiceOrResourceType, Session) -> Optional[models.Service] + """ + Retrieves the service-specialized resource corresponding to the top-level resource in the tree hierarchy. + + .. seealso:: + - :func:`get_resource_root_service` for same operation but directly using the resource + """ + resource = ResourceService.by_resource_id(resource_id, db_session=db_session) + if resource is None: + return None + return get_resource_root_service(resource, db_session=db_session) + + +def get_resource_root_service_impl(resource, request): + # type: (ServiceOrResourceType, Request) -> ServiceInterface + """ + Obtain the root service implementation. + + Retrieves the root-resource from the provided resource within a tree hierarchy and generates the + corresponding top-level service's implementation from the :func:`service_factory`. + + .. seealso:: + :func:`get_resource_root_service` to retrieve only the service flavored resource model + """ + service = get_resource_root_service(resource, db_session=request.db) + return service_factory(service, request) + + def create_resource(resource_name, resource_display_name, resource_type, parent_id, db_session): # type: (Str, Optional[Str], Str, int, Session) -> HTTPException - ax.verify_param(resource_name, param_name=u"resource_name", not_none=True, not_empty=True, + ax.verify_param(resource_name, param_name="resource_name", not_none=True, not_empty=True, http_error=HTTPBadRequest, msg_on_fail="Invalid 'resource_name' specified for child resource creation.") - ax.verify_param(resource_type, param_name=u"resource_type", not_none=True, not_empty=True, + ax.verify_param(resource_type, param_name="resource_type", not_none=True, not_empty=True, http_error=HTTPBadRequest, msg_on_fail="Invalid 'resource_type' specified for child resource creation.") - ax.verify_param(parent_id, param_name=u"parent_id", not_none=True, not_empty=True, param_compare=int, is_type=True, + ax.verify_param(parent_id, param_name="parent_id", not_none=True, is_type=True, param_compare=int, http_error=HTTPBadRequest, msg_on_fail="Invalid 'parent_id' specified for child resource creation.") parent_resource = ax.evaluate_call(lambda: ResourceService.by_resource_id(parent_id, db_session=db_session), fallback=lambda: db_session.rollback(), http_error=HTTPNotFound, msg_on_fail=s.Resources_POST_NotFoundResponseSchema.description, - content={u"parent_id": str(parent_id), u"resource_name": str(resource_name), - u"resource_type": str(resource_type)}) + content={"parent_id": str(parent_id), "resource_name": str(resource_name), + "resource_type": str(resource_type)}) # verify for valid permissions from top-level service-specific corresponding resources permissions root_service = check_valid_service_resource(parent_resource, resource_type, db_session) @@ -177,13 +291,9 @@ def create_resource(resource_name, resource_display_name, resource_type, parent_ root_service_id=root_service.resource_id, parent_id=parent_resource.resource_id) - # Two resources with the same parent can't have the same name ! - tree_struct = models.RESOURCE_TREE_SERVICE.from_parent_deeper(parent_id, limit_depth=1, db_session=db_session) - tree_struct_dict = models.RESOURCE_TREE_SERVICE.build_subtree_strut(tree_struct) - direct_children = tree_struct_dict[u"children"] - ax.verify_param(resource_name, param_name=u"resource_name", not_in=True, http_error=HTTPConflict, - msg_on_fail=s.Resources_POST_ConflictResponseSchema.description, - param_compare=[child_dict[u"node"].resource_name for child_dict in direct_children.values()]) + # two resources with the same parent can't have the same name + err_msg = s.Resources_POST_ConflictResponseSchema.description + check_unique_child_resource_name(resource_name, parent_id, err_msg, db_session=db_session) def add_resource_in_tree(new_res, db): db_session.add(new_res) @@ -195,13 +305,13 @@ def add_resource_in_tree(new_res, db): fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, msg_on_fail=s.Resources_POST_ForbiddenResponseSchema.description) return ax.valid_http(http_success=HTTPCreated, detail=s.Resources_POST_CreatedResponseSchema.description, - content={u"resource": format_resource(new_resource, basic_info=True)}) + content={"resource": format_resource(new_resource, basic_info=True)}) def delete_resource(request): resource = ar.get_resource_matchdict_checked(request) - service_push = asbool(ar.get_multiformat_post(request, "service_push")) - res_content = {u"resource": format_resource(resource, basic_info=True)} + service_push = asbool(ar.get_multiformat_body(request, "service_push", default=False)) + res_content = {"resource": format_resource(resource, basic_info=True)} ax.evaluate_call( lambda: models.RESOURCE_TREE_SERVICE.delete_branch(resource_id=resource.resource_id, db_session=request.db), fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, diff --git a/magpie/api/management/resource/resource_views.py b/magpie/api/management/resource/resource_views.py index 23db0e0cb..ee4430c95 100644 --- a/magpie/api/management/resource/resource_views.py +++ b/magpie/api/management/resource/resource_views.py @@ -1,4 +1,4 @@ -from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPInternalServerError, HTTPOk +from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPForbidden, HTTPInternalServerError, HTTPOk from pyramid.settings import asbool from pyramid.view import view_config @@ -28,7 +28,7 @@ def get_resources_view(request): for svc in services: res_json[svc_type][svc.resource_name] = format_service_resources( svc, request.db, show_all_children=True, show_private_url=False) - res_json = {u"resources": res_json} + res_json = {"resources": res_json} return ax.valid_http(http_success=HTTPOk, detail=s.Resources_GET_OkResponseSchema.description, content=res_json) @@ -42,7 +42,7 @@ def get_resource_view(request): res_json = ax.evaluate_call(lambda: rf.format_resource_with_children(resource, db_session=request.db), fallback=lambda: request.db.rollback(), http_error=HTTPInternalServerError, msg_on_fail=s.Resource_GET_InternalServerErrorResponseSchema.description, - content={u"resource": rf.format_resource(resource, basic_info=True)}) + content={"resource": rf.format_resource(resource, basic_info=False)}) return ax.valid_http(http_success=HTTPOk, content={"resource": res_json}, detail=s.Resource_GET_OkResponseSchema.description) @@ -54,10 +54,10 @@ def create_resource_view(request): """ Register a new resource. """ - resource_name = ar.get_value_multiformat_post_checked(request, "resource_name") - resource_display_name = ar.get_multiformat_any(request, "resource_display_name", default=resource_name) - resource_type = ar.get_value_multiformat_post_checked(request, "resource_type") - parent_id = ar.get_value_multiformat_post_checked(request, "parent_id") + resource_name = ar.get_value_multiformat_body_checked(request, "resource_name") + resource_display_name = ar.get_multiformat_body(request, "resource_display_name", default=resource_name) + resource_type = ar.get_value_multiformat_body_checked(request, "resource_type") + parent_id = ar.get_value_multiformat_body_checked(request, "parent_id", check_type=int) return ru.create_resource(resource_name, resource_display_name, resource_type, parent_id, request.db) @@ -71,33 +71,45 @@ def delete_resource_view(request): return ru.delete_resource(request) -@s.ResourceAPI.put(schema=s.Resource_PUT_RequestSchema(), tags=[s.ResourcesTag], - response_schemas=s.Resource_PUT_responses) -@view_config(route_name=s.ResourceAPI.name, request_method="PUT") +@s.ResourceAPI.patch(schema=s.Resource_PATCH_RequestSchema(), tags=[s.ResourcesTag], + response_schemas=s.Resource_PATCH_responses) +@view_config(route_name=s.ResourceAPI.name, request_method="PATCH") def update_resource(request): """ Update a resource information. """ resource = ar.get_resource_matchdict_checked(request, "resource_id") - service_push = asbool(ar.get_multiformat_post(request, "service_push")) + service_push = asbool(ar.get_multiformat_body(request, "service_push", default=False)) res_old_name = resource.resource_name - res_new_name = ar.get_value_multiformat_post_checked(request, "resource_name") + res_new_name = ar.get_value_multiformat_body_checked(request, "resource_name") + ax.verify_param(res_new_name, not_equal=True, param_compare=res_old_name, param_name="resource_name", + http_error=HTTPBadRequest, msg_on_fail=s.Resource_PATCH_BadRequestResponseSchema.description) + db_session = request.db - def rename_service_magpie_and_phoenix(res, new_name, svc_push, db): - if res.resource_type != "service": - svc_push = False - res.resource_name = new_name - if svc_push: - sync_services_phoenix(db.query(models.Service)) + # check for conflicting name, either with services or children resources + err_msg = s.Resource_PATCH_ConflictResponseSchema.description + is_res_svc = resource.resource_type == models.Service.resource_type_name + if is_res_svc: + all_services = db_session.query(models.Service) + all_svc_names = [svc.resource_name for svc in all_services] + ax.verify_param(res_new_name, not_in=True, param_compare=all_svc_names, with_param=False, + http_error=HTTPConflict, content={"resource_name": str(res_new_name)}, msg_on_fail=err_msg) + else: + ru.check_unique_child_resource_name(res_new_name, resource.parent_id, err_msg, db_session=db_session) - ax.evaluate_call(lambda: rename_service_magpie_and_phoenix(resource, res_new_name, service_push, request.db), - fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, - msg_on_fail=s.Resource_PUT_ForbiddenResponseSchema.description, - content={u"resource_id": resource.resource_id, u"resource_name": resource.resource_name, - u"old_resource_name": res_old_name, u"new_resource_name": res_new_name}) - return ax.valid_http(http_success=HTTPOk, detail=s.Resource_PUT_OkResponseSchema.description, - content={u"resource_id": resource.resource_id, u"resource_name": resource.resource_name, - u"old_resource_name": res_old_name, u"new_resource_name": res_new_name}) + def rename_service_magpie_and_phoenix(): + resource.resource_name = res_new_name + if is_res_svc and service_push: + sync_services_phoenix(all_services) + + ax.evaluate_call(lambda: rename_service_magpie_and_phoenix(), + fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, + msg_on_fail=s.Resource_PATCH_ForbiddenResponseSchema.description, + content={"resource_id": resource.resource_id, "resource_name": resource.resource_name, + "old_resource_name": res_old_name, "new_resource_name": res_new_name}) + return ax.valid_http(http_success=HTTPOk, detail=s.Resource_PATCH_OkResponseSchema.description, + content={"resource_id": resource.resource_id, "resource_name": resource.resource_name, + "old_resource_name": res_old_name, "new_resource_name": res_new_name}) @s.ResourcePermissionsAPI.get(tags=[s.ResourcesTag], response_schemas=s.ResourcePermissions_GET_responses) @@ -110,6 +122,6 @@ def get_resource_permissions_view(request): res_perm = ax.evaluate_call(lambda: ru.get_resource_permissions(resource, db_session=request.db), fallback=lambda: request.db.rollback(), http_error=HTTPBadRequest, msg_on_fail=s.ResourcePermissions_GET_BadRequestResponseSchema.description, - content={u"resource": rf.format_resource(resource, basic_info=True)}) + content={"resource": rf.format_resource(resource, basic_info=True)}) return ax.valid_http(http_success=HTTPOk, detail=s.ResourcePermissions_GET_OkResponseSchema.description, - content={u"permission_names": format_permissions(res_perm)}) + content={"permission_names": format_permissions(res_perm)}) diff --git a/magpie/api/management/service/__init__.py b/magpie/api/management/service/__init__.py index 03b81368e..a16455ce1 100644 --- a/magpie/api/management/service/__init__.py +++ b/magpie/api/management/service/__init__.py @@ -5,11 +5,12 @@ def includeme(config): - LOGGER.info("Adding api service...") + LOGGER.info("Adding API service...") # NOTE: # routes 'by type' must be before 'by name' to be evaluated first # order is important to preserve expected behaviour, # otherwise service named 'types' is searched before + # --- service by type --- config.add_route(**s.service_api_route_info(s.ServiceTypesAPI)) config.add_route(**s.service_api_route_info(s.ServiceTypeAPI)) diff --git a/magpie/api/management/service/service_formats.py b/magpie/api/management/service/service_formats.py index 844de9859..0c8490702 100644 --- a/magpie/api/management/service/service_formats.py +++ b/magpie/api/management/service/service_formats.py @@ -3,19 +3,22 @@ from pyramid.httpexceptions import HTTPInternalServerError from magpie.api.exception import evaluate_call -from magpie.api.management.resource.resource_formats import format_resource_tree, get_resource_children -from magpie.api.management.resource.resource_utils import crop_tree_with_permission +from magpie.api.management.resource.resource_formats import format_resource_tree +from magpie.api.management.resource.resource_utils import crop_tree_with_permission, get_resource_children from magpie.permissions import format_permissions from magpie.services import SERVICE_TYPE_DICT from magpie.utils import get_twitcher_protected_service_url if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Optional, JSON, Str, Dict, List, Type # noqa: F401 + from typing import List, Optional, Type + from sqlalchemy.orm.session import Session + from magpie.models import Resource, Service from magpie.permissions import Permission from magpie.services import ServiceInterface + from magpie.typedefs import JSON, ResourcePermissionMap def format_service(service, permissions=None, show_private_url=False, show_resources_allowed=False): @@ -31,60 +34,68 @@ def format_service(service, permissions=None, show_private_url=False, show_resou def fmt_svc(svc, perms): svc_info = { - u"public_url": str(get_twitcher_protected_service_url(svc.resource_name)), - u"service_name": str(svc.resource_name), - u"service_type": str(svc.type), - u"service_sync_type": str(svc.sync_type) if svc.sync_type is not None else svc.sync_type, - u"resource_id": svc.resource_id, + "public_url": str(get_twitcher_protected_service_url(svc.resource_name)), + "service_name": str(svc.resource_name), + "service_type": str(svc.type), + "service_sync_type": str(svc.sync_type) if svc.sync_type is not None else svc.sync_type, + "resource_id": svc.resource_id, } if perms is None: # user/group permission specify empty list perms = SERVICE_TYPE_DICT[svc.type].permissions - svc_info[u"permission_names"] = format_permissions(perms) + svc_info["permission_names"] = format_permissions(perms) if show_private_url: - svc_info[u"service_url"] = str(svc.url) + svc_info["service_url"] = str(svc.url) if show_resources_allowed: - svc_info[u"resource_types_allowed"] = sorted(SERVICE_TYPE_DICT[svc.type].resource_type_names) - svc_info[u"resource_child_allowed"] = SERVICE_TYPE_DICT[svc.type].child_resource_allowed + svc_info["resource_types_allowed"] = sorted(SERVICE_TYPE_DICT[svc.type].resource_type_names) + svc_info["resource_child_allowed"] = SERVICE_TYPE_DICT[svc.type].child_resource_allowed return svc_info return evaluate_call( lambda: fmt_svc(service, permissions), http_error=HTTPInternalServerError, msg_on_fail="Failed to format service.", - content={u"service": repr(service), u"permissions": repr(permissions)} + content={"service": repr(service), "permissions": repr(permissions)} ) def format_service_resources(service, # type: Service db_session, # type: Session service_perms=None, # type: Optional[List[Permission]] - resources_perms_dict=None, # type: Optional[Dict[Str, List[Str]]] + resources_perms_dict=None, # type: Optional[ResourcePermissionMap] show_all_children=False, # type: bool show_private_url=True, # type: bool ): # type: (...) -> JSON """ - Formats the service and its resource tree as a JSON body. + Formats the service and its children resource tree as a JSON body. :param service: service for which to display details with sub-resources :param db_session: database session - :param service_perms: permissions to display instead of specific ``service``-type ones - :param resources_perms_dict: permission(s) of resource(s) id(s) to *preserve* if ``resources_perms_dict = False`` - :param show_all_children: display all children resources recursively, or only ones matching ``resources_perms_dict`` + :param service_perms: + If provided, sets :term:`Applied Permissions` to display on the formatted :paramref:`service`. + Otherwise, sets the :term:`Allowed Permissions` specific to the :paramref:`service`'s type. + :param resources_perms_dict: + If provided (not ``None``), set the :term:`Applied Permissions` on each specified resource matched by ID. + If ``None``, retrieve and set :term:`Allowed Permissions` for the corresponding resources under the service. + To set empty :term:`Applied Permissions` (e.g.: :term:`User` doesn't have permissions on that resource), provide + an explicit empty dictionary instead. + :param show_all_children: + Display all children resources recursively, or only ones specified by ID with :paramref:`resources_perms_dict`. :param show_private_url: displays the :return: JSON body representation of the service resource tree """ def fmt_svc_res(svc, db, svc_perms, res_perms, show_all): tree = get_resource_children(svc, db) if not show_all: - tree, _ = crop_tree_with_permission(tree, list(res_perms.keys())) + filter_res_ids = list(res_perms) if res_perms else [] + tree, _ = crop_tree_with_permission(tree, filter_res_ids) svc_perms = SERVICE_TYPE_DICT[svc.type].permissions if svc_perms is None else svc_perms svc_res = format_service(svc, svc_perms, show_private_url=show_private_url) - svc_res[u"resources"] = format_resource_tree(tree, resources_perms_dict=res_perms, db_session=db) + svc_res["resources"] = format_resource_tree(tree, resources_perms_dict=res_perms, db_session=db) return svc_res return evaluate_call( - lambda: fmt_svc_res(service, db_session, service_perms, resources_perms_dict or {}, show_all_children), + lambda: fmt_svc_res(service, db_session, service_perms, resources_perms_dict, show_all_children), fallback=lambda: db_session.rollback(), http_error=HTTPInternalServerError, msg_on_fail="Failed to format service resources tree", content=format_service(service, service_perms, show_private_url=show_private_url) @@ -94,9 +105,9 @@ def fmt_svc_res(svc, db, svc_perms, res_perms, show_all): def format_service_resource_type(resource_class, service_class): # type: (Type[Resource], Type[ServiceInterface]) -> JSON return { - u"resource_type": resource_class.resource_type_name, - u"resource_child_allowed": resource_class.child_resource_allowed, - u"permission_names": format_permissions( + "resource_type": resource_class.resource_type_name, + "resource_child_allowed": resource_class.child_resource_allowed, + "permission_names": format_permissions( service_class.get_resource_permissions(resource_class.resource_type_name) ), } diff --git a/magpie/api/management/service/service_utils.py b/magpie/api/management/service/service_utils.py index 20d617950..a3dc21cdd 100644 --- a/magpie/api/management/service/service_utils.py +++ b/magpie/api/management/service/service_utils.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING -from pyramid.httpexceptions import HTTPBadRequest, HTTPCreated, HTTPForbidden, HTTPInternalServerError +import six +from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPCreated, HTTPForbidden, HTTPInternalServerError from ziggurat_foundations.models.services.group import GroupService from ziggurat_foundations.models.services.resource import ResourceService @@ -17,9 +18,13 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Str # noqa: F401 - from sqlalchemy.orm.session import Session + from typing import Iterable + from pyramid.httpexceptions import HTTPException + from sqlalchemy.orm.session import Session + + from magpie.typedefs import Str + LOGGER = get_logger(__name__) @@ -39,32 +44,49 @@ def _add_service_magpie_and_phoenix(svc, svc_push, db): svc = ax.evaluate_call(lambda: models.Service.by_service_name(service_name, db_session=db_session), fallback=lambda: db_session.rollback(), http_error=HTTPInternalServerError, msg_on_fail=s.Services_POST_InternalServerErrorResponseSchema.description, - content={u"service_name": str(service_name), u"resource_id": svc.resource_id}) + content={"service_name": str(service_name), "resource_id": svc.resource_id}) ax.verify_param(svc.resource_id, not_none=True, param_compare=int, is_type=True, http_error=HTTPInternalServerError, msg_on_fail=s.Services_POST_InternalServerErrorResponseSchema.description, - content={u"service_name": str(service_name), u"resource_id": svc.resource_id}, - param_name=u"service_name") + content={"service_name": str(service_name), "resource_id": svc.resource_id}, + param_name="service_name") return svc + ax.verify_param(service_type, not_none=True, not_empty=True, is_type=True, + param_name="service_type", param_compare=six.string_types, + http_error=HTTPBadRequest, msg_on_fail=s.Services_POST_BadRequestResponseSchema.description) + ax.verify_param(service_type, is_in=True, param_compare=SERVICE_TYPE_DICT.keys(), param_name="service_type", + http_error=HTTPBadRequest, msg_on_fail=s.Services_POST_BadRequestResponseSchema.description) + ax.verify_param(service_url, matches=True, param_compare=ax.URL_REGEX, param_name="service_url", + http_error=HTTPBadRequest, msg_on_fail=s.Services_POST_Params_BadRequestResponseSchema.description) + ax.verify_param(service_name, not_empty=True, not_none=True, matches=True, + param_name="service_name", param_compare=ax.PARAM_REGEX, + http_error=HTTPBadRequest, msg_on_fail=s.Services_POST_Params_BadRequestResponseSchema.description) + ax.verify_param(models.Service.by_service_name(service_name, db_session=db_session), is_none=True, + param_name="service_name", with_param=False, content={"service_name": str(service_name)}, + http_error=HTTPConflict, msg_on_fail=s.Services_POST_ConflictResponseSchema.description) service = ax.evaluate_call(lambda: models.Service(resource_name=str(service_name), resource_type=models.Service.resource_type_name, url=str(service_url), type=str(service_type)), # noqa fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, msg_on_fail=s.Services_POST_UnprocessableEntityResponseSchema.description, - content={u"service_name": str(service_name), - u"resource_type": models.Service.resource_type_name, - u"service_url": str(service_url), u"service_type": str(service_type)}) + content={"service_name": str(service_name), + "resource_type": models.Service.resource_type_name, + "service_url": str(service_url), "service_type": str(service_type)}) service = ax.evaluate_call(lambda: _add_service_magpie_and_phoenix(service, service_push, db_session), fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, msg_on_fail=s.Services_POST_ForbiddenResponseSchema.description, content=format_service(service, show_private_url=True)) return ax.valid_http(http_success=HTTPCreated, detail=s.Services_POST_CreatedResponseSchema.description, - content={u"service": format_service(service, show_private_url=True)}) + content={"service": format_service(service, show_private_url=True)}) def get_services_by_type(service_type, db_session): + # type: (Str, Session) -> Iterable[models.Service] + """ + Obtains all services that correspond to requested service-type. + """ ax.verify_param(service_type, not_none=True, not_empty=True, http_error=HTTPBadRequest, msg_on_fail="Invalid 'service_type' value '" + str(service_type) + "' specified") services = db_session.query(models.Service).filter(models.Service.type == service_type) diff --git a/magpie/api/management/service/service_views.py b/magpie/api/management/service/service_views.py index aa2fbb209..96ea9c6d6 100644 --- a/magpie/api/management/service/service_views.py +++ b/magpie/api/management/service/service_views.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPForbidden, HTTPNotFound, HTTPOk from pyramid.settings import asbool from pyramid.view import view_config @@ -6,7 +8,7 @@ from magpie.api import exception as ax from magpie.api import requests as ar from magpie.api import schemas as s -from magpie.api.management.resource.resource_utils import create_resource, delete_resource +from magpie.api.management.resource import resource_utils as ru from magpie.api.management.service import service_formats as sf from magpie.api.management.service import service_utils as su from magpie.permissions import Permission, format_permissions @@ -14,6 +16,11 @@ from magpie.services import SERVICE_TYPE_DICT from magpie.utils import CONTENT_TYPE_JSON +if TYPE_CHECKING: + from typing import List, Union + + from magpie.typedefs import JSON + @s.ServiceTypesAPI.get(tags=[s.ServicesTag], response_schemas=s.ServiceTypes_GET_responses) @view_config(route_name=s.ServiceTypesAPI.name, request_method="GET") @@ -21,11 +28,12 @@ def get_service_types_view(request): # noqa: F811 """ List all available service types. """ - return ax.valid_http(http_success=HTTPOk, content={u"service_types": list(sorted(SERVICE_TYPE_DICT.keys()))}, + return ax.valid_http(http_success=HTTPOk, content={"service_types": list(sorted(SERVICE_TYPE_DICT.keys()))}, detail=s.ServiceTypes_GET_OkResponseSchema.description) -@s.ServiceTypeAPI.get(tags=[s.ServicesTag], response_schemas=s.ServiceType_GET_responses) +@s.ServiceTypeAPI.get(schema=s.ServiceTypes_GET_RequestSchema(), tags=[s.ServicesTag], + response_schemas=s.ServiceType_GET_responses) @view_config(route_name=s.ServiceTypeAPI.name, request_method="GET") def get_services_by_type_view(request): """ @@ -34,7 +42,8 @@ def get_services_by_type_view(request): return get_services_runner(request) -@s.ServicesAPI.get(tags=[s.ServicesTag], response_schemas=s.Services_GET_responses) +@s.ServicesAPI.get(schema=s.Services_GET_RequestSchema(), tags=[s.ServicesTag], + response_schemas=s.Services_GET_responses) @view_config(route_name=s.ServicesAPI.name, request_method="GET") def get_services_view(request): """ @@ -44,23 +53,36 @@ def get_services_view(request): def get_services_runner(request): + """ + Generates services response format from request conditions. + + Obtains the full or filtered list of services categorized by type, or listed as flat list according to request path + and query parameters. + """ service_type_filter = request.matchdict.get("service_type") # no check because None/empty is for 'all services' - json_response = {} + services_as_list = asbool(ar.get_query_param(request, "flatten", False)) + if not service_type_filter: service_types = SERVICE_TYPE_DICT.keys() else: ax.verify_param(service_type_filter, param_compare=SERVICE_TYPE_DICT.keys(), is_in=True, http_error=HTTPBadRequest, msg_on_fail=s.Services_GET_BadRequestResponseSchema.description, - content={u"service_type": str(service_type_filter)}, content_type=CONTENT_TYPE_JSON) + content={"service_type": str(service_type_filter)}, content_type=CONTENT_TYPE_JSON) service_types = [service_type_filter] + svc_content = [] if services_as_list else {} # type: Union[List[JSON], JSON] for service_type in service_types: services = su.get_services_by_type(service_type, db_session=request.db) - json_response[service_type] = {} + if not services_as_list: + svc_content[service_type] = {} for service in services: - json_response[service_type][service.resource_name] = sf.format_service(service, show_private_url=True) + svc_fmt = sf.format_service(service, show_private_url=True) + if services_as_list: + svc_content.append(svc_fmt) # pylint: disable=E1101 + else: + svc_content[service_type][service.resource_name] = svc_fmt - return ax.valid_http(http_success=HTTPOk, content={u"services": json_response}, + return ax.valid_http(http_success=HTTPOk, content={"services": svc_content}, detail=s.Services_GET_OkResponseSchema.description) @@ -71,49 +93,43 @@ def register_service_view(request): """ Registers a new service. """ - service_name = ar.get_value_multiformat_post_checked(request, "service_name") - service_url = ar.get_value_multiformat_post_checked(request, "service_url") - service_type = ar.get_value_multiformat_post_checked(request, "service_type") - service_push = asbool(ar.get_multiformat_post(request, "service_push")) - ax.verify_param(service_type, is_in=True, param_compare=SERVICE_TYPE_DICT.keys(), - http_error=HTTPBadRequest, msg_on_fail=s.Services_POST_BadRequestResponseSchema.description) - ax.verify_param(models.Service.by_service_name(service_name, db_session=request.db), is_none=True, - http_error=HTTPConflict, msg_on_fail=s.Services_POST_ConflictResponseSchema.description, - content={u"service_name": str(service_name)}, param_name=u"service_name") + # accomplish basic validations here, create_service will do more field-specific checks + service_name = ar.get_value_multiformat_body_checked(request, "service_name") + service_url = ar.get_value_multiformat_body_checked(request, "service_url", pattern=ax.PARAM_REGEX) + service_type = ar.get_value_multiformat_body_checked(request, "service_type") + service_push = asbool(ar.get_multiformat_body(request, "service_push", default=False)) return su.create_service(service_name, service_type, service_url, service_push, db_session=request.db) -@s.ServiceAPI.put(schema=s.Service_PUT_RequestBodySchema(), tags=[s.ServicesTag], - response_schemas=s.Service_PUT_responses) -@view_config(route_name=s.ServiceAPI.name, request_method="PUT") +@s.ServiceAPI.patch(schema=s.Service_PATCH_RequestBodySchema(), tags=[s.ServicesTag], + response_schemas=s.Service_PATCH_responses) +@view_config(route_name=s.ServiceAPI.name, request_method="PATCH") def update_service_view(request): """ - Update a service information. + Update service information. """ service = ar.get_service_matchdict_checked(request) - service_push = asbool(ar.get_multiformat_post(request, "service_push", default=False)) + service_push = asbool(ar.get_multiformat_body(request, "service_push", default=False)) def select_update(new_value, old_value): return new_value if new_value is not None and not new_value == "" else old_value # None/Empty values are accepted in case of unspecified - svc_name = select_update(ar.get_multiformat_post(request, "service_name"), service.resource_name) - svc_url = select_update(ar.get_multiformat_post(request, "service_url"), service.url) + svc_name = select_update(ar.get_multiformat_body(request, "service_name"), service.resource_name) + svc_url = select_update(ar.get_multiformat_body(request, "service_url"), service.url) ax.verify_param(svc_name, param_compare="types", not_equal=True, - param_name="service_name", http_error=HTTPBadRequest, - msg_on_fail=s.Service_PUT_BadRequestResponseSchema_ReservedKeyword.description) + param_name="service_name", http_error=HTTPForbidden, + msg_on_fail=s.Service_PATCH_ForbiddenResponseSchema_ReservedKeyword.description) ax.verify_param(svc_name == service.resource_name and svc_url == service.url, not_equal=True, param_compare=True, param_name="service_name/service_url", - http_error=HTTPBadRequest, msg_on_fail=s.Service_PUT_BadRequestResponseSchema.description) + http_error=HTTPBadRequest, msg_on_fail=s.Service_PATCH_BadRequestResponseSchema.description) if svc_name != service.resource_name: - all_svc_names = list() - for svc_type in SERVICE_TYPE_DICT: - for svc in su.get_services_by_type(svc_type, db_session=request.db): - all_svc_names.append(svc.resource_name) - ax.verify_param(svc_name, not_in=True, param_compare=all_svc_names, http_error=HTTPConflict, - msg_on_fail=s.Service_PUT_ConflictResponseSchema.description, - content={u"service_name": str(svc_name)}) + all_services = request.db.query(models.Service) + all_svc_names = [svc.resource_name for svc in all_services] + ax.verify_param(svc_name, not_in=True, param_compare=all_svc_names, with_param=False, + http_error=HTTPConflict, content={"service_name": str(svc_name)}, + msg_on_fail=s.Service_PATCH_ConflictResponseSchema.description) def update_service_magpie_and_phoenix(_svc, new_name, new_url, svc_push, db_session): _svc.resource_name = new_name @@ -125,13 +141,13 @@ def update_service_magpie_and_phoenix(_svc, new_name, new_url, svc_push, db_sess sync_services_phoenix(db_session.query(models.Service)) # push all services old_svc_content = sf.format_service(service, show_private_url=True) - err_svc_content = {u"service": old_svc_content, u"new_service_name": svc_name, u"new_service_url": svc_url} + err_svc_content = {"service": old_svc_content, "new_service_name": svc_name, "new_service_url": svc_url} ax.evaluate_call(lambda: update_service_magpie_and_phoenix(service, svc_name, svc_url, service_push, request.db), fallback=lambda: request.db.rollback(), - http_error=HTTPForbidden, msg_on_fail=s.Service_PUT_ForbiddenResponseSchema.description, + http_error=HTTPForbidden, msg_on_fail=s.Service_PATCH_ForbiddenResponseSchema.description, content=err_svc_content) - return ax.valid_http(http_success=HTTPOk, detail=s.Service_PUT_OkResponseSchema.description, - content={u"service": sf.format_service(service, show_private_url=True)}) + return ax.valid_http(http_success=HTTPOk, detail=s.Service_PATCH_OkResponseSchema.description, + content={"service": sf.format_service(service, show_private_url=True)}) @s.ServiceAPI.get(tags=[s.ServicesTag], response_schemas=s.Service_GET_responses) @@ -143,7 +159,7 @@ def get_service_view(request): service = ar.get_service_matchdict_checked(request) service_info = sf.format_service(service, show_private_url=True, show_resources_allowed=True) return ax.valid_http(http_success=HTTPOk, detail=s.Service_GET_OkResponseSchema.description, - content={u"service": service_info}) + content={"service": service_info}) @s.ServiceAPI.delete(schema=s.Service_DELETE_RequestSchema(), tags=[s.ServicesTag], @@ -154,7 +170,7 @@ def unregister_service_view(request): Unregister a service. """ service = ar.get_service_matchdict_checked(request) - service_push = asbool(ar.get_multiformat_delete(request, "service_push", default=False)) + service_push = asbool(ar.get_multiformat_body(request, "service_push", default=False)) svc_content = sf.format_service(service, show_private_url=True) svc_res_id = service.resource_id ax.evaluate_call(lambda: models.RESOURCE_TREE_SERVICE.delete_branch(resource_id=svc_res_id, db_session=request.db), @@ -184,7 +200,7 @@ def get_service_permissions_view(request): fallback=request.db.rollback(), http_error=HTTPBadRequest, content=svc_content, msg_on_fail=s.ServicePermissions_GET_BadRequestResponseSchema.description) return ax.valid_http(http_success=HTTPOk, detail=s.ServicePermissions_GET_OkResponseSchema.description, - content={u"permission_names": format_permissions(svc_perms)}) + content={"permission_names": format_permissions(svc_perms)}) @s.ServiceResourceAPI.delete(schema=s.ServiceResource_DELETE_RequestSchema(), tags=[s.ServicesTag], @@ -194,7 +210,7 @@ def delete_service_resource_view(request): """ Unregister a resource. """ - return delete_resource(request) + return ru.delete_resource(request) @s.ServiceResourcesAPI.get(tags=[s.ServicesTag], response_schemas=s.ServiceResources_GET_responses) @@ -213,19 +229,33 @@ def get_service_resources_view(request): @s.ServiceResourcesAPI.post(schema=s.ServiceResources_POST_RequestSchema, tags=[s.ServicesTag], response_schemas=s.ServiceResources_POST_responses) @view_config(route_name=s.ServiceResourcesAPI.name, request_method="POST") -def create_service_direct_resource_view(request): +def create_service_resource_view(request): """ - Register a new resource directly under a service. + Register a new resource directly under a service or under one of its children resources. """ service = ar.get_service_matchdict_checked(request) - resource_name = ar.get_multiformat_post(request, "resource_name") - resource_display_name = ar.get_multiformat_post(request, "resource_display_name", default=resource_name) - resource_type = ar.get_multiformat_post(request, "resource_type") - parent_id = ar.get_multiformat_post(request, "parent_id") # no check because None/empty is allowed - if not parent_id: + resource_name = ar.get_multiformat_body(request, "resource_name") + resource_display_name = ar.get_multiformat_body(request, "resource_display_name", default=resource_name) + resource_type = ar.get_multiformat_body(request, "resource_type") + parent_id = ar.get_multiformat_body(request, "parent_id") # no check because None/empty is allowed + db_session = request.db + if parent_id is None: parent_id = service.resource_id - return create_resource(resource_name, resource_display_name, resource_type, - parent_id=parent_id, db_session=request.db) + else: + parent_id = ax.evaluate_call(lambda: int(parent_id), + http_error=HTTPBadRequest, + msg_on_fail=s.ServiceResources_POST_BadRequestResponseSchema.description) + # validate target service is actually the root service of the provided parent resource ID + root_service = ru.get_resource_root_service_by_id(parent_id, db_session=db_session) + ax.verify_param(root_service, not_none=True, param_name="parent_id", + msg_on_fail=s.ServiceResources_POST_NotFoundResponseSchema.description, + http_error=HTTPNotFound) + ax.verify_param(root_service.resource_id, is_equal=True, + param_compare=service.resource_id, param_name="parent_id", + msg_on_fail=s.ServiceResources_POST_BadRequestResponseSchema.description, + http_error=HTTPBadRequest) + return ru.create_resource(resource_name, resource_display_name, resource_type, + parent_id=parent_id, db_session=db_session) @s.ServiceTypeResourcesAPI.get(tags=[s.ServicesTag], response_schemas=s.ServiceTypeResources_GET_responses) @@ -244,10 +274,10 @@ def _get_resource_types_info(res_type_names): msg_on_fail=s.ServiceTypeResources_GET_NotFoundResponseSchema.description) resource_types_names = ax.evaluate_call( lambda: SERVICE_TYPE_DICT[service_type].resource_type_names, - http_error=HTTPForbidden, content={u"service_type": str(service_type)}, + http_error=HTTPForbidden, content={"service_type": str(service_type)}, msg_on_fail=s.ServiceTypeResourceTypes_GET_ForbiddenResponseSchema.description) return ax.valid_http(http_success=HTTPOk, detail=s.ServiceTypeResourceTypes_GET_OkResponseSchema.description, - content={u"resource_types": _get_resource_types_info(resource_types_names)}) + content={"resource_types": _get_resource_types_info(resource_types_names)}) @s.ServiceTypeResourceTypesAPI.get(tags=[s.ServicesTag], response_schemas=s.ServiceTypeResourceTypes_GET_responses) @@ -260,7 +290,7 @@ def get_service_type_resource_types_view(request): ax.verify_param(service_type, param_compare=SERVICE_TYPE_DICT.keys(), is_in=True, http_error=HTTPNotFound, msg_on_fail=s.ServiceTypeResourceTypes_GET_NotFoundResponseSchema.description) resource_types = ax.evaluate_call(lambda: SERVICE_TYPE_DICT[service_type].resource_type_names, - http_error=HTTPForbidden, content={u"service_type": str(service_type)}, + http_error=HTTPForbidden, content={"service_type": str(service_type)}, msg_on_fail=s.ServiceTypeResourceTypes_GET_ForbiddenResponseSchema.description) return ax.valid_http(http_success=HTTPOk, detail=s.ServiceTypeResourceTypes_GET_OkResponseSchema.description, - content={u"resource_types": resource_types}) + content={"resource_types": resource_types}) diff --git a/magpie/api/management/user/__init__.py b/magpie/api/management/user/__init__.py index 1c47f39e3..bf9691f2e 100644 --- a/magpie/api/management/user/__init__.py +++ b/magpie/api/management/user/__init__.py @@ -1,45 +1,40 @@ from magpie.api import schemas as s +from magpie.models import UserFactory from magpie.utils import get_logger LOGGER = get_logger(__name__) def includeme(config): - LOGGER.info("Adding api user...") - # Add all the rest api routes - config.add_route(**s.service_api_route_info(s.UsersAPI)) - config.add_route(**s.service_api_route_info(s.UserAPI)) - config.add_route(**s.service_api_route_info(s.UserGroupsAPI)) - config.add_route(**s.service_api_route_info(s.UserGroupAPI)) - config.add_route(**s.service_api_route_info(s.UserServicesAPI)) - config.add_route(**s.service_api_route_info(s.UserInheritedServicesAPI)) - config.add_route(**s.service_api_route_info(s.UserServicePermissionsAPI)) - config.add_route(**s.service_api_route_info(s.UserServicePermissionAPI)) - config.add_route(**s.service_api_route_info(s.UserServiceInheritedPermissionsAPI)) - config.add_route(**s.service_api_route_info(s.UserServiceResourcesAPI)) - config.add_route(**s.service_api_route_info(s.UserServiceInheritedResourcesAPI)) - config.add_route(**s.service_api_route_info(s.UserResourcesAPI)) - config.add_route(**s.service_api_route_info(s.UserInheritedResourcesAPI)) - config.add_route(**s.service_api_route_info(s.UserResourceTypesAPI)) - config.add_route(**s.service_api_route_info(s.UserResourcePermissionsAPI)) - config.add_route(**s.service_api_route_info(s.UserResourcePermissionAPI)) - config.add_route(**s.service_api_route_info(s.UserResourceInheritedPermissionsAPI)) + LOGGER.info("Adding API user...") + + # note: routes that require user 'self' operations must add the following, otherwise default RootFactory is used + user_kwargs = {"factory": UserFactory, "traverse": "/{user_name}"} + + # Add user variable routes + config.add_route(**s.service_api_route_info(s.UsersAPI)) # no user instance, admin-only + config.add_route(**s.service_api_route_info(s.UserAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserGroupsAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserGroupAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserServicesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserServicePermissionsAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserServicePermissionAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserServiceResourcesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserResourcesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserResourceTypesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserResourcePermissionsAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.UserResourcePermissionAPI, **user_kwargs)) # Logged User routes - config.add_route(**s.service_api_route_info(s.LoggedUserAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserGroupsAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserGroupAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserServicesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserInheritedServicesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserServicePermissionsAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserServicePermissionAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserServiceInheritedPermissionsAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserServiceResourcesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserServiceInheritedResourcesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserResourcesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserInheritedResourcesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserResourceTypesAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserResourcePermissionsAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserResourcePermissionAPI)) - config.add_route(**s.service_api_route_info(s.LoggedUserResourceInheritedPermissionsAPI)) + config.add_route(**s.service_api_route_info(s.LoggedUserAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserGroupsAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserGroupAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserServicesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserServicePermissionsAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserServicePermissionAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserServiceResourcesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserResourcesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserResourceTypesAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserResourcePermissionsAPI, **user_kwargs)) + config.add_route(**s.service_api_route_info(s.LoggedUserResourcePermissionAPI, **user_kwargs)) config.scan() diff --git a/magpie/api/management/user/user_formats.py b/magpie/api/management/user/user_formats.py index 21569fd2b..f95e91110 100644 --- a/magpie/api/management/user/user_formats.py +++ b/magpie/api/management/user/user_formats.py @@ -7,17 +7,17 @@ def format_user(user, group_names=None): def fmt_usr(usr, grp_names): user_info = { - u"user_name": str(usr.user_name), - u"email": str(usr.email), - u"group_names": sorted(list(grp_names) if grp_names else [grp.group_name for grp in user.groups]), + "user_name": str(usr.user_name), + "email": str(usr.email), + "group_names": sorted(list(grp_names) if grp_names else [grp.group_name for grp in user.groups]), } if user.user_name != get_constant("MAGPIE_ANONYMOUS_USER"): - user_info[u"user_id"] = int(user.id) + user_info["user_id"] = int(user.id) return user_info return evaluate_call( lambda: fmt_usr(user, group_names), http_error=HTTPInternalServerError, msg_on_fail="Failed to format user.", - content={u"user": repr(user)} + content={"user": repr(user)} ) diff --git a/magpie/api/management/user/user_utils.py b/magpie/api/management/user/user_utils.py index e6a03a798..86c166225 100644 --- a/magpie/api/management/user/user_utils.py +++ b/magpie/api/management/user/user_utils.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +import six from pyramid.httpexceptions import ( HTTPBadRequest, HTTPConflict, @@ -17,7 +18,7 @@ from magpie import models from magpie.api import exception as ax from magpie.api import schemas as s -from magpie.api.management.resource.resource_utils import check_valid_service_or_resource_permission +from magpie.api.management.resource import resource_utils as ru from magpie.api.management.service.service_formats import format_service from magpie.api.management.user import user_formats as uf from magpie.constants import get_constant @@ -26,51 +27,68 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.services import ServiceInterface # noqa: F401 + from typing import Dict, Iterable, List, Optional + from pyramid.httpexceptions import HTTPException from pyramid.request import Request from sqlalchemy.orm.session import Session - from magpie.typedefs import ( # noqa: F401 - Any, Str, Dict, Iterable, List, Optional, ResourcePermissionType, UserServicesType, ServiceOrResourceType + + from magpie.permissions import Permission + from magpie.typedefs import ( + AnyPermissionType, + ResourcePermissionMap, + ResourcePermissionType, + ServiceOrResourceType, + Str, + UserServicesType ) - from magpie.permissions import Permission # noqa: F401 def create_user(user_name, password, email, group_name, db_session): - # type: (Str, Optional[Str], Str, Str, Session) -> HTTPException + # type: (Str, Optional[Str], Str, Optional[Str], Session) -> HTTPException """ - Creates a user if it is permitted and not conflicting. Password must be set to `None` if using external identity. + Creates a user if it is permitted and not conflicting. Password must be set to ``None`` if using external identity. - Created user will be part of group matching ``group_name`` (can be ``MAGPIE_ANONYMOUS_GROUP`` for minimal access). - Furthermore, the user will also *always* be associated with ``MAGPIE_ANONYMOUS_GROUP`` (if not already explicitly - requested with ``group_name``) to allow access to resources with public permission. The ``group_name`` **must** - be an existing group. + Created user will immediately assigned membership to the group matching :paramref:`group_name` + (can be :py:data:`MAGPIE_ANONYMOUS_GROUP` for minimal access). If no group is provided, this anonymous group will + be applied by default, creating a user effectively without any permissions other than ones set directly for him. + + Furthermore, the user will also *always* be associated with :py:data:`MAGPIE_ANONYMOUS_GROUP` (if not already + explicitly or implicitly requested with :paramref:`group_name`) to allow access to resources with public permission. + Argument :paramref:`group_name` **MUST** be an existing group if provided. :returns: valid HTTP response on successful operation. """ def _get_group(grp_name): # type: (Str) -> models.Group + ax.verify_param(grp_name, not_none=True, not_empty=True, matches=True, + param_compare=ax.PARAM_REGEX, param_name="group_name", + http_error=HTTPBadRequest, msg_on_fail=s.UserGroup_Check_BadRequestResponseSchema.description) grp = ax.evaluate_call(lambda: GroupService.by_group_name(grp_name, db_session=db_session), http_error=HTTPForbidden, msg_on_fail=s.UserGroup_GET_ForbiddenResponseSchema.description) - ax.verify_param(grp, not_none=True, http_error=HTTPBadRequest, - msg_on_fail=s.UserGroup_Check_BadRequestResponseSchema.description) + ax.verify_param(grp, not_none=True, http_error=HTTPNotFound, with_param=False, + msg_on_fail=s.UserGroup_Check_NotFoundResponseSchema.description) return grp # Check that group already exists + if group_name is None: + group_name = get_constant("MAGPIE_ANONYMOUS_GROUP") + is_internal = password is not None + check_user_info(user_name, email, password, group_name, check_password=is_internal) group_checked = _get_group(group_name) # Check if user already exists user_checked = ax.evaluate_call(lambda: UserService.by_user_name(user_name=user_name, db_session=db_session), http_error=HTTPForbidden, msg_on_fail=s.User_Check_ForbiddenResponseSchema.description) - ax.verify_param(user_checked, is_none=True, http_error=HTTPConflict, + ax.verify_param(user_checked, is_none=True, with_param=False, http_error=HTTPConflict, msg_on_fail=s.User_Check_ConflictResponseSchema.description) # Create user with specified name and group to assign new_user = models.User(user_name=user_name, email=email) # noqa - if password: + if is_internal: UserService.set_password(new_user, password) UserService.regenerate_security_code(new_user) ax.evaluate_call(lambda: db_session.add(new_user), fallback=lambda: db_session.rollback(), @@ -96,7 +114,7 @@ def _add_to_group(usr, grp): new_user_groups.append(anonym_grp_name) return ax.valid_http(http_success=HTTPCreated, detail=s.Users_POST_CreatedResponseSchema.description, - content={u"user": uf.format_user(new_user, new_user_groups)}) + content={"user": uf.format_user(new_user, new_user_groups)}) def create_user_resource_permission_response(user, resource, permission, db_session): @@ -106,19 +124,18 @@ def create_user_resource_permission_response(user, resource, permission, db_sess :returns: valid HTTP response on successful operation. """ - check_valid_service_or_resource_permission(permission.value, resource, db_session) - resource_id = resource.resource_id + ru.check_valid_service_or_resource_permission(permission.value, resource, db_session) + res_id = resource.resource_id existing_perm = UserResourcePermissionService.by_resource_user_and_perm( - user_id=user.id, resource_id=resource_id, perm_name=permission.value, db_session=db_session) - ax.verify_param(existing_perm, is_none=True, http_error=HTTPConflict, - content={u"resource_id": resource_id, u"user_id": user.id, u"permission_name": permission.value}, + user_id=user.id, resource_id=res_id, perm_name=permission.value, db_session=db_session) + ax.verify_param(existing_perm, is_none=True, with_param=False, http_error=HTTPConflict, + content={"resource_id": res_id, "user_id": user.id, "permission_name": permission.value}, msg_on_fail=s.UserResourcePermissions_POST_ConflictResponseSchema.description) - new_perm = models.UserResourcePermission(resource_id=resource_id, - user_id=user.id, perm_name=permission.value) # noqa - usr_res_data = {u"resource_id": resource_id, u"user_id": user.id, u"permission_name": permission.value} + new_perm = models.UserResourcePermission(resource_id=res_id, user_id=user.id, perm_name=permission.value) # noqa + usr_res_data = {"resource_id": res_id, "user_id": user.id, "permission_name": permission.value} ax.verify_param(new_perm, not_none=True, http_error=HTTPForbidden, - content={u"resource_id": resource_id, u"user_id": user.id}, + content={"resource_id": res_id, "user_id": user.id}, msg_on_fail=s.UserResourcePermissions_POST_ForbiddenResponseSchema.description) ax.evaluate_call(lambda: db_session.add(new_perm), fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, content=usr_res_data, @@ -127,6 +144,28 @@ def create_user_resource_permission_response(user, resource, permission, db_sess detail=s.UserResourcePermissions_POST_CreatedResponseSchema.description) +def delete_user_group(user, group, db_session): + # type: (models.User, models.Group, Session) -> None + """ + Deletes a user-group relationship (user membership to a group). + + :returns: nothing - user-group is deleted. + :raises HTTPNotFound: if the combination cannot be found. + """ + def del_usr_grp(usr, grp): + db_session.query(models.UserGroup) \ + .filter(models.UserGroup.user_id == usr.id) \ + .filter(models.UserGroup.group_id == grp.id) \ + .delete() + + ax.verify_param(group.group_name, not_equal=True, param_compare=get_constant("MAGPIE_ANONYMOUS_GROUP"), + param_name="group_name", http_error=HTTPForbidden, + msg_on_fail=s.UserGroup_DELETE_ForbiddenResponseSchema.description) + ax.evaluate_call(lambda: del_usr_grp(user, group), fallback=lambda: db_session.rollback(), + http_error=HTTPNotFound, msg_on_fail=s.UserGroup_DELETE_NotFoundResponseSchema.description, + content={"user_name": user.user_name, "group_name": group.group_name}) + + def delete_user_resource_permission_response(user, resource, permission, db_session): # type: (models.User, ServiceOrResourceType, Permission, Session) -> HTTPException """ @@ -135,35 +174,23 @@ def delete_user_resource_permission_response(user, resource, permission, db_sess :returns: valid HTTP response on successful operations. :raises HTTPException: error HTTP response of corresponding situation. """ - check_valid_service_or_resource_permission(permission.value, resource, db_session) - resource_id = resource.resource_id - del_perm = UserResourcePermissionService.get(user.id, resource_id, permission.value, db_session) + ru.check_valid_service_or_resource_permission(permission.value, resource, db_session) + res_id = resource.resource_id + del_perm = UserResourcePermissionService.get(user.id, res_id, permission.value, db_session) ax.evaluate_call(lambda: db_session.delete(del_perm), fallback=lambda: db_session.rollback(), http_error=HTTPNotFound, msg_on_fail=s.UserResourcePermissions_DELETE_NotFoundResponseSchema.description, - content={u"resource_id": resource_id, u"user_id": user.id, u"permission_name": permission.value}) + content={"resource_id": res_id, "user_id": user.id, "permission_name": permission.value}) return ax.valid_http(http_success=HTTPOk, detail=s.UserResourcePermissions_DELETE_OkResponseSchema.description) -def get_resource_root_service(resource, request): - # type: (models.Resource, Request) -> ServiceInterface - """ - Retrieves the service class corresponding to the specified resource's root service-resource. - """ - if resource.resource_type == models.Service.resource_type_name: - res_root_svc = resource - else: - res_root_svc = ResourceService.by_resource_id(resource.root_service_id, db_session=request.db) - return service_factory(res_root_svc, request) - - def filter_user_permission(resource_permission_list, user): # type: (List[ResourcePermissionType], models.User) -> Iterable[ResourcePermissionType] """ Retrieves only direct user permissions on resources amongst a list of user/group resource/service permissions. """ def is_user_perm(perm): - return perm.group is None and perm.type == u"user" and perm.user.user_name == user.user_name + return perm.group is None and perm.type == "user" and perm.user.user_name == user.user_name return filter(is_user_perm, resource_permission_list) @@ -181,23 +208,25 @@ def get_user_resource_permissions_response(user, resource, request, def get_usr_res_perms(): if resource.owner_user_id == user.id: + # FIXME: no 'magpie.models.Resource.permissions' - ok for now because no owner handling... res_perm_list = models.RESOURCE_TYPE_DICT[resource.type].permissions else: if effective_permissions: - svc = get_resource_root_service(resource, request) + svc = ru.get_resource_root_service_impl(resource, request) res_perm_list = svc.effective_permissions(resource, user) else: - res_perm_list = ResourceService.perms_for_user(resource, user, db_session=db_session) - if not inherit_groups_permissions: - res_perm_list = filter_user_permission(res_perm_list, user) + if inherit_groups_permissions: + res_perm_list = ResourceService.perms_for_user(resource, user, db_session=db_session) + else: + res_perm_list = ResourceService.direct_perms_for_user(resource, user, db_session=db_session) return format_permissions(res_perm_list) perm_names = ax.evaluate_call( lambda: get_usr_res_perms(), fallback=lambda: db_session.rollback(), http_error=HTTPInternalServerError, msg_on_fail=s.UserServicePermissions_GET_NotFoundResponseSchema.description, - content={u"resource_name": str(resource.resource_name), u"user_name": str(user.user_name)}) - return ax.valid_http(http_success=HTTPOk, content={u"permission_names": perm_names}, + content={"resource_name": str(resource.resource_name), "user_name": str(user.user_name)}) + return ax.valid_http(http_success=HTTPOk, content={"permission_names": perm_names}, detail=s.UserResourcePermissions_GET_OkResponseSchema.description) @@ -210,17 +239,23 @@ def get_user_services(user, request, cascade_resources=False, :param user: user for which to find services :param request: request with database session connection :param cascade_resources: - If `False`, return only services with *Direct* user permissions on their corresponding service-resource. - Otherwise, return every service that has at least one sub-resource with user permissions. + If ``False``, return only services which the :term:`User` has :term:`Immediate Permissions` on specialized + top-level resources corresponding to services. + Otherwise, return every service that has at least one sub-resource with permissions (children at any-level). + In both cases, the *permissions* looked for consider either only :term:`Direct Permissions` or any + :term:`Inherited Permissions` according to the value of :paramref:`inherit_groups_permissions`. :param inherit_groups_permissions: - If `False`, return only user-specific service/sub-resources permissions. - Otherwise, resolve inherited permissions using all groups the user is member of. + If ``False``, return only user-specific service/sub-resources :term:`Direct Permissions`. + Otherwise, resolve :term:`Inherited Permissions` using all groups the user is member of. :param format_as_list: returns as list of service dict information (not grouped by type and by name) - :return: only services which the user as *Direct* or *Inherited* permissions, according to `inherit_from_resources` + :return: + Only services which the user as :term:`Direct Permissions` or considering all tree hierarchy, + and for each case, either considering only user permissions or every :term:`Inherited Permissions`, + according to provided options. :rtype: - dict of services by type with corresponding services by name containing sub-dict information, - unless `format_as_list` is `True` + Dict of services by type with corresponding services by name containing sub-dict information, + unless :paramref:`format_as_list` is ``True`` """ db_session = request.db resource_type = None if cascade_resources else [models.Service.resource_type] @@ -230,21 +265,25 @@ def get_user_services(user, request, cascade_resources=False, services = {} for resource_id, perms in res_perm_dict.items(): resource = ResourceService.by_resource_id(resource_id=resource_id, db_session=db_session) - service_id = resource.root_service_id or resource.resource_id - is_service = resource.resource_type == models.Service.resource_type_name if not is_service: + # if any children resource had user/group permissions, minimally return its root service without + # any immediate permission, otherwise (cascade off) it is skipped and not returned at all in response if not cascade_resources: continue - perms = get_resource_root_service(resource, request).permissions + perms = [] - svc = db_session.query(models.Service).filter_by(resource_id=service_id).first() + svc = ru.get_resource_root_service_impl(resource, request) + if svc.service_type not in services: + services[svc.service_type] = {} + svc_name = svc.service.resource_name + svc_type = svc.service_type - if svc.type not in services: - services[svc.type] = {} - if svc.resource_name not in services[svc.type]: - services[svc.type][svc.resource_name] = format_service(svc, perms, show_private_url=False) + # if service was not already added, add it (could be directly its permissions, or empty via children resource) + # otherwise, set explicit immediate permissions on service instead of empty children resource permissions + if svc_name not in services[svc_type] or is_service: + services[svc_type][svc_name] = format_service(svc.service, perms, show_private_url=False) if not format_as_list: return services @@ -261,15 +300,16 @@ def get_user_service_permissions(user, service, request, inherit_groups_permissi if service.owner_user_id == user.id: usr_svc_perms = service_factory(service, request).permissions else: - usr_svc_perms = ResourceService.perms_for_user(service, user, db_session=request.db) - if not inherit_groups_permissions: - usr_svc_perms = filter_user_permission(usr_svc_perms, user) + if inherit_groups_permissions: + usr_svc_perms = ResourceService.perms_for_user(service, user, db_session=request.db) + else: + usr_svc_perms = ResourceService.direct_perms_for_user(service, user, db_session=request.db) return [convert_permission(p) for p in usr_svc_perms] def get_user_resources_permissions_dict(user, request, resource_types=None, resource_ids=None, inherit_groups_permissions=True): - # type: (models.User, Request, Optional[List[Str]], Optional[List[int]], bool) -> Dict[Str, Any] + # type: (models.User, Request, Optional[List[Str]], Optional[List[int]], bool) -> Dict[Str, AnyPermissionType] """ Creates a dictionary of resources by id with corresponding permissions of the user. @@ -278,9 +318,11 @@ def get_user_resources_permissions_dict(user, request, resource_types=None, :param resource_types: filter the search query with specified resource types :param resource_ids: filter the search query with specified resource ids :param inherit_groups_permissions: - If `False`, return only user-specific resource permissions. + If ``False``, return only user-specific resource permissions. Otherwise, resolve inherited permissions using all groups the user is member of. - :return: only services which the user as *Direct* or *Inherited* permissions, according to `inherit_from_resources` + :return: + Only services which the user as permissions on, or including all :term:`Inherited Permissions`, according to + :paramref:`inherit_groups_permissions` argument. """ ax.verify_param(user, not_none=True, http_error=HTTPNotFound, msg_on_fail=s.UserResourcePermissions_GET_NotFoundResponseSchema.description) @@ -303,40 +345,82 @@ def get_user_resources_permissions_dict(user, request, resource_types=None, def get_user_service_resources_permissions_dict(user, service, request, inherit_groups_permissions=True): - # type: (models.User, models.Service, Request, bool) -> Dict[Str, Any] + # type: (models.User, models.Service, Request, bool) -> ResourcePermissionMap + """ + Retrieves all permissions the user has for all resources nested under the service. + + The retrieved permissions can either include only direct permissions or also include inherited group permissions. + + :returns: dictionary of resource IDs with corresponding permissions. + """ resources_under_service = models.RESOURCE_TREE_SERVICE.from_parent_deeper(parent_id=service.resource_id, db_session=request.db) resource_ids = [resource.Resource.resource_id for resource in resources_under_service] + if not resource_ids: + return {} # return immediately, otherwise empty list generates dict of all existing resources (i.e. no-filter) return get_user_resources_permissions_dict(user, request, resource_types=None, resource_ids=resource_ids, inherit_groups_permissions=inherit_groups_permissions) -def check_user_info(user_name, email, password, group_name): - # type: (Str, Str, Str, Str) -> None - ax.verify_param(user_name, not_none=True, not_empty=True, http_error=HTTPBadRequest, - param_name=u"user_name", - msg_on_fail=s.Users_CheckInfo_Name_BadRequestResponseSchema.description) - ax.verify_param(len(user_name), is_in=True, http_error=HTTPBadRequest, - param_name=u"user_name", param_compare=range(1, 1 + get_constant("MAGPIE_USER_NAME_MAX_LENGTH")), - msg_on_fail=s.Users_CheckInfo_Size_BadRequestResponseSchema.description) - ax.verify_param(user_name, param_compare=get_constant("MAGPIE_LOGGED_USER"), not_equal=True, - param_name=u"user_name", http_error=HTTPBadRequest, - msg_on_fail=s.Users_CheckInfo_ReservedKeyword_BadRequestResponseSchema.description) - ax.verify_param(email, not_none=True, not_empty=True, http_error=HTTPBadRequest, - param_name=u"email", msg_on_fail=s.Users_CheckInfo_Email_BadRequestResponseSchema.description) - ax.verify_param(password, not_none=True, not_empty=True, http_error=HTTPBadRequest, - param_name=u"password", - msg_on_fail=s.Users_CheckInfo_Password_BadRequestResponseSchema.description) - ax.verify_param(group_name, not_none=True, not_empty=True, http_error=HTTPBadRequest, - param_name=u"group_name", - msg_on_fail=s.Users_CheckInfo_GroupName_BadRequestResponseSchema.description) - - -def get_user_groups_checked(request, user): - # type: (Request, models.User) -> List[Str] +def check_user_info(user_name=None, email=None, password=None, group_name=None, # required unless disabled explicitly + check_name=True, check_email=True, check_password=True, check_group=True): + # type: (Str, Str, Str, Str, bool, bool, bool, bool) -> None + """ + Validates provided user information to ensure they are adequate for user creation. + + Using ``check_`` prefixed arguments, individual field checks can be disabled (check all by default). + + :raises HTTPException: appropriate error for the invalid field value or format that was checked as applicable. + :returns: nothing if all enabled checks are successful. + """ + if check_name: + ax.verify_param(user_name, not_none=True, not_empty=True, param_name="user_name", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_UserNameValue_BadRequestResponseSchema.description) + ax.verify_param(user_name, matches=True, param_name="user_name", param_compare=ax.PARAM_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_UserNameValue_BadRequestResponseSchema.description) + name_range = range(1, 1 + get_constant("MAGPIE_USER_NAME_MAX_LENGTH")) + ax.verify_param(len(user_name), is_in=True, param_name="user_name", param_compare=name_range, + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_UserNameSize_BadRequestResponseSchema.description) + name_logged = get_constant("MAGPIE_LOGGED_USER") + ax.verify_param(user_name, param_compare=name_logged, not_equal=True, param_name="user_name", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_ReservedKeyword_BadRequestResponseSchema.description) + if check_email: + ax.verify_param(email, not_none=True, not_empty=True, param_name="email", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_Email_BadRequestResponseSchema.description) + ax.verify_param(email, matches=True, param_compare=ax.EMAIL_REGEX, param_name="email", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_Email_BadRequestResponseSchema.description) + if check_password: + ax.verify_param(password, not_none=True, not_empty=True, param_name="password", + is_type=True, param_compare=six.string_types, # no match since it can be any character + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_PasswordValue_BadRequestResponseSchema.description) + ax.verify_param(len(password), not_in=True, param_name="password", + param_compare=range(get_constant("MAGPIE_PASSWORD_MIN_LENGTH")), + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_PasswordSize_BadRequestResponseSchema.description) + if check_group: + ax.verify_param(group_name, not_none=True, not_empty=True, param_name="group_name", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_GroupName_BadRequestResponseSchema.description) + ax.verify_param(group_name, matches=True, param_name="group_name", param_compare=ax.PARAM_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_GroupName_BadRequestResponseSchema.description) + + +def get_user_groups_checked(user, db_session): + # type: (models.User, Session) -> List[Str] + """ + Obtains the validated list of group names from a pre-validated user. + """ ax.verify_param(user, not_none=True, http_error=HTTPNotFound, msg_on_fail=s.Groups_CheckInfo_NotFoundResponseSchema.description) - group_names = ax.evaluate_call(lambda: [group.group_name for group in user.groups], - fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, + group_names = ax.evaluate_call(lambda: [group.group_name for group in user.groups], # noqa + fallback=lambda: db_session.rollback(), http_error=HTTPForbidden, msg_on_fail=s.Groups_CheckInfo_ForbiddenResponseSchema.description) return sorted(group_names) diff --git a/magpie/api/management/user/user_views.py b/magpie/api/management/user/user_views.py index b819f48b9..3543805a8 100644 --- a/magpie/api/management/user/user_views.py +++ b/magpie/api/management/user/user_views.py @@ -1,13 +1,7 @@ -from pyramid.httpexceptions import ( - HTTPBadRequest, - HTTPConflict, - HTTPCreated, - HTTPForbidden, - HTTPMovedPermanently, - HTTPNotFound, - HTTPOk -) -from pyramid.security import NO_PERMISSION_REQUIRED +""" +User Views, both for specific user-name provided as request path variable and special keyword for logged session user. +""" +from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPCreated, HTTPForbidden, HTTPNotFound, HTTPOk from pyramid.settings import asbool from pyramid.view import view_config from ziggurat_foundations.models.services.group import GroupService @@ -21,7 +15,8 @@ from magpie.api.management.service.service_formats import format_service_resources from magpie.api.management.user import user_formats as uf from magpie.api.management.user import user_utils as uu -from magpie.constants import get_constant +from magpie.constants import MAGPIE_CONTEXT_PERMISSION, MAGPIE_LOGGED_PERMISSION, get_constant +from magpie.services import SERVICE_TYPE_DICT from magpie.utils import get_logger LOGGER = get_logger(__name__) @@ -37,7 +32,7 @@ def get_users_view(request): UserService.all(models.User, db_session=request.db)], fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, msg_on_fail=s.Users_GET_ForbiddenResponseSchema.description) - return ax.valid_http(http_success=HTTPOk, content={u"user_names": sorted(user_name_list)}, + return ax.valid_http(http_success=HTTPOk, content={"user_names": sorted(user_name_list)}, detail=s.Users_GET_OkResponseSchema.description) @@ -47,79 +42,96 @@ def create_user_view(request): """ Create a new user. """ - user_name = ar.get_multiformat_post(request, "user_name") - email = ar.get_multiformat_post(request, "email") - password = ar.get_multiformat_post(request, "password") - group_name = ar.get_multiformat_post(request, "group_name") - uu.check_user_info(user_name, email, password, group_name) + user_name = ar.get_multiformat_body(request, "user_name") + email = ar.get_multiformat_body(request, "email") + password = ar.get_multiformat_body(request, "password") + group_name = ar.get_multiformat_body(request, "group_name") return uu.create_user(user_name, password, email, group_name, db_session=request.db) -@s.UserAPI.put(schema=s.User_PUT_RequestSchema(), tags=[s.UsersTag], response_schemas=s.User_PUT_responses) -@s.LoggedUserAPI.put(schema=s.User_PUT_RequestSchema(), tags=[s.LoggedUserTag], - response_schemas=s.LoggedUser_PUT_responses) -@view_config(route_name=s.UserAPI.name, request_method="PUT") +@s.UserAPI.patch(schema=s.User_PATCH_RequestSchema(), tags=[s.UsersTag], response_schemas=s.User_PATCH_responses) +@s.LoggedUserAPI.patch(schema=s.User_PATCH_RequestSchema(), tags=[s.LoggedUserTag], + response_schemas=s.LoggedUser_PATCH_responses) +@view_config(route_name=s.UserAPI.name, request_method="PATCH", permission=MAGPIE_LOGGED_PERMISSION) def update_user_view(request): """ Update user information by user name. """ - - user_name = ar.get_value_matchdict_checked(request, key="user_name") - ax.verify_param(user_name, param_compare=get_constant("MAGPIE_LOGGED_USER"), not_equal=True, - http_error=HTTPBadRequest, param_name="user_name", content={u"user_name": user_name}, - msg_on_fail=s.Service_PUT_BadRequestResponseSchema_ReservedKeyword.description) - - user = ar.get_user_matchdict_checked(request, user_name_key="user_name") - new_user_name = ar.get_multiformat_post(request, "user_name", default=user.user_name) - new_email = ar.get_multiformat_post(request, "email", default=user.email) - new_password = ar.get_multiformat_post(request, "password", default=user.user_password) - uu.check_user_info(new_user_name, new_email, new_password, group_name=new_user_name) - - update_username = user.user_name != new_user_name - update_password = user.user_password != new_password - update_email = user.email != new_email - ax.verify_param(any([update_username, update_password, update_email]), is_true=True, http_error=HTTPBadRequest, - content={u"user_name": user.user_name}, - msg_on_fail=s.User_PUT_BadRequestResponseSchema.description) - - if user.user_name != new_user_name: + user = ar.get_user_matchdict_checked_or_logged(request) + new_user_name = ar.get_multiformat_body(request, "user_name", default=user.user_name) + new_email = ar.get_multiformat_body(request, "email", default=user.email) + new_password = ar.get_multiformat_body(request, "password", default=user.user_password) + + update_username = user.user_name != new_user_name and new_user_name is not None + update_password = user.user_password != new_password and new_password is not None + update_email = user.email != new_email and new_email is not None + ax.verify_param(any([update_username, update_password, update_email]), is_true=True, + with_param=False, # params are not useful in response for this case + content={"user_name": user.user_name}, + http_error=HTTPBadRequest, msg_on_fail=s.User_PATCH_BadRequestResponseSchema.description) + # user name change is admin-only operation + if update_username: + ax.verify_param(get_constant("MAGPIE_ADMIN_GROUP"), is_in=True, + param_compare=uu.get_user_groups_checked(request.user, request.db), with_param=False, + http_error=HTTPForbidden, msg_on_fail=s.User_PATCH_ForbiddenResponseSchema.description) + + # logged user updating itself is forbidden if it corresponds to special users + # cannot edit reserved keywords nor apply them to another user + forbidden_user_names = [ + get_constant("MAGPIE_ADMIN_USER", request), + get_constant("MAGPIE_ANONYMOUS_USER", request), + get_constant("MAGPIE_LOGGED_USER", request), + ] + check_user_name_cases = [user.user_name, new_user_name] if update_username else [user.user_name] + for check_user_name in check_user_name_cases: + ax.verify_param(check_user_name, not_in=True, param_compare=forbidden_user_names, param_name="user_name", + http_error=HTTPForbidden, content={"user_name": str(check_user_name)}, + msg_on_fail=s.User_PATCH_ForbiddenResponseSchema.description) + if update_username: + uu.check_user_info(user_name=new_user_name, check_email=False, check_password=False, check_group=False) existing_user = ax.evaluate_call(lambda: UserService.by_user_name(new_user_name, db_session=request.db), fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, - msg_on_fail=s.User_PUT_ForbiddenResponseSchema.description) - ax.verify_param(existing_user, is_none=True, http_error=HTTPConflict, - msg_on_fail=s.User_PUT_ConflictResponseSchema.description) + msg_on_fail=s.User_PATCH_ForbiddenResponseSchema.description) + ax.verify_param(existing_user, is_none=True, with_param=False, http_error=HTTPConflict, + msg_on_fail=s.User_PATCH_ConflictResponseSchema.description) user.user_name = new_user_name - if user.email != new_email: + if update_email: + uu.check_user_info(email=new_email, check_name=False, check_password=False, check_group=False) user.email = new_email - if user.user_password != new_password and new_password is not None: + if update_password: + uu.check_user_info(password=new_password, check_name=False, check_email=False, check_group=False) UserService.set_password(user, new_password) UserService.regenerate_security_code(user) - return ax.valid_http(http_success=HTTPOk, detail=s.Users_PUT_OkResponseSchema.description) + return ax.valid_http(http_success=HTTPOk, detail=s.Users_PATCH_OkResponseSchema.description) @s.UserAPI.get(tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.User_GET_responses) @s.LoggedUserAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUser_GET_responses) -@view_config(route_name=s.UserAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_view(request): """ Get user information by name. """ user = ar.get_user_matchdict_checked_or_logged(request) - return ax.valid_http(http_success=HTTPOk, content={u"user": uf.format_user(user)}, + return ax.valid_http(http_success=HTTPOk, content={"user": uf.format_user(user)}, detail=s.User_GET_OkResponseSchema.description) @s.UserAPI.delete(schema=s.User_DELETE_RequestSchema(), tags=[s.UsersTag], response_schemas=s.User_DELETE_responses) @s.LoggedUserAPI.delete(schema=s.User_DELETE_RequestSchema(), tags=[s.LoggedUserTag], response_schemas=s.LoggedUser_DELETE_responses) -@view_config(route_name=s.UserAPI.name, request_method="DELETE") +@view_config(route_name=s.UserAPI.name, request_method="DELETE") # FIXME: permission=MAGPIE_LOGGED_USER self-unregister def delete_user_view(request): """ Delete a user by name. """ user = ar.get_user_matchdict_checked_or_logged(request) + ax.verify_param(user.user_name, not_in=True, with_param=False, # avoid leaking username details + param_compare=[get_constant("MAGPIE_ADMIN_USER", request), + get_constant("MAGPIE_ANONYMOUS_USER", request)], + http_error=HTTPForbidden, msg_on_fail=s.User_DELETE_ForbiddenResponseSchema.description) ax.evaluate_call(lambda: request.db.delete(user), fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, msg_on_fail=s.User_DELETE_ForbiddenResponseSchema.description) return ax.valid_http(http_success=HTTPOk, detail=s.User_DELETE_OkResponseSchema.description) @@ -128,14 +140,14 @@ def delete_user_view(request): @s.UserGroupsAPI.get(tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.UserGroups_GET_responses) @s.LoggedUserGroupsAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUserGroups_GET_responses) -@view_config(route_name=s.UserGroupsAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserGroupsAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_groups_view(request): """ List all groups a user belongs to. """ user = ar.get_user_matchdict_checked_or_logged(request) - group_names = uu.get_user_groups_checked(request, user) - return ax.valid_http(http_success=HTTPOk, content={u"group_names": group_names}, + group_names = uu.get_user_groups_checked(user, request.db) + return ax.valid_http(http_success=HTTPOk, content={"group_names": group_names}, detail=s.UserGroups_GET_OkResponseSchema.description) @@ -150,21 +162,21 @@ def assign_user_group_view(request): """ user = ar.get_user_matchdict_checked_or_logged(request) - group_name = ar.get_value_multiformat_post_checked(request, "group_name") + group_name = ar.get_value_multiformat_body_checked(request, "group_name") group = ax.evaluate_call(lambda: GroupService.by_group_name(group_name, db_session=request.db), fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, msg_on_fail=s.UserGroups_POST_ForbiddenResponseSchema.description) ax.verify_param(group, not_none=True, http_error=HTTPNotFound, msg_on_fail=s.UserGroups_POST_GroupNotFoundResponseSchema.description) - ax.verify_param(user.id, param_compare=[usr.id for usr in group.users], not_in=True, http_error=HTTPConflict, - content={u"user_name": user.user_name, u"group_name": group.group_name}, + ax.verify_param(user.id, param_compare=[usr.id for usr in group.users], not_in=True, with_param=False, + http_error=HTTPConflict, content={"user_name": user.user_name, "group_name": group.group_name}, msg_on_fail=s.UserGroups_POST_ConflictResponseSchema.description) ax.evaluate_call(lambda: request.db.add(models.UserGroup(group_id=group.id, user_id=user.id)), # noqa fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, msg_on_fail=s.UserGroups_POST_RelationshipForbiddenResponseSchema.description, - content={u"user_name": user.user_name, u"group_name": group.group_name}) + content={"user_name": user.user_name, "group_name": group.group_name}) return ax.valid_http(http_success=HTTPCreated, detail=s.UserGroups_POST_CreatedResponseSchema.description, - content={u"user_name": user.user_name, u"group_name": group.group_name}) + content={"user_name": user.user_name, "group_name": group.group_name}) @s.UserGroupAPI.delete(schema=s.UserGroup_DELETE_RequestSchema(), tags=[s.UsersTag], @@ -174,21 +186,11 @@ def assign_user_group_view(request): @view_config(route_name=s.UserGroupAPI.name, request_method="DELETE") def delete_user_group_view(request): """ - Remove a user from a group. + Removes a user from a group. """ - db = request.db user = ar.get_user_matchdict_checked_or_logged(request) group = ar.get_group_matchdict_checked(request) - - def del_usr_grp(usr, grp): - db.query(models.UserGroup) \ - .filter(models.UserGroup.user_id == usr.id) \ - .filter(models.UserGroup.group_id == grp.id) \ - .delete() - - ax.evaluate_call(lambda: del_usr_grp(user, group), fallback=lambda: db.rollback(), - http_error=HTTPNotFound, msg_on_fail=s.UserGroup_DELETE_NotFoundResponseSchema.description, - content={u"user_name": user.user_name, u"group_name": group.group_name}) + uu.delete_user_group(user, group, request.db) return ax.valid_http(http_success=HTTPOk, detail=s.UserGroup_DELETE_OkResponseSchema.description) @@ -198,91 +200,75 @@ def del_usr_grp(usr, grp): @s.LoggedUserResourcesAPI.get(schema=s.UserResources_GET_RequestSchema(), tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUserResources_GET_responses) -@view_config(route_name=s.UserResourcesAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserResourcesAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_resources_view(request): """ List all resources a user has permissions on. """ - inherit_groups_perms = asbool(ar.get_query_param(request, "inherit")) + inherit_groups_perms = asbool(ar.get_query_param(request, "inherit") or ar.get_query_param(request, "inherited")) + filtered_perms = asbool(ar.get_query_param(request, "filter") or ar.get_query_param(request, "filtered")) user = ar.get_user_matchdict_checked_or_logged(request) db = request.db + # skip admin-only full listing of resources if filtered view is requested + is_admin = False + if not filtered_perms and request.user is not None: + admin_group = get_constant("MAGPIE_ADMIN_GROUP", settings_container=request) + is_admin = admin_group in [group.group_name for group in request.user.groups] + def build_json_user_resource_tree(usr): json_res = {} services = ResourceService.all(models.Service, db_session=db) + # add service-types so they are ordered and listed if no service of that type was defined + for svc_type in sorted(SERVICE_TYPE_DICT): + json_res[svc_type] = {} for svc in services: svc_perms = uu.get_user_service_permissions( user=usr, service=svc, request=request, inherit_groups_permissions=inherit_groups_perms) - if svc.type not in json_res: - json_res[svc.type] = {} res_perms_dict = uu.get_user_service_resources_permissions_dict( user=usr, service=svc, request=request, inherit_groups_permissions=inherit_groups_perms) - json_res[svc.type][svc.resource_name] = format_service_resources( - svc, - db_session=db, - service_perms=svc_perms, - resources_perms_dict=res_perms_dict, - show_all_children=False, - show_private_url=False, - ) + # always allow admin to view full resource tree, unless explicitly requested to be filtered + # otherwise (non-admin), only add details if there is at least one resource permission (any level) + if (is_admin and not filtered_perms) or (svc_perms or res_perms_dict): + json_res[svc.type][svc.resource_name] = format_service_resources( + svc, + db_session=db, + service_perms=svc_perms, + resources_perms_dict=res_perms_dict, + show_all_children=False, + show_private_url=False, + ) return json_res usr_res_dict = ax.evaluate_call(lambda: build_json_user_resource_tree(user), fallback=lambda: db.rollback(), http_error=HTTPNotFound, msg_on_fail=s.UserResources_GET_NotFoundResponseSchema.description, - content={u"user_name": user.user_name, - u"resource_types": [models.Service.resource_type_name]}) - return ax.valid_http(http_success=HTTPOk, content={u"resources": usr_res_dict}, + content={"user_name": user.user_name, + "resource_types": [models.Service.resource_type_name]}) + return ax.valid_http(http_success=HTTPOk, content={"resources": usr_res_dict}, detail=s.UserResources_GET_OkResponseSchema.description) -@s.UserInheritedResourcesAPI.get(tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.UserResources_GET_responses) -@s.LoggedUserInheritedResourcesAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.LoggedUserResources_GET_responses) -@view_config(route_name=s.UserInheritedResourcesAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) -def get_user_inherited_resources_view(request): - """[DEPRECATED: use '/users/{user_name}/resources?inherit=true'] - List all resources a user has permissions on with his inherited user and groups permissions.""" - LOGGER.warning("Route deprecated: [%s], Instead Use: [%s]", - s.UserInheritedResourcesAPI.path, s.UserResourcesAPI.path + "?inherit=true") - return HTTPMovedPermanently(location=request.path.replace("/inherited_resources", "/resources?inherit=true")) - - @s.UserResourcePermissionsAPI.get(schema=s.UserResourcePermissions_GET_RequestSchema(), tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.UserResourcePermissions_GET_responses) @s.LoggedUserResourcePermissionsAPI.get(schema=s.UserResourcePermissions_GET_RequestSchema(), tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUserResourcePermissions_GET_responses) -@view_config(route_name=s.UserResourcePermissionsAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserResourcePermissionsAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_resource_permissions_view(request): """ List all permissions a user has on a specific resource. """ user = ar.get_user_matchdict_checked_or_logged(request) resource = ar.get_resource_matchdict_checked(request, "resource_id") - inherit_groups_perms = asbool(ar.get_query_param(request, "inherit")) + inherit_groups_perms = asbool(ar.get_query_param(request, "inherit") or ar.get_query_param(request, "inherited")) effective_perms = asbool(ar.get_query_param(request, "effective")) return uu.get_user_resource_permissions_response(user, resource, request, inherit_groups_permissions=inherit_groups_perms, effective_permissions=effective_perms) -@s.UserResourceInheritedPermissionsAPI.get(tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.UserResourcePermissions_GET_responses) -@s.LoggedUserResourceInheritedPermissionsAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.LoggedUserResourcePermissions_GET_responses) -@view_config(route_name=s.UserResourceInheritedPermissionsAPI.name, request_method="GET", - permission=NO_PERMISSION_REQUIRED) -def get_user_resource_inherit_groups_permissions_view(request): - """[DEPRECATED: use '/users/{user_name}/resources/{resource_id}/permissions?inherit=true'] - List all permissions a user has on a specific resource with his inherited user and groups permissions.""" - LOGGER.warning("Route deprecated: [%s], Instead Use: [%s]", - s.UserResourceInheritedPermissionsAPI.path, s.UserResourcePermissionsAPI.path + "?inherit=true") - return HTTPMovedPermanently(location=request.path.replace("/inherited_permissions", "/permissions?inherit=true")) - - @s.UserResourcePermissionsAPI.post(schema=s.UserResourcePermissions_POST_RequestSchema(), tags=[s.UsersTag], response_schemas=s.UserResourcePermissions_POST_responses) @s.LoggedUserResourcePermissionsAPI.post(schema=s.UserResourcePermissions_POST_RequestSchema(), tags=[s.LoggedUserTag], @@ -294,7 +280,7 @@ def create_user_resource_permission_view(request): """ user = ar.get_user_matchdict_checked_or_logged(request) resource = ar.get_resource_matchdict_checked(request) - permission = ar.get_permission_multiformat_post_checked(request, resource) + permission = ar.get_permission_multiformat_body_checked(request, resource) return uu.create_user_resource_permission_response(user, resource, permission, request.db) @@ -306,7 +292,7 @@ def create_user_resource_permission_view(request): @view_config(route_name=s.UserResourcePermissionAPI.name, request_method="DELETE") def delete_user_resource_permission_view(request): """ - Delete a direct permission on a resource for a user (not including his groups permissions). + Delete an applied permission on a resource for a user (not including his groups permissions). """ user = ar.get_user_matchdict_checked_or_logged(request) resource = ar.get_resource_matchdict_checked(request) @@ -318,74 +304,45 @@ def delete_user_resource_permission_view(request): api_security=s.SecurityEveryoneAPI, response_schemas=s.UserServices_GET_responses) @s.LoggedUserServicesAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUserServices_GET_responses) -@view_config(route_name=s.UserServicesAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserServicesAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_services_view(request): """ List all services a user has permissions on. """ user = ar.get_user_matchdict_checked_or_logged(request) cascade_resources = asbool(ar.get_query_param(request, "cascade")) - inherit_groups_perms = asbool(ar.get_query_param(request, "inherit")) - format_as_list = asbool(ar.get_query_param(request, "list")) + inherit_groups_perms = asbool(ar.get_query_param(request, "inherit") or ar.get_query_param(request, "inherited")) + format_as_list = asbool(ar.get_query_param(request, "flatten")) svc_json = uu.get_user_services(user, request=request, cascade_resources=cascade_resources, inherit_groups_permissions=inherit_groups_perms, format_as_list=format_as_list) - return ax.valid_http(http_success=HTTPOk, content={u"services": svc_json}, + return ax.valid_http(http_success=HTTPOk, content={"services": svc_json}, detail=s.UserServices_GET_OkResponseSchema.description) -@s.UserInheritedServicesAPI.get(tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.UserServices_GET_responses) -@s.LoggedUserInheritedServicesAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.LoggedUserServices_GET_responses) -@view_config(route_name=s.UserInheritedServicesAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) -def get_user_inherited_services_view(request): - """[DEPRECATED: use '/users/{user_name}/services?inherit=true'] - List all services a user has permissions on with his inherited user and groups permissions.""" - LOGGER.warning("Route deprecated: [%s], Instead Use: [%s]", - s.LoggedUserInheritedServicesAPI.path, s.LoggedUserServicesAPI.path + "?inherit=true") - return HTTPMovedPermanently(location=request.path.replace("/inherited_services", "/services?inherit=true")) - - -@s.UserServiceInheritedPermissionsAPI.get(schema=s.UserServicePermissions_GET_RequestSchema, - tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.UserServicePermissions_GET_responses) -@s.LoggedUserServiceInheritedPermissionsAPI.get(schema=s.UserServicePermissions_GET_RequestSchema, - tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.LoggedUserServicePermissions_GET_responses) -@view_config(route_name=s.UserServiceInheritedPermissionsAPI.name, request_method="GET", - permission=NO_PERMISSION_REQUIRED) -def get_user_service_inherited_permissions_view(request): - """[DEPRECATED: use '/users/{user_name}/services/{service_name}/permissions?inherit=true'] - List all permissions a user has on a service using all his inherited user and groups permissions.""" - LOGGER.warning("Route deprecated: [%s], Instead Use: [%s]", - s.UserServiceInheritedPermissionsAPI.path, s.UserServicePermissionsAPI.path + "?inherit=true") - return HTTPMovedPermanently(location=request.path.replace("/inherited_permissions", "/permissions?inherit=true")) - - @s.UserServicePermissionsAPI.get(schema=s.UserServicePermissions_GET_RequestSchema, tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.UserServicePermissions_GET_responses) @s.LoggedUserServicePermissionsAPI.get(schema=s.UserServicePermissions_GET_RequestSchema, tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUserServicePermissions_GET_responses) -@view_config(route_name=s.UserServicePermissionsAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserServicePermissionsAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_service_permissions_view(request): """ List all permissions a user has on a service. """ user = ar.get_user_matchdict_checked_or_logged(request) service = ar.get_service_matchdict_checked(request) - inherit_groups_perms = asbool(ar.get_query_param(request, "inherit")) + inherit_groups_perms = asbool(ar.get_query_param(request, "inherit") or ar.get_query_param(request, "inherited")) perms = ax.evaluate_call(lambda: uu.get_user_service_permissions(service=service, user=user, request=request, inherit_groups_permissions=inherit_groups_perms), fallback=lambda: request.db.rollback(), http_error=HTTPNotFound, msg_on_fail=s.UserServicePermissions_GET_NotFoundResponseSchema.description, - content={u"service_name": str(service.resource_name), u"user_name": str(user.user_name)}) + content={"service_name": str(service.resource_name), "user_name": str(user.user_name)}) return ax.valid_http(http_success=HTTPOk, detail=s.UserServicePermissions_GET_OkResponseSchema.description, - content={u"permission_names": sorted(p.value for p in perms)}) + content={"permission_names": sorted(p.value for p in perms)}) @s.UserServicePermissionsAPI.post(schema=s.UserServicePermissions_POST_RequestSchema, tags=[s.UsersTag], @@ -399,7 +356,7 @@ def create_user_service_permission_view(request): """ user = ar.get_user_matchdict_checked_or_logged(request) service = ar.get_service_matchdict_checked(request) - permission = ar.get_permission_multiformat_post_checked(request, service) + permission = ar.get_permission_multiformat_body_checked(request, service) return uu.create_user_resource_permission_response(user, service, permission, request.db) @@ -410,7 +367,7 @@ def create_user_service_permission_view(request): @view_config(route_name=s.UserServicePermissionAPI.name, request_method="DELETE") def delete_user_service_permission_view(request): """ - Delete a direct permission on a service for a user (not including his groups permissions). + Delete an applied permission on a service for a user (not including his groups permissions). """ user = ar.get_user_matchdict_checked_or_logged(request) service = ar.get_service_matchdict_checked(request) @@ -424,12 +381,12 @@ def delete_user_service_permission_view(request): @s.LoggedUserServiceResourcesAPI.get(schema=s.UserServiceResources_GET_RequestSchema, tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, response_schemas=s.LoggedUserServiceResources_GET_responses) -@view_config(route_name=s.UserServiceResourcesAPI.name, request_method="GET", permission=NO_PERMISSION_REQUIRED) +@view_config(route_name=s.UserServiceResourcesAPI.name, request_method="GET", permission=MAGPIE_CONTEXT_PERMISSION) def get_user_service_resources_view(request): """ List all resources under a service a user has permission on. """ - inherit_groups_perms = asbool(ar.get_query_param(request, "inherit")) + inherit_groups_perms = asbool(ar.get_query_param(request, "inherit") or ar.get_query_param(request, "inherited")) user = ar.get_user_matchdict_checked_or_logged(request) service = ar.get_service_matchdict_checked(request) service_perms = uu.get_user_service_permissions( @@ -444,19 +401,5 @@ def get_user_service_resources_view(request): show_all_children=False, show_private_url=False, ) - return ax.valid_http(http_success=HTTPOk, detail=s.UserServiceResources_GET_OkResponseSchema.description, - content={u"service": user_svc_res_json}) - - -@s.UserServiceInheritedResourcesAPI.get(tags=[s.UsersTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.UserServiceResources_GET_responses) -@s.LoggedUserServiceInheritedResourcesAPI.get(tags=[s.LoggedUserTag], api_security=s.SecurityEveryoneAPI, - response_schemas=s.LoggedUserServiceResources_GET_responses) -@view_config(route_name=s.UserServiceInheritedResourcesAPI.name, request_method="GET", - permission=NO_PERMISSION_REQUIRED) -def get_user_service_inherited_resources_view(request): - """[DEPRECATED: use '/users/{user_name}/services/{service_name}/resources?inherit=true'] - List all resources under a service a user has permission on using all his inherited user and groups permissions.""" - LOGGER.warning("Route deprecated: [%s], Instead Use: [%s]", - s.UserServiceInheritedResourcesAPI.path, s.UserServiceResourcesAPI.path + "?inherit=true") - return HTTPMovedPermanently(location=request.path.replace("/inherited_resources", "/resources?inherit=true")) + return ax.valid_http(http_success=HTTPOk, content={"service": user_svc_res_json}, + detail=s.UserServiceResources_GET_OkResponseSchema.description) diff --git a/magpie/api/requests.py b/magpie/api/requests.py index 520cde36c..373322454 100644 --- a/magpie/api/requests.py +++ b/magpie/api/requests.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING -from pyramid.authentication import IAuthenticationPolicy +import six +from pyramid.authentication import Authenticated, IAuthenticationPolicy from pyramid.httpexceptions import ( HTTPBadRequest, HTTPForbidden, @@ -13,16 +14,21 @@ from ziggurat_foundations.models.services.user import UserService from magpie import models +from magpie.api import exception as ax from magpie.api import schemas as s -from magpie.api.exception import evaluate_call, verify_param from magpie.constants import get_constant -from magpie.utils import CONTENT_TYPE_JSON +from magpie.utils import CONTENT_TYPE_JSON, get_logger if TYPE_CHECKING: # pylint: disable=W0611,unused-import + from typing import Any, List, Optional, Union + from pyramid.request import Request - from magpie.typedefs import Any, Str, Optional, ServiceOrResourceType # noqa: F401 - from magpie.permissions import Permission # noqa: F401 + + from magpie.permissions import Permission + from magpie.typedefs import AnyAccessPrincipalType, ServiceOrResourceType, Str + +LOGGER = get_logger(__name__) def get_request_method_content(request): @@ -31,10 +37,15 @@ def get_request_method_content(request): return getattr(request, method_property) -def get_multiformat_any(request, key, default=None): +def get_multiformat_body(request, key, default=None): # type: (Request, Str, Optional[Any]) -> Any """ - Obtains the ``key`` element from the request body using found `Content-Type` header. + Obtains the value of :paramref:`key` element from the request body according to specified `Content-Type` header. + + .. seealso:: + - :func:`get_multiformat_body_checked` + - :func:`get_permission_multiformat_body_checked` + - :func:`get_value_multiformat_body_checked` """ msg = "Key '{key}' could not be extracted from '{method}' of type '{type}'" \ .format(key=repr(key), method=request.method, type=request.content_type) @@ -42,126 +53,220 @@ def get_multiformat_any(request, key, default=None): # avoid json parse error if body is empty if not len(request.body): return default - return evaluate_call(lambda: request.json.get(key, default), - http_error=HTTPInternalServerError, msg_on_fail=msg) - return evaluate_call(lambda: get_request_method_content(request).get(key, default), - http_error=HTTPInternalServerError, msg_on_fail=msg) - - -def get_multiformat_post(request, key, default=None): - return get_multiformat_any(request, key, default) + return ax.evaluate_call(lambda: request.json.get(key, default), + http_error=HTTPInternalServerError, msg_on_fail=msg) + return ax.evaluate_call(lambda: get_request_method_content(request).get(key, default), + http_error=HTTPInternalServerError, msg_on_fail=msg) -def get_multiformat_put(request, key, default=None): - return get_multiformat_any(request, key, default) - - -def get_multiformat_delete(request, key, default=None): - return get_multiformat_any(request, key, default) - - -def get_permission_multiformat_post_checked(request, service_or_resource, permission_name_key="permission_name"): +def get_permission_multiformat_body_checked(request, service_or_resource, permission_name_key="permission_name"): # type: (Request, ServiceOrResourceType, Str) -> Permission """ Retrieves the permission from the body and validates that it is allowed for the specified `service` or `resource`. + + Validation combines basic field checks followed by contextual values applicable for the `service` or `resource`. + + .. seealso:: + - :func:`get_value_multiformat_body_checked` """ # import here to avoid circular import error with undefined functions between (api_request, resource_utils) from magpie.api.management.resource.resource_utils import check_valid_service_or_resource_permission - perm_name = get_value_multiformat_post_checked(request, permission_name_key) + perm_name = get_value_multiformat_body_checked(request, permission_name_key) return check_valid_service_or_resource_permission(perm_name, service_or_resource, request.db) -def get_value_multiformat_post_checked(request, key, default=None): - val = get_multiformat_any(request, key, default=default) - verify_param(val, not_none=True, not_empty=True, http_error=HTTPUnprocessableEntity, - param_name=key, msg_on_fail=s.UnprocessableEntityResponseSchema.description) +def get_value_multiformat_body_checked(request, key, default=None, check_type=six.string_types, pattern=ax.PARAM_REGEX): + # type: (Request, Str, Any, Any, Optional[Union[Str, bool]]) -> Str + """ + Obtains and validates the matched value under :paramref:`key` element from the request body. + + Parsing of the body is accomplished according to ``Content-Type`` header. + + :param request: request from which to retrieve the key. + :param key: body key variable. + :param default: value to return instead if not found. If this default is ``None``, it will raise. + :param check_type: verify that parameter value is of specified type. Set to ``None`` to disable check. + :param pattern: regex pattern to validate the input with. + If value evaluates to ``False``, skip this kind of validation + (default: :py:data:`magpie.api.exception.PARAM_REGEX`). + :return: matched path variable value. + :raises HTTPBadRequest: if the key could not be retrieved from the request body and has no provided default value. + :raises HTTPUnprocessableEntity: if the retrieved value from the key is invalid for this request. + + .. seealso:: + - :func:`get_multiformat_body` + """ + val = get_multiformat_body(request, key, default=default) + ax.verify_param(val, not_none=True, is_type=bool(check_type), param_compare=check_type, param_name=key, + http_error=HTTPBadRequest, msg_on_fail=s.BadRequestResponseSchema.description) + if bool(pattern) and check_type in six.string_types: + ax.verify_param(val, not_empty=True, matches=True, param_compare=pattern, param_name=key, + http_error=HTTPUnprocessableEntity, msg_on_fail=s.UnprocessableEntityResponseSchema.description) return val +def get_principals(request): + # type: (Request) -> List[AnyAccessPrincipalType] + """ + Obtains the list of effective principals according to detected request session user. + """ + authn_policy = request.registry.queryUtility(IAuthenticationPolicy) # noqa + principals = authn_policy.effective_principals(request) + return principals + + +def get_logged_user(request): + # type: (Request) -> Optional[models.User] + try: + principals = get_principals(request) + if Authenticated in principals: + LOGGER.info("User '%s' is authenticated", request.user.user_name) + return request.user + except AttributeError: + pass + return None + + def get_user(request, user_name_or_token=None): # type: (Request, Optional[Str]) -> models.User - logged_user_name = get_constant("MAGPIE_LOGGED_USER") + """ + Obtains the user corresponding to the provided user-name, token or via lookup of the logged user request session. + + :param request: request from which to obtain application settings and session user as applicable. + :param user_name_or_token: reference value to employ for lookup of the user. + :returns: found user. + :raises HTTPForbidden: if the requesting user does not have sufficient permission to execute this request. + :raises HTTPNotFound: if the specified user name or token does not correspond to any existing user. + """ + logged_user_name = get_constant("MAGPIE_LOGGED_USER", settings_container=request) if user_name_or_token is None: user_name_or_token = logged_user_name if user_name_or_token == logged_user_name: curr_user = request.user if curr_user: return curr_user - anonymous_user = get_constant("MAGPIE_ANONYMOUS_USER") - anonymous = evaluate_call(lambda: UserService.by_user_name(anonymous_user, db_session=request.db), - fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, - msg_on_fail=s.User_CheckAnonymous_ForbiddenResponseSchema.description) - verify_param(anonymous, not_none=True, http_error=HTTPNotFound, - msg_on_fail=s.User_CheckAnonymous_NotFoundResponseSchema.description) + anonymous_user = get_constant("MAGPIE_ANONYMOUS_USER", settings_container=request) + anonymous = ax.evaluate_call(lambda: UserService.by_user_name(anonymous_user, db_session=request.db), + fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, + msg_on_fail=s.User_CheckAnonymous_ForbiddenResponseSchema.description) + ax.verify_param(anonymous, not_none=True, http_error=HTTPNotFound, + msg_on_fail=s.User_CheckAnonymous_NotFoundResponseSchema.description) return anonymous - authn_policy = request.registry.queryUtility(IAuthenticationPolicy) - principals = authn_policy.effective_principals(request) - admin_group = GroupService.by_group_name(get_constant("MAGPIE_ADMIN_GROUP"), db_session=request.db) - admin_principal = "group:{}".format(admin_group.id) - if admin_principal not in principals: - raise HTTPForbidden() - user = evaluate_call(lambda: UserService.by_user_name(user_name_or_token, db_session=request.db), - fallback=lambda: request.db.rollback(), - http_error=HTTPForbidden, msg_on_fail=s.User_GET_ForbiddenResponseSchema.description) - verify_param(user, not_none=True, http_error=HTTPNotFound, - msg_on_fail=s.User_GET_NotFoundResponseSchema.description) + ax.verify_param(user_name_or_token, not_none=True, not_empty=True, matches=True, + param_compare=ax.PARAM_REGEX, param_name="user_name", + http_error=HTTPBadRequest, msg_on_fail=s.User_Check_BadRequestResponseSchema.description) + user = ax.evaluate_call(lambda: UserService.by_user_name(user_name_or_token, db_session=request.db), + fallback=lambda: request.db.rollback(), + http_error=HTTPInternalServerError, + msg_on_fail=s.User_GET_InternalServerErrorResponseSchema.description) + ax.verify_param(user, not_none=True, http_error=HTTPNotFound, + msg_on_fail=s.User_GET_NotFoundResponseSchema.description) return user def get_user_matchdict_checked_or_logged(request, user_name_key="user_name"): - logged_user_name = get_constant("MAGPIE_LOGGED_USER") - logged_user_path = s.UserAPI.path.replace("{" + user_name_key + "}", logged_user_name) - if user_name_key not in request.matchdict and request.path_info.startswith(logged_user_path): + # type: (Request, Str) -> models.User + """ + Obtains either the explicit or logged user specified in the request path variable. + + :returns found user. + :raises HTTPForbidden: if the requesting user does not have sufficient permission to execute this request. + :raises HTTPNotFound: if the specified user name or logged user keyword does not correspond to any existing user. + """ + logged_user_name = get_constant("MAGPIE_LOGGED_USER", settings_container=request) + # add final slash to avoid trailing characters that mismatches the logged user keyword (eg: "random") + logged_user_path = s.UserAPI.path.replace("{" + user_name_key + "}", logged_user_name + "/") + request_path = request.path_info if request.path_info.endswith("/") else request.path_info + "/" + if user_name_key not in request.matchdict or request_path.startswith(logged_user_path): return get_user(request, logged_user_name) return get_user_matchdict_checked(request, user_name_key) def get_user_matchdict_checked(request, user_name_key="user_name"): + # type: (Request, Str) -> models.User + """ + Obtains the user matched against the specified request path variable. + + :returns: found user. + :raises HTTPForbidden: if the requesting user does not have sufficient permission to execute this request. + :raises HTTPNotFound: if the specified user name does not correspond to any existing user. + + .. seealso:: + - :func:`get_value_matchdict_checked` + - :func:`get_user` + """ user_name = get_value_matchdict_checked(request, user_name_key) return get_user(request, user_name) def get_group_matchdict_checked(request, group_name_key="group_name"): + # type: (Request, Str) -> models.Group + """ + Obtains the group matched against the specified request path variable. + + :returns: found group. + :raises HTTPForbidden: if the requesting user does not have sufficient permission to execute this request. + :raises HTTPNotFound: if the specified group name does not correspond to any existing group. + """ group_name = get_value_matchdict_checked(request, group_name_key) - group = evaluate_call(lambda: GroupService.by_group_name(group_name, db_session=request.db), - fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, - msg_on_fail=s.Group_MatchDictCheck_ForbiddenResponseSchema.description) - verify_param(group, not_none=True, http_error=HTTPNotFound, - msg_on_fail=s.Group_MatchDictCheck_NotFoundResponseSchema.description) + group = ax.evaluate_call(lambda: GroupService.by_group_name(group_name, db_session=request.db), + fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, + msg_on_fail=s.Group_MatchDictCheck_ForbiddenResponseSchema.description) + ax.verify_param(group, not_none=True, http_error=HTTPNotFound, + param_content={"value": group_name}, param_name="group_name", + msg_on_fail=s.Group_MatchDictCheck_NotFoundResponseSchema.description) return group def get_resource_matchdict_checked(request, resource_name_key="resource_id"): # type: (Request, Str) -> models.Resource - resource_id = get_value_matchdict_checked(request, resource_name_key) - resource_id = evaluate_call(lambda: int(resource_id), http_error=HTTPBadRequest, - msg_on_fail=s.Resource_MatchDictCheck_BadRequestResponseSchema.description) - resource = evaluate_call(lambda: ResourceService.by_resource_id(resource_id, db_session=request.db), - fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, - msg_on_fail=s.Resource_MatchDictCheck_ForbiddenResponseSchema.description) - verify_param(resource, not_none=True, http_error=HTTPNotFound, - msg_on_fail=s.Resource_MatchDictCheck_NotFoundResponseSchema.description) + """ + Obtains the resource matched against the specified request path variable. + + :returns: found resource. + :raises HTTPForbidden: if the requesting user does not have sufficient permission to execute this request. + :raises HTTPNotFound: if the specified resource ID does not correspond to any existing resource. + """ + resource_id = get_value_matchdict_checked(request, resource_name_key, pattern=ax.INDEX_REGEX) + resource_id = ax.evaluate_call(lambda: int(resource_id), http_error=HTTPBadRequest, + msg_on_fail=s.Resource_MatchDictCheck_BadRequestResponseSchema.description) + resource = ax.evaluate_call(lambda: ResourceService.by_resource_id(resource_id, db_session=request.db), + fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, + msg_on_fail=s.Resource_MatchDictCheck_ForbiddenResponseSchema.description) + ax.verify_param(resource, not_none=True, http_error=HTTPNotFound, + param_content={"value": resource_id}, param_name="resource_id", + msg_on_fail=s.Resource_MatchDictCheck_NotFoundResponseSchema.description) return resource def get_service_matchdict_checked(request, service_name_key="service_name"): + # type: (Request, Str) -> models.Service + """ + Obtains the service matched against the specified request path variable. + + :returns: found service. + :raises HTTPForbidden: if the requesting user does not have sufficient permission to execute this request. + :raises HTTPNotFound: if the specified service name does not correspond to any existing service. + """ service_name = get_value_matchdict_checked(request, service_name_key) - service = evaluate_call(lambda: models.Service.by_service_name(service_name, db_session=request.db), - fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, - msg_on_fail=s.Service_MatchDictCheck_ForbiddenResponseSchema.description) - verify_param(service, not_none=True, http_error=HTTPNotFound, content={u"service_name": service_name}, - msg_on_fail=s.Service_MatchDictCheck_NotFoundResponseSchema.description) + service = ax.evaluate_call(lambda: models.Service.by_service_name(service_name, db_session=request.db), + fallback=lambda: request.db.rollback(), http_error=HTTPForbidden, + msg_on_fail=s.Service_MatchDictCheck_ForbiddenResponseSchema.description) + ax.verify_param(service, not_none=True, http_error=HTTPNotFound, + param_content={"value": service_name}, param_name="service_name", + msg_on_fail=s.Service_MatchDictCheck_NotFoundResponseSchema.description) return service def get_permission_matchdict_checked(request, service_or_resource, permission_name_key="permission_name"): # type: (Request, models.Resource, Str) -> Permission """ - Obtains the `permission` specified in the ``request`` path and validates that it is allowed for the specified - ``service_or_resource`` which can be a `service` or a children `resource`. + Obtains the permission specified in the request path variable and validates that it is allowed for the specified. + + :paramref:`service_or_resource` which can be a `service` or a children `resource`. - Allowed permissions correspond to the direct `service` permissions or restrained permissions of the `resource` + Allowed permissions correspond to the *direct* `service` permissions or restrained permissions of the `resource` under its root `service`. :returns: found permission name if valid for the service/resource @@ -172,10 +277,25 @@ def get_permission_matchdict_checked(request, service_or_resource, permission_na return check_valid_service_or_resource_permission(perm_name, service_or_resource, request.db) -def get_value_matchdict_checked(request, key): +def get_value_matchdict_checked(request, key, check_type=six.string_types, pattern=ax.PARAM_REGEX): + # type: (Request, Str, Any, Optional[Union[Str, bool]]) -> Str + """ + Obtains the matched value located at the expected position of the specified path variable. + + :param request: request from which to retrieve the key. + :param key: path variable key. + :param check_type: verify that parameter value is of specified type. Set to ``None`` to disable check. + :param pattern: regex pattern to validate the input with. + If value evaluates to ``False``, skip this kind of validation (default: :py:data:`ax.PARAM_REGEX`). + :return: matched path variable value. + :raises HTTPUnprocessableEntity: if the key is not an applicable path variable for this request. + """ val = request.matchdict.get(key) - verify_param(val, not_none=True, not_empty=True, http_error=HTTPUnprocessableEntity, - param_name=key, msg_on_fail=s.UnprocessableEntityResponseSchema.description) + ax.verify_param(val, not_none=True, is_type=bool(check_type), param_compare=check_type, param_name=key, + http_error=HTTPUnprocessableEntity, msg_on_fail=s.UnprocessableEntityResponseSchema.description) + if bool(pattern) and (check_type in six.string_types or check_type == six.string_types): + ax.verify_param(val, not_empty=True, matches=True, param_name=key, param_compare=pattern, + http_error=HTTPUnprocessableEntity, msg_on_fail=s.UnprocessableEntityResponseSchema.description) return val diff --git a/magpie/api/schemas.py b/magpie/api/schemas.py index 99588dc42..e0fac7e1d 100644 --- a/magpie/api/schemas.py +++ b/magpie/api/schemas.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + import colander import six from cornice import Service @@ -18,18 +20,24 @@ HTTPUnprocessableEntity ) from pyramid.security import NO_PERMISSION_REQUIRED -from typing import TYPE_CHECKING -# from magpie.security import get_provider_names from magpie import __meta__ from magpie.constants import get_constant from magpie.permissions import Permission -from magpie.utils import CONTENT_TYPE_HTML, CONTENT_TYPE_JSON, get_magpie_url +from magpie.security import get_provider_names +from magpie.utils import ( + CONTENT_TYPE_HTML, + CONTENT_TYPE_JSON, + KNOWN_CONTENT_TYPES, + SUPPORTED_ACCEPT_TYPES, + SUPPORTED_FORMAT_TYPES +) if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Dict, List, JSON, Str, Union # noqa: F401 - from pyramid.request import Request # noqa: F401 + from typing import Dict, List, Union + + from magpie.typedefs import JSON, Str # ignore naming style of tags # pylint: disable=C0103,invalid-name @@ -42,10 +50,11 @@ # Tags APITag = "API" -LoginTag = "Login" +SessionTag = "Session" UsersTag = "User" LoggedUserTag = "Logged User" GroupsTag = "Group" +RegisterTag = "Register" ResourcesTag = "Resource" ServicesTag = "Service" @@ -53,6 +62,7 @@ # Security SecurityCookieAuthAPI = {"cookieAuth": {"type": "apiKey", "in": "cookie", "name": get_constant("MAGPIE_COOKIE_NAME")}} SecurityDefinitionsAPI = {"securityDefinitions": SecurityCookieAuthAPI} +SecurityAuthenticatedAPI = [{"cookieAuth": []}] SecurityAdministratorAPI = [{"cookieAuth": []}] SecurityEveryoneAPI = [{}] @@ -75,12 +85,18 @@ def get_security(service, method): # Service Routes -def service_api_route_info(service_api): - return {"name": service_api.name, "pattern": service_api.path} - +def service_api_route_info(service_api, **kwargs): + kwargs.update({ + "name": service_api.name, + "pattern": service_api.path, + }) + kwargs.setdefault("traverse", getattr(service_api, "traverse", None)) + kwargs.setdefault("factory", getattr(service_api, "factory", None)) + return kwargs -LoggedUserBase = "/users/{}".format(get_constant("MAGPIE_LOGGED_USER")) +_LOGGED_USER_VALUE = get_constant("MAGPIE_LOGGED_USER") +LoggedUserBase = "/users/{}".format(_LOGGED_USER_VALUE) SwaggerGenerator = Service( path="/json", @@ -101,42 +117,27 @@ def service_api_route_info(service_api): UserGroupAPI = Service( path="/users/{user_name}/groups/{group_name}", name="UserGroup") -UserInheritedResourcesAPI = Service( - path="/users/{user_name}/inherited_resources", - name="UserInheritedResources") UserResourcesAPI = Service( path="/users/{user_name}/resources", name="UserResources") -UserResourceInheritedPermissionsAPI = Service( - path="/users/{user_name}/resources/{resource_id}/inherited_permissions", - name="UserResourceInheritedPermissions") -UserResourcePermissionAPI = Service( - path="/users/{user_name}/resources/{resource_id}/permissions/{permission_name}", - name="UserResourcePermission") UserResourcePermissionsAPI = Service( path="/users/{user_name}/resources/{resource_id}/permissions", name="UserResourcePermissions") +UserResourcePermissionAPI = Service( + path="/users/{user_name}/resources/{resource_id}/permissions/{permission_name}", + name="UserResourcePermission") UserResourceTypesAPI = Service( path="/users/{user_name}/resources/types/{resource_type}", name="UserResourceTypes") -UserInheritedServicesAPI = Service( - path="/users/{user_name}/inherited_services", - name="UserInheritedServices") UserServicesAPI = Service( path="/users/{user_name}/services", name="UserServices") UserServiceAPI = Service( path="/users/{user_name}/services/{service_name}", name="UserService") -UserServiceInheritedResourcesAPI = Service( - path="/users/{user_name}/services/{service_name}/inherited_resources", - name="UserServiceInheritedResources") UserServiceResourcesAPI = Service( path="/users/{user_name}/services/{service_name}/resources", name="UserServiceResources") -UserServiceInheritedPermissionsAPI = Service( - path="/users/{user_name}/services/{service_name}/inherited_permissions", - name="UserServiceInheritedPermissions") UserServicePermissionsAPI = Service( path="/users/{user_name}/services/{service_name}/permissions", name="UserServicePermissions") @@ -152,15 +153,9 @@ def service_api_route_info(service_api): LoggedUserGroupAPI = Service( path=LoggedUserBase + "/groups/{group_name}", name="LoggedUserGroup") -LoggedUserInheritedResourcesAPI = Service( - path=LoggedUserBase + "/inherited_resources", - name="LoggedUserInheritedResources") LoggedUserResourcesAPI = Service( path=LoggedUserBase + "/resources", name="LoggedUserResources") -LoggedUserResourceInheritedPermissionsAPI = Service( - path=LoggedUserBase + "/resources/{resource_id}/inherited_permissions", - name="LoggedUserResourceInheritedPermissions") LoggedUserResourcePermissionAPI = Service( path=LoggedUserBase + "/resources/{resource_id}/permissions/{permission_name}", name="LoggedUserResourcePermission") @@ -170,21 +165,12 @@ def service_api_route_info(service_api): LoggedUserResourceTypesAPI = Service( path=LoggedUserBase + "/resources/types/{resource_type}", name="LoggedUserResourceTypes") -LoggedUserInheritedServicesAPI = Service( - path=LoggedUserBase + "/inherited_services", - name="LoggedUserInheritedServices") LoggedUserServicesAPI = Service( path=LoggedUserBase + "/services", name="LoggedUserServices") -LoggedUserServiceInheritedResourcesAPI = Service( - path=LoggedUserBase + "/services/{service_name}/inherited_resources", - name="LoggedUserServiceInheritedResources") LoggedUserServiceResourcesAPI = Service( path=LoggedUserBase + "/services/{service_name}/resources", name="LoggedUserServiceResources") -LoggedUserServiceInheritedPermissionsAPI = Service( - path=LoggedUserBase + "/services/{service_name}/inherited_permissions", - name="LoggedUserServiceInheritedPermissions") LoggedUserServicePermissionsAPI = Service( path=LoggedUserBase + "/services/{service_name}/permissions", name="LoggedUserServicePermissions") @@ -224,6 +210,12 @@ def service_api_route_info(service_api): GroupResourceTypesAPI = Service( path="/groups/{group_name}/resources/types/{resource_type}", name="GroupResourceTypes") +RegisterGroupsAPI = Service( + path="/register/groups", + name="RegisterGroups") +RegisterGroupAPI = Service( + path="/register/groups/{group_name}", + name="RegisterGroup") ResourcesAPI = Service( path="/resources", name="Resources") @@ -283,6 +275,26 @@ def service_api_route_info(service_api): name="homepage") +TAG_DESCRIPTIONS = { + APITag: "General information about the API.", + SessionTag: "Session user management and available providers for authentication.", + UsersTag: + "Users information management and control of their applicable groups, services, resources and permissions.\n\n" + "Administrator-level permissions are required to access most paths. Depending on context, some paths are " + "permitted additional access if the logged session user corresponds to the path variable user.", + LoggedUserTag: + "Utility paths that correspond to their {} counterparts, but that automatically ".format(UserAPI.path) + + "determine the applicable user from the logged session. If there is no active session, the public anonymous " + "access is employed.\n\nNOTE: Value '{}' depends on Magpie configuration.".format(_LOGGED_USER_VALUE), + GroupsTag: + "Groups management and control of their applicable users, services, resources and permissions.\n\n" + "Administrator-level permissions are required to access most paths. ", + RegisterTag: "Registration paths for operations available to users (including non-administrators).", + ResourcesTag: "Management of resources that reside under a given service and their applicable permissions.", + ServicesTag: "Management of service definitions, children resources and their applicable permissions.", +} + + # Common path parameters GroupNameParameter = colander.SchemaNode( colander.String(), @@ -296,7 +308,7 @@ def service_api_route_info(service_api): colander.String(), description="External identity provider.", example="DKRZ", - # validator=colander.OneOf(get_provider_names()) + validator=colander.OneOf(get_provider_names()) ) PermissionNameParameter = colander.SchemaNode( colander.String(), @@ -312,47 +324,83 @@ def service_api_route_info(service_api): example="my-wps") -class HeaderResponseSchema(colander.MappingSchema): - content_type = colander.SchemaNode( - colander.String(), - default=CONTENT_TYPE_JSON, - example=CONTENT_TYPE_JSON, - description="Content type of the response body.", - ) - content_type.name = "Content-Type" +class AcceptType(colander.SchemaNode): + schema_type = colander.String + default = CONTENT_TYPE_JSON + example = CONTENT_TYPE_JSON + missing = colander.drop + + +class ContentType(colander.SchemaNode): + schema_type = colander.String + name = "Content-Type" + default = CONTENT_TYPE_JSON + example = CONTENT_TYPE_JSON + missing = colander.drop class HeaderRequestSchemaAPI(colander.MappingSchema): - content_type = colander.SchemaNode( - colander.String(), - default=CONTENT_TYPE_JSON, - example=CONTENT_TYPE_JSON, - missing=colander.drop, - ) - content_type.name = "Content-Type" + accept = AcceptType(name="Accept", validator=colander.OneOf(SUPPORTED_ACCEPT_TYPES), + description="Desired MIME type for the response body content.") + content_type = ContentType(validator=colander.OneOf(KNOWN_CONTENT_TYPES), + description="MIME content type of the request body.") class HeaderRequestSchemaUI(colander.MappingSchema): - content_type = colander.SchemaNode( - colander.String(), - default=CONTENT_TYPE_HTML, - example=CONTENT_TYPE_HTML, - missing=colander.drop, - ) - content_type.name = "Content-Type" + content_type = ContentType(default=CONTENT_TYPE_HTML, example=CONTENT_TYPE_HTML, + description="MIME content type of the request body.") + + +class QueryRequestSchemaAPI(colander.MappingSchema): + format = AcceptType(validator=colander.OneOf(SUPPORTED_FORMAT_TYPES), + description="Desired MIME type for the response body content. " + "This formatting alternative by query parameter overrides the Accept header.") QueryEffectivePermissions = colander.SchemaNode( - colander.Boolean(), default=False, missing=colander.drop, - description="User groups effective permissions resolved with corresponding service inheritance functionality. " - "(Note: group inheritance is enforced regardless of any 'inherit' flag).") + colander.Boolean(), name="effective", default=False, missing=colander.drop, + description="Obtain user's effective permissions resolved with corresponding service inheritance functionality. " + "(Note: group inheritance is enforced regardless of other query parameter values).") QueryInheritGroupsPermissions = colander.SchemaNode( - colander.Boolean(), default=False, missing=colander.drop, - description="User groups memberships inheritance to resolve service resource permissions.") + colander.Boolean(), name="inherited", default=False, missing=colander.drop, + description="Include the user's groups memberships inheritance to resolve permissions.") +QueryFilterResources = colander.SchemaNode( + colander.Boolean(), name="filtered", default=False, missing=colander.drop, + description="Filter returned resources only where user has permissions on, either directly or inherited by groups " + "according to other query parameters. Otherwise (default), return all existing resources " + "with empty permission sets when user has no permission on them. Filtered view is enforced for " + "non-admin request user.") QueryCascadeResourcesPermissions = colander.SchemaNode( - colander.Boolean(), default=False, missing=colander.drop, - description="Display any service that has at least one sub-resource user permission, " - "or only services that have user permissions directly set on them.", ) + colander.Boolean(), name="cascade", default=False, missing=colander.drop, + description="Display all services that has at least one permission at any level in his hierarchy " + "(including all children resources). Otherwise (default), only returns services that have permissions " + "explicitly set on them, ignoring permissions set on children resources.") +QueryFlattenServices = colander.SchemaNode( + colander.Boolean(), name="flatten", default=False, missing=colander.drop, + description="Return elements as a flattened list of JSON objects instead of default response format. " + "Default is a nested JSON of service-type keys with children service-name keys, each containing " + "their respective service definition as JSON object.") + + +class PhoenixServicePushOption(colander.SchemaNode): + schema_type = colander.Boolean + description = "Push service update to Phoenix if applicable" + missing = colander.drop + default = False + + +class BaseRequestSchemaAPI(colander.MappingSchema): + header = HeaderRequestSchemaAPI() + querystring = QueryRequestSchemaAPI() + + +class HeaderResponseSchema(colander.MappingSchema): + content_type = ContentType(validator=colander.OneOf(SUPPORTED_ACCEPT_TYPES), + description="MIME content type of the response body.") + + +class BaseResponseSchemaAPI(colander.MappingSchema): + header = HeaderResponseSchema() class BaseResponseBodySchema(colander.MappingSchema): @@ -382,18 +430,49 @@ def __init__(self, code, description, **kw): example=description)) +class ErrorVerifyParamConditions(colander.MappingSchema): + not_none = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + not_empty = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + not_in = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + not_equal = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_none = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_empty = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_in = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_equal = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_true = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_false = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + is_type = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + matches = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + + class ErrorVerifyParamBodySchema(colander.MappingSchema): name = colander.SchemaNode( colander.String(), - description="Name of the failing condition parameter.", + description="Name of the failing condition parameter that caused the error.", missing=colander.drop) value = colander.SchemaNode( colander.String(), - description="Value of the failing condition parameter.") + description="Value of the failing condition parameter that caused the error.", + default=None) compare = colander.SchemaNode( colander.String(), - description="Test comparison value of the failing condition parameter.", + description="Comparison value(s) employed for evaluation of the failing condition parameter.", missing=colander.drop) + conditions = ErrorVerifyParamConditions( + description="Evaluated conditions on the parameter value with corresponding validation status. " + "Some results are relative to the comparison value when provided.") + + +class ErrorFallbackBodySchema(colander.MappingSchema): + exception = colander.SchemaNode(colander.String(), description="Raise exception.") + error = colander.SchemaNode(colander.String(), description="Error message describing the cause of exception.") + + +class ErrorCallBodySchema(ErrorFallbackBodySchema): + detail = colander.SchemaNode(colander.String(), description="Contextual explanation about the cause of error.") + content = colander.MappingSchema(default=None, unknown="preserve", + description="Additional contextual details that lead to the error. " + "Can have any amount of sub-field to describe evaluated values.") class ErrorResponseBodySchema(BaseResponseBodySchema): @@ -407,12 +486,23 @@ def __init__(self, code, description, **kw): example="/users/toto") request_url = colander.SchemaNode( colander.String(), + title="Request URL", description="Request URL that generated the error.", example="http://localhost:2001/magpie/users/toto") method = colander.SchemaNode( colander.String(), description="Request method that generated the error.", example="GET") + param = ErrorVerifyParamBodySchema( + title="Parameter", + missing=colander.drop, + description="Additional parameter details to explain the cause of error.") + call = ErrorCallBodySchema( + missing=colander.drop, + description="Additional details to explain failure reason of operation call or raised error.") + fallback = ErrorFallbackBodySchema( + missing=colander.drop, + description="Additional details to explain failure reason of fallback operation to cleanup call error.") class InternalServerErrorResponseBodySchema(ErrorResponseBodySchema): @@ -421,55 +511,52 @@ def __init__(self, **kw): super(InternalServerErrorResponseBodySchema, self).__init__(**kw) -class UnauthorizedResponseBodySchema(BaseResponseBodySchema): +class BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Required value for request is missing." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class UnauthorizedResponseBodySchema(ErrorResponseBodySchema): def __init__(self, **kw): kw["code"] = HTTPUnauthorized.code super(UnauthorizedResponseBodySchema, self).__init__(**kw) - route_name = colander.SchemaNode(colander.String(), description="Specified route") - request_url = colander.SchemaNode(colander.String(), description="Specified url") + route_name = colander.SchemaNode(colander.String(), description="Specified API route.") + request_url = colander.SchemaNode(colander.String(), description="Specified request URL.") -class UnauthorizedResponseSchema(colander.MappingSchema): - description = "Unauthorized access to this resource. " + \ - "Insufficient user privileges or missing authentication headers." - header = HeaderResponseSchema() +class UnauthorizedResponseSchema(BaseResponseSchemaAPI): + description = "Unauthorized access to this resource. Missing authentication headers or cookies." body = UnauthorizedResponseBodySchema(code=HTTPUnauthorized.code, description=description) -class HTTPForbiddenResponseSchema(colander.MappingSchema): - description = "Forbidden operation under this resource." - header = HeaderResponseSchema() +class HTTPForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Forbidden operation for this resource or insufficient user privileges." body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class NotFoundResponseSchema(colander.MappingSchema): +class NotFoundResponseSchema(BaseResponseSchemaAPI): description = "The route resource could not be found." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class MethodNotAllowedResponseSchema(colander.MappingSchema): +class MethodNotAllowedResponseSchema(BaseResponseSchemaAPI): description = "The method is not allowed for this resource." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPMethodNotAllowed.code, description=description) -class NotAcceptableResponseSchema(colander.MappingSchema): - description = "Unsupported 'Accept Header' was specified." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPNotAcceptable.code, description=description) +class NotAcceptableResponseSchema(BaseResponseSchemaAPI): + description = "Unsupported Content-Type in 'Accept' header was specified." + body = ErrorResponseBodySchema(code=HTTPNotAcceptable.code, description=description) -class UnprocessableEntityResponseSchema(colander.MappingSchema): +class UnprocessableEntityResponseSchema(BaseResponseSchemaAPI): description = "Invalid value specified." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPUnprocessableEntity.code, description=description) + body = ErrorResponseBodySchema(code=HTTPUnprocessableEntity.code, description=description) -class InternalServerErrorResponseSchema(colander.MappingSchema): +class InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Internal Server Error. Unhandled exception occurred." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPInternalServerError.code, description=description) @@ -516,23 +603,34 @@ class UserBodySchema(colander.MappingSchema): ) -class GroupBodySchema(colander.MappingSchema): +class GroupBaseBodySchema(colander.MappingSchema): group_name = colander.SchemaNode( colander.String(), description="Name of the group.", example="Administrators") - group_id = colander.SchemaNode( - colander.Integer(), - description="ID of the group.", - example=1) -class GroupDetailBodySchema(GroupBodySchema): - description = colander.SchemaNode( +class GroupPublicBodySchema(GroupBaseBodySchema): + # note: use an underscore to differentiate between the node and the parent 'description' metadata + description = "Publicly available group information." + _description = colander.SchemaNode( colander.String(), + name="description", description="Description associated to the group.", example="", missing=colander.drop) + + +class GroupInfoBodySchema(GroupBaseBodySchema): + description = "Minimal information returned by administrative API routes." + group_id = colander.SchemaNode( + colander.Integer(), + description="ID of the group.", + example=1) + + +class GroupDetailBodySchema(GroupPublicBodySchema, GroupInfoBodySchema): + description = "Detailed information of the group obtained by specifically requesting it." member_count = colander.SchemaNode( colander.Integer(), description="Number of users member of the group.", @@ -542,6 +640,13 @@ class GroupDetailBodySchema(GroupBodySchema): example=["alice", "bob"], missing=colander.drop ) + discoverable = colander.SchemaNode( + colander.Boolean(), + description="Indicates if this group is publicly accessible. " + "Discoverable groups can be joined by any logged user.", + example=True, + default=False + ) class ServiceBodySchema(colander.MappingSchema): @@ -550,6 +655,7 @@ class ServiceBodySchema(colander.MappingSchema): description="Resource identification number", ) permission_names = PermissionListSchema( + description="List of service permissions applicable or effective for a given user/group according to context.", example=[Permission.READ.value, Permission.WRITE.value] ) service_name = colander.SchemaNode( @@ -611,19 +717,23 @@ class ResourceBodySchema(colander.MappingSchema): default=colander.null, # if no parent missing=colander.drop # if not returned (basic_info = True) ) - permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) + permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value], + description="List of resource permissions applicable or effective " + "for a given user/group according to context.") permission_names.default = colander.null # if no parent permission_names.missing = colander.drop # if not returned (basic_info = True) -# TODO: improve by making recursive resources work (?) +# FIXME: improve by making recursive resources work (?) class Resource_ChildrenContainerWithoutChildResourceBodySchema(ResourceBodySchema): - children = colander.MappingSchema(default={}) + children = colander.MappingSchema( + default={}, + description="Recursive '{}' schema for each applicable children resources.".format(ResourceBodySchema.__name__) + ) class Resource_ChildResourceWithoutChildrenBodySchema(colander.MappingSchema): - id = Resource_ChildrenContainerWithoutChildResourceBodySchema() - id.name = "{resource_id}" + id = Resource_ChildrenContainerWithoutChildResourceBodySchema(name="{resource_id}") class Resource_ParentResourceWithChildrenContainerBodySchema(ResourceBodySchema): @@ -635,8 +745,7 @@ class Resource_ChildrenContainerWithChildResourceBodySchema(ResourceBodySchema): class Resource_ChildResourceWithChildrenContainerBodySchema(colander.MappingSchema): - id = Resource_ChildrenContainerWithChildResourceBodySchema() - id.name = "{resource_id}" + id = Resource_ChildrenContainerWithChildResourceBodySchema(name="{resource_id}") class Resource_ServiceWithChildrenResourcesContainerBodySchema(ServiceBodySchema): @@ -644,8 +753,7 @@ class Resource_ServiceWithChildrenResourcesContainerBodySchema(ServiceBodySchema class Resource_ServiceType_geoserverapi_SchemaNode(colander.MappingSchema): - geoserver_api = Resource_ServiceWithChildrenResourcesContainerBodySchema() - geoserver_api.name = "geoserver-api" + geoserver_api = Resource_ServiceWithChildrenResourcesContainerBodySchema(name="geoserver-api") class Resource_ServiceType_ncwms_SchemaNode(colander.MappingSchema): @@ -657,8 +765,7 @@ class Resource_ServiceType_thredds_SchemaNode(colander.MappingSchema): class ResourcesSchemaNode(colander.MappingSchema): - geoserver_api = Resource_ServiceType_geoserverapi_SchemaNode() - geoserver_api.name = "geoserver-api" + geoserver_api = Resource_ServiceType_geoserverapi_SchemaNode(name="geoserver-api") ncwms = Resource_ServiceType_ncwms_SchemaNode() thredds = Resource_ServiceType_thredds_SchemaNode() @@ -667,59 +774,49 @@ class Resources_ResponseBodySchema(BaseResponseBodySchema): resources = ResourcesSchemaNode() -class Resource_MatchDictCheck_ForbiddenResponseSchema(colander.MappingSchema): +class Resource_MatchDictCheck_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Resource query by id refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Resource_MatchDictCheck_NotFoundResponseSchema(colander.MappingSchema): - description = "Resource ID not found in db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPNotFound.code, description=description) +class Resource_MatchDictCheck_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Resource ID not found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class Resource_MatchDictCheck_BadRequestResponseSchema(colander.MappingSchema): +class Resource_MatchDictCheck_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Resource ID is an invalid literal for 'int' type." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) class Resource_GET_ResponseBodySchema(BaseResponseBodySchema): resource = Resource_ParentResourceWithChildrenContainerBodySchema() -class Resource_GET_OkResponseSchema(colander.MappingSchema): +class Resource_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get resource successful." - header = HeaderResponseSchema() body = Resource_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Resource_GET_InternalServerErrorResponseSchema(colander.MappingSchema): +class Resource_GET_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed building resource children json formatted tree." - header = HeaderResponseSchema() body = InternalServerErrorResponseBodySchema(code=HTTPInternalServerError.code, description=description) -class Resource_PUT_RequestBodySchema(colander.MappingSchema): +class Resource_PATCH_RequestBodySchema(colander.MappingSchema): resource_name = colander.SchemaNode( colander.String(), description="New name to apply to the resource to update", ) - service_push = colander.SchemaNode( - colander.Boolean(), - description="Push service resource update to Phoenix", - missing=False, - ) + service_push = PhoenixServicePushOption() -class Resource_PUT_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() - body = Resource_PUT_RequestBodySchema() +class Resource_PATCH_RequestSchema(BaseRequestSchemaAPI): + body = Resource_PATCH_RequestBodySchema() resource_id = ResourceIdParameter -class Resource_PUT_ResponseBodySchema(BaseResponseBodySchema): +class Resource_PATCH_ResponseBodySchema(BaseResponseBodySchema): resource_id = colander.SchemaNode( colander.String(), description="Updated resource identification number." @@ -738,48 +835,47 @@ class Resource_PUT_ResponseBodySchema(BaseResponseBodySchema): ) -class Resource_PUT_OkResponseSchema(colander.MappingSchema): +class Resource_PATCH_OkResponseSchema(BaseResponseSchemaAPI): description = "Update resource successful." - header = HeaderResponseSchema() - body = Resource_PUT_ResponseBodySchema(code=HTTPOk.code, description=description) + body = Resource_PATCH_ResponseBodySchema(code=HTTPOk.code, description=description) -class Resource_PUT_ForbiddenResponseSchema(colander.MappingSchema): +class Resource_PATCH_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Cannot update resource with provided new name." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class Resource_PATCH_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to update resource with new name." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class Resource_PATCH_ConflictResponseSchema(BaseResponseSchemaAPI): + description = "Resource name already exists at requested tree level for update." + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) class Resource_DELETE_RequestBodySchema(colander.MappingSchema): - service_push = colander.SchemaNode( - colander.Boolean(), - description="Push service update to Phoenix if applicable", - missing=colander.drop, - default=False, - ) + service_push = PhoenixServicePushOption() -class Resource_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Resource_DELETE_RequestSchema(BaseRequestSchemaAPI): body = Resource_DELETE_RequestBodySchema() resource_id = ResourceIdParameter -class Resource_DELETE_OkResponseSchema(colander.MappingSchema): +class Resource_DELETE_OkResponseSchema(BaseResponseSchemaAPI): description = "Delete resource successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class Resource_DELETE_ForbiddenResponseSchema(colander.MappingSchema): +class Resource_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Delete resource from db failed." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Resources_GET_OkResponseSchema(colander.MappingSchema): +class Resources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get resources successful." - header = HeaderResponseSchema() body = Resources_ResponseBodySchema(code=HTTPOk.code, description=description) @@ -804,8 +900,7 @@ class Resources_POST_RequestBodySchema(colander.MappingSchema): ) -class Resources_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Resources_POST_RequestSchema(BaseRequestSchemaAPI): body = Resources_POST_RequestBodySchema() @@ -813,50 +908,46 @@ class Resource_POST_ResponseBodySchema(BaseResponseBodySchema): resource = Resource_ChildResourceWithChildrenContainerBodySchema() -class Resources_POST_CreatedResponseSchema(colander.MappingSchema): +class Resources_POST_CreatedResponseSchema(BaseResponseSchemaAPI): description = "Create resource successful." - header = HeaderResponseSchema() body = Resource_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) -class Resources_POST_BadRequestResponseSchema(colander.MappingSchema): +class Resources_POST_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid ['resource_name'|'resource_type'|'parent_id'] specified for child resource creation." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Resources_POST_ForbiddenResponseSchema(colander.MappingSchema): +class Resources_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to insert new resource in service tree using parent id." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Resources_POST_NotFoundResponseSchema(colander.MappingSchema): +class Resources_POST_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Could not find specified resource parent id." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPNotFound.code, description=description) + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class Resources_POST_ConflictResponseSchema(colander.MappingSchema): +class Resources_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Resource name already exists at requested tree level for creation." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPConflict.code, description=description) + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) class ResourcePermissions_GET_ResponseBodySchema(BaseResponseBodySchema): - permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) + permission_names = PermissionListSchema( + description="List of permissions applicable for the referenced resource.", + example=[Permission.READ.value, Permission.WRITE.value] + ) -class ResourcePermissions_GET_OkResponseSchema(colander.MappingSchema): +class ResourcePermissions_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get resource permissions successful." - header = HeaderResponseSchema() body = ResourcePermissions_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class ResourcePermissions_GET_BadRequestResponseSchema(colander.MappingSchema): +class ResourcePermissions_GET_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid resource type to extract permissions." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) class ServiceResourcesBodySchema(ServiceBodySchema): @@ -864,40 +955,47 @@ class ServiceResourcesBodySchema(ServiceBodySchema): class ServiceType_access_SchemaNode(colander.MappingSchema): + title = "Services typed for Access" + description = "These services are all-or-nothing root endpoint access." frontend = ServiceBodySchema(missing=colander.drop) - geoserver_web = ServiceBodySchema(missing=colander.drop) - geoserver_web.name = "geoserver-web" + geoserver_web = ServiceBodySchema(missing=colander.drop, name="geoserver-web") magpie = ServiceBodySchema(missing=colander.drop) class ServiceType_geoserverapi_SchemaNode(colander.MappingSchema): - geoserver_api = ServiceBodySchema(missing=colander.drop) - geoserver_api.name = "geoserver-api" + name = "geoserver-api" + title = "Services typed for GeoServer API" + geoserver_api = ServiceBodySchema(missing=colander.drop, name="geoserver-api") class ServiceType_geoserverwms_SchemaNode(colander.MappingSchema): + title = "Services typed for GeoServer WMS" geoserverwms = ServiceBodySchema(missing=colander.drop) class ServiceType_ncwms_SchemaNode(colander.MappingSchema): - ncwms = ServiceBodySchema(missing=colander.drop) - ncwms.name = "ncWMS2" + title = "Services typed for ncWMS2" + ncwms = ServiceBodySchema(missing=colander.drop, name="ncWMS2") class ServiceType_projectapi_SchemaNode(colander.MappingSchema): - project_api = ServiceBodySchema(missing=colander.drop) - project_api.name = "project-api" + name = "project-api" + title = "Services typed for Project-API" + project_api = ServiceBodySchema(missing=colander.drop, name="project-api") class ServiceType_thredds_SchemaNode(colander.MappingSchema): + title = "Services typed for Thredds" thredds = ServiceBodySchema(missing=colander.drop) class ServiceType_wfs_SchemaNode(colander.MappingSchema): + title = "Services typed for GeoServer WFS" geoserver = ServiceBodySchema(missing=colander.drop) class ServiceType_wps_SchemaNode(colander.MappingSchema): + title = "Services typed for WPS" lb_flyingpigeon = ServiceBodySchema(missing=colander.drop) flyingpigeon = ServiceBodySchema(missing=colander.drop) project = ServiceBodySchema(missing=colander.drop) @@ -914,79 +1012,76 @@ class ServiceTypesList(colander.SequenceSchema): ) +class ServiceListingQuerySchema(QueryRequestSchemaAPI): + flatten = QueryFlattenServices + + +class ServiceTypes_GET_RequestSchema(BaseRequestSchemaAPI): + querystring = ServiceListingQuerySchema() + + class ServiceTypes_GET_OkResponseBodySchema(BaseResponseBodySchema): service_types = ServiceTypesList(description="List of available service types.") -class ServiceTypes_GET_OkResponseSchema(colander.MappingSchema): +class ServiceTypes_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get service types successful." - header = HeaderResponseSchema() body = ServiceTypes_GET_OkResponseBodySchema(code=HTTPOk.code, description=description) -class ServicesSchemaNode(colander.MappingSchema): +class ServicesCategorizedSchemaNode(colander.MappingSchema): + description = "Registered services categorized by supported service-type. " + \ + "Listed service-types depend on Magpie version." access = ServiceType_access_SchemaNode() geoserver_api = ServiceType_geoserverapi_SchemaNode(missing=colander.drop) - geoserver_api.name = "geoserver-api" geoserverwms = ServiceType_geoserverwms_SchemaNode(missing=colander.drop) ncwms = ServiceType_ncwms_SchemaNode() project_api = ServiceType_projectapi_SchemaNode(missing=colander.drop) - project_api.name = "project-api" thredds = ServiceType_thredds_SchemaNode() wfs = ServiceType_wfs_SchemaNode(missing=colander.drop) wps = ServiceType_wps_SchemaNode(missing=colander.drop) -class Service_FailureBodyResponseSchema(BaseResponseBodySchema): - service_name = colander.SchemaNode( - colander.String(), - description="Service name extracted from path" - ) +class ServicesListingSchemaNode(colander.SequenceSchema): + service = ServiceBodySchema() -class Service_MatchDictCheck_ForbiddenResponseSchema(colander.MappingSchema): +class Service_MatchDictCheck_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Service query by name refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Service_MatchDictCheck_NotFoundResponseSchema(colander.MappingSchema): - description = "Service name not found in db." - header = HeaderResponseSchema() - body = Service_FailureBodyResponseSchema(code=HTTPNotFound.code, description=description) +class Service_MatchDictCheck_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Service name not found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +Services_GET_RequestSchema = ServiceTypes_GET_RequestSchema class Service_GET_ResponseBodySchema(BaseResponseBodySchema): service = ServiceBodySchema() -class Service_GET_OkResponseSchema(colander.MappingSchema): +class Service_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get service successful." - header = HeaderResponseSchema() body = Service_GET_ResponseBodySchema(code=HTTPOk.code, description=description) class Services_GET_ResponseBodySchema(BaseResponseBodySchema): - services = ServicesSchemaNode() + # FIXME: add support schema OneOf(ServicesCategorizedSchemaNode, ServicesListingSchemaNode) + # requires https://github.com/fmigneault/cornice.ext.swagger/tree/oneOf-objects + services = ServicesCategorizedSchemaNode() -class Services_GET_OkResponseSchema(colander.MappingSchema): +class Services_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get services successful." - header = HeaderResponseSchema() body = Services_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Services_GET_BadRequestResponseBodySchema(BaseResponseBodySchema): - service_type = colander.SchemaNode( - colander.String(), - description="Name of the service type filter employed when applicable", - missing=colander.drop) - - -class Services_GET_BadRequestResponseSchema(colander.MappingSchema): +class Services_GET_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'service_type' value does not correspond to any of the existing service types." - header = HeaderResponseSchema() - body = Services_GET_BadRequestResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) class Services_POST_BodySchema(colander.MappingSchema): @@ -1012,48 +1107,46 @@ class Services_POST_BodySchema(colander.MappingSchema): ) -class Services_POST_RequestBodySchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Services_POST_RequestBodySchema(BaseRequestSchemaAPI): body = Services_POST_BodySchema() -class Services_POST_CreatedResponseSchema(colander.MappingSchema): +class Services_POST_CreatedResponseSchema(BaseResponseSchemaAPI): description = "Service registration to db successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class Services_POST_BadRequestResponseSchema(colander.MappingSchema): +class Services_POST_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'service_type' value does not correspond to any of the existing service types." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class Services_POST_Params_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Invalid parameter value for service creation." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Services_POST_ForbiddenResponseSchema(colander.MappingSchema): +class Services_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Service registration forbidden by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Services_POST_ConflictResponseSchema(colander.MappingSchema): +class Services_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Specified 'service_name' value already exists." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPConflict.code, description=description) + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) -class Services_POST_UnprocessableEntityResponseSchema(colander.MappingSchema): +class Services_POST_UnprocessableEntityResponseSchema(BaseResponseSchemaAPI): description = "Service creation for registration failed." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPUnprocessableEntity.code, description=description) + body = ErrorResponseBodySchema(code=HTTPUnprocessableEntity.code, description=description) -class Services_POST_InternalServerErrorResponseSchema(colander.MappingSchema): +class Services_POST_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Service registration status could not be validated." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPInternalServerError.code, description=description) + body = ErrorResponseBodySchema(code=HTTPInternalServerError.code, description=description) -class Service_PUT_ResponseBodySchema(colander.MappingSchema): +class Service_PATCH_ResponseBodySchema(colander.MappingSchema): service_name = colander.SchemaNode( colander.String(), description="New service name to apply to service specified in path", @@ -1068,88 +1161,72 @@ class Service_PUT_ResponseBodySchema(colander.MappingSchema): default=colander.null, example="http://localhost:9000/new_service_name" ) - service_push = colander.SchemaNode( - colander.Boolean(), - description="Push service update to Phoenix if applicable", - missing=colander.drop, - default=False, - ) + service_push = PhoenixServicePushOption() -class Service_PUT_RequestBodySchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() - body = Service_PUT_ResponseBodySchema() +class Service_PATCH_RequestBodySchema(BaseRequestSchemaAPI): + body = Service_PATCH_ResponseBodySchema() class Service_SuccessBodyResponseSchema(BaseResponseBodySchema): service = ServiceBodySchema() -class Service_PUT_OkResponseSchema(colander.MappingSchema): +class Service_PATCH_OkResponseSchema(BaseResponseSchemaAPI): description = "Update service successful." - header = HeaderResponseSchema() body = Service_SuccessBodyResponseSchema(code=HTTPOk.code, description=description) -class Service_PUT_BadRequestResponseSchema(colander.MappingSchema): +class Service_PATCH_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Registered service values are already equal to update values." - header = HeaderResponseSchema() - body = Service_FailureBodyResponseSchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Service_PUT_BadRequestResponseSchema_ReservedKeyword(colander.MappingSchema): +class Service_PATCH_ForbiddenResponseSchema_ReservedKeyword(BaseResponseSchemaAPI): description = "Update service name to 'types' not allowed (reserved keyword)." - header = HeaderResponseSchema() - body = Service_FailureBodyResponseSchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Service_PUT_ForbiddenResponseSchema(colander.MappingSchema): +class Service_PATCH_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Update service failed during value assignment." - header = HeaderResponseSchema() - body = Service_FailureBodyResponseSchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Service_PUT_ConflictResponseSchema(colander.MappingSchema): +class Service_PATCH_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Specified 'service_name' already exists." - header = HeaderResponseSchema() - body = Service_FailureBodyResponseSchema(code=HTTPConflict.code, description=description) + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) -class Service_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Service_DELETE_RequestSchema(BaseRequestSchemaAPI): body = Resource_DELETE_RequestBodySchema() service_name = ServiceNameParameter -class Service_DELETE_OkResponseSchema(colander.MappingSchema): +class Service_DELETE_OkResponseSchema(BaseResponseSchemaAPI): description = "Delete service successful." - header = HeaderResponseSchema() body = ServiceBodySchema(code=HTTPOk.code, description=description) -class Service_DELETE_ForbiddenResponseSchema(colander.MappingSchema): +class Service_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Delete service from db refused by db." - header = HeaderResponseSchema() - body = Service_FailureBodyResponseSchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) class ServicePermissions_ResponseBodySchema(BaseResponseBodySchema): permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) -class ServicePermissions_GET_OkResponseSchema(colander.MappingSchema): +class ServicePermissions_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get service permissions successful." - header = HeaderResponseSchema() body = ServicePermissions_ResponseBodySchema(code=HTTPOk.code, description=description) -class ServicePermissions_GET_BadRequestResponseBodySchema(BaseResponseBodySchema): +class ServicePermissions_GET_BadRequestResponseBodySchema(ErrorResponseBodySchema): service = ServiceBodySchema() -class ServicePermissions_GET_BadRequestResponseSchema(colander.MappingSchema): +class ServicePermissions_GET_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid service type specified by service." - header = HeaderResponseSchema() body = ServicePermissions_GET_BadRequestResponseBodySchema(code=HTTPBadRequest.code, description=description) @@ -1159,12 +1236,15 @@ class ServiceResources_POST_RequestSchema(Resources_POST_RequestSchema): ServiceResources_POST_CreatedResponseSchema = Resources_POST_CreatedResponseSchema -ServiceResources_POST_BadRequestResponseSchema = Resources_POST_BadRequestResponseSchema ServiceResources_POST_ForbiddenResponseSchema = Resources_POST_ForbiddenResponseSchema ServiceResources_POST_NotFoundResponseSchema = Resources_POST_NotFoundResponseSchema ServiceResources_POST_ConflictResponseSchema = Resources_POST_ConflictResponseSchema +class ServiceResources_POST_BadRequestResponseSchema(Resources_POST_BadRequestResponseSchema): + description = "Invalid 'parent_id' specified for child resource creation under requested service." + + # delete service's resource use same method as direct resource delete class ServiceResource_DELETE_RequestSchema(Resource_DELETE_RequestSchema): service_name = ServiceNameParameter @@ -1175,20 +1255,14 @@ class ServiceResource_DELETE_RequestSchema(Resource_DELETE_RequestSchema): class ServiceResources_GET_ResponseBodySchema(BaseResponseBodySchema): - service_name = Resource_ServiceWithChildrenResourcesContainerBodySchema() - service_name.name = "{service_name}" + service_name = Resource_ServiceWithChildrenResourcesContainerBodySchema(name="{service_name}") -class ServiceResources_GET_OkResponseSchema(colander.MappingSchema): +class ServiceResources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get service resources successful." - header = HeaderResponseSchema() body = ServiceResources_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class ServiceTypeResourceTypes_GET_FailureBodyResponseSchema(BaseResponseBodySchema): - service_type = colander.SchemaNode(colander.String(), description="Service type retrieved from route path.") - - class ServiceTypeResourceInfo(colander.MappingSchema): resource_type = colander.SchemaNode( colander.String(), @@ -1212,113 +1286,103 @@ class ServiceTypeResources_GET_ResponseBodySchema(BaseResponseBodySchema): resource_types = ServiceTypeResourcesList(description="Supported resources types under specific service type.") -class ServiceTypeResources_GET_OkResponseSchema(colander.MappingSchema): +class ServiceTypeResources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get service type resources successful." - header = HeaderResponseSchema() body = ServiceTypeResources_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class ServiceTypeResources_GET_ForbiddenResponseSchema(colander.MappingSchema): +class ServiceTypeResources_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to obtain resource types for specified service type." - header = HeaderResponseSchema() - body = ServiceTypeResourceTypes_GET_FailureBodyResponseSchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class ServiceTypeResources_GET_NotFoundResponseSchema(colander.MappingSchema): +class ServiceTypeResources_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'service_type' does not exist to obtain its resource types." - header = HeaderResponseSchema() - body = ServiceTypeResourceTypes_GET_FailureBodyResponseSchema(code=HTTPNotFound.code, description=description) + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) class ServiceTypeResourceTypes_GET_ResponseBodySchema(BaseResponseBodySchema): resource_types = ResourceTypesListSchema() -class ServiceTypeResourceTypes_GET_OkResponseSchema(colander.MappingSchema): +class ServiceTypeResourceTypes_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get service type resource types successful." - header = HeaderResponseSchema() body = ServiceTypeResourceTypes_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class ServiceTypeResourceTypes_GET_ForbiddenResponseSchema(colander.MappingSchema): +class ServiceTypeResourceTypes_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to obtain resource types for specified service type." - header = HeaderResponseSchema() - body = ServiceTypeResourceTypes_GET_FailureBodyResponseSchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class ServiceTypeResourceTypes_GET_NotFoundResponseSchema(colander.MappingSchema): +class ServiceTypeResourceTypes_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'service_type' does not exist to obtain its resource types." - header = HeaderResponseSchema() - body = ServiceTypeResourceTypes_GET_FailureBodyResponseSchema(code=HTTPNotFound.code, description=description) + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) class Users_GET_ResponseBodySchema(BaseResponseBodySchema): user_names = UserNamesListSchema() -class Users_GET_OkResponseSchema(colander.MappingSchema): +class Users_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get users successful." - header = HeaderResponseSchema() body = Users_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Users_GET_ForbiddenResponseSchema(colander.MappingSchema): +class Users_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Get users query refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) - - -class Users_CheckInfo_ResponseBodySchema(BaseResponseBodySchema): - param = ErrorVerifyParamBodySchema() + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Users_CheckInfo_Name_BadRequestResponseSchema(colander.MappingSchema): +class Users_CheckInfo_UserNameValue_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'user_name' value specified." - header = HeaderResponseSchema() - body = Users_CheckInfo_ResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Users_CheckInfo_Size_BadRequestResponseSchema(colander.MappingSchema): +class Users_CheckInfo_UserNameSize_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'user_name' length specified (>{length} characters)." \ - .format(length=get_constant("MAGPIE_USER_NAME_MAX_LENGTH")) - header = HeaderResponseSchema() - body = Users_CheckInfo_ResponseBodySchema(code=HTTPBadRequest.code, description=description) + .format(length=get_constant("MAGPIE_USER_NAME_MAX_LENGTH")) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Users_CheckInfo_Email_BadRequestResponseSchema(colander.MappingSchema): +class Users_CheckInfo_Email_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'email' value specified." - header = HeaderResponseSchema() - body = Users_CheckInfo_ResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Users_CheckInfo_Password_BadRequestResponseSchema(colander.MappingSchema): +class Users_CheckInfo_PasswordValue_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'password' value specified." - header = HeaderResponseSchema() - body = Users_CheckInfo_ResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class Users_CheckInfo_PasswordSize_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Invalid 'password' length specified (<{length} characters)." \ + .format(length=get_constant("MAGPIE_PASSWORD_MIN_LENGTH")) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Users_CheckInfo_GroupName_BadRequestResponseSchema(colander.MappingSchema): +class Users_CheckInfo_GroupName_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'group_name' value specified." - header = HeaderResponseSchema() - body = Users_CheckInfo_ResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Users_CheckInfo_ReservedKeyword_BadRequestResponseSchema(colander.MappingSchema): +class Users_CheckInfo_ReservedKeyword_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'user_name' not allowed (reserved keyword)." - header = HeaderResponseSchema() - body = Users_CheckInfo_ResponseBodySchema(code=HTTPConflict.code, description=description) + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) + +# alias for readability across code, but we actually do the same check +User_Check_BadRequestResponseSchema = Users_CheckInfo_UserNameValue_BadRequestResponseSchema -class User_Check_ForbiddenResponseSchema(colander.MappingSchema): + +class User_Check_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "User check query was refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class User_Check_ConflictResponseSchema(colander.MappingSchema): +class User_Check_ConflictResponseSchema(BaseResponseSchemaAPI): description = "User name matches an already existing user name." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) class User_POST_RequestBodySchema(colander.MappingSchema): @@ -1344,8 +1408,7 @@ class User_POST_RequestBodySchema(colander.MappingSchema): ) -class Users_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Users_POST_RequestSchema(BaseRequestSchemaAPI): body = User_POST_RequestBodySchema() @@ -1353,25 +1416,22 @@ class Users_POST_ResponseBodySchema(BaseResponseBodySchema): user = UserBodySchema() -class Users_POST_CreatedResponseSchema(colander.MappingSchema): +class Users_POST_CreatedResponseSchema(BaseResponseSchemaAPI): description = "Add user to db successful." - header = HeaderResponseSchema() body = Users_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) -class Users_POST_ForbiddenResponseSchema(colander.MappingSchema): +class Users_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to add user to db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class UserNew_POST_ForbiddenResponseSchema(colander.MappingSchema): +class UserNew_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "New user query was refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class User_PUT_RequestBodySchema(colander.MappingSchema): +class User_PATCH_RequestBodySchema(colander.MappingSchema): user_name = colander.SchemaNode( colander.String(), description="New name to apply to the user", @@ -1392,120 +1452,108 @@ class User_PUT_RequestBodySchema(colander.MappingSchema): ) -class User_PUT_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() - body = User_PUT_RequestBodySchema() +class User_PATCH_RequestSchema(BaseRequestSchemaAPI): + body = User_PATCH_RequestBodySchema() -class Users_PUT_OkResponseSchema(colander.MappingSchema): +class Users_PATCH_OkResponseSchema(BaseResponseSchemaAPI): description = "Update user successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class User_PUT_BadRequestResponseSchema(colander.MappingSchema): +class User_PATCH_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Missing new user parameters to update." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class User_PUT_ForbiddenResponseSchema(colander.MappingSchema): - description = "Failed user verification with db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) +class User_PATCH_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Targeted user update not allowed by requesting user." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class User_PUT_ConflictResponseSchema(colander.MappingSchema): +class User_PATCH_ConflictResponseSchema(BaseResponseSchemaAPI): description = "New name user already exists." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPConflict.code, description=description) + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) class User_GET_ResponseBodySchema(BaseResponseBodySchema): user = UserBodySchema() -class User_GET_OkResponseSchema(colander.MappingSchema): +class User_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user successful." - header = HeaderResponseSchema() body = User_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class User_CheckAnonymous_ForbiddenResponseSchema(colander.MappingSchema): +class User_CheckAnonymous_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Anonymous user query refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class User_CheckAnonymous_NotFoundResponseSchema(colander.MappingSchema): - description = "Anonymous user not found in db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPNotFound.code, description=description) +class User_CheckAnonymous_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Anonymous user not found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class User_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "User access forbidden for this resource." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class User_GET_ForbiddenResponseSchema(colander.MappingSchema): +class User_GET_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "User name query refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPInternalServerError.code, description=description) -class User_GET_NotFoundResponseSchema(colander.MappingSchema): - description = "User name not found in db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPNotFound.code, description=description) +class User_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "User name not found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class User_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class User_DELETE_RequestSchema(BaseRequestSchemaAPI): body = colander.MappingSchema(default={}) -class User_DELETE_OkResponseSchema(colander.MappingSchema): +class User_DELETE_OkResponseSchema(BaseResponseSchemaAPI): description = "Delete user successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) -class User_DELETE_ForbiddenResponseSchema(colander.MappingSchema): - description = "Delete user by name refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) +class User_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "User could not be deleted." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class UserGroup_Check_BadRequestResponseSchema(colander.MappingSchema): - description = "Group for new user doesn't exist." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) +class UserGroup_Check_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Invalid group name to associate to user." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class UserGroup_GET_ForbiddenResponseSchema(colander.MappingSchema): +class UserGroup_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Group query was refused by db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class UserGroup_Check_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Group for new user doesn't exist." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class UserGroup_Check_ForbiddenResponseSchema(colander.MappingSchema): +class UserGroup_Check_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to add user-group to db." - header = HeaderResponseSchema() - body = BaseResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) class UserGroups_GET_ResponseBodySchema(BaseResponseBodySchema): group_names = GroupNamesListSchema() -class UserGroups_GET_OkResponseSchema(colander.MappingSchema): +class UserGroups_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user groups successful." - header = HeaderResponseSchema() body = UserGroups_GET_ResponseBodySchema(code=HTTPOk.code, description=description) class UserGroups_POST_RequestBodySchema(colander.MappingSchema): - user_name = colander.SchemaNode( - colander.String(), - description="Name of the user in the user-group relationship", - example="toto", - ) group_name = colander.SchemaNode( colander.String(), description="Name of the group in the user-group relationship", @@ -1513,8 +1561,7 @@ class UserGroups_POST_RequestBodySchema(colander.MappingSchema): ) -class UserGroups_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserGroups_POST_RequestSchema(BaseRequestSchemaAPI): body = UserGroups_POST_RequestBodySchema() user_name = UserNameParameter @@ -1532,27 +1579,23 @@ class UserGroups_POST_ResponseBodySchema(BaseResponseBodySchema): ) -class UserGroups_POST_CreatedResponseSchema(colander.MappingSchema): - description = "Create user-group assignation successful." - header = HeaderResponseSchema() +class UserGroups_POST_CreatedResponseSchema(BaseResponseSchemaAPI): + description = "Create user-group assignation successful. User is a member of the group." body = UserGroups_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) -class UserGroups_POST_GroupNotFoundResponseSchema(colander.MappingSchema): - description = "Can't find the group to assign to." - header = HeaderResponseSchema() +class UserGroups_POST_GroupNotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Cannot find the group to assign to." body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class UserGroups_POST_ForbiddenResponseSchema(colander.MappingSchema): +class UserGroups_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Group query by name refused by db." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class UserGroups_POST_RelationshipForbiddenResponseSchema(colander.MappingSchema): +class UserGroups_POST_RelationshipForbiddenResponseSchema(BaseResponseSchemaAPI): description = "User-Group relationship creation refused by db." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) @@ -1570,35 +1613,36 @@ class UserGroups_POST_ConflictResponseBodySchema(ErrorResponseBodySchema): ) -class UserGroups_POST_ConflictResponseSchema(colander.MappingSchema): +class UserGroups_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "User already belongs to this group." - header = HeaderResponseSchema() body = UserGroups_POST_ConflictResponseBodySchema(code=HTTPConflict.code, description=description) -class UserGroup_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserGroup_DELETE_RequestSchema(BaseRequestSchemaAPI): body = colander.MappingSchema(default={}) -class UserGroup_DELETE_OkResponseSchema(colander.MappingSchema): - description = "Delete user-group successful." - header = HeaderResponseSchema() +class UserGroup_DELETE_OkResponseSchema(BaseResponseSchemaAPI): + description = "Delete user-group successful. User is not a member of the group anymore." body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class UserGroup_DELETE_NotFoundResponseSchema(colander.MappingSchema): - description = "Invalid user-group combination for delete." - header = HeaderResponseSchema() +class UserGroup_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Delete user-group relationship not permitted for this combination." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class UserGroup_DELETE_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Could not remove user from group. Could not find any matching group membership for user." body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class UserResources_GET_QuerySchema(colander.MappingSchema): - inherit = QueryInheritGroupsPermissions +class UserResources_GET_QuerySchema(QueryRequestSchemaAPI): + inherited = QueryInheritGroupsPermissions + filtered = QueryFilterResources -class UserResources_GET_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserResources_GET_RequestSchema(BaseRequestSchemaAPI): querystring = UserResources_GET_QuerySchema() @@ -1606,40 +1650,34 @@ class UserResources_GET_ResponseBodySchema(BaseResponseBodySchema): resources = ResourcesSchemaNode() -class UserResources_GET_OkResponseSchema(colander.MappingSchema): +class UserResources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user resources successful." - header = HeaderResponseSchema() body = UserResources_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class UserResources_GET_NotFoundResponseBodySchema(BaseResponseBodySchema): - user_name = colander.SchemaNode(colander.String(), description="User name value read from path") - resource_types = ResourceTypesListSchema(description="Resource types searched for") - - -class UserResources_GET_NotFoundResponseSchema(colander.MappingSchema): +class UserResources_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Failed to populate user resources." - header = HeaderResponseSchema() - body = UserResources_GET_NotFoundResponseBodySchema(code=HTTPNotFound.code, description=description) + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class UserResourcePermissions_GET_QuerySchema(colander.MappingSchema): - inherit = QueryInheritGroupsPermissions +class UserResourcePermissions_GET_QuerySchema(QueryRequestSchemaAPI): + inherited = QueryInheritGroupsPermissions effective = QueryEffectivePermissions -class UserResourcePermissions_GET_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserResourcePermissions_GET_RequestSchema(BaseRequestSchemaAPI): querystring = UserResourcePermissions_GET_QuerySchema() class UserResourcePermissions_GET_ResponseBodySchema(BaseResponseBodySchema): - permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) + permission_names = PermissionListSchema( + description="List of resource permissions effective for the referenced user.", + example=[Permission.READ.value, Permission.WRITE.value] + ) -class UserResourcePermissions_GET_OkResponseSchema(colander.MappingSchema): +class UserResourcePermissions_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user resource permissions successful." - header = HeaderResponseSchema() body = UserResourcePermissions_GET_ResponseBodySchema(code=HTTPNotFound.code, description=description) @@ -1654,30 +1692,26 @@ class UserResourcePermissions_GET_BadRequestResponseBodySchema(colander.MappingS param = UserResourcePermissions_GET_BadRequestParamResponseSchema() -class UserResourcePermissions_GET_BadRequestRootServiceResponseSchema(colander.MappingSchema): +class UserResourcePermissions_GET_BadRequestRootServiceResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'resource' specified for resource permission retrieval." - header = HeaderResponseSchema() body = UserResourcePermissions_GET_BadRequestResponseBodySchema( code=HTTPBadRequest.code, description=description) -class UserResourcePermissions_GET_BadRequestResourceResponseSchema(colander.MappingSchema): +class UserResourcePermissions_GET_BadRequestResourceResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'resource' specified for resource permission retrieval." - header = HeaderResponseSchema() body = UserResourcePermissions_GET_BadRequestResponseBodySchema( code=HTTPBadRequest.code, description=description) -class UserResourcePermissions_GET_BadRequestResourceTypeResponseSchema(colander.MappingSchema): +class UserResourcePermissions_GET_BadRequestResourceTypeResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'resource_type' for corresponding service resource permission retrieval." - header = HeaderResponseSchema() body = UserResourcePermissions_GET_BadRequestResponseBodySchema( code=HTTPBadRequest.code, description=description) -class UserResourcePermissions_GET_NotFoundResponseSchema(colander.MappingSchema): +class UserResourcePermissions_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Specified user not found to obtain resource permissions." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) @@ -1687,8 +1721,7 @@ class UserResourcePermissions_POST_RequestBodySchema(colander.MappingSchema): description="permission_name of the created user-resource-permission reference.") -class UserResourcePermissions_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserResourcePermissions_POST_RequestSchema(BaseRequestSchemaAPI): body = UserResourcePermissions_POST_RequestBodySchema() resource_id = ResourceIdParameter user_name = UserNameParameter @@ -1706,9 +1739,8 @@ class UserResourcePermissions_POST_ResponseBodySchema(BaseResponseBodySchema): description="permission_name of the created user-resource-permission reference.") -class UserResourcePermissions_POST_CreatedResponseSchema(colander.MappingSchema): +class UserResourcePermissions_POST_CreatedResponseSchema(BaseResponseSchemaAPI): description = "Create user resource permission successful." - header = HeaderResponseSchema() body = UserResourcePermissions_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) @@ -1724,21 +1756,18 @@ class UserResourcePermissions_POST_BadResponseBodySchema(BaseResponseBodySchema) param = UserResourcePermissions_POST_ParamResponseBodySchema(missing=colander.drop) -class UserResourcePermissions_POST_BadRequestResponseSchema(colander.MappingSchema): +class UserResourcePermissions_POST_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Permission not allowed for specified 'resource_type'." - header = HeaderResponseSchema() body = UserResourcePermissions_POST_BadResponseBodySchema(code=HTTPBadRequest.code, description=description) -class UserResourcePermissions_POST_ForbiddenResponseSchema(colander.MappingSchema): +class UserResourcePermissions_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Creation of permission on resource for user refused by db." - header = HeaderResponseSchema() body = UserResourcePermissions_POST_BadResponseBodySchema(code=HTTPForbidden.code, description=description) -class UserResourcePermissions_POST_ConflictResponseSchema(colander.MappingSchema): +class UserResourcePermissions_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Permission already exist on resource for user." - header = HeaderResponseSchema() body = UserResourcePermissions_POST_ResponseBodySchema(code=HTTPConflict.code, description=description) @@ -1747,23 +1776,20 @@ class UserResourcePermissions_POST_ConflictResponseSchema(colander.MappingSchema UserResourcePermissions_DELETE_BadRequestResponseSchema = UserResourcePermissions_POST_BadRequestResponseSchema -class UserResourcePermission_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserResourcePermission_DELETE_RequestSchema(BaseRequestSchemaAPI): body = colander.MappingSchema(default={}) user_name = UserNameParameter resource_id = ResourceIdParameter permission_name = PermissionNameParameter -class UserResourcePermissions_DELETE_OkResponseSchema(colander.MappingSchema): +class UserResourcePermissions_DELETE_OkResponseSchema(BaseResponseSchemaAPI): description = "Delete user resource permission successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class UserResourcePermissions_DELETE_NotFoundResponseSchema(colander.MappingSchema): +class UserResourcePermissions_DELETE_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Could not find user resource permission to delete from db." - header = HeaderResponseSchema() body = UserResourcePermissions_DELETE_BadResponseBodySchema(code=HTTPOk.code, description=description) @@ -1771,18 +1797,16 @@ class UserServiceResources_GET_ResponseBodySchema(BaseResponseBodySchema): service = ServiceResourcesBodySchema() -class UserServiceResources_GET_OkResponseSchema(colander.MappingSchema): +class UserServiceResources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user service resources successful." - header = HeaderResponseSchema() body = UserServiceResources_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class UserServiceResources_GET_QuerySchema(colander.MappingSchema): - inherit = QueryInheritGroupsPermissions +class UserServiceResources_GET_QuerySchema(QueryRequestSchemaAPI): + inherited = QueryInheritGroupsPermissions -class UserServiceResources_GET_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserServiceResources_GET_RequestSchema(BaseRequestSchemaAPI): querystring = UserServiceResources_GET_QuerySchema() user_name = UserNameParameter service_name = ServiceNameParameter @@ -1792,93 +1816,83 @@ class UserServicePermissions_POST_RequestBodySchema(colander.MappingSchema): permission_name = colander.SchemaNode(colander.String(), description="Name of the permission to create.") -class UserServicePermissions_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserServicePermissions_POST_RequestSchema(BaseRequestSchemaAPI): body = UserServicePermissions_POST_RequestBodySchema() user_name = UserNameParameter service_name = ServiceNameParameter -class UserServicePermission_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserServicePermission_DELETE_RequestSchema(BaseRequestSchemaAPI): body = colander.MappingSchema(default={}) user_name = UserNameParameter service_name = ServiceNameParameter permission_name = PermissionNameParameter -class UserServices_GET_QuerySchema(colander.MappingSchema): +class UserServices_GET_QuerySchema(QueryRequestSchemaAPI): cascade = QueryCascadeResourcesPermissions inherit = QueryInheritGroupsPermissions - list = colander.SchemaNode( - colander.Boolean(), default=False, missing=colander.drop, - description="Return services as a list of dicts. Default is a dict by service type, and by service name.") + flatten = QueryFlattenServices -class UserServices_GET_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserServices_GET_RequestSchema(BaseRequestSchemaAPI): querystring = UserServices_GET_QuerySchema() user_name = UserNameParameter class UserServices_GET_ResponseBodySchema(BaseResponseBodySchema): - services = ServicesSchemaNode() + services = ServicesCategorizedSchemaNode() -class UserServices_GET_OkResponseSchema(colander.MappingSchema): +class UserServices_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user services successful." - header = HeaderResponseSchema() body = UserServices_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class UserServicePermissions_GET_QuerySchema(colander.MappingSchema): - inherit = QueryInheritGroupsPermissions +class UserServicePermissions_GET_QuerySchema(QueryRequestSchemaAPI): + inherited = QueryInheritGroupsPermissions -class UserServicePermissions_GET_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class UserServicePermissions_GET_RequestSchema(BaseRequestSchemaAPI): querystring = UserServicePermissions_GET_QuerySchema() user_name = UserNameParameter service_name = ServiceNameParameter class UserServicePermissions_GET_ResponseBodySchema(BaseResponseBodySchema): - permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) + permission_names = PermissionListSchema( + description="List of service permissions effective for the referenced user.", + example=[Permission.READ.value, Permission.WRITE.value] + ) -class UserServicePermissions_GET_OkResponseSchema(colander.MappingSchema): +class UserServicePermissions_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get user service permissions successful." - header = HeaderResponseSchema() body = UserServicePermissions_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class UserServicePermissions_GET_NotFoundResponseSchema(colander.MappingSchema): +class UserServicePermissions_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Could not find permissions using specified 'service_name' and 'user_name'." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class Group_MatchDictCheck_ForbiddenResponseSchema(colander.MappingSchema): +class Group_MatchDictCheck_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Group query by name refused by db." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Group_MatchDictCheck_NotFoundResponseSchema(colander.MappingSchema): - description = "Group name not found in db." - header = HeaderResponseSchema() +class Group_MatchDictCheck_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Group name not found." body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class Groups_CheckInfo_NotFoundResponseSchema(colander.MappingSchema): - description = "User name not found in db." - header = HeaderResponseSchema() +class Groups_CheckInfo_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "User name not found." body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class Groups_CheckInfo_ForbiddenResponseSchema(colander.MappingSchema): +class Groups_CheckInfo_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to obtain groups of user." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) @@ -1886,52 +1900,54 @@ class Groups_GET_ResponseBodySchema(BaseResponseBodySchema): group_names = GroupNamesListSchema() -class Groups_GET_OkResponseSchema(colander.MappingSchema): +class Groups_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get groups successful." - header = HeaderResponseSchema() body = Groups_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Groups_GET_ForbiddenResponseSchema(colander.MappingSchema): +class Groups_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Obtain group names refused by db." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) class Groups_POST_RequestBodySchema(colander.MappingSchema): group_name = colander.SchemaNode(colander.String(), description="Name of the group to create.") + description = colander.SchemaNode(colander.String(), default="", + description="Description to apply to the created group.") + discoverable = colander.SchemaNode(colander.Boolean(), default=False, + description="Discoverability status of the created group.") -class Groups_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Groups_POST_RequestSchema(BaseRequestSchemaAPI): body = Groups_POST_RequestBodySchema() class Groups_POST_ResponseBodySchema(BaseResponseBodySchema): - group = GroupBodySchema() + group = GroupInfoBodySchema() -class Groups_POST_CreatedResponseSchema(colander.MappingSchema): +class Groups_POST_CreatedResponseSchema(BaseResponseSchemaAPI): description = "Create group successful." - header = HeaderResponseSchema() body = Groups_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) -class Groups_POST_ForbiddenCreateResponseSchema(colander.MappingSchema): +class Groups_POST_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Invalid parameter for group creation." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class Groups_POST_ForbiddenCreateResponseSchema(BaseResponseSchemaAPI): description = "Create new group by name refused by db." - header = HeaderResponseSchema() - body = Groups_POST_ResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Groups_POST_ForbiddenAddResponseSchema(colander.MappingSchema): +class Groups_POST_ForbiddenAddResponseSchema(BaseResponseSchemaAPI): description = "Add new group by name refused by db." - header = HeaderResponseSchema() - body = Groups_POST_ResponseBodySchema(code=HTTPForbidden.code, description=description) + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Groups_POST_ConflictResponseSchema(colander.MappingSchema): +class Groups_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Group name matches an already existing group name." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) @@ -1939,74 +1955,78 @@ class Group_GET_ResponseBodySchema(BaseResponseBodySchema): group = GroupDetailBodySchema() -class Group_GET_OkResponseSchema(colander.MappingSchema): +class Group_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group successful." - header = HeaderResponseSchema() body = Group_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Group_GET_NotFoundResponseSchema(colander.MappingSchema): +class Group_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Group name was not found." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) -class Group_PUT_RequestBodySchema(colander.MappingSchema): - group_name = colander.SchemaNode(colander.String(), description="New name to apply to the group.") +class Group_PATCH_RequestBodySchema(colander.MappingSchema): + group_name = colander.SchemaNode(colander.String(), missing=colander.drop, + description="New name to apply to the group.") + description = colander.SchemaNode(colander.String(), missing=colander.drop, + description="New description to apply to the group.") + discoverable = colander.SchemaNode(colander.Boolean(), missing=colander.drop, + description="New discoverable status to apply to the group.") -class Group_PUT_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() - body = Group_PUT_RequestBodySchema() +class Group_PATCH_RequestSchema(BaseRequestSchemaAPI): + body = Group_PATCH_RequestBodySchema() group_name = GroupNameParameter -class Group_PUT_OkResponseSchema(colander.MappingSchema): +class Group_PATCH_OkResponseSchema(BaseResponseSchemaAPI): description = "Update group successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class Group_PUT_Name_BadRequestResponseSchema(colander.MappingSchema): +class Group_PATCH_None_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Missing new group parameters to update." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class Group_PATCH_Name_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'group_name' value specified." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Group_PUT_Size_BadRequestResponseSchema(colander.MappingSchema): +class Group_PATCH_Size_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'group_name' length specified (>{length} characters)." \ .format(length=get_constant("MAGPIE_USER_NAME_MAX_LENGTH")) - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Group_PUT_Same_BadRequestResponseSchema(colander.MappingSchema): - description = "Invalid 'group_name' must be different than current name." - header = HeaderResponseSchema() - body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) +class Group_PATCH_ReservedKeyword_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Update of reserved keyword or special group forbidden." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Group_PUT_ConflictResponseSchema(colander.MappingSchema): +class Group_PATCH_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Group name already exists." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) -class Group_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class Group_DELETE_RequestSchema(BaseRequestSchemaAPI): body = colander.MappingSchema(default={}) group_name = GroupNameParameter -class Group_DELETE_OkResponseSchema(colander.MappingSchema): +class Group_DELETE_OkResponseSchema(BaseResponseSchemaAPI): description = "Delete group successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class Group_DELETE_ForbiddenResponseSchema(colander.MappingSchema): +class Group_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Delete group forbidden by db." - header = HeaderResponseSchema() + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class Group_DELETE_ReservedKeyword_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Deletion of reserved keyword or special group forbidden." body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) @@ -2014,57 +2034,54 @@ class GroupUsers_GET_ResponseBodySchema(BaseResponseBodySchema): user_names = UserNamesListSchema() -class GroupUsers_GET_OkResponseSchema(colander.MappingSchema): +class GroupUsers_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group users successful." - header = HeaderResponseSchema() body = GroupUsers_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class GroupUsers_GET_ForbiddenResponseSchema(colander.MappingSchema): +class GroupUsers_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Failed to obtain group user names from db." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) class GroupServices_GET_ResponseBodySchema(BaseResponseBodySchema): - services = ServicesSchemaNode() + services = ServicesCategorizedSchemaNode() -class GroupServices_GET_OkResponseSchema(colander.MappingSchema): +class GroupServices_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group services successful." - header = HeaderResponseSchema() body = GroupServices_GET_ResponseBodySchema(code=HTTPOk.code, description=description) class GroupServices_InternalServerErrorResponseBodySchema(InternalServerErrorResponseBodySchema): - group = GroupBodySchema() + group = GroupInfoBodySchema() -class GroupServices_InternalServerErrorResponseSchema(colander.MappingSchema): +class GroupServices_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed to populate group services." - header = HeaderResponseSchema() body = GroupServices_InternalServerErrorResponseBodySchema( code=HTTPInternalServerError.code, description=description) class GroupServicePermissions_GET_ResponseBodySchema(BaseResponseBodySchema): - permission_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) + permission_names = PermissionListSchema( + description="List of service permissions effective for the referenced group.", + example=[Permission.READ.value, Permission.WRITE.value] + ) -class GroupServicePermissions_GET_OkResponseSchema(colander.MappingSchema): +class GroupServicePermissions_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group service permissions successful." - header = HeaderResponseSchema() body = GroupServicePermissions_GET_ResponseBodySchema(code=HTTPOk.code, description=description) class GroupServicePermissions_GET_InternalServerErrorResponseBodySchema(InternalServerErrorResponseBodySchema): - group = GroupBodySchema() + group = GroupInfoBodySchema() service = ServiceBodySchema() -class GroupServicePermissions_GET_InternalServerErrorResponseSchema(colander.MappingSchema): +class GroupServicePermissions_GET_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed to extract permissions names from group-service." - header = HeaderResponseSchema() body = GroupServicePermissions_GET_InternalServerErrorResponseBodySchema( code=HTTPInternalServerError.code, description=description) @@ -2073,15 +2090,13 @@ class GroupServicePermissions_POST_RequestBodySchema(colander.MappingSchema): permission_name = colander.SchemaNode(colander.String(), description="Name of the permission to create.") -class GroupServicePermissions_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class GroupServicePermissions_POST_RequestSchema(BaseRequestSchemaAPI): body = GroupServicePermissions_POST_RequestBodySchema() group_name = GroupNameParameter service_name = ServiceNameParameter -class GroupResourcePermissions_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class GroupResourcePermissions_POST_RequestSchema(BaseRequestSchemaAPI): body = GroupServicePermissions_POST_RequestBodySchema() group_name = GroupNameParameter resource_id = ResourceIdParameter @@ -2090,41 +2105,35 @@ class GroupResourcePermissions_POST_RequestSchema(colander.MappingSchema): class GroupResourcePermissions_POST_ResponseBodySchema(BaseResponseBodySchema): permission_name = colander.SchemaNode(colander.String(), description="Name of the permission requested.") resource = ResourceBodySchema() - group = GroupBodySchema() + group = GroupInfoBodySchema() -class GroupResourcePermissions_POST_CreatedResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_POST_CreatedResponseSchema(BaseResponseSchemaAPI): description = "Create group resource permission successful." - header = HeaderResponseSchema() body = GroupResourcePermissions_POST_ResponseBodySchema(code=HTTPCreated.code, description=description) -class GroupResourcePermissions_POST_ForbiddenAddResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_POST_ForbiddenAddResponseSchema(BaseResponseSchemaAPI): description = "Add group resource permission refused by db." - header = HeaderResponseSchema() body = GroupResourcePermissions_POST_ResponseBodySchema(code=HTTPForbidden.code, description=description) -class GroupResourcePermissions_POST_ForbiddenCreateResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_POST_ForbiddenCreateResponseSchema(BaseResponseSchemaAPI): description = "Create group resource permission failed." - header = HeaderResponseSchema() body = GroupResourcePermissions_POST_ResponseBodySchema(code=HTTPForbidden.code, description=description) -class GroupResourcePermissions_POST_ForbiddenGetResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_POST_ForbiddenGetResponseSchema(BaseResponseSchemaAPI): description = "Get group resource permission failed." - header = HeaderResponseSchema() body = GroupResourcePermissions_POST_ResponseBodySchema(code=HTTPForbidden.code, description=description) -class GroupResourcePermissions_POST_ConflictResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Group resource permission already exists." - header = HeaderResponseSchema() body = GroupResourcePermissions_POST_ResponseBodySchema(code=HTTPConflict.code, description=description) -class GroupResourcePermission_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class GroupResourcePermission_DELETE_RequestSchema(BaseRequestSchemaAPI): body = colander.MappingSchema(default={}) group_name = GroupNameParameter resource_id = ResourceIdParameter @@ -2137,9 +2146,8 @@ class GroupResourcesPermissions_InternalServerErrorResponseBodySchema(InternalSe resource_types = colander.SchemaNode(colander.String(), description="Object representation of the resource types.") -class GroupResourcesPermissions_InternalServerErrorResponseSchema(colander.MappingSchema): +class GroupResourcesPermissions_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed to build group resources json tree." - header = HeaderResponseSchema() body = GroupResourcesPermissions_InternalServerErrorResponseBodySchema( code=HTTPInternalServerError.code, description=description) @@ -2149,9 +2157,8 @@ class GroupResourcePermissions_InternalServerErrorResponseBodySchema(InternalSer resource = colander.SchemaNode(colander.String(), description="Object representation of the resource.") -class GroupResourcePermissions_InternalServerErrorResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed to obtain group resource permissions." - header = HeaderResponseSchema() body = GroupResourcePermissions_InternalServerErrorResponseBodySchema( code=HTTPInternalServerError.code, description=description) @@ -2160,9 +2167,8 @@ class GroupResources_GET_ResponseBodySchema(BaseResponseBodySchema): resources = ResourcesSchemaNode() -class GroupResources_GET_OkResponseSchema(colander.MappingSchema): +class GroupResources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group resources successful." - header = HeaderResponseSchema() body = GroupResources_GET_ResponseBodySchema(code=HTTPOk.code, description=description) @@ -2170,20 +2176,21 @@ class GroupResources_GET_InternalServerErrorResponseBodySchema(InternalServerErr group = colander.SchemaNode(colander.String(), description="Object representation of the group.") -class GroupResources_GET_InternalServerErrorResponseSchema(colander.MappingSchema): +class GroupResources_GET_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed to build group resources json tree." - header = HeaderResponseSchema() body = GroupResources_GET_InternalServerErrorResponseBodySchema( code=HTTPInternalServerError.code, description=description) class GroupResourcePermissions_GET_ResponseBodySchema(BaseResponseBodySchema): - permissions_names = PermissionListSchema(example=[Permission.READ.value, Permission.WRITE.value]) + permissions_names = PermissionListSchema( + description="List of resource permissions effective for the referenced group.", + example=[Permission.READ.value, Permission.WRITE.value] + ) -class GroupResourcePermissions_GET_OkResponseSchema(colander.MappingSchema): +class GroupResourcePermissions_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group resource permissions successful." - header = HeaderResponseSchema() body = GroupResourcePermissions_GET_ResponseBodySchema(code=HTTPOk.code, description=description) @@ -2191,9 +2198,8 @@ class GroupServiceResources_GET_ResponseBodySchema(BaseResponseBodySchema): service = ServiceResourcesBodySchema() -class GroupServiceResources_GET_OkResponseSchema(colander.MappingSchema): +class GroupServiceResources_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get group service resources successful." - header = HeaderResponseSchema() body = GroupServiceResources_GET_ResponseBodySchema(code=HTTPOk.code, description=description) @@ -2201,8 +2207,7 @@ class GroupServicePermission_DELETE_RequestBodySchema(colander.MappingSchema): permission_name = colander.SchemaNode(colander.String(), description="Name of the permission to delete.") -class GroupServicePermission_DELETE_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() +class GroupServicePermission_DELETE_RequestSchema(BaseRequestSchemaAPI): body = GroupServicePermission_DELETE_RequestBodySchema() group_name = GroupNameParameter service_name = ServiceNameParameter @@ -2212,37 +2217,103 @@ class GroupServicePermission_DELETE_RequestSchema(colander.MappingSchema): class GroupServicePermission_DELETE_ResponseBodySchema(BaseResponseBodySchema): permission_name = colander.SchemaNode(colander.String(), description="Name of the permission requested.") resource = ResourceBodySchema() - group = GroupBodySchema() + group = GroupInfoBodySchema() -class GroupServicePermission_DELETE_OkResponseSchema(colander.MappingSchema): +class GroupServicePermission_DELETE_OkResponseSchema(BaseResponseSchemaAPI): description = "Delete group resource permission successful." - header = HeaderResponseSchema() body = GroupServicePermission_DELETE_ResponseBodySchema(code=HTTPOk.code, description=description) -class GroupServicePermission_DELETE_ForbiddenGetResponseSchema(colander.MappingSchema): +class GroupServicePermission_DELETE_ForbiddenGetResponseSchema(BaseResponseSchemaAPI): description = "Get group resource permission failed." - header = HeaderResponseSchema() body = GroupServicePermission_DELETE_ResponseBodySchema(code=HTTPForbidden.code, description=description) -class GroupServicePermission_DELETE_ForbiddenResponseSchema(colander.MappingSchema): +class GroupServicePermission_DELETE_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Delete group resource permission refused by db." - header = HeaderResponseSchema() body = GroupServicePermission_DELETE_ResponseBodySchema(code=HTTPForbidden.code, description=description) -class Signout_GET_OkResponseSchema(colander.MappingSchema): - description = "Sign out successful." - header = HeaderResponseSchema() +class GroupServicePermission_DELETE_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Permission not found for corresponding group and resource." + body = GroupServicePermission_DELETE_ResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class RegisterGroup_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Could not find any discoverable group matching provided name." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class RegisterGroups_GET_ResponseBodySchema(BaseResponseBodySchema): + group_names = GroupNamesListSchema(description="List of discoverable group names.") + + +class RegisterGroups_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Get discoverable groups successful." + body = RegisterGroups_GET_ResponseBodySchema(code=HTTPOk.code, description=description) + + +class RegisterGroups_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Obtain discoverable groups refused by db." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class RegisterGroup_GET_ResponseBodySchema(BaseResponseBodySchema): + group = GroupPublicBodySchema() # not detailed because authenticated route has limited information + + +class RegisterGroup_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Get discoverable group successful." + body = RegisterGroup_GET_ResponseBodySchema(code=HTTPOk.code, description=description) + + +class RegisterGroup_POST_RequestSchema(BaseRequestSchemaAPI): + body = colander.MappingSchema(description="Nothing required.") + group_name = GroupNameParameter + + +class RegisterGroup_POST_ResponseBodySchema(BaseResponseBodySchema): + user_name = colander.SchemaNode( + colander.String(), + description="Name of the user in the user-group relationship.", + example="logged-user", + ) + group_name = colander.SchemaNode( + colander.String(), + description="Name of the group in the user-group relationship.", + example="public-group", + ) + + +class RegisterGroup_POST_CreatedResponseSchema(BaseResponseSchemaAPI): + description = "Logged user successfully joined the discoverable group. User is now a member of the group." + body = RegisterGroup_POST_ResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class RegisterGroup_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Group membership was not permitted for the logged user." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class RegisterGroup_POST_ConflictResponseSchema(BaseResponseSchemaAPI): + description = "Logged user is already a member of the group." + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) + + +class RegisterGroup_DELETE_RequestSchema(BaseRequestSchemaAPI): + body = colander.MappingSchema(description="Nothing required.") + group_name = GroupNameParameter + + +class RegisterGroup_DELETE_OkResponseSchema(BaseResponseSchemaAPI): + description = "Logged user successfully removed from the group. User is not a member of the group anymore." body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class GroupServicePermission_DELETE_NotFoundResponseSchema(colander.MappingSchema): - description = "Permission not found for corresponding group and resource." - header = HeaderResponseSchema() - body = GroupServicePermission_DELETE_ResponseBodySchema(code=HTTPNotFound.code, description=description) +# check done using same util function +RegisterGroup_DELETE_ForbiddenResponseSchema = UserGroup_DELETE_ForbiddenResponseSchema +RegisterGroup_DELETE_NotFoundResponseSchema = UserGroup_DELETE_NotFoundResponseSchema class Session_GET_ResponseBodySchema(BaseResponseBodySchema): @@ -2252,15 +2323,13 @@ class Session_GET_ResponseBodySchema(BaseResponseBodySchema): description="Indicates if any user session is currently authenticated (user logged in).") -class Session_GET_OkResponseSchema(colander.MappingSchema): +class Session_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get session successful." - header = HeaderResponseSchema() body = Session_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Session_GET_InternalServerErrorResponseSchema(colander.MappingSchema): +class Session_GET_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Failed to get session details." - header = HeaderResponseSchema() body = InternalServerErrorResponseSchema() @@ -2273,26 +2342,30 @@ class Providers_GET_ResponseBodySchema(BaseResponseBodySchema): providers = ProvidersBodySchema() -class Providers_GET_OkResponseSchema(colander.MappingSchema): +class Providers_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get providers successful." - header = HeaderResponseSchema() body = Providers_GET_ResponseBodySchema(code=HTTPOk.code, description=description) class ProviderSignin_GET_HeaderRequestSchema(HeaderRequestSchemaAPI): Authorization = colander.SchemaNode( - colander.String(), missing=colander.drop, example="Bearer MyF4ncy4ccEsT0k3n", + colander.String(), + missing=colander.drop, + example="Bearer MyF4ncy4ccEsT0k3n", description="Access token to employ for direct signin with external provider bypassing the login procedure. " "Access token must have been validated with the corresponding provider beforehand. " "Supported format is 'Authorization: Bearer MyF4ncy4ccEsT0k3n'") HomepageRoute = colander.SchemaNode( - colander.String(), missing=colander.drop, example="/session", default="Magpie UI Homepage", + colander.String(), + missing=colander.drop, + example="/session", + default="Magpie UI Homepage", + name="Homepage-Route", description="Alternative redirection homepage after signin. " "Must be a relative path to Magpie for security reasons.") - HomepageRoute.name = "Homepage-Route" -class ProviderSignin_GET_RequestSchema(colander.MappingSchema): +class ProviderSignin_GET_RequestSchema(BaseRequestSchemaAPI): header = ProviderSignin_GET_HeaderRequestSchema() provider_name = ProviderNameParameter @@ -2301,9 +2374,8 @@ class ProviderSignin_GET_FoundResponseBodySchema(BaseResponseBodySchema): homepage_route = colander.SchemaNode(colander.String(), description="Route to be used for following redirection.") -class ProviderSignin_GET_FoundResponseSchema(colander.MappingSchema): +class ProviderSignin_GET_FoundResponseSchema(BaseResponseSchemaAPI): description = "External login homepage route found. Temporary status before redirection to 'Homepage-Route' header." - header = HeaderResponseSchema() body = ProviderSignin_GET_FoundResponseBodySchema(code=HTTPFound.code, description=description) @@ -2311,21 +2383,18 @@ class ProviderSignin_GET_BadRequestResponseBodySchema(ErrorResponseBodySchema): reason = colander.SchemaNode(colander.String(), description="Additional detail about the error.") -class ProviderSignin_GET_BadRequestResponseSchema(colander.MappingSchema): +class ProviderSignin_GET_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Incorrectly formed 'Authorization: Bearer ' header." - header = HeaderResponseSchema() body = ProviderSignin_GET_BadRequestResponseBodySchema(code=HTTPBadRequest.code, description=description) -class ProviderSignin_GET_UnauthorizedResponseSchema(colander.MappingSchema): +class ProviderSignin_GET_UnauthorizedResponseSchema(BaseResponseSchemaAPI): description = "Unauthorized 'UserInfo' update using provided Authorization headers." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPUnauthorized.code, description=description) -class ProviderSignin_GET_ForbiddenResponseSchema(colander.MappingSchema): +class ProviderSignin_GET_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Forbidden 'Homepage-Route' host not matching Magpie refused for security reasons." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) @@ -2335,59 +2404,57 @@ class ProviderSignin_GET_NotFoundResponseBodySchema(ErrorResponseBodySchema): providers = ProvidersListSchema() -class ProviderSignin_GET_NotFoundResponseSchema(colander.MappingSchema): +class ProviderSignin_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): description = "Invalid 'provider_name' not found within available providers." - header = HeaderResponseSchema() body = ProviderSignin_GET_NotFoundResponseBodySchema(code=HTTPNotFound.code, description=description) -class Signin_POST_RequestBodySchema(colander.MappingSchema): - user_name = colander.SchemaNode(colander.String(), description="User name to use for sign in.") +class Signin_BaseRequestSchema(colander.MappingSchema): + user_name = colander.SchemaNode(colander.String(), + description="User name to use for sign in. " + "Can also be the email provided during registration.") password = colander.SchemaNode(colander.String(), description="Password to use for sign in.") - provider_name = colander.SchemaNode(colander.String(), description="Provider to use for sign in.", + provider_name = colander.SchemaNode(colander.String(), + description="Provider to use for sign in. " + "Required for external provider login.", default=get_constant("MAGPIE_DEFAULT_PROVIDER"), missing=colander.drop) -class Signin_POST_RequestSchema(colander.MappingSchema): - header = HeaderRequestSchemaAPI() - body = Signin_POST_RequestBodySchema() +class SigninQueryParamSchema(QueryRequestSchemaAPI, Signin_BaseRequestSchema): + pass + + +class Signin_GET_RequestSchema(BaseRequestSchemaAPI): + querystring = SigninQueryParamSchema() -class Signin_POST_OkResponseSchema(colander.MappingSchema): +class Signin_POST_RequestSchema(BaseRequestSchemaAPI): + body = Signin_BaseRequestSchema() + + +class Signin_POST_OkResponseSchema(BaseResponseSchemaAPI): description = "Login successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) -class Signin_POST_BadRequestResponseSchema(colander.MappingSchema): +class Signin_POST_BadRequestResponseSchema(BaseResponseSchemaAPI): description = "Missing credentials." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) -class Signin_POST_UnauthorizedResponseSchema(colander.MappingSchema): +class Signin_POST_UnauthorizedResponseSchema(BaseResponseSchemaAPI): description = "Incorrect credentials." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPUnauthorized.code, description=description) -class Signin_POST_ForbiddenResponseSchema(colander.MappingSchema): +class Signin_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): description = "Could not verify 'user_name'." - header = HeaderResponseSchema() body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) -class Signin_POST_ConflictResponseBodySchema(ErrorResponseBodySchema): - provider_name = colander.SchemaNode(colander.String()) - internal_user_name = colander.SchemaNode(colander.String()) - external_user_name = colander.SchemaNode(colander.String()) - external_id = colander.SchemaNode(colander.String()) - - -class Signin_POST_ConflictResponseSchema(colander.MappingSchema): +class Signin_POST_ConflictResponseSchema(BaseResponseSchemaAPI): description = "Add external user identity refused by db because it already exists." - header = HeaderResponseSchema() - body = Signin_POST_ConflictResponseBodySchema(code=HTTPConflict.code, description=description) + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) class Signin_POST_InternalServerErrorBodySchema(InternalServerErrorResponseBodySchema): @@ -2395,18 +2462,21 @@ class Signin_POST_InternalServerErrorBodySchema(InternalServerErrorResponseBodyS provider_name = colander.SchemaNode(colander.String(), description="Specified provider retrieved from the request.") -class Signin_POST_Internal_InternalServerErrorResponseSchema(colander.MappingSchema): +class Signin_POST_Internal_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Unknown login error." - header = HeaderResponseSchema() body = Signin_POST_InternalServerErrorBodySchema(code=HTTPInternalServerError.code, description=description) -class Signin_POST_External_InternalServerErrorResponseSchema(colander.MappingSchema): +class Signin_POST_External_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): description = "Error occurred while signing in with external provider." - header = HeaderResponseSchema() body = Signin_POST_InternalServerErrorBodySchema(code=HTTPInternalServerError.code, description=description) +class Signout_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Sign out successful." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + class Version_GET_ResponseBodySchema(BaseResponseBodySchema): version = colander.SchemaNode( colander.String(), @@ -2418,15 +2488,13 @@ class Version_GET_ResponseBodySchema(BaseResponseBodySchema): exemple="a395ef9d3fe6") -class Version_GET_OkResponseSchema(colander.MappingSchema): +class Version_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get version successful." - header = HeaderResponseSchema() body = Version_GET_ResponseBodySchema(code=HTTPOk.code, description=description) -class Homepage_GET_OkResponseSchema(colander.MappingSchema): +class Homepage_GET_OkResponseSchema(BaseResponseSchemaAPI): description = "Get homepage successful." - header = HeaderResponseSchema() body = BaseResponseBodySchema(code=HTTPOk.code, description=description) @@ -2447,19 +2515,21 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "422": UnprocessableEntityResponseSchema(), "500": Resource_GET_InternalServerErrorResponseSchema() } -Resource_PUT_responses = { - "200": Resource_PUT_OkResponseSchema(), - "400": Resource_MatchDictCheck_BadRequestResponseSchema(), - "403": Resource_PUT_ForbiddenResponseSchema(), +Resource_PATCH_responses = { + "200": Resource_PATCH_OkResponseSchema(), + "400": Resource_PATCH_BadRequestResponseSchema(), + "403": Resource_PATCH_ForbiddenResponseSchema(), "404": Resource_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), + "409": Resource_PATCH_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Resources_GET_responses = { "200": Resources_GET_OkResponseSchema(), "401": UnauthorizedResponseSchema(), "406": NotAcceptableResponseSchema(), - "500": Resource_GET_InternalServerErrorResponseSchema() + "500": Resource_GET_InternalServerErrorResponseSchema(), } Resources_POST_responses = { "201": Resources_POST_CreatedResponseSchema(), @@ -2470,6 +2540,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": Resources_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Resources_DELETE_responses = { "200": Resource_DELETE_OkResponseSchema(), @@ -2479,6 +2550,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Resource_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ResourcePermissions_GET_responses = { "200": ResourcePermissions_GET_OkResponseSchema(), @@ -2488,23 +2560,27 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Resource_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceTypes_GET_responses = { "200": ServiceTypes_GET_OkResponseSchema(), "401": UnauthorizedResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceType_GET_responses = { "200": Services_GET_OkResponseSchema(), "400": Services_GET_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Services_GET_responses = { "200": Services_GET_OkResponseSchema(), "400": Services_GET_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Services_POST_responses = { "201": Services_POST_CreatedResponseSchema(), @@ -2514,6 +2590,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": Services_POST_ConflictResponseSchema(), "422": Services_POST_UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Service_GET_responses = { "200": Service_GET_OkResponseSchema(), @@ -2521,14 +2598,16 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "403": Service_MatchDictCheck_ForbiddenResponseSchema(), "404": Service_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } -Service_PUT_responses = { - "200": Service_PUT_OkResponseSchema(), - "400": Service_PUT_BadRequestResponseSchema(), +Service_PATCH_responses = { + "200": Service_PATCH_OkResponseSchema(), + "400": Service_PATCH_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), - "403": Service_PUT_ForbiddenResponseSchema(), + "403": Service_PATCH_ForbiddenResponseSchema(), "406": NotAcceptableResponseSchema(), - "409": Service_PUT_ConflictResponseSchema(), + "409": Service_PATCH_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Service_DELETE_responses = { "200": Service_DELETE_OkResponseSchema(), @@ -2536,6 +2615,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "403": Service_DELETE_ForbiddenResponseSchema(), "404": Service_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServicePermissions_GET_responses = { "200": ServicePermissions_GET_OkResponseSchema(), @@ -2545,6 +2625,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Service_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceResources_GET_responses = { "200": ServiceResources_GET_OkResponseSchema(), @@ -2553,6 +2634,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Service_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceResources_POST_responses = { "201": ServiceResources_POST_CreatedResponseSchema(), @@ -2563,6 +2645,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": ServiceResources_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceTypeResources_GET_responses = { "200": ServiceTypeResources_GET_OkResponseSchema(), @@ -2571,6 +2654,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": ServiceTypeResources_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceTypeResourceTypes_GET_responses = { "200": ServiceTypeResourceTypes_GET_OkResponseSchema(), @@ -2579,6 +2663,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": ServiceTypeResourceTypes_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ServiceResource_DELETE_responses = { "200": ServiceResource_DELETE_OkResponseSchema(), @@ -2588,74 +2673,90 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Resource_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Users_GET_responses = { "200": Users_GET_OkResponseSchema(), "401": UnauthorizedResponseSchema(), "403": Users_GET_ForbiddenResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Users_POST_responses = { "201": Users_POST_CreatedResponseSchema(), - "400": Users_CheckInfo_Name_BadRequestResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "403": Users_POST_ForbiddenResponseSchema(), "406": NotAcceptableResponseSchema(), "409": User_Check_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), } User_GET_responses = { "200": User_GET_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } -User_PUT_responses = { - "200": Users_PUT_OkResponseSchema(), - "400": Users_CheckInfo_Name_BadRequestResponseSchema(), +User_PATCH_responses = { + "200": Users_PATCH_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "403": UserGroup_GET_ForbiddenResponseSchema(), "406": NotAcceptableResponseSchema(), "409": User_Check_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), } User_DELETE_responses = { "200": User_DELETE_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserResources_GET_responses = { "200": UserResources_GET_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), "404": UserResources_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserGroups_GET_responses = { "200": UserGroups_GET_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserGroups_POST_responses = { "201": UserGroups_POST_CreatedResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "403": User_CheckAnonymous_ForbiddenResponseSchema(), "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "409": UserGroups_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserGroup_DELETE_responses = { "200": UserGroup_DELETE_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), - "403": User_CheckAnonymous_ForbiddenResponseSchema(), - "404": User_CheckAnonymous_NotFoundResponseSchema(), + "403": UserGroup_DELETE_ForbiddenResponseSchema(), + "404": UserGroup_DELETE_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserResourcePermissions_GET_responses = { "200": UserResourcePermissions_GET_OkResponseSchema(), @@ -2664,6 +2765,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Resource_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserResourcePermissions_POST_responses = { "201": UserResourcePermissions_POST_CreatedResponseSchema(), @@ -2673,6 +2775,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": UserResourcePermissions_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserResourcePermission_DELETE_responses = { "200": UserResourcePermissions_DELETE_OkResponseSchema(), @@ -2681,27 +2784,34 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": UserResourcePermissions_DELETE_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserServices_GET_responses = { "200": UserServices_GET_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "403": User_GET_ForbiddenResponseSchema(), "404": User_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserServicePermissions_GET_responses = { "200": UserServicePermissions_GET_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "403": User_GET_ForbiddenResponseSchema(), "404": UserServicePermissions_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserServiceResources_GET_responses = { "200": UserServiceResources_GET_OkResponseSchema(), + "400": User_Check_BadRequestResponseSchema(), "403": User_GET_ForbiddenResponseSchema(), "404": Service_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } UserServicePermissions_POST_responses = UserResourcePermissions_POST_responses UserServicePermission_DELETE_responses = UserResourcePermission_DELETE_responses @@ -2711,14 +2821,16 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } -LoggedUser_PUT_responses = { - "200": Users_PUT_OkResponseSchema(), - "400": User_PUT_BadRequestResponseSchema(), +LoggedUser_PATCH_responses = { + "200": Users_PATCH_OkResponseSchema(), + "400": User_PATCH_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), - "403": User_PUT_ForbiddenResponseSchema(), + "403": User_PATCH_ForbiddenResponseSchema(), "406": NotAcceptableResponseSchema(), - "409": User_PUT_ConflictResponseSchema(), + "409": User_PATCH_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUser_DELETE_responses = { "200": User_DELETE_OkResponseSchema(), @@ -2727,6 +2839,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserResources_GET_responses = { "200": UserResources_GET_OkResponseSchema(), @@ -2734,6 +2847,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": UserResources_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserGroups_GET_responses = { "200": UserGroups_GET_OkResponseSchema(), @@ -2741,6 +2855,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserGroups_POST_responses = { "201": UserGroups_POST_CreatedResponseSchema(), @@ -2750,6 +2865,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": UserGroups_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserGroup_DELETE_responses = { "200": UserGroup_DELETE_OkResponseSchema(), @@ -2758,6 +2874,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": User_CheckAnonymous_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserResourcePermissions_GET_responses = { "200": UserResourcePermissions_GET_OkResponseSchema(), @@ -2766,6 +2883,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Resource_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserResourcePermissions_POST_responses = { "201": UserResourcePermissions_POST_CreatedResponseSchema(), @@ -2774,6 +2892,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": UserResourcePermissions_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserResourcePermission_DELETE_responses = { "200": UserResourcePermissions_DELETE_OkResponseSchema(), @@ -2782,6 +2901,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": UserResourcePermissions_DELETE_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserServices_GET_responses = { "200": UserServices_GET_OkResponseSchema(), @@ -2789,6 +2909,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": User_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserServicePermissions_GET_responses = { "200": UserServicePermissions_GET_OkResponseSchema(), @@ -2796,6 +2917,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": UserServicePermissions_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserServiceResources_GET_responses = { "200": UserServiceResources_GET_OkResponseSchema(), @@ -2803,6 +2925,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Service_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } LoggedUserServicePermissions_POST_responses = LoggedUserResourcePermissions_POST_responses LoggedUserServicePermission_DELETE_responses = LoggedUserResourcePermission_DELETE_responses @@ -2811,14 +2934,17 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "401": UnauthorizedResponseSchema(), "403": Groups_GET_ForbiddenResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Groups_POST_responses = { "201": Groups_POST_CreatedResponseSchema(), + "400": Groups_POST_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), "403": Groups_POST_ForbiddenCreateResponseSchema(), "406": NotAcceptableResponseSchema(), "409": Groups_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Group_GET_responses = { "200": Group_GET_OkResponseSchema(), @@ -2827,24 +2953,27 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Group_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } -Group_PUT_responses = { - "200": Group_PUT_OkResponseSchema(), - "400": Group_PUT_Name_BadRequestResponseSchema(), +Group_PATCH_responses = { + "200": Group_PATCH_OkResponseSchema(), + "400": Group_PATCH_Name_BadRequestResponseSchema(), "401": UnauthorizedResponseSchema(), - "403": Group_MatchDictCheck_ForbiddenResponseSchema(), + "403": Group_PATCH_ReservedKeyword_ForbiddenResponseSchema(), "404": Group_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), - "409": Group_PUT_ConflictResponseSchema(), + "409": Group_PATCH_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Group_DELETE_responses = { "200": Group_DELETE_OkResponseSchema(), "401": UnauthorizedResponseSchema(), - "403": Group_DELETE_ForbiddenResponseSchema(), + "403": Group_DELETE_ReservedKeyword_ForbiddenResponseSchema(), "404": Group_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } GroupUsers_GET_responses = { "200": GroupUsers_GET_OkResponseSchema(), @@ -2853,6 +2982,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Group_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } GroupServices_GET_responses = { "200": GroupServices_GET_OkResponseSchema(), @@ -2878,6 +3008,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Group_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } GroupResourcePermissions_POST_responses = { "201": GroupResourcePermissions_POST_CreatedResponseSchema(), @@ -2887,6 +3018,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "406": NotAcceptableResponseSchema(), "409": GroupResourcePermissions_POST_ConflictResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } GroupServicePermissions_POST_responses = GroupResourcePermissions_POST_responses GroupServicePermission_DELETE_responses = { @@ -2896,6 +3028,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": GroupServicePermission_DELETE_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } GroupResources_GET_responses = { "200": GroupResources_GET_OkResponseSchema(), @@ -2913,11 +3046,40 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "404": Group_MatchDictCheck_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), "422": UnprocessableEntityResponseSchema(), + "500": InternalServerErrorResponseSchema(), } GroupResourcePermission_DELETE_responses = GroupServicePermission_DELETE_responses +RegisterGroups_GET_responses = { + "200": RegisterGroups_GET_OkResponseSchema(), + "401": UnauthorizedResponseSchema(), + "403": RegisterGroups_GET_ForbiddenResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +RegisterGroup_GET_responses = { + "200": RegisterGroup_GET_OkResponseSchema(), + "401": UnauthorizedResponseSchema(), + "404": RegisterGroup_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +RegisterGroup_POST_responses = { + "201": RegisterGroup_POST_CreatedResponseSchema(), + "401": UnauthorizedResponseSchema(), + "403": RegisterGroup_POST_ForbiddenResponseSchema(), + "404": RegisterGroup_NotFoundResponseSchema(), + "409": RegisterGroup_POST_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +RegisterGroup_DELETE_responses = { + "200": RegisterGroup_DELETE_OkResponseSchema(), + "401": UnauthorizedResponseSchema(), + "403": RegisterGroup_DELETE_ForbiddenResponseSchema(), + "404": RegisterGroup_DELETE_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} Providers_GET_responses = { "200": Providers_GET_OkResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } ProviderSignin_GET_responses = { "302": ProviderSignin_GET_FoundResponseSchema(), @@ -2926,7 +3088,7 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "403": ProviderSignin_GET_ForbiddenResponseSchema(), "404": ProviderSignin_GET_NotFoundResponseSchema(), "406": NotAcceptableResponseSchema(), - "500": InternalServerErrorResponseSchema() + "500": InternalServerErrorResponseSchema(), } Signin_POST_responses = { "200": Signin_POST_OkResponseSchema(), @@ -2939,9 +3101,11 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "422": UnprocessableEntityResponseSchema(), "500": Signin_POST_Internal_InternalServerErrorResponseSchema(), } +Signin_GET_responses = Signin_POST_responses Signout_GET_responses = { "200": Signout_GET_OkResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Session_GET_responses = { "200": Session_GET_OkResponseSchema(), @@ -2951,13 +3115,16 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): Version_GET_responses = { "200": Version_GET_OkResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } Homepage_GET_responses = { "200": Homepage_GET_OkResponseSchema(), "406": NotAcceptableResponseSchema(), + "500": InternalServerErrorResponseSchema(), } SwaggerAPI_GET_responses = { "200": SwaggerAPI_GET_OkResponseSchema(), + "500": InternalServerErrorResponseSchema(), } @@ -2966,6 +3133,8 @@ def generate_api_schema(swagger_base_spec): """ Return JSON Swagger specifications of Magpie REST API. + Uses Cornice Services and Schemas to return swagger specification. + :param swagger_base_spec: dictionary that specifies the 'host' and list of HTTP 'schemes' to employ. """ generator = CorniceSwagger(get_services()) @@ -2975,17 +3144,6 @@ def generate_api_schema(swagger_base_spec): swagger_base_spec.update(SecurityDefinitionsAPI) generator.swagger = swagger_base_spec json_api_spec = generator.generate(title=TitleAPI, version=__meta__.__version__, info=InfoAPI) + for tag in json_api_spec["tags"]: + tag["description"] = TAG_DESCRIPTIONS[tag["name"]] return json_api_spec - - -# use Cornice Services and Schemas to return swagger specifications -def api_schema(request): - # type: (Request) -> JSON - """ - Return JSON Swagger specifications of Magpie REST API. - """ - swagger_base_spec = { - "host": get_magpie_url(request.registry), - "schemes": [request.scheme] - } - return generate_api_schema(swagger_base_spec) diff --git a/magpie/api/swagger/__init__.py b/magpie/api/swagger/__init__.py index 372e1e97c..9f94f3ce6 100644 --- a/magpie/api/swagger/__init__.py +++ b/magpie/api/swagger/__init__.py @@ -1,7 +1,7 @@ from pyramid.security import NO_PERMISSION_REQUIRED from magpie.api import schemas as s -from magpie.api.swagger.views import api_swagger +from magpie.api.swagger.views import api_schema, api_swagger from magpie.utils import get_logger LOGGER = get_logger(__name__) @@ -11,7 +11,7 @@ def includeme(config): LOGGER.info("Adding swagger...") config.add_route(**s.service_api_route_info(s.SwaggerAPI)) config.add_route(**s.service_api_route_info(s.SwaggerGenerator)) - config.add_view(s.api_schema, route_name=s.SwaggerGenerator.name, request_method="GET", + config.add_view(api_schema, route_name=s.SwaggerGenerator.name, request_method="GET", renderer="json", permission=NO_PERMISSION_REQUIRED) config.add_view(api_swagger, route_name=s.SwaggerAPI.name, renderer="templates/swagger_ui.mako", permission=NO_PERMISSION_REQUIRED) diff --git a/magpie/api/swagger/views.py b/magpie/api/swagger/views.py index b806095cb..0062bae72 100644 --- a/magpie/api/swagger/views.py +++ b/magpie/api/swagger/views.py @@ -1,7 +1,14 @@ import os +from typing import TYPE_CHECKING from magpie.api import schemas as s from magpie.constants import MAGPIE_MODULE_DIR +from magpie.utils import get_magpie_url + +if TYPE_CHECKING: + from pyramid.request import Request + + from magpie.typedefs import JSON @s.SwaggerAPI.get(tags=[s.APITag], response_schemas=s.SwaggerAPI_GET_responses) @@ -15,3 +22,16 @@ def api_swagger(request): # noqa: F811 "api_schema_path": swagger_ui_path, "api_schema_versions_dir": swagger_versions_dir} return return_data + + +@s.SwaggerGenerator.get(tags=[s.APITag], response_schemas=s.SwaggerAPI_GET_responses) +def api_schema(request): + # type: (Request) -> JSON + """ + Return JSON Swagger specifications of Magpie REST API. + """ + swagger_base_spec = { + "host": get_magpie_url(request.registry), + "schemes": [request.scheme] + } + return s.generate_api_schema(swagger_base_spec) diff --git a/magpie/app.py b/magpie/app.py index 5b78cc433..95c842004 100644 --- a/magpie/app.py +++ b/magpie/app.py @@ -8,9 +8,9 @@ from pyramid.settings import asbool from pyramid_beaker import set_cache_regions_from_settings +from magpie.cli.register_defaults import register_defaults from magpie.constants import get_constant from magpie.db import get_db_session_from_config_ini, run_database_migration_when_ready, set_sqlalchemy_log_level -from magpie.helpers.register_default_users import register_default_users from magpie.register import magpie_register_permissions_from_config, magpie_register_services_from_config from magpie.security import get_auth_config from magpie.utils import get_logger, patch_magpie_url, print_log @@ -52,20 +52,27 @@ def main(global_config=None, **settings): # noqa: F811 # with a new engine class and logging settings don't get re-evaluated/applied db_session = get_db_session_from_config_ini(config_ini, settings_override=sa_settings) + print_log("Validate settings that require explicit definitions...", LOGGER) + for req_config in ["MAGPIE_SECRET", "MAGPIE_ADMIN_USER", "MAGPIE_ADMIN_PASSWORD"]: + get_constant(req_config, settings_container=settings, raise_missing=True, raise_not_set=True) + print_log("Register default users...", LOGGER) - register_default_users(db_session=db_session, settings=settings) + register_defaults(db_session=db_session, settings=settings) + combined_config = get_constant("MAGPIE_CONFIG_PATH", default_value=None, + raise_missing=False, raise_not_set=False, print_missing=True) print_log("Register configuration providers...", logger=LOGGER) - push_phoenix = asbool(get_constant("PHOENIX_PUSH", settings, settings_name="magpie.phoenix_push", + push_phoenix = asbool(get_constant("PHOENIX_PUSH", settings, settings_name="phoenix.push", default_value=False, raise_missing=False, raise_not_set=False, print_missing=True)) - prov_cfg = get_constant("MAGPIE_PROVIDERS_CONFIG_PATH", default_value="", - raise_missing=False, raise_not_set=False, print_missing=True) + + prov_cfg = combined_config or get_constant("MAGPIE_PROVIDERS_CONFIG_PATH", default_value="", + raise_missing=False, raise_not_set=False, print_missing=True) magpie_register_services_from_config(prov_cfg, push_to_phoenix=push_phoenix, force_update=True, disable_getcapabilities=False, db_session=db_session) print_log("Register configuration permissions...", LOGGER) - perm_cfg = get_constant("MAGPIE_PERMISSIONS_CONFIG_PATH", default_value="", - raise_missing=False, raise_not_set=False, print_missing=True) + perm_cfg = combined_config or get_constant("MAGPIE_PERMISSIONS_CONFIG_PATH", default_value="", + raise_missing=False, raise_not_set=False, print_missing=True) magpie_register_permissions_from_config(perm_cfg, db_session=db_session) print_log("Running configurations setup...", LOGGER) diff --git a/magpie/helpers/__init__.py b/magpie/cli/__init__.py similarity index 86% rename from magpie/helpers/__init__.py rename to magpie/cli/__init__.py index 14bcad403..5cd4fed8e 100644 --- a/magpie/helpers/__init__.py +++ b/magpie/cli/__init__.py @@ -10,9 +10,9 @@ from typing import Callable -def magpie_helper_cli(): +def magpie_helper_cli(args=None): """ - Groups all sub-helper CLI listed in :py:mod:`magpie.helpers` as a common ``magpie_helper``. + Groups all sub-helper CLI listed in :py:mod:`magpie.cli` as a common ``magpie_helper``. Dispatches the provided arguments to the appropriate sub-helper CLI as requested. Each sub-helper CLI must implement functions ``make_parser`` and ``main`` to generate the arguments and dispatch them to the corresponding caller. @@ -28,7 +28,7 @@ def magpie_helper_cli(): helper_path = os.path.join(helpers_dir, module_item) if os.path.isfile(helper_path) and "__init__" not in module_item and module_item.endswith(".py"): helper_name = module_item.replace(".py", "") - helper_root = "magpie.helpers" + helper_root = "magpie.cli" helper_module = importlib.import_module("{}.{}".format(helper_root, helper_name), helper_root) parser_maker = getattr(helper_module, "make_parser", None) # type: Callable[[], argparse.ArgumentParser] helper_caller = getattr(helper_module, "main", None) @@ -39,8 +39,8 @@ def magpie_helper_cli(): add_help=False, help=helper_parser.description, description=helper_parser.description, usage=helper_parser.usage) helpers[helper_name] = {"caller": helper_caller, "parser": helper_parser} - args = sys.argv[1:] # save as was parse args does, but we must provide them to subparser - ns = parser.parse_args() # if 'helper' is unknown, auto prints the help message with exit(2) + args = args or sys.argv[1:] # same as was parse args does, but we must provide them to subparser + ns = parser.parse_args(args=args) # if 'helper' is unknown, auto prints the help message with exit(2) helper_name = vars(ns).pop("helper") if not helper_name: parser.print_help() diff --git a/magpie/cli/batch_update_users.py b/magpie/cli/batch_update_users.py new file mode 100644 index 000000000..a1a32efe1 --- /dev/null +++ b/magpie/cli/batch_update_users.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Magpie helper to create or delete a list of users using a set of input parameters. + +Useful for batch operations. +""" +import argparse +import datetime +import logging +import os +import uuid +from typing import TYPE_CHECKING + +import requests + +from magpie.constants import get_constant +from magpie.register import get_all_configs, pseudo_random_string +from magpie.utils import get_json, get_logger + +if TYPE_CHECKING: + from typing import Any, Dict, List, Optional, Sequence + + from magpie.typedefs import Str + UserConfig = List[Dict[Str, Str]] + +LOGGER = get_logger(__name__, + message_format="%(asctime)s - %(levelname)s - %(message)s", + datetime_format="%d-%b-%y %H:%M:%S", force_stdout=False) + +ERROR_PARAMS = 2 +ERROR_EXEC = 1 + + +def format_response(response): + response_json = get_json(response) + return str(response_json.get("code")) + " : " + str(response_json.get("detail")) + + +def get_login_session(magpie_url, username, password, return_response=False): + session = requests.Session() + data = {"user_name": username, "password": password} + response = session.post(magpie_url + "/signin", data=data) + fmt_resp = format_response(response) + if return_response: + return fmt_resp + if response.status_code != 200: + LOGGER.error(fmt_resp) + return None + return session + + +def create_users(user_config, magpie_url, magpie_admin_username, magpie_admin_password, password_length=None): + # type: (UserConfig, Str, Str, Str, Optional[int]) -> UserConfig + """ + Creates the users using provided configuration. + + :returns: updated configuration with generated user-credentials. + """ + session = get_login_session(magpie_url, magpie_admin_username, magpie_admin_password) + if not session: + return [] + + password_length = password_length or get_constant("MAGPIE_PASSWORD_MIN_LENGTH") + for usr_cfg in user_config: + usr_cfg["password"] = pseudo_random_string(length=password_length) + data = {"user_name": usr_cfg["username"], "password": usr_cfg["password"], + "group_name": usr_cfg["group"], "email": usr_cfg["email"]} + response = session.post(magpie_url + "/users", json=data) + if response.status_code != 201: + usr_cfg["result"] = format_response(response) + + # test each successful users with a login + for usr_cfg in user_config: + if not usr_cfg.get("result"): + usr_cfg["result"] = get_login_session( + magpie_url, usr_cfg["username"], usr_cfg["password"], return_response=True + ) + return user_config + + +def delete_users(user_config, magpie_url, magpie_admin_username, magpie_admin_password, **__): + # type: (UserConfig, Str, Str, Str, Any) -> UserConfig + """ + Deletes the specified users. + + :returns: details about request success or failure for each user to be deleted. + """ + session = get_login_session(magpie_url, magpie_admin_username, magpie_admin_password) + if not session: + return [] + + users = [] + for user in user_config: + if "username" not in user or not user["username"]: + LOGGER.error("Cannot delete with missing username") + users.append({"username": "", "result": ""}) + continue + response = session.delete(magpie_url + "/users/" + user["username"]) + users.append({"username": user["username"], "result": format_response(response)}) + return users + + +def make_output(user_results, is_delete, output_location=None): + # type: (UserConfig, bool, Optional[Str]) -> None + """ + Generates the output from obtained user creation/deletion results. + """ + + cols_space = 5 + cols_width = {"username": 8, "password": 8, "result": 8} + for user in user_results: + cols_width["username"] = max(cols_width["username"], len(user["username"])) + cols_width["result"] = max(cols_width["result"], len(user["result"])) + if not is_delete: + cols_width["password"] = max(cols_width["password"], len(user["password"])) + for col in cols_width: + cols_width[col] += cols_space + + output = "\n" + "USERNAME".ljust(cols_width["username"]) + \ + ("PASSWORD".ljust(cols_width["password"]) if not is_delete else "") + \ + "RESULT".ljust(cols_width["result"]) + "\n" + output += "".ljust(len(output), "_") + "\n\n" + for user in user_results: + output += user["username"].ljust(cols_width["username"]) + \ + (user["password"].ljust(cols_width["password"]) if not is_delete else "") + \ + user.get("result", "").ljust(cols_width["result"]) + "\n" # noqa: E126 + + oper_name = "delete" if is_delete else "create" + filename = "magpie_" + oper_name + "_users_log__" + datetime.datetime.now().strftime("%Y%m%d__%H%M%S") + ".txt" + if output_location: + if not os.path.exists(output_location): + os.makedirs(output_location) + filename = os.path.join(output_location, filename) + with open(filename, "w") as file: + file.write(output) + LOGGER.info("Output results sent to [%s]", filename) + + +def make_parser(): + # type: () -> argparse.ArgumentParser + parser = argparse.ArgumentParser(description="Batch update users on a running Magpie instance.") + parser.add_argument("url", help="URL used to access the magpie service.") + parser.add_argument("username", help="Admin username for magpie login.") + parser.add_argument("password", help="Admin password for magpie login.") + parser.add_argument("-l", "--length", type=int, + help="Required length for passwords to be generated (must full Magpie conditions).") + parser.add_argument("-d", "--delete", action="store_true", help="Delete users instead of creating them.") + parser.add_argument("-o", "--output", help="Alternate output directory of results.") + parser.add_argument("-q", "--quiet", help="Suppress informative logging.") + parser.add_argument("-f", "--file", help="Batch file listing user details to apply updates. " + "See 'config/config.yml' for expected users/groups format.") + parser.add_argument("-e", "--emails", nargs="*", default=[], + help="List of emails for users to be created. " + "User names will be auto-generated if not provided.") + parser.add_argument("-u", "--users", nargs="*", default=[], + help="List of user names corresponding to emails.") + parser.add_argument("-g", "--group", help="Common group applied to all users (when using emails) " + "or if missing (when using file). Defaults to no group association.") + return parser + + +def main(args=None, parser=None, namespace=None): + # type: (Optional[Sequence[Str]], Optional[argparse.ArgumentParser], Optional[argparse.Namespace]) -> Any + if not parser: + parser = make_parser() + args = parser.parse_args(args=args, namespace=namespace) + LOGGER.setLevel(logging.WARNING if args.quiet else logging.DEBUG) + + if args.file: + users_cfg = [] + for cfg in get_all_configs(args.file, "users"): + for user in cfg: + user.setdefault("group", args.group) + users_cfg.extend(cfg) + elif args.emails or args.delete: + if args.users: + names = args.users + elif args.delete: + LOGGER.error("No users to delete. User names are needed for this operation.") + return ERROR_PARAMS + else: + names = [str(uuid.uuid4()) for _ in range(len(args.emails))] + if not args.delete and len(names) != len(args.emails): + LOGGER.error("Invalid user names/email counts.") + return ERROR_PARAMS + if args.delete: + users_cfg = [{"username": name} for name in names] + else: + users_cfg = [{"username": name, "email": email, "group": args.group} + for name, email in zip(names, args.emails)] + else: + LOGGER.error("Either batch file, user names or emails must be provided for processing.") + return ERROR_PARAMS + + oper_name = "delete" if args.delete else "create" + if len(users_cfg) == 0: + LOGGER.warning("No users to %s", oper_name) + return ERROR_EXEC + oper_users = delete_users if args.delete else create_users + users = oper_users(users_cfg, args.url, args.username, args.password, password_length=args.length) + make_output(users, args.delete, args.output) + return 0 + + +if __name__ == "__main__": + main() diff --git a/magpie/helpers/register_default_users.py b/magpie/cli/register_defaults.py similarity index 76% rename from magpie/helpers/register_default_users.py rename to magpie/cli/register_defaults.py index aa9b00b53..99cafea41 100644 --- a/magpie/helpers/register_default_users.py +++ b/magpie/cli/register_defaults.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Magpie helpers for user and group registration. +""" import argparse import logging import time @@ -15,8 +20,9 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import AnySettingsContainer, Str # noqa: F401 - from typing import Any, AnyStr, Optional, Sequence # noqa: F401 + from typing import Any, AnyStr, Optional, Sequence + + from magpie.typedefs import AnySettingsContainer, Str LOGGER = get_logger(__name__) @@ -68,23 +74,36 @@ def register_user_with_group(user_name, group_name, email, password, db_session) def init_anonymous(db_session, settings=None): # type: (Session, Optional[AnySettingsContainer]) -> None """ - Registers in db the user and group matching ``MAGPIE_ANONYMOUS_USER`` and ``MAGPIE_ANONYMOUS_GROUP`` respectively if - not defined. + Registers into the database the user and group matching configuration values of. + + :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER` and :py:data:`magpie.constants.MAGPIE_ANONYMOUS_GROUP` + respectively if not defined. + + Afterwards, updates the group's parameters to ensure integrity with `Magpie` settings. """ + anonymous_group = get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=settings) register_user_with_group(user_name=get_constant("MAGPIE_ANONYMOUS_USER", settings_container=settings), - group_name=get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=settings), + group_name=anonymous_group, email=get_constant("MAGPIE_ANONYMOUS_EMAIL", settings_container=settings), password=get_constant("MAGPIE_ANONYMOUS_PASSWORD", settings_container=settings), db_session=db_session) + # enforce some admin group fields + group = GroupService.by_group_name(anonymous_group, db_session=db_session) + group.description = "Group that grants public access to its members for applicable resources." + group.discoverable = False + def init_admin(db_session, settings=None): # type: (Session, Optional[AnySettingsContainer]) -> None """ - Registers in db the user and group matching ``MAGPIE_ADMIN_USER`` and ``MAGPIE_ADMIN_GROUP`` respectively if not - defined. + Registers into the database the user and group matching configuration values of. + + :py:data:`magpie.constants.MAGPIE_ADMIN_USER` and :py:data:`magpie.constants.MAGPIE_ADMIN_GROUP` respectively if + not defined. Also associates the created admin user with the admin group and give it admin permissions. + Finally, updates the group's parameters to ensure integrity with `Magpie` settings. """ admin_usr_name = get_constant("MAGPIE_ADMIN_USER", settings_container=settings) admin_grp_name = get_constant("MAGPIE_ADMIN_GROUP", settings_container=settings) @@ -97,7 +116,7 @@ def init_admin(db_session, settings=None): db_session=db_session) # Check if MAGPIE_ADMIN_GROUP has permission MAGPIE_ADMIN_PERMISSION - magpie_admin_group = GroupService.by_group_name(admin_grp_name, db_session=db_session) + magpie_admin_group = GroupService.by_group_name(admin_grp_name, db_session=db_session) # type: models.Group permission_names = [permission.perm_name for permission in magpie_admin_group.permissions] admin_perm = get_constant("MAGPIE_ADMIN_PERMISSION", settings_container=settings) if admin_perm not in permission_names: @@ -108,11 +127,15 @@ def init_admin(db_session, settings=None): db_session.rollback() raise_log("Failed to create admin user-group permission", exception=type(exc)) + # enforce some admin group fields + magpie_admin_group.description = "Administrative group that grants full access management control to its members." + magpie_admin_group.discoverable = False + def init_users_group(db_session, settings=None): # type: (Session, Optional[AnySettingsContainer]) -> None """ - Registers in db the group matching ``MAGPIE_USERS_GROUP`` if not defined. + Registers into database the group matching :py:data:`magpie.constants.MAGPIE_USERS_GROUP` if not defined. """ usr_grp_name = get_constant("MAGPIE_USERS_GROUP", settings_container=settings) if not GroupService.by_group_name(usr_grp_name, db_session=db_session): @@ -122,15 +145,15 @@ def init_users_group(db_session, settings=None): print_log("MAGPIE_USERS_GROUP already initialized", level=logging.DEBUG) -def register_default_users(db_session=None, settings=None, ini_file_path=None): +def register_defaults(db_session=None, settings=None, ini_file_path=None): # type: (Optional[Session], Optional[AnySettingsContainer], Optional[AnyStr]) -> None """ - Registers in db every undefined default users and groups matching following variables : + Registers into database every undefined default users and groups matching following variables: - - ``MAGPIE_ANONYMOUS_USER`` - - ``MAGPIE_USERS_GROUP`` - - ``MAGPIE_ADMIN_GROUP`` - - ``MAGPIE_ADMIN_USER`` + - :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER` + - :py:data:`magpie.constants.MAGPIE_USERS_GROUP` + - :py:data:`magpie.constants.MAGPIE_ADMIN_GROUP` + - :py:data:`magpie.constants.MAGPIE_ADMIN_USER` """ if not isinstance(db_session, Session): if not ini_file_path: @@ -149,7 +172,7 @@ def register_default_users(db_session=None, settings=None, ini_file_path=None): def make_parser(): # type: () -> argparse.ArgumentParser - parser = argparse.ArgumentParser(description="Registers default users in Magpie") + parser = argparse.ArgumentParser(description="Registers default users and groups in Magpie.") parser.add_argument("ini_file_path", help="Path of the configuration INI file to use to retrieve required settings") return parser @@ -159,7 +182,7 @@ def main(args=None, parser=None, namespace=None): if not parser: parser = make_parser() args = parser.parse_args(args=args, namespace=namespace) - return register_default_users(ini_file_path=args.ini_file_path) + return register_defaults(ini_file_path=args.ini_file_path) if __name__ == "__main__": diff --git a/magpie/helpers/register_providers.py b/magpie/cli/register_providers.py similarity index 94% rename from magpie/helpers/register_providers.py rename to magpie/cli/register_providers.py index e2d9733ff..4cddaf248 100644 --- a/magpie/helpers/register_providers.py +++ b/magpie/cli/register_providers.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Magpie helpers for service providers registration. +""" import argparse from typing import TYPE_CHECKING @@ -7,7 +12,7 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Any, AnyStr, Optional, Sequence # noqa: F401 + from typing import Any, AnyStr, Optional, Sequence def make_parser(): diff --git a/magpie/helpers/run_db_migration.py b/magpie/cli/run_db_migration.py similarity index 88% rename from magpie/helpers/run_db_migration.py rename to magpie/cli/run_db_migration.py index f2c807174..46d8e5262 100644 --- a/magpie/helpers/run_db_migration.py +++ b/magpie/cli/run_db_migration.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Magpie helpers for database migration. +""" import argparse from typing import TYPE_CHECKING @@ -6,7 +11,7 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Any, AnyStr, Optional, Sequence # noqa: F401 + from typing import Any, AnyStr, Optional, Sequence def make_parser(): diff --git a/magpie/helpers/sync_resources.py b/magpie/cli/sync_resources.py similarity index 88% rename from magpie/helpers/sync_resources.py rename to magpie/cli/sync_resources.py index 059992fbc..efcdc58ee 100644 --- a/magpie/helpers/sync_resources.py +++ b/magpie/cli/sync_resources.py @@ -1,7 +1,12 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ Synchronize local and remote resources. -To implement a new service, see :class:`magpie.helpers.sync_services.SyncServiceInterface`. +To implement a new service, see :class:`magpie.cli.sync_services.SyncServiceInterface`. + +.. seealso:: + - :py:mod:`magpie.cli.sync_services` """ import argparse import copy @@ -15,13 +20,15 @@ import transaction from magpie import constants, db, models -from magpie.helpers.sync_services import SYNC_SERVICES_TYPES, SyncServiceDefault, is_valid_resource_schema +from magpie.api.management.resource.resource_utils import get_resource_children +from magpie.cli.sync_services import SYNC_SERVICES_TYPES, SyncServiceDefault, is_valid_resource_schema from magpie.utils import get_logger if TYPE_CHECKING: # pylint: disable=W0611,unused-import + from typing import Any, AnyStr, Optional, Sequence, Union + from sqlalchemy.orm.session import Session - from typing import Any, AnyStr, Optional, Sequence, Union # noqa: F401 LOGGER = get_logger(__name__) @@ -55,7 +62,7 @@ def _merge_resources(resources_local, resources_remote, max_depth=None): - remote_id: id of the RemoteResource - matches_remote: True or False depending if the resource is present on the remote server - :returns: dictionary of the form validated by `magpie.helpers.sync_services.is_valid_resource_schema`. + :returns: dictionary of the form validated by `magpie.cli.sync_services.is_valid_resource_schema`. """ if not resources_remote: return resources_local @@ -170,7 +177,7 @@ def _create_main_resource(service_id, session): sync_info = models.RemoteResourcesSyncInfo.by_service_id(service_id, session) main_resource = models.RemoteResource(service_id=service_id, resource_name=str(sync_info.service.resource_name), - resource_type=u"directory") # noqa + resource_type="directory") # noqa session.add(main_resource) session.flush() sync_info.remote_resource_id = main_resource.resource_id @@ -209,45 +216,11 @@ def add_children(resources, parent_id, position=0): session.flush() -def _get_resource_children(resource, db_session): - """ - Mostly copied from ziggurat_foundations to use RemoteResource instead of Resource. - - :param resource: - :param db_session: - :return: - """ - query = models.REMOTE_RESOURCE_TREE_SERVICE.from_parent_deeper(resource.resource_id, db_session=db_session) - - def build_subtree_strut(result): - """ - Returns a dictionary in form of. - - {node:Resource, children:{node_id: RemoteResource}} - """ - items = list(result) - root_elem = {"node": None, "children": OrderedDict()} - if len(items) == 0: - return root_elem - for node in items: - new_elem = {"node": node.RemoteResource, "children": OrderedDict()} - path = list(map(int, node.path.split("/"))) - parent_node = root_elem - normalized_path = path[:-1] - if normalized_path: - for path_part in normalized_path: - parent_node = parent_node["children"][path_part] - parent_node["children"][new_elem["node"].resource_id] = new_elem - return root_elem - - return build_subtree_strut(query)["children"] - - def _format_resource_tree(children): fmt_res_tree = {} for child_dict in children.values(): - resource = child_dict[u"node"] - new_children = child_dict[u"children"] + resource = child_dict["node"] + new_children = child_dict["children"] resource_display_name = resource.resource_display_name or resource.resource_name resource_dict = {"children": _format_resource_tree(new_children), "remote_id": resource.resource_id, @@ -268,7 +241,7 @@ def _query_remote_resources_in_database(service_id, session): sync_info = models.RemoteResourcesSyncInfo.by_service_id(service_id, session) main_resource = session.query(models.RemoteResource).filter_by( resource_id=sync_info.remote_resource_id).first() - tree = _get_resource_children(main_resource, session) + tree = get_resource_children(main_resource, session, models.REMOTE_RESOURCE_TREE_SERVICE) remote_resources = _format_resource_tree(tree) return {service.resource_name: {"children": remote_resources, "remote_id": main_resource.resource_id}} diff --git a/magpie/helpers/sync_services.py b/magpie/cli/sync_services.py similarity index 92% rename from magpie/helpers/sync_services.py rename to magpie/cli/sync_services.py index 4bf818d65..24462a41e 100644 --- a/magpie/helpers/sync_services.py +++ b/magpie/cli/sync_services.py @@ -1,16 +1,26 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Sync registered services in Magpie with resources retrieved from actual service. + +.. seealso:: + - :py:mod:`magpie.cli.sync_resources` +""" import abc from collections import OrderedDict, defaultdict from typing import TYPE_CHECKING import requests +import six import threddsclient -from six import with_metaclass from magpie.utils import CONTENT_TYPE_JSON if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Dict, JSON, Str, Type # noqa: F401 + from typing import Dict, Type + + from magpie.typedefs import JSON, Str def is_valid_resource_schema(resources): @@ -39,7 +49,8 @@ def is_valid_resource_schema(resources): return True -class SyncServiceInterface(with_metaclass(abc.ABCMeta)): +@six.add_metaclass(abc.ABCMeta) +class SyncServiceInterface(object): sync_type = None # type: Str def __init__(self, service_name, url): @@ -65,7 +76,7 @@ def get_resources(self): class SyncServiceGeoserver(SyncServiceInterface): - sync_type = u"geoserver-api" + sync_type = "geoserver-api" @property def max_depth(self): @@ -93,7 +104,7 @@ def get_resources(self): class SyncServiceProjectAPI(SyncServiceInterface): - sync_type = u"project-api" + sync_type = "project-api" @property def max_depth(self): @@ -118,7 +129,7 @@ def get_resources(self): class SyncServiceThredds(SyncServiceInterface): - sync_type = u"thredds" + sync_type = "thredds" @property def max_depth(self): diff --git a/magpie/constants.py b/magpie/constants.py index 65ecf69ea..66f1f887f 100644 --- a/magpie/constants.py +++ b/magpie/constants.py @@ -23,7 +23,9 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Str, Optional, SettingValue, AnySettingsContainer # noqa: F401 + from typing import Optional + + from magpie.typedefs import AnySettingsContainer, SettingValue, Str # =========================== # path variables @@ -36,6 +38,7 @@ "MAGPIE_PROVIDERS_CONFIG_PATH", "{}/providers.cfg".format(MAGPIE_CONFIG_DIR)) MAGPIE_PERMISSIONS_CONFIG_PATH = os.getenv( "MAGPIE_PERMISSIONS_CONFIG_PATH", "{}/permissions.cfg".format(MAGPIE_CONFIG_DIR)) +MAGPIE_CONFIG_PATH = os.getenv("MAGPIE_CONFIG_PATH") # default None, require explicit specification MAGPIE_INI_FILE_PATH = os.getenv( "MAGPIE_INI_FILE_PATH", "{}/magpie.ini".format(MAGPIE_CONFIG_DIR)) # allow custom location of env files directory to avoid @@ -48,7 +51,7 @@ # if files still cannot be found at 'MAGPIE_ENV_DIR' and variables are still not set, # default values in following sections will be used instead _MAGPIE_ENV_EXAMPLE = MAGPIE_ENV_FILE + ".example" -_POSTGRES_ENV_EXAMPLE = MAGPIE_ENV_FILE + ".example" +_POSTGRES_ENV_EXAMPLE = MAGPIE_POSTGRES_ENV_FILE + ".example" if not os.path.isfile(MAGPIE_ENV_FILE) and os.path.isfile(_MAGPIE_ENV_EXAMPLE): shutil.copyfile(_MAGPIE_ENV_EXAMPLE, MAGPIE_ENV_FILE) if not os.path.isfile(MAGPIE_POSTGRES_ENV_FILE) and os.path.isfile(_POSTGRES_ENV_EXAMPLE): @@ -82,11 +85,12 @@ def _get_default_log_level(): # variables from magpie.env # =========================== MAGPIE_URL = os.getenv("MAGPIE_URL", None) -MAGPIE_SECRET = os.getenv("MAGPIE_SECRET", "seekrit") +MAGPIE_SECRET = os.getenv("MAGPIE_SECRET", "") MAGPIE_COOKIE_NAME = os.getenv("MAGPIE_COOKIE_NAME", "auth_tkt") MAGPIE_COOKIE_EXPIRE = os.getenv("MAGPIE_COOKIE_EXPIRE", None) -MAGPIE_ADMIN_USER = os.getenv("MAGPIE_ADMIN_USER", "admin") -MAGPIE_ADMIN_PASSWORD = os.getenv("MAGPIE_ADMIN_PASSWORD", "qwerty") +MAGPIE_PASSWORD_MIN_LENGTH = os.getenv("MAGPIE_PASSWORD_MIN_LENGTH", 12) +MAGPIE_ADMIN_USER = os.getenv("MAGPIE_ADMIN_USER", "") +MAGPIE_ADMIN_PASSWORD = os.getenv("MAGPIE_ADMIN_PASSWORD", "") MAGPIE_ADMIN_EMAIL = "{}@mail.com".format(MAGPIE_ADMIN_USER) MAGPIE_ADMIN_GROUP = os.getenv("MAGPIE_ADMIN_GROUP", "administrators") MAGPIE_ANONYMOUS_USER = os.getenv("MAGPIE_ANONYMOUS_USER", "anonymous") @@ -103,13 +107,15 @@ def _get_default_log_level(): MAGPIE_LOG_REQUEST = asbool(os.getenv("MAGPIE_LOG_REQUEST", True)) # log detail of every incoming request MAGPIE_LOG_EXCEPTION = asbool(os.getenv("MAGPIE_LOG_EXCEPTION", True)) # log detail of generated exceptions MAGPIE_UI_ENABLED = asbool(os.getenv("MAGPIE_UI_ENABLED", True)) +MAGPIE_UI_THEME = os.getenv("MAGPIE_UI_THEME", "blue") PHOENIX_USER = os.getenv("PHOENIX_USER", "phoenix") PHOENIX_PASSWORD = os.getenv("PHOENIX_PASSWORD", "qwerty") PHOENIX_HOST = os.getenv("PHOENIX_HOST") # default None to use HOSTNAME PHOENIX_PORT = int(os.getenv("PHOENIX_PORT", 8443)) -PHOENIX_PUSH = asbool(os.getenv("PHOENIX_PUSH", True)) +PHOENIX_PUSH = asbool(os.getenv("PHOENIX_PUSH", False)) TWITCHER_PROTECTED_PATH = os.getenv("TWITCHER_PROTECTED_PATH", "/ows/proxy") TWITCHER_PROTECTED_URL = os.getenv("TWITCHER_PROTECTED_URL", None) +TWITCHER_HOST = os.getenv("TWITCHER_HOST", None) # =========================== # variables from postgres.env @@ -121,16 +127,30 @@ def _get_default_log_level(): MAGPIE_POSTGRES_DB = os.getenv("MAGPIE_POSTGRES_DB", "magpie") # =========================== -# other constants +# constants # =========================== -MAGPIE_ADMIN_PERMISSION = "admin" -# MAGPIE_ADMIN_PERMISSION = NO_PERMISSION_REQUIRED +MAGPIE_ADMIN_PERMISSION = "admin" # user must be administrator to access a view (default permission, always allowed) +MAGPIE_LOGGED_PERMISSION = "MAGPIE_LOGGED_USER" # user must be MAGPIE_LOGGED_USER (either literally or inferred) +MAGPIE_CONTEXT_PERMISSION = "MAGPIE_CONTEXT_USER" # path user must be itself, MAGPIE_LOGGED_USER or unauthenticated MAGPIE_LOGGED_USER = "current" MAGPIE_DEFAULT_PROVIDER = "ziggurat" # above this length is considered a token, # refuse longer username creation MAGPIE_USER_NAME_MAX_LENGTH = 64 +MAGPIE_GROUP_NAME_MAX_LENGTH = 64 + +# ignore matches of settings and environment variables for following cases +MAGPIE_CONSTANTS = [ + "MAGPIE_CONSTANTS", + "MAGPIE_ADMIN_PERMISSION", + "MAGPIE_LOGGED_PERMISSION", + "MAGPIE_CONTEXT_PERMISSION", + "MAGPIE_LOGGED_USER", + "MAGPIE_DEFAULT_PROVIDER", + "MAGPIE_USER_NAME_MAX_LENGTH", + "MAGPIE_GROUP_NAME_MAX_LENGTH", +] # =========================== # utilities @@ -156,32 +176,36 @@ def get_constant(constant_name, # type: Str raise_not_set=True # type: bool ): # type: (...) -> SettingValue """ - Search in order for matched value of ``constant_name``: - 1. search in settings if specified - 2. search alternative setting names - 3. search in ``magpie.constants`` definitions - 4. search in environment variables - - Parameter ``constant_name`` is expected to have the format ``MAGPIE_[VARIABLE_NAME]`` although any value can + Search in order for matched value of :paramref:`constant_name`: + 1. search in :py:data:`MAGPIE_CONSTANTS` + 2. search in settings if specified + 3. search alternative setting names (see below) + 4. search in :mod:`magpie.constants` definitions + 5. search in environment variables + + Parameter :paramref:`constant_name` is expected to have the format ``MAGPIE_[VARIABLE_NAME]`` although any value can be passed to retrieve generic settings from all above mentioned search locations. - If ``settings_name`` is provided as alternative name, it is used as is to search for results if ``constant_name`` - was not found. Otherwise, ``magpie.[variable_name]`` is used for additional search when the format - ``MAGPIE_[VARIABLE_NAME]`` was used for ``constant_name`` - (ie: ``MAGPIE_ADMIN_USER`` will also search for ``magpie.admin_user`` and so on for corresponding constants). + If :paramref:`settings_name` is provided as alternative name, it is used as is to search for results if + :paramref:`constant_name` was not found. Otherwise, ``magpie.[variable_name]`` is used for additional search when + the format ``MAGPIE_[VARIABLE_NAME]`` was used for :paramref:`constant_name` + (i.e.: ``MAGPIE_ADMIN_USER`` will also search for ``magpie.admin_user`` and so on for corresponding constants). :param constant_name: key to search for a value :param settings_container: wsgi app settings container :param settings_name: alternative name for `settings` if specified :param default_value: default value to be returned if not found anywhere, and exception raises are disabled. :param raise_missing: raise exception if key is not found anywhere - :param print_missing: print message if key is not found anywhere, return `None` - :param raise_not_set: raise an exception if the found key is None, search until last case if previous are `None` + :param print_missing: print message if key is not found anywhere, return ``None`` + :param raise_not_set: raise an exception if the found key is ``None``, search until last case if others are ``None`` :returns: found value or `default_value` - :raises: according message based on options (by default raise missing/`None` value) + :raises ValueError: if resulting value is invalid based on options (by default raise missing/``None`` value) + :raises LookupError: if no appropriate value could be found from all search locations (according to options) """ - from magpie.utils import get_settings, raise_log, print_log # pylint: disable=C0415 # avoid circular import error + from magpie.utils import get_settings, print_log, raise_log # pylint: disable=C0415 # avoid circular import error + if constant_name in MAGPIE_CONSTANTS: + return globals()[constant_name] missing = True magpie_value = None settings = get_settings(settings_container) if settings_container else None diff --git a/magpie/db.py b/magpie/db.py index 1a15c3cc5..c5eeca1d5 100644 --- a/magpie/db.py +++ b/magpie/db.py @@ -28,8 +28,11 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Any, AnySettingsContainer, SettingsType, Str, Optional, Union # noqa: F401 - from sqlalchemy.engine.base import Engine # noqa: F401 + from typing import Any, Optional, Union + + from sqlalchemy.engine.base import Engine + + from magpie.typedefs import AnySettingsContainer, SettingsType, Str LOGGER = get_logger(__name__) @@ -136,23 +139,28 @@ def get_db_session_from_config_ini(config_ini_path, ini_main_section_name="app:m def run_database_migration(settings=None, db_session=None): # type: (Optional[SettingsType], Optional[Session]) -> None """ - Runs db migration operations with alembic, using db session or a new engine connection. + Runs database migration operations with :mod:`alembic`, using the provided session or a new engine connection. """ ini_file = get_constant("MAGPIE_INI_FILE_PATH", settings) LOGGER.info("Using file '%s' for migration.", ini_file) alembic_args = ["-c", ini_file, "upgrade", "heads"] - if not isinstance(db_session, Session): - alembic.config.main(argv=alembic_args) - else: - engine = db_session.bind - with engine.begin() as connection: - alembic_cfg = alembic.config.Config(file_=ini_file) - alembic_cfg.attributes["connection"] = connection # pylint: disable=E1137 - alembic.command.upgrade(alembic_cfg, "head") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=sa_exc.SAWarning) + if not isinstance(db_session, Session): + alembic.config.main(argv=alembic_args) + else: + engine = db_session.bind + with engine.begin() as connection: + alembic_cfg = alembic.config.Config(file_=ini_file) + alembic_cfg.attributes["connection"] = connection # pylint: disable=E1137 + alembic.command.upgrade(alembic_cfg, "head") def get_database_revision(db_session): # type: (Session) -> Str + """ + Obtains the database revision number employed by :mod:`alembic` for schema migration. + """ query = "SELECT version_num FROM alembic_version" result = db_session.execute(query).fetchone() return result["version_num"] @@ -160,6 +168,9 @@ def get_database_revision(db_session): def is_database_ready(db_session=None, container=None): # type: (Optional[Session], Optional[AnySettingsContainer]) -> bool + """ + Obtains the database status against expected table names to ensure it is ready for use. + """ if isinstance(db_session, Session): engine = db_session.bind else: @@ -170,7 +181,8 @@ def is_database_ready(db_session=None, container=None): for _, obj in inspect.getmembers(models): if inspect.isclass(obj) and hasattr(obj, "__tablename__"): if obj.__tablename__ not in table_names: - LOGGER.error("Database table (or its associated parent) is missing for '%s' object", obj) + print_log("Database table (or its associated parent) is missing for '{}' object".format(obj), + logger=LOGGER, level=logging.ERROR) return False return True @@ -180,40 +192,46 @@ def run_database_migration_when_ready(settings, db_session=None): """ Runs db migration if requested by config and need from revisions. """ - db_ready = False if asbool(get_constant("MAGPIE_DB_MIGRATION", settings, "magpie.db_migration", default_value=True, raise_missing=False, raise_not_set=False, print_missing=True)): - attempts = int(get_constant("MAGPIE_DB_MIGRATION_ATTEMPTS", settings, "magpie.db_migration_attempts", - default_value=5, raise_missing=False, raise_not_set=False, print_missing=True)) - - print_log("Running database migration (as required)...") - attempts = max(attempts, 2) # enforce at least 2 attempts, 1 for db creation and one for actual migration + conf_attempts = int(get_constant("MAGPIE_DB_MIGRATION_ATTEMPTS", settings, "magpie.db_migration_attempts", + default_value=5, raise_missing=False, raise_not_set=False, print_missing=True)) + + print_log("Running database migration (as required)...", logger=LOGGER) + attempts = max(conf_attempts, 1) + if attempts != conf_attempts: + print_log("Database migration attempts updated to {}".format(attempts), + logger=LOGGER, level=logging.WARNING) for i in range(1, attempts + 1): try: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=sa_exc.SAWarning) - run_database_migration(db_session=db_session, settings=settings) + run_database_migration(db_session=db_session, settings=settings) except ImportError as exc: - print_log("Database migration produced [{!r}] (ignored).".format(exc), level=logging.WARNING) + print_log("Database migration produced [{!r}] (ignored).".format(exc), + logger=LOGGER, level=logging.WARNING, exc_info=exc) except Exception as exc: if i <= attempts: - print_log("Database migration failed [{!r}]. Retrying... ({}/{})".format(exc, i, attempts)) + print_log("Database migration failed [{!r}]. Retrying... ({}/{})".format(exc, i, attempts), + logger=LOGGER, level=logging.WARNING, exc_info=exc) time.sleep(2) continue - raise_log("Database migration failed [{!r}]".format(exc), exception=RuntimeError) + raise_log("Database migration failed [{!r}]".format(exc), exception=RuntimeError, logger=LOGGER) db_ready = is_database_ready(db_session) if not db_ready: - print_log("Database not ready. Retrying... ({}/{})".format(i, attempts)) - time.sleep(2) - continue + if i <= attempts: + print_log("Database not ready. Retrying... ({}/{})".format(i, attempts), + logger=LOGGER, level=logging.WARNING) + time.sleep(2) + continue + print_log("Database not ready. Maximum attempts reached ({})".format(attempts), + logger=LOGGER, level=logging.WARNING) break else: + print_log("Database migration skipped as per 'MAGPIE_DB_MIGRATION' requirement...", logger=LOGGER) db_ready = is_database_ready(db_session) if not db_ready: - time.sleep(2) - raise_log("Database not ready", exception=RuntimeError) + raise_log("Database not ready", exception=RuntimeError, logger=LOGGER) def set_sqlalchemy_log_level(magpie_log_level): diff --git a/magpie/helpers/create_users.py b/magpie/helpers/create_users.py deleted file mode 100644 index 71ec773b8..000000000 --- a/magpie/helpers/create_users.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import datetime -import logging -import random -import string -from typing import TYPE_CHECKING - -import requests - -if TYPE_CHECKING: - from typing import Any, AnyStr, Optional, Sequence - -LOGGER = logging.getLogger(__name__) -COLUMN_SIZE = 60 - - -def format_response(response): - response_json = response.json() - return str(response_json.get("code")) + " : " + response_json.get("detail") - - -def pseudo_random_pwd(length=8): - """ - Generate a password made of random alphanumeric characters. - """ - rnd = random.SystemRandom() - return "".join(rnd.choice(string.ascii_letters + string.digits) for _ in range(length)) - - -def create_users(email_list, magpie_url, magpie_admin_user_name, magpie_admin_password): - session = requests.Session() - response = session.post(magpie_url + "/signin", data={"user_name": magpie_admin_user_name, - "password": magpie_admin_password, - "provider_name": "ziggurat"}) - if not response.ok: - LOGGER.error(format_response(response)) - return [] - - users = [] - for email in email_list: - user = {"email": email, - "user_name": email, - "password": pseudo_random_pwd(), - "result": ""} - users.append(user) - response = session.post(magpie_url + "/users", data={"user_name": user["user_name"], - "email": user["email"], - "password": user["password"], - "group_name": "users"}) - if not response.ok: - user["result"] = format_response(response) - - # test each successful users with a login - for user in users: - if user["result"] == "": - session = requests.Session() - response = session.post(magpie_url + "/signin", - data={"user_name": user["user_name"], "password": user["password"], - "provider_name": "ziggurat"}) - user["result"] = format_response(response) - return users - - -def delete_users(user_names, magpie_url, magpie_admin_user_name, magpie_admin_password): - session = requests.Session() - response = session.post(magpie_url + "/signin", data={"user_name": magpie_admin_user_name, - "password": magpie_admin_password, - "provider_name": "ziggurat"}) - if not response.ok: - LOGGER.error(format_response(response)) - return - - users = [] - for user in user_names: - response = session.delete(magpie_url + "/users/" + user) - users.append({"user_name": user, "result": format_response(response)}) - return users - - -def make_parser(): - # type: () -> argparse.ArgumentParser - parser = argparse.ArgumentParser(description="Create users on a running Magpie instance") - parser.add_argument("url", help="url used to access the magpie service") - parser.add_argument("user_name", help="admin username for magpie login") - parser.add_argument("password", help="admin password for magpie login") - parser.add_argument("emails", nargs="*", help="list of emails for users to be created") - return parser - - -def main(args=None, parser=None, namespace=None): - # type: (Optional[Sequence[AnyStr]], Optional[argparse.ArgumentParser], Optional[argparse.Namespace]) -> Any - if not parser: - parser = make_parser() - args = parser.parse_args(args=args, namespace=namespace) - - LOGGER.setLevel(logging.DEBUG) - logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", datefmt="%d-%b-%y %H:%M:%S") - users = create_users(args.emails, args.url, args.user_name, args.password) - - if len(users) == 0: - LOGGER.warning("No users to create") - else: - output = "\nUSERNAME".ljust(COLUMN_SIZE) + \ - "PASSWORD".ljust(COLUMN_SIZE) + \ - "RESULT".ljust(COLUMN_SIZE) + "\n" - output += "".ljust(COLUMN_SIZE * 3, "_") + "\n\n" - for user in users: - output += user["user_name"].ljust(COLUMN_SIZE) + \ - user["password"].ljust(COLUMN_SIZE) + \ - user["result"].ljust(COLUMN_SIZE) + "\n" # noqa: E126 - - LOGGER.info(output) - - filename = "createUsers_log__" + datetime.datetime.now().strftime("%Y%m%d__%H%M%S") + ".txt" - with open(filename, "w+") as file: - file.write(output) - LOGGER.info("Output results sent to %s", filename) - - -if __name__ == "__main__": - main() diff --git a/magpie/models.py b/magpie/models.py index 81e8d7425..e7aa28b4d 100644 --- a/magpie/models.py +++ b/magpie/models.py @@ -2,8 +2,7 @@ import sqlalchemy as sa from pyramid.httpexceptions import HTTPInternalServerError -from pyramid.security import ALL_PERMISSIONS -from pyramid.security import Allow as ALLOW # noqa +from pyramid.security import ALL_PERMISSIONS, Allow, Authenticated, Everyone from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import relationship from ziggurat_foundations import ziggurat_model_init @@ -14,6 +13,7 @@ from ziggurat_foundations.models.group_resource_permission import GroupResourcePermissionMixin from ziggurat_foundations.models.resource import ResourceMixin from ziggurat_foundations.models.services import BaseService +from ziggurat_foundations.models.services.group import GroupService from ziggurat_foundations.models.services.resource_tree import ResourceTreeService from ziggurat_foundations.models.services.resource_tree_postgres import ResourceTreeServicePostgreSQL from ziggurat_foundations.models.services.user import UserService @@ -24,11 +24,14 @@ from ziggurat_foundations.permissions import permission_to_pyramid_acls from magpie.api.exception import evaluate_call +from magpie.constants import get_constant from magpie.permissions import Permission if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Str # noqa: F401 + from typing import Dict, Type + + from magpie.typedefs import Str Base = declarative_base() # pylint: disable=C0103,invalid-name @@ -41,6 +44,13 @@ class Group(GroupMixin, Base): def get_member_count(self, db_session=None): return BaseService.all(UserGroup, db_session=db_session).filter(UserGroup.group_id == self.id).count() + @declared_attr + def discoverable(self): + """ + Indicates if the group is discoverable for users to self-register to it. + """ + return sa.Column(sa.Boolean(), default=False) + class GroupPermission(GroupPermissionMixin, Base): pass @@ -72,13 +82,14 @@ def root_service_id(self): @property def __acl__(self): + """ + User or group that owns a resource are granted full access to it. + """ acl = [] - if self.owner_user_id: - acl.extend([(ALLOW, self.owner_user_id, ALL_PERMISSIONS,), ]) - + acl.extend([(Allow, self.owner_user_id, ALL_PERMISSIONS,), ]) if self.owner_group_id: - acl.extend([(ALLOW, "group:%s" % self.owner_group_id, ALL_PERMISSIONS,), ]) + acl.extend([(Allow, "group:%s" % self.owner_group_id, ALL_PERMISSIONS,), ]) return acl @@ -100,11 +111,91 @@ class ExternalIdentity(ExternalIdentityMixin, Base): class RootFactory(object): + """ + Used to build base Access Control List (ACL) of the request user. + + All API and UI routes will employ this set of effective principals to determine if the user is authorized to access + the pyramid view according to the ``permission`` value it was configured with. + + .. note:: + Keep in mind that `Magpie` is configured with default permission + :py:data:`magpie.constants.MAGPIE_ADMIN_PERMISSION`. + Views that require more permissive authorization must be overridden with ``permission`` argument. + + .. seealso:: + - ``set_default_permission`` within :func:`magpie.includeme` initialization steps + """ + __name__ = None + __parent__ = "" + def __init__(self, request): - self.__acl__ = [] - if request.user: - permissions = UserService.permissions(request.user, request.db) - self.__acl__.extend(permission_to_pyramid_acls(permissions)) + self.request = request + + @property + def __acl__(self): + """ + Administrators have all permissions, user/group-specific permissions added if user is logged in. + """ + user = self.request.user + # allow if role MAGPIE_ADMIN_PERMISSION is somehow directly set instead of inferred via members of admin-group + acl = [(Allow, get_constant("MAGPIE_ADMIN_PERMISSION"), ALL_PERMISSIONS)] + admins = GroupService.by_group_name(get_constant("MAGPIE_ADMIN_GROUP"), db_session=self.request.db) + if admins: + # need to add explicit admin-group ALL_PERMISSIONS otherwise views with other permissions than the + # default MAGPIE_ADMIN_PERMISSION will be refused access (e.g.: views with MAGPIE_LOGGED_PERMISSION) + acl += [(Allow, "group:{}".format(admins.id), ALL_PERMISSIONS)] + if user: + # user-specific permissions (including group memberships) + permissions = UserService.permissions(user, self.request.db) + user_acl = permission_to_pyramid_acls(permissions) + # allow views that require minimally to be logged in (regardless of who is the user) + auth_acl = [(Allow, user.id, Authenticated)] + acl += user_acl + auth_acl + return acl + + +class UserFactory(RootFactory): + def __init__(self, request): + super(UserFactory, self).__init__(request) + self.path_user = None + + def __getitem__(self, user_name): + context = UserFactory(self.request) + if user_name == get_constant("MAGPIE_LOGGED_USER", self.request): + self.path_user = self.request.user + else: + self.path_user = UserService.by_user_name(user_name, self.request.db) + if self.path_user is not None: + self.path_user.__parent__ = self + self.path_user.__name__ = user_name + context.path_user = self.path_user + return context + + @property + def __acl__(self): + """ + Grant access to :term:`Request User` according to its relationship to :term:`Context User`. + + If it is the same user (either from explicit name or by :py:data:`magpie.constants.MAGPIE_LOGGED_USER` reserved + keyword), allow :py:data:`magpie.constants.MAGPIE_LOGGED_PERMISSION` for itself to access corresponding views. + + If request user is unauthenticated (``None``), :py:data:`magpie.constants.MAGPIE_LOGGED_USER` or itself, + also grant :py:data:`magpie.constants.MAGPIE_CONTEXT_PERMISSION` to allow access to contextually-available + details (e.g.: user can view his own information and public ones). + + All ACL permissions from :class:`RootFactory` are applied on top of user-specific permissions added here. + """ + user = self.request.user + acl = super(UserFactory, self).__acl__ # inherit default permissions for non user-scoped routes + # when user is authenticated and refers to itself, simultaneously fulfill both logged/context conditions + if user and self.path_user and user.id == self.path_user.id: + acl += [(Allow, user.id, get_constant("MAGPIE_LOGGED_PERMISSION")), + (Allow, user.id, get_constant("MAGPIE_CONTEXT_PERMISSION"))] + # unauthenticated context is allowed if and only if referring also to the unauthenticated user + elif user is None: + if self.path_user is None or self.path_user.user_name == get_constant("MAGPIE_ANONYMOUS_USER"): + acl += [(Allow, Everyone, get_constant("MAGPIE_CONTEXT_PERMISSION"))] + return acl class Service(Resource): @@ -112,7 +203,7 @@ class Service(Resource): Resource of `service` type. """ - __tablename__ = u"services" + __tablename__ = "services" resource_id = sa.Column(sa.Integer(), sa.ForeignKey("resources.resource_id", @@ -120,12 +211,14 @@ class Service(Resource): ondelete="CASCADE", ), primary_key=True, ) - resource_type_name = u"service" - __mapper_args__ = {u"polymorphic_identity": resource_type_name, - u"inherit_condition": resource_id == Resource.resource_id} + resource_type_name = "service" + __mapper_args__ = { + "polymorphic_identity": resource_type_name, + "inherit_condition": resource_id == Resource.resource_id + } @property - def permissions(self): + def permissions(self): # pragma: no cover raise TypeError("Service permissions must be accessed by 'magpie.services.ServiceInterface' " "instead of 'magpie.models.Service'.") @@ -145,7 +238,7 @@ def type(self): @declared_attr def sync_type(self): """ - Identifier matching ``magpie.helpers.SyncServiceInterface.sync_type``. + Identifier matching ``magpie.cli.SyncServiceInterface.sync_type``. """ # project-api, geoserver-api,... return sa.Column(sa.UnicodeText(), nullable=True) @@ -171,18 +264,18 @@ class PathBase(object): class File(Resource, PathBase): child_resource_allowed = False - resource_type_name = u"file" - __mapper_args__ = {u"polymorphic_identity": resource_type_name} + resource_type_name = "file" + __mapper_args__ = {"polymorphic_identity": resource_type_name} class Directory(Resource, PathBase): - resource_type_name = u"directory" - __mapper_args__ = {u"polymorphic_identity": resource_type_name} + resource_type_name = "directory" + __mapper_args__ = {"polymorphic_identity": resource_type_name} class Workspace(Resource): - resource_type_name = u"workspace" - __mapper_args__ = {u"polymorphic_identity": resource_type_name} + resource_type_name = "workspace" + __mapper_args__ = {"polymorphic_identity": resource_type_name} permissions = [ Permission.GET_CAPABILITIES, @@ -198,8 +291,8 @@ class Workspace(Resource): class Route(Resource): - resource_type_name = u"route" - __mapper_args__ = {u"polymorphic_identity": resource_type_name} + resource_type_name = "route" + __mapper_args__ = {"polymorphic_identity": resource_type_name} permissions = [ Permission.READ, # access with inheritance (this route and all under it) @@ -287,9 +380,9 @@ class RemoteResourceTreeServicePostgresSQL(ResourceTreeServicePostgreSQL): RESOURCE_TREE_SERVICE = ResourceTreeService(ResourceTreeServicePostgreSQL) REMOTE_RESOURCE_TREE_SERVICE = RemoteResourceTreeService(RemoteResourceTreeServicePostgresSQL) -RESOURCE_TYPE_DICT = dict() +RESOURCE_TYPE_DICT = dict() # type: Dict[Str, Type[Resource]] for res in [Service, Directory, File, Workspace, Route]: - if res.resource_type_name in RESOURCE_TYPE_DICT: + if res.resource_type_name in RESOURCE_TYPE_DICT: # pragma: no cover raise KeyError("Duplicate resource type identifiers not allowed") RESOURCE_TYPE_DICT[res.resource_type_name] = res @@ -297,10 +390,11 @@ class RemoteResourceTreeServicePostgresSQL(ResourceTreeServicePostgreSQL): def resource_factory(**kwargs): resource_type = evaluate_call(lambda: kwargs["resource_type"], http_error=HTTPInternalServerError, msg_on_fail="kwargs do not contain required 'resource_type'", - content={u"kwargs": repr(kwargs)}) - return evaluate_call(lambda: RESOURCE_TYPE_DICT[resource_type](**kwargs), http_error=HTTPInternalServerError, + content={"kwargs": repr(kwargs)}) + return evaluate_call(lambda: RESOURCE_TYPE_DICT[resource_type](**kwargs), # noqa + http_error=HTTPInternalServerError, msg_on_fail="kwargs unpacking failed from specified 'resource_type' and 'RESOURCE_TYPE_DICT'", - content={u"kwargs": repr(kwargs), u"RESOURCE_TYPE_DICT": repr(RESOURCE_TYPE_DICT)}) + content={"kwargs": repr(kwargs), "RESOURCE_TYPE_DICT": repr(RESOURCE_TYPE_DICT)}) def find_children_by_name(child_name, parent_id, db_session): diff --git a/magpie/owsrequest.py b/magpie/owsrequest.py index f1dc19d78..9b5155da9 100644 --- a/magpie/owsrequest.py +++ b/magpie/owsrequest.py @@ -9,7 +9,7 @@ import lxml.etree # nosec: B410 # module safe but bandit flags it : https://github.com/tiran/defusedxml/issues/38 -from magpie.api.requests import get_multiformat_any +from magpie.api.requests import get_multiformat_body from magpie.utils import CONTENT_TYPE_FORM, CONTENT_TYPE_JSON, CONTENT_TYPE_PLAIN, get_header, get_logger, is_json_body if TYPE_CHECKING: @@ -108,4 +108,4 @@ def _get_param_value(self, param): class MultiFormatParser(OWSParser): def _get_param_value(self, param): - return get_multiformat_any(self.request, param, None) + return get_multiformat_body(self.request, param, None) diff --git a/magpie/permissions.py b/magpie/permissions.py index f58c6e747..a2d0ce5fe 100644 --- a/magpie/permissions.py +++ b/magpie/permissions.py @@ -1,58 +1,61 @@ -from enum import Enum from typing import TYPE_CHECKING -from six import with_metaclass - -from magpie.utils import ExtendedEnumMeta +from magpie.utils import ExtendedEnum if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import Iterable, List, Optional, Str, AnyPermissionType # noqa: F401 + from typing import Iterable, List, Optional + + from magpie.typedefs import AnyPermissionType, Str -class Permission(with_metaclass(ExtendedEnumMeta, Enum)): +class Permission(ExtendedEnum): + """ + Applicable :term:`Permission` values under certain :term:`Service` and :term:`Resource`. + """ # file/dir permissions - READ = u"read" - READ_MATCH = u"read-match" - WRITE = u"write" - WRITE_MATCH = u"write-match" - ACCESS = u"access" + READ = "read" + READ_MATCH = "read-match" + WRITE = "write" + WRITE_MATCH = "write-match" + ACCESS = "access" # WPS permissions - GET_CAPABILITIES = u"getcapabilities" - GET_MAP = u"getmap" - GET_FEATURE_INFO = u"getfeatureinfo" - GET_LEGEND_GRAPHIC = u"getlegendgraphic" - GET_METADATA = u"getmetadata" - GET_FEATURE = u"getfeature" - DESCRIBE_FEATURE_TYPE = u"describefeaturetype" - DESCRIBE_PROCESS = u"describeprocess" - EXECUTE = u"execute" - LOCK_FEATURE = u"lockfeature" - TRANSACTION = u"transaction" + GET_CAPABILITIES = "getcapabilities" + GET_MAP = "getmap" + GET_FEATURE_INFO = "getfeatureinfo" + GET_LEGEND_GRAPHIC = "getlegendgraphic" + GET_METADATA = "getmetadata" + GET_FEATURE = "getfeature" + DESCRIBE_FEATURE_TYPE = "describefeaturetype" + DESCRIBE_PROCESS = "describeprocess" + EXECUTE = "execute" + LOCK_FEATURE = "lockfeature" + TRANSACTION = "transaction" def convert_permission(permission): # type: (AnyPermissionType) -> Optional[Permission] """ - Converts any permission representation to the ``Permission`` enum. + Converts any permission representation to the :class:`Permission` enum. If the permission cannot be matched to one of the enum's value, ``None`` is returned instead. """ - if permission in Permission: - return permission + perm = Permission.get(permission) + if perm is not None: + return perm return Permission.get(getattr(permission, "perm_name", None) or permission) def format_permissions(permissions): # type: (Iterable[AnyPermissionType]) -> List[Str] """ - Obtains the formatted permission representation after validation that it is a member of ``Permission`` enum. + Obtains the formatted permission representation after validation that it is a member of :class:`Permission` enum. The returned list is sorted alphabetically and cleaned of any duplicate entries. """ perms = [] - for p in permissions: - p_enum = convert_permission(p) + for perm in permissions: + p_enum = convert_permission(perm) if p_enum: perms.append(p_enum) - return list(sorted(set([p.value for p in perms]))) # remove any duplicates entries + return list(sorted({perm.value for perm in perms})) # remove any duplicates entries diff --git a/magpie/register.py b/magpie/register.py index 1d07e9580..3db79d03c 100644 --- a/magpie/register.py +++ b/magpie/register.py @@ -1,6 +1,8 @@ import logging import os -import subprocess # nosec +import random +import string +import subprocess # nosec import time from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING @@ -47,9 +49,10 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import ( # noqa: F401 - Any, Str, Dict, List, JSON, Optional, Tuple, Union, CookiesOrSessionType - ) + from typing import Any, Dict, Iterable, List, Optional, Tuple, Union + + from magpie.typedefs import JSON, AnyCookiesType, CookiesOrSessionType, Str + ConfigItem = Dict[Str, Str] ConfigList = List[ConfigItem] ConfigDict = Dict[Str, Union[ConfigItem, ConfigList]] @@ -136,7 +139,7 @@ def _request_curl(url, cookie_jar=None, cookies=None, form_params=None, msg="Res curl_out = subprocess.Popen(params, stdout=subprocess.PIPE) # nosec curl_msg = curl_out.communicate()[0] # type: Str curl_err = curl_out.returncode # type: int - http_code = int(curl_msg.split(msg_sep)[1]) + http_code = int(six.ensure_text(curl_msg).split(msg_sep)[1]) print_log("[{url}] {response}".format(response=curl_msg, url=url), logger=LOGGER) return curl_err, http_code @@ -197,7 +200,7 @@ def _phoenix_remove_services(): error, _ = _request_curl(remove_services_url, cookies=phoenix_cookies_file.name, msg="Phoenix remove services") except Exception as exc: - print_log("Exception during phoenix remove services: [{!r}]".format(exc), logger=LOGGER) + print_log("Exception during phoenix remove services: [{!r}]".format(exc), logger=LOGGER, level=logging.ERROR) return error == 0 @@ -276,25 +279,27 @@ def _register_services(where, # type: Optional[Str] return success, statuses -def sync_services_phoenix(services_object_dict, services_as_dicts=False): - # type: (Dict[Str, models.Service], Optional[Union[bool, JSON]]) -> bool +def sync_services_phoenix(services, services_as_dicts=False): + # type: (Union[Iterable[models.Service], JSON], bool) -> bool """ Syncs Magpie services by pushing updates to Phoenix. Services must be one of types specified in :py:data:`magpie.register.SERVICES_PHOENIX_ALLOWED`. - :param services_object_dict: - dictionary of ``{svc-name: models.Service}`` objects containing each service's information - :param services_as_dicts: - alternatively specify `services_object_dict` as dict of ``{svc-name: {service-info}}`` - where ``{service-info}`` is defined as:: + :param services: + An iterable of :class:`models.Service` by default, or a dictionary of ``{svc-name: {}}`` JSON + objects containing each service's information if :paramref:`services_ad_dicts` is ``True``. + + where ```` is defined as:: {"public_url": , "service_name": , "service_type": } + + :param services_as_dicts: indicate if services must be parsed as JSON definitions. """ services_dict = {} - for svc in services_object_dict: + for svc in services: if services_as_dicts: - svc_dict = services_object_dict[svc] + svc_dict = services[svc] services_dict[svc] = {"url": svc_dict["public_url"], "title": svc_dict["service_name"], "type": svc_dict["service_type"], "c4i": False, "public": True} else: @@ -358,7 +363,7 @@ def _magpie_add_register_services_perms(services, statuses, curl_cookies, reques def _magpie_update_services_conflict(conflict_services, services_dict, request_cookies): - # type: (List[Str], ConfigDict, Dict[Str, Str]) -> Dict[Str, int] + # type: (List[Str], ConfigDict, AnyCookiesType) -> Dict[Str, int] """ Resolve conflicting services by name during registration by updating them only if pointing to different URL. """ @@ -373,7 +378,7 @@ def _magpie_update_services_conflict(conflict_services, services_dict, request_c svc_url_old = svc_info["service_url"] if svc_url_old != svc_url_new: svc_info["service_url"] = svc_url_new - res_svc_put = requests.put(svc_url_db, data=svc_info, cookies=request_cookies) + res_svc_put = requests.patch(svc_url_db, data=svc_info, cookies=request_cookies) statuses[svc_name] = res_svc_put.status_code print_log("[{url_old}] => [{url_new}] Service URL update ({svc}): {resp}" .format(svc=svc_name, url_old=svc_url_old, url_new=svc_url_new, resp=res_svc_put.status_code), @@ -498,8 +503,8 @@ def _magpie_register_services_with_db_session(services_dict, db_session, push_to return True -def _load_config(path_or_dict, section): - # type: (Union[Str, ConfigDict], Str) -> ConfigDict +def _load_config(path_or_dict, section, allow_missing=False): + # type: (Union[Str, ConfigDict], Str, bool) -> ConfigDict """ Loads a file path or dictionary as YAML/JSON configuration. """ @@ -510,22 +515,26 @@ def _load_config(path_or_dict, section): cfg = path_or_dict return _expand_all(cfg[section]) except KeyError: - raise_log("Config file section [{!s}] not found.".format(section), exception=RegistrationError, logger=LOGGER) + msg = "Config file section [{!s}] not found.".format(section) + if allow_missing: + print_log(msg, level=logging.WARNING, logger=LOGGER) + return {} + raise_log(msg, exception=RegistrationError, logger=LOGGER) except Exception as exc: raise_log("Invalid config file [{!r}]".format(exc), exception=RegistrationError, logger=LOGGER) -def _get_all_configs(path_or_dict, section): - # type: (Union[Str, ConfigDict], Str) -> List[ConfigDict] +def get_all_configs(path_or_dict, section, allow_missing=False): + # type: (Union[Str, ConfigDict], Str, bool) -> List[ConfigDict] """ Loads all configuration files specified by the path (if a directory), a single configuration (if a file) or directly returns the specified dictionary section (if a configuration dictionary). :returns: - list of configurations loaded if input was a directory path - list of single configuration if input was a file path - list of single configuration if input was a JSON dict - empty list if none of the other cases where matched + - list of configurations loaded if input was a directory path + - list of single configuration if input was a file path + - list of single configuration if input was a JSON dict + - empty list if none of the other cases where matched .. note:: Order of file loading will be resolved by alphabetically sorted filename if specifying a directory path. @@ -536,11 +545,11 @@ def _get_all_configs(path_or_dict, section): known_extensions = [".cfg", ".yml", ".yaml", ".json"] cfg_names = list(sorted({fn for fn in os.listdir(dir_path) if any([fn.endswith(ext) for ext in known_extensions])})) - return [_load_config(os.path.join(dir_path, fn), section) for fn in cfg_names] + return [_load_config(os.path.join(dir_path, fn), section, allow_missing) for fn in cfg_names] if os.path.isfile(path_or_dict): - return [_load_config(path_or_dict, section)] + return [_load_config(path_or_dict, section, allow_missing)] elif isinstance(path_or_dict, dict): - return [_load_config(path_or_dict, section)] + return [_load_config(path_or_dict, section, allow_missing)] return [] @@ -559,7 +568,7 @@ def _expand_all(config): for i, cfg in enumerate(config): config[i] = _expand_all(cfg) elif isinstance(config, six.string_types): - config = os.path.expandvars(config) + config = os.path.expandvars(str(config)) elif isinstance(config, (int, bool, float)): pass else: @@ -577,7 +586,7 @@ def magpie_register_services_from_config(service_config_path, push_to_phoenix=Fa pushes updates to Phoenix. """ LOGGER.info("Starting services processing.") - services_configs = _get_all_configs(service_config_path, "providers") + services_configs = get_all_configs(service_config_path, "providers") services_config_count = len(services_configs) LOGGER.log(logging.INFO if services_config_count else logging.WARNING, "Found %s service configurations to process", services_config_count) @@ -608,7 +617,7 @@ def _log_permission(message, permission_index, trail=", skipping...", detail=Non """ Logs a message related to a 'permission' entry. - Log message format is as follows:: + Log message format is as follows (detail portion omitted if none provided):: {message} [permission: #{permission_index}] [{permission}]{trail} Detail: [{detail}] @@ -740,6 +749,8 @@ def _apply_permission_entry(permission_config_entry, # type: ConfigItem resource_id, # type: int cookies_or_session, # type: CookiesOrSessionType magpie_url, # type: Str + users, # type: ConfigDict + groups, # type: ConfigDict ): # type: (...) -> None """ Applies the single permission entry retrieved from the permission configuration. @@ -777,8 +788,8 @@ def _apply_session(_usr_name=None, _grp_name=None): """ # pylint: disable=C0415 # avoid circular imports # pylint: disable=R1705 # aligned methods are easier to read - from magpie.api.management.user import user_utils as ut from magpie.api.management.group import group_utils as gt + from magpie.api.management.user import user_utils as ut res = ResourceService.by_resource_id(resource_id, db_session=cookies_or_session) if _usr_name: @@ -798,30 +809,39 @@ def _apply_profile(_usr_name=None, _grp_name=None): """ Creates the user/group profile as required. """ - usr_data = {"user_name": _usr_name, "password": "12345", "email": "{}@mail.com".format(_usr_name), - "group_name": get_constant("MAGPIE_ANONYMOUS_GROUP")} + password = pseudo_random_string(length=get_constant("MAGPIE_PASSWORD_MIN_LENGTH")) + usr_data = { + "user_name": _usr_name, + "password": users.get(_usr_name, {}).get("password", password), + "email": users.get(_usr_name, {}).get("email", "{}@mail.com".format(_usr_name)), + "group_name": users.get(_usr_name, {}).get("group", get_constant("MAGPIE_ANONYMOUS_GROUP")) + } + grp_data = { + "group_name": _grp_name, + "description": groups.get(_grp_name, {}).get("description", ""), + "discoverable": groups.get(_grp_name, {}).get("discoverable", False) + } if _use_request(cookies_or_session): if _usr_name: path = "{url}{path}".format(url=magpie_url, path=UsersAPI.path) return requests.post(path, json=usr_data) if _grp_name: path = "{url}{path}".format(url=magpie_url, path=GroupsAPI.path) - data = {"group_name": _grp_name} - return requests.post(path, json=data) + return requests.post(path, json=grp_data) else: if _usr_name: from magpie.api.management.user.user_utils import create_user usr_data["db_session"] = cookies_or_session # back-compatibility python 2 cannot have kw after **unpack return create_user(**usr_data) if _grp_name: + grp_data["db_session"] = cookies_or_session # back-compatibility python 2 cannot have kw after **unpack from magpie.api.management.group.group_utils import create_group - return create_group(_grp_name, cookies_or_session) + return create_group(**grp_data) def _validate_response(operation, is_create, item_type="Permission"): """ - Validate action/operation applied. + Validate action/operation applied and handles raised ``HTTPException`` as returned response. """ - # handle HTTPException raised if not islambda(operation): raise TypeError("invalid use of method") try: @@ -857,15 +877,15 @@ def _validate_response(operation, is_create, item_type="Permission"): grp_name = permission_config_entry.get("group") perm = Permission.get(perm_name) - _validate_response(lambda: _apply_profile(usr_name, None), is_create=True) + # process groups first as they can be referenced by user definitions _validate_response(lambda: _apply_profile(None, grp_name), is_create=True) - + _validate_response(lambda: _apply_profile(usr_name, None), is_create=True) if _use_request(cookies_or_session): - _validate_response(lambda: _apply_request(usr_name, None), is_create=create_perm) _validate_response(lambda: _apply_request(None, grp_name), is_create=create_perm) + _validate_response(lambda: _apply_request(usr_name, None), is_create=create_perm) else: - _validate_response(lambda: _apply_session(usr_name, None), is_create=create_perm) _validate_response(lambda: _apply_session(None, grp_name), is_create=create_perm) + _validate_response(lambda: _apply_session(usr_name, None), is_create=create_perm) def magpie_register_permissions_from_config(permissions_config, magpie_url=None, db_session=None): @@ -893,18 +913,39 @@ def magpie_register_permissions_from_config(permissions_config, magpie_url=None, cookies_or_session = db_session LOGGER.debug("Loading configurations.") - permissions = _get_all_configs(permissions_config, "permissions") + permissions = get_all_configs(permissions_config, "permissions") perms_cfg_count = len(permissions) LOGGER.log(logging.INFO if perms_cfg_count else logging.WARNING, "Found %s permissions configurations.", perms_cfg_count) + users = groups = None + if perms_cfg_count: + users = get_all_configs(permissions_config, "users", allow_missing=True) + groups = get_all_configs(permissions_config, "groups", allow_missing=True) for i, perms in enumerate(permissions): LOGGER.info("Processing permissions from configuration (%s/%s).", i + 1, perms_cfg_count) - _process_permissions(perms, magpie_url, cookies_or_session) + _process_permissions(perms, magpie_url, cookies_or_session, users, groups) LOGGER.info("All permissions processed.") -def _process_permissions(permissions, magpie_url, cookies_or_session): - # type: (ConfigDict, Str, Session) -> None +def _make_config_registry(config_entries, key): + # type: (Optional[ConfigList], Str) -> ConfigDict + """ + Converts a list of configurations entries into a single mapping of configurations based on :paramref:`key`. + + First configuration entries have priority over later ones if keys are duplicated. + """ + config_map = {} + for cfg in config_entries or []: + if not cfg: + continue + cfg_key = cfg.get(key, None) + if cfg_key: + config_map.setdefault(cfg_key, cfg) + return config_map + + +def _process_permissions(permissions, magpie_url, cookies_or_session, users=None, groups=None): + # type: (ConfigDict, Str, Session, Optional[ConfigList], Optional[ConfigList]) -> None """ Processes a single `permissions` configuration. """ @@ -912,6 +953,9 @@ def _process_permissions(permissions, magpie_url, cookies_or_session): LOGGER.warning("Permissions configuration are empty.") return + users_conf = _make_config_registry(users, "username") + groups_conf = _make_config_registry(groups, "name") + perm_count = len(permissions) LOGGER.log(logging.INFO if perm_count else logging.WARNING, "Found %s permissions to evaluate from configuration.", perm_count) @@ -958,8 +1002,17 @@ def _process_permissions(permissions, magpie_url, cookies_or_session): if found: if not resource_id: resource_id = service_info["resource_id"] - _apply_permission_entry(perm_cfg, i, resource_id, cookies_or_session, magpie_url) + _apply_permission_entry(perm_cfg, i, resource_id, cookies_or_session, magpie_url, users_conf, groups_conf) if not _use_request(cookies_or_session): transaction.commit() LOGGER.info("Done processing permissions configuration.") + + +def pseudo_random_string(length=8, allow_chars=string.ascii_letters + string.digits): + # type: (int, Str) -> Str + """ + Generate a string made of random characters. + """ + rnd = random.SystemRandom() + return "".join(rnd.choice(allow_chars) for _ in range(length)) diff --git a/magpie/security.py b/magpie/security.py index 8d7447fcc..72c74d342 100644 --- a/magpie/security.py +++ b/magpie/security.py @@ -11,16 +11,22 @@ from magpie.api.login import esgfopenid, wso2 from magpie.constants import get_constant +from magpie.models import RootFactory from magpie.utils import get_logger, get_settings if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import JSON # noqa: F401 + from magpie.typedefs import JSON, AnySettingsContainer + AUTHOMATIC_LOGGER = get_logger("magpie.authomatic", level=logging.DEBUG) -LOGGER = get_logger("magpie.security") +LOGGER = get_logger(__name__) def get_auth_config(container): + # type: (AnySettingsContainer) -> Configurator + """ + Generates Magpie application configuration with all utilities required for security and access control. + """ settings = get_settings(container) magpie_secret = get_constant("MAGPIE_SECRET", settings, settings_name="magpie.secret") magpie_cookie_expire = get_constant("MAGPIE_COOKIE_EXPIRE", settings, @@ -46,11 +52,9 @@ def get_auth_config(container): # create configurator or use one defined as input to preserve previous setup/include/etc. config = Configurator() if not isinstance(container, Configurator) else container - - from magpie import models # pylint: disable=C0415 # avoid circular import config.setup_registry( settings=settings, - root_factory=models.RootFactory, + root_factory=RootFactory, authentication_policy=authn_policy, authorization_policy=authz_policy ) diff --git a/magpie/services.py b/magpie/services.py index 3821b6bde..d6fd8e047 100644 --- a/magpie/services.py +++ b/magpie/services.py @@ -1,10 +1,9 @@ from typing import TYPE_CHECKING +import six from beaker.cache import cache_region, cache_regions from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNotFound, HTTPNotImplemented -from pyramid.security import Allow as ALLOW -from pyramid.security import Everyone as EVERYONE # noqa -from six import with_metaclass +from pyramid.security import Allow, Everyone from ziggurat_foundations.models.services.resource import ResourceService from ziggurat_foundations.models.services.user import UserService from ziggurat_foundations.permissions import permission_to_pyramid_acls @@ -17,11 +16,12 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from magpie.typedefs import ( # noqa: F401 - AccessControlListType, Str, List, Dict, Type, ResourcePermissionType - ) + from typing import Dict, List, Type + from pyramid.request import Request + from magpie.typedefs import AccessControlListType, ResourcePermissionType, Str + class ServiceMeta(type): @property @@ -57,7 +57,8 @@ def get_resource_permissions(cls, resource_type_name): return [] -class ServiceInterface(with_metaclass(ServiceMeta)): +@six.add_metaclass(ServiceMeta) +class ServiceInterface(object): # required service type identifier (unique) service_type = None # type: Str # required request parameters for the service @@ -68,8 +69,9 @@ class ServiceInterface(with_metaclass(ServiceMeta)): resource_types_permissions = {} # type: Dict[models.Resource, List[Permission]] def __init__(self, service, request): - self.service = service - self.request = request + # type: (models.Service, Request) -> None + self.service = service # type: models.Service + self.request = request # type: Request self.acl = [] # type: AccessControlListType self.parser = ows_parser_factory(request) self.parser.parse(self.params_expected) @@ -113,12 +115,12 @@ def expand_acl(self, resource, user): raise Exception("No Anonymous user in the database") permissions = ResourceService.perms_for_user(resource, user, db_session=self.request.db) for outcome, perm_user, perm_name in permission_to_pyramid_acls(permissions): - self.acl.append((outcome, EVERYONE, perm_name,)) + self.acl.append((outcome, Everyone, perm_name,)) def permission_requested(self): # type: () -> Permission try: - req = self.parser.params[u"request"] + req = self.parser.params["request"] perm = Permission.get(req) if perm is None: raise NotImplementedError("Undefined 'Permission' from 'request' parameter: {!s}".format(req)) @@ -130,8 +132,8 @@ def permission_requested(self): def effective_permissions(self, resource, user): # type: (models.Resource, models.User) -> List[ResourcePermissionType] """ - Recursively rewind the resource tree from the specified resource up to the topmost parent service resource and - retrieve permissions along the way that should be applied to children when using resource inheritance. + Recursively rewind the resource tree from the specified resource up to the top-most parent service's resource + and retrieve permissions along the way that should be applied to children when using resource inheritance. """ resource_effective_perms = list() while resource is not None: @@ -145,7 +147,7 @@ def effective_permissions(self, resource, user): class ServiceWPS(ServiceInterface): - service_type = u"wps" + service_type = "wps" permissions = [ Permission.GET_CAPABILITIES, @@ -154,9 +156,9 @@ class ServiceWPS(ServiceInterface): ] params_expected = [ - u"service", - u"request", - u"version" + "service", + "request", + "version" ] resource_types_permissions = {} @@ -179,12 +181,12 @@ class ServiceBaseWMS(ServiceInterface): ] params_expected = [ - u"service", - u"request", - u"version", - u"layers", - u"layername", - u"dataset" + "service", + "request", + "version", + "layers", + "layername", + "dataset" ] resource_types_permissions = { @@ -205,7 +207,7 @@ def get_acl(self): class ServiceNCWMS2(ServiceBaseWMS): - service_type = u"ncwms" + service_type = "ncwms" resource_types_permissions = { models.File: [ @@ -255,7 +257,7 @@ def get_acl(self): netcdf_file = netcdf_file.rsplit("/", 1)[0] else: - return [(ALLOW, EVERYONE, permission_requested.value,)] + return [(Allow, Everyone, permission_requested.value,)] if netcdf_file: ax.verify_param("outputs/", param_compare=netcdf_file, http_error=HTTPNotFound, @@ -276,7 +278,7 @@ def get_acl(self): class ServiceGeoserverWMS(ServiceBaseWMS): - service_type = u"geoserverwms" + service_type = "geoserverwms" def __init__(self, service, request): super(ServiceGeoserverWMS, self).__init__(service, request) @@ -314,7 +316,7 @@ def get_acl(self): class ServiceAccess(ServiceInterface): - service_type = u"access" + service_type = "access" permissions = [Permission.ACCESS] @@ -334,7 +336,7 @@ def permission_requested(self): class ServiceAPI(ServiceInterface): - service_type = u"api" + service_type = "api" permissions = models.Route.permissions @@ -397,7 +399,7 @@ def effective_permissions(self, resource, user): class ServiceWFS(ServiceInterface): - service_type = u"wfs" + service_type = "wfs" permissions = [ Permission.GET_CAPABILITIES, @@ -408,10 +410,10 @@ class ServiceWFS(ServiceInterface): ] params_expected = [ - u"service", - u"request", - u"version", - u"typenames" + "service", + "request", + "version", + "typenames" ] resource_types_permissions = {} @@ -443,7 +445,7 @@ def get_acl(self): class ServiceTHREDDS(ServiceInterface): - service_type = u"thredds" + service_type = "thredds" permissions = [ Permission.READ, @@ -451,7 +453,7 @@ class ServiceTHREDDS(ServiceInterface): ] params_expected = [ - u"request" + "request" ] resource_types_permissions = { @@ -507,12 +509,12 @@ def service_factory(service, request): Retrieve the specific service class from the provided database service entry. """ ax.verify_param(service, param_compare=models.Service, is_type=True, - http_error=HTTPBadRequest, content={u"service": repr(service)}, + http_error=HTTPBadRequest, content={"service": repr(service)}, msg_on_fail="Cannot process invalid service object") service_type = ax.evaluate_call(lambda: service.type, http_error=HTTPInternalServerError, msg_on_fail="Cannot retrieve service type from object") ax.verify_param(service_type, is_in=True, param_compare=SERVICE_TYPE_DICT.keys(), - http_error=HTTPNotImplemented, content={u"service_type": service_type}, + http_error=HTTPNotImplemented, content={"service_type": service_type}, msg_on_fail="Undefined service type mapping to service object") return ax.evaluate_call(lambda: SERVICE_TYPE_DICT[service_type](service, request), http_error=HTTPInternalServerError, diff --git a/magpie/typedefs.py b/magpie/typedefs.py index 3cc8fae0c..1f8bc630c 100644 --- a/magpie/typedefs.py +++ b/magpie/typedefs.py @@ -6,25 +6,27 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import ( # noqa: F401,W0212 - Any, AnyStr as _AnyStr, Callable, Dict, List, Iterable, Optional, Tuple, Type, Union # noqa: F401,W0212 - ) - from sqlalchemy.orm.session import Session - from magpie import models - from magpie.permissions import Permission - from webob.headers import ResponseHeaders, EnvironHeaders - from webob.response import Response as WebobResponse - from webtest.response import TestResponse - from webtest.app import TestApp - from requests.cookies import RequestsCookieJar - from pyramid.response import Response as PyramidResponse + from typing import Any + from typing import AnyStr as _AnyStr + from typing import Dict, Iterable, List, Tuple, Union + + import six + from pyramid.config import Configurator + from pyramid.httpexceptions import HTTPException from pyramid.registry import Registry from pyramid.request import Request - from pyramid.config import Configurator + from pyramid.response import Response as PyramidResponse + from requests.cookies import RequestsCookieJar from requests.structures import CaseInsensitiveDict - from logging import Logger as LoggerType # noqa: F401 - from tests.interfaces import Base_Magpie_TestCase - import six + from sqlalchemy.orm.session import Session + from webob.headers import EnvironHeaders, ResponseHeaders + from webob.response import Response as WebobResponse + from webtest.response import TestResponse + + from magpie import models + from magpie.permissions import Permission + + # pylint: disable=W0611,unused-import # following definitions provided to be employed elsewhere in the code if six.PY2: # pylint: disable=E0602,undefined-variable # unicode not recognized by python 3 @@ -41,21 +43,24 @@ ParamsType = Dict[Str, Any] CookiesType = Union[Dict[Str, Str], List[Tuple[Str, Str]]] HeadersType = Union[Dict[Str, Str], List[Tuple[Str, Str]]] - OptionalHeaderCookiesType = Union[Tuple[None, None], Tuple[HeadersType, CookiesType]] AnyHeadersType = Union[HeadersType, ResponseHeaders, EnvironHeaders, CaseInsensitiveDict] - AnyResponseType = Union[WebobResponse, PyramidResponse, TestResponse] + AnyCookiesType = Union[CookiesType, RequestsCookieJar] + AnyResponseType = Union[WebobResponse, PyramidResponse, HTTPException, TestResponse] CookiesOrSessionType = Union[RequestsCookieJar, Session] AnyKey = Union[Str, int] AnyValue = Union[Str, Number, bool, None] BaseJSON = Union[AnyValue, List["BaseJSON"], Dict[AnyKey, "BaseJSON"]] - JSON = Dict[AnyKey, BaseJSON] + JSON = Union[Dict[AnyKey, Union[BaseJSON, "JSON"]], List[BaseJSON]] + + # recursive nodes structure employed by functions for listing children resources hierarchy + # {: {"node": , "children": {: ... }} + ChildrenResourceNodes = Dict[int, Dict[Str, Union[models.Resource, "ChildrenResourceNodes"]]] + ResourcePermissionMap = Dict[int, List[Str]] # raw mapping of permission-names applied per resource ID UserServicesType = Union[Dict[Str, Dict[Str, Any]], List[Dict[Str, Any]]] ServiceOrResourceType = Union[models.Service, models.Resource] ResourcePermissionType = Union[models.GroupPermission, models.UserPermission] AnyPermissionType = Union[Permission, ResourcePermissionType, Str] - AccessControlListType = List[Tuple[Str, Str, Str]] - - TestAppOrUrlType = Union[Str, TestApp] - AnyMagpieTestType = Union[Type[Base_Magpie_TestCase], Base_Magpie_TestCase, TestAppOrUrlType] + AnyAccessPrincipalType = Union[Str, Iterable[Str]] + AccessControlListType = List[Union[Tuple[Str, Str, Str], Str]] diff --git a/magpie/ui/__init__.py b/magpie/ui/__init__.py index b6e5258a4..58c39b439 100644 --- a/magpie/ui/__init__.py +++ b/magpie/ui/__init__.py @@ -7,6 +7,7 @@ def includeme(config): LOGGER.info("Adding UI routes...") # Add all the admin ui routes - config.include("magpie.ui.login") config.include("magpie.ui.home") + config.include("magpie.ui.login") config.include("magpie.ui.management") + config.include("magpie.ui.user") diff --git a/magpie/ui/home/__init__.py b/magpie/ui/home/__init__.py index 3b81e3d7f..6b01ba418 100644 --- a/magpie/ui/home/__init__.py +++ b/magpie/ui/home/__init__.py @@ -1,31 +1,12 @@ -from pyramid.authentication import Authenticated, IAuthenticationPolicy - from magpie.utils import get_logger LOGGER = get_logger(__name__) -def add_template_data(request, data=None): - all_data = data or {} - magpie_logged_user = None - - try: - authn_policy = request.registry.queryUtility(IAuthenticationPolicy) - principals = authn_policy.effective_principals(request) - - if Authenticated in principals: - LOGGER.info("User '%s' is authenticated", request.user.user_name) - magpie_logged_user = request.user.user_name - except AttributeError: - pass - - if magpie_logged_user: - all_data.update({u"MAGPIE_LOGGED_USER": magpie_logged_user}) - return all_data - - def includeme(config): - LOGGER.info("Adding home...") + LOGGER.info("Adding UI home...") config.add_route("home", "/") - config.add_static_view("static", "static", cache_max_age=3600) + config.add_route("home_ui", "/ui") + config.add_route("error", "/ui/error") + config.add_static_view("static", "static/", cache_max_age=3600) config.scan() diff --git a/magpie/ui/home/static/account.png b/magpie/ui/home/static/account.png new file mode 100644 index 000000000..633f6f31e Binary files /dev/null and b/magpie/ui/home/static/account.png differ diff --git a/magpie/ui/home/static/add.png b/magpie/ui/home/static/add.png index 215cda07c..c8050c819 100644 Binary files a/magpie/ui/home/static/add.png and b/magpie/ui/home/static/add.png differ diff --git a/magpie/ui/home/static/exclamation-circle.png b/magpie/ui/home/static/exclamation-circle.png new file mode 100755 index 000000000..4121e07e7 Binary files /dev/null and b/magpie/ui/home/static/exclamation-circle.png differ diff --git a/magpie/ui/home/static/exclamation-triangle.png b/magpie/ui/home/static/exclamation-triangle.png new file mode 100755 index 000000000..9ccf18b05 Binary files /dev/null and b/magpie/ui/home/static/exclamation-triangle.png differ diff --git a/magpie/ui/home/static/forbidden.png b/magpie/ui/home/static/forbidden.png new file mode 100644 index 000000000..e7f5d5e9a Binary files /dev/null and b/magpie/ui/home/static/forbidden.png differ diff --git a/magpie/ui/home/static/groups.png b/magpie/ui/home/static/groups.png index c482a5e0b..b4ce22278 100644 Binary files a/magpie/ui/home/static/groups.png and b/magpie/ui/home/static/groups.png differ diff --git a/magpie/ui/home/static/login.png b/magpie/ui/home/static/login.png new file mode 100644 index 000000000..aaf3cf3ec Binary files /dev/null and b/magpie/ui/home/static/login.png differ diff --git a/magpie/ui/home/static/logout.png b/magpie/ui/home/static/logout.png new file mode 100644 index 000000000..45ec7a220 Binary files /dev/null and b/magpie/ui/home/static/logout.png differ diff --git a/magpie/ui/home/static/services.png b/magpie/ui/home/static/services.png index aea6ffb76..4da6b0139 100644 Binary files a/magpie/ui/home/static/services.png and b/magpie/ui/home/static/services.png differ diff --git a/magpie/ui/home/static/style.css b/magpie/ui/home/static/style.css index 77768b34d..8c60ad612 100644 --- a/magpie/ui/home/static/style.css +++ b/magpie/ui/home/static/style.css @@ -1,63 +1,88 @@ -html,body { +/* +Note: + Theme must be loaded before this file. + They are employed for buttons, headers, etc. unless overridden here for specific use-case. +*/ + +html, +body { margin:0; padding:0; border:0; - font-family: "Open Sans"; + font-family: "Open Sans", sans-serif; } .content { margin: 5em; } -.img_button { +.version-box { + position: absolute; + right: 0; +} + +.version-box>div{ + font-size: 75%; + display: inline-block; + position: relative; + bottom: 1em; + right: 1em; +} + +.version-title { + color: gray; +} + +.version-tag { +} + +.img-button { padding: 0.7em; - margin: 1em 0em 1em; + margin: 1em 0 1em; +} + +.img-button>img { + color: black; + vertical-align: -0.1em; } a { - color: #337ab7; + color: #337AB7; text-decoration: none; } -a:focus, a:hover { - color: #23527c; +a:focus, +a:hover { + color: #23527C; text-decoration: underline; } -input[type="submit"].img_button.disabled, -input[type="submit"].img_button.disabled:hover, -input[type="submit"].img_button.disabled:focus, -input[type="button"].img_button.disabled, -input[type="button"].img_button.disabled:hover, -input[type="button"].img_button.disabled:focus, +input[type="submit"].img-button.disabled, +input[type="button"].img-button.disabled, input[type="submit"].button.disabled, -input[type="submit"].button.disabled:hover, -input[type="submit"].button.disabled:focus, -input[type="button"].button.disabled, -input[type="button"].button.disabled:hover, -input[type="button"].button.disabled:focus { - border: 1px solid #808080; - background-color: #DDDDDD; - color: #808080; +input[type="button"].button.disabled { + color: currentColor; /* inherit from theme */ + opacity: 50%; vertical-align: center; - cursor:not-allowed; + cursor: not-allowed; + pointer-events: none; } -input[type="button"].img_button.delete, -input[type="submit"].img_button.delete, +input[type="button"].img-button.delete, +input[type="submit"].img-button.delete, input[type="button"].button.delete, input[type="submit"].button.delete { border: 1px solid #8B3A3A; background-color: #CD0000; color: white; vertical-align: center; - cursor:pointer; + cursor: pointer; } -input[type="button"].img_button.delete:hover, -input[type="button"].img_button.delete:focus, -input[type="submit"].img_button.delete:hover, -input[type="submit"].img_button.delete:focus, +input[type="button"].img-button.delete:hover, +input[type="button"].img-button.delete:focus, +input[type="submit"].img-button.delete:hover, +input[type="submit"].img-button.delete:focus, input[type="button"].button.delete:hover, input[type="button"].button.delete:focus, input[type="submit"].button.delete:hover, @@ -65,21 +90,21 @@ input[type="submit"].button.delete:focus { background-color: #EE6363; } -input[type="button"].img_button.warning, -input[type="submit"].img_button.warning, -input[type="button"].button.warning, -input[type="submit"].button.warning { +input[type="button"].img-button-warning, +input[type="submit"].img-button-warning, +input[type="button"].button-warning, +input[type="submit"].button-warning { border: 1px solid #8B3A3A; background-color: #CD6600; color: white; vertical-align: center; - cursor:pointer; + cursor: pointer; } -input[type="button"].img_button.warning:hover, -input[type="button"].img_button.warning:focus, -input[type="submit"].img_button.warning:hover, -input[type="submit"].img_button.warning:focus, +input[type="button"].img-button.warning:hover, +input[type="button"].img-button.warning:focus, +input[type="submit"].img-button.warning:hover, +input[type="submit"].img-button.warning:focus, input[type="button"].button.warning:hover, input[type="button"].button.warning:focus, input[type="submit"].button.warning:hover, @@ -87,8 +112,8 @@ input[type="submit"].button.warning:focus { background-color: #FFAA33; } -input[type="button"].img_button.cancel, -input[type="submit"].img_button.cancel, +input[type="button"].img-button.cancel, +input[type="submit"].img-button.cancel, input[type="button"].button.cancel, input[type="submit"].button.cancel { border: 1px solid #808080; @@ -96,29 +121,30 @@ input[type="submit"].button.cancel { color: #444444; } +input[type="button"].img-button.cancel:focus, +input[type="button"].img-button.cancel:hover, +input[type="submit"].img-button.cancel:focus, +input[type="submit"].img-button.cancel:hover, +input[type="button"].button.cancel:focus, +input[type="button"].button.cancel:hover, +input[type="submit"].button.cancel:focus, +input[type="submit"].button.cancel:hover { + background-color: #8A8A8A; +} + a.tab:link, a.tab:visited, -.img_button, -.admin_button, +.img-button, +.admin-button, input[type="submit"], input[type="submit"].button.normal { - border: 1px solid #2C8F30; - background-color: #4CAF50; vertical-align: center; - cursor:pointer; + cursor: pointer; text-decoration: none; color: #FFFFFF; } -a.tab:hover, a.tab:active, -.img_button:hover, .img_button:focus, -.admin_button:hover, .admin_button:active, -input[type="submit"]:hover, input[type="submit"]:focus { - background-color: #5CBF60; - text-decoration: none; -} - -.img_button, +.img-button, input[type="button"], input[type="submit"] { border-radius: 3px; @@ -133,26 +159,27 @@ input[type="submit"] { height: 1.5em; } -.img_button>img { +.img-button>img { height: 1em; } table { font-family: arial, sans-serif; border-collapse: collapse; - width: 100%; + width: auto; } td, th { border: 1px solid #dddddd; - padding: 8px; + padding: 0.5em; } -tr:nth-child(even) { - /*background-color: #dddddd;*/ +table thead { + color: white; + font-weight: bold; } -.checkbox_align label { +.checkbox-align label { display: block; /*float: left;*/ padding-left: 0.2em; @@ -160,60 +187,87 @@ tr:nth-child(even) { padding-bottom: 1em; white-space: nowrap; } -.checkbox_align input { +.checkbox-align input { vertical-align: middle; } -.checkbox_align label span { +.checkbox-align label span { vertical-align: middle; } /*---Panel Information Box---*/ -.panel_box { - background-color: #fff; +.panel-body { + padding: 0.5em; +} + +.panel-box { + background-color: #FFFFFF; border: 1px solid; border-radius: 4px; - border-color: #ddd; margin-top: 10px; padding: 1px; } -.panel_heading { - background-color: #f5f5f5; - border-bottom: 1px solid transparent; +.panel-heading { + border-bottom: 1px solid #333333; border-top-left-radius: 3px; border-top-right-radius: 3px; - border-color: #ddd; - color: #333; padding: 10px 15px; + font-weight: bold; +} + +.subsection { + background-color: #CCCCCC; } -.panel_heading_button { - float: right!important; +.panel-heading-button { + float: right; margin-top: -3px; } -.panel_body { - padding: 15px; +.panel-fields { + padding: 0.5em; } -.panel_line { +.panel-line { margin: 0.5em; } -.panel_line input[type="submit"] { +table.panel-line { + margin: 0.25em; +} + +table.panel-line th, +table.panel-line td { + border: 0; + white-space: nowrap; + padding: 0; /* override global th/td */ +} + +.panel-error { + margin-left: 0.5em; +} + +.panel-line input[type="submit"] { height: auto; } -.panel_title { +.panel-line-checkbox { + vertical-align: middle; + display: inline-flex; + margin-top: 0; + margin-bottom: 0; +} + +.panel-title { font-weight: bold; } -.panel_entry { +.panel-entry { font-weight: bold; } -.panel_value { +.panel-value { } /*---Labels---*/ @@ -232,19 +286,19 @@ tr:nth-child(even) { background-color: #5BC0DE; /*info default*/ } -.label.danger { +.label-danger { background-color: #CD0000; } -.label.warning { +.label-warning { background-color: #F0AD4E; } -.label.info { +.label-info { background-color: #5BC0DE; } -.label.success { +.label-success { background-color: #4CAF50; } @@ -260,59 +314,60 @@ tr:nth-child(even) { background-color: #2196F3; /*info default*/ } -.alert.danger { +.alert-danger { background-color: #F44336; } -.alert.warning { +.alert-warning { background-color: #FF9900; } -.alert.info { +.alert-info { background-color: #2196F3; } -.alert.success { +.alert-success { background-color: #4CAF50; } -.alert.danger.visible { +.alert-visible { display: block; /*override hidden default*/ } -.alert.warning.visible { - display: block; /*override hidden default*/ +.alert-title { + margin: 0; } -.alert.info.visible { - display: block; /*override hidden default*/ +.alert-title-danger { + color: #660000; } -.alert.success.visible { - display: block; /*override hidden default*/ +.alert-title-warning { + color: #994400; } -.alert_title { - margin: 0; +.alert-title-info { + color: #FFFFFF; } -.alert_title.danger { - color: #660000; +.alert-title-success { + color: #FFFFFF; } -.alert_title.warning { - color: #994400; +.alert-info { + display: inline-flex; + text-align: center; } -.alert_title.info { - color: #FFFFFF; +.alert-info>img { + margin: 0.10em 0.25em 0.15em 0; } -.alert_title.success { - color: #FFFFFF; +.alert-text { + } -.alert_button { +.alert-button { margin-left: 15px; color: white; font-weight: bold; @@ -323,38 +378,76 @@ tr:nth-child(even) { transition: 0.3s; } -.alert_button:hover { +.alert-button:hover { color: black; } -.alert_form_error>img { - width: 1.5em; - height: 1.5em; - margin: -0.25em 0 0 0; +.alert-form-error>img { + width: 1.25em; + height: 1.25em; + margin: -0.05em 0.25em 0.15em 0; } -.alert_form_error { - width: 100px; - color: red; +.alert-form-error { + width: auto; display: inline-flex; float: left; text-align: center; - margin: 0.25em 0 0 0; + margin-top: 0.25em; +} + +.alert-form-text { + color: #CD0000; +} + +.alert-form-align { + float: left; + padding: 0 0.5em 0 0; +} + +.icon-error { + width: 1.25em; + height: 1.25em; +} + +.icon-warning { + width: 1.25em; + height: 1.25em; +} + +.icon-color-invert { + filter: saturate(0) invert(100%); + -webkit-filter: saturate(0) invert(100%); } -/*---View users page ---*/ +/*---View users & groups pages ---*/ -.simple_list_table tr th:first-child, - tr td:first-child { +table.simple-list { + width: 100%; +} + +/* note: use explicit row classes instead of 'tr:nth-child(even)' because generated code doesn't handle it well */ +table.simple-list tr.list-row-odd { + background-color: white; +} +table.simple-list tr.list-row-even { + background-color: #F2F2F2; +} + +table.simple-list tr, +table.simple-list td:first-child, +table.simple-list th:first-child { width: 75%; text-align: left; } -.simple_list_table tr, th:not(:first-child) { +table.simple-list td:not(:first-child), +table.simple-list th:not(:first-child) { text-align: center; } -.simple_list_table input[type="submit"] { +table.simple-list input[type="button"], +table.simple-list input[type="submit"]{ margin: 0 0.5em; } @@ -364,8 +457,8 @@ tr:nth-child(even) { background: white; } -.tree_header { - padding: 5em 0em 2em; +.tree-header { + padding: 5em 0 2em; font-weight: bold; } @@ -377,20 +470,20 @@ tr:nth-child(even) { } .Collapsable input { - float:left; + float: left; padding: 5px 100px; border: 1px solid #eee; - cursor:pointer; + cursor: pointer; } -div.tree_item { - display:block; - float:left; +div.tree-item { + display: block; + float: left; } -div.perm_title { - width:3em; - float:right; +div.perm-title { + width: 3em; + float: right; text-align: left; -webkit-transform: rotate(-70deg); /* Safari */ @@ -399,11 +492,11 @@ div.perm_title { -o-transform: rotate(-70deg); /* Opera */ } -.tree_button.goto_service { +.tree-button.goto-service { margin: 0.1em 0 0 -4em; } -.tree_item_message { +.tree-item-message { text-align: left; color: gray; display: inline-flex; @@ -411,20 +504,20 @@ div.perm_title { margin: 0.25em 0 0 0; } -.tree_item_message>img { +.tree-item-message>img { width: 1.5em; height: 1.5em; margin: -0.15em 0 0 0; } -div.perm_checkbox { +div.perm-checkbox { width:3em; float:right; text-align: left; margin: 0.1em 0 0 0; } -div.tree_button { +div.tree-button { width:5em; float:right; text-align: center; @@ -461,8 +554,8 @@ ul.breadcrumb li a:hover { /*---Header---*/ +/*noinspection CssInvalidFunction*/ .header { - background: #000000; background: black; /* For browsers that do not support gradients */ background: -webkit-gradient(bottom top, black, gray); /* Chrome, Safari4+ */ background: -webkit-linear-gradient(left bottom, black, gray); /* Chrome10+, Safari5.1+ */ @@ -470,14 +563,14 @@ ul.breadcrumb li a:hover { background: -moz-linear-gradient(bottom top, black, gray); /* Firefox 3.6 to 15 */ background: -ms-linear-gradient(bottom top, black, gray); /* IE10+ */ background: linear-gradient(to bottom top, black, gray); /* Standard syntax (must be last) */ - padding: 1em 1em 0em 1em; + padding: 1em 1em 0 1em; } .header{ vertical-align: center; } -.header>div>a{ +.header>div>a { color: white; font-size: 3em; text-decoration: none; @@ -485,36 +578,36 @@ ul.breadcrumb li a:hover { vertical-align: center; } -.header>button{ +.header>button { height: 40px; width: 100px; float: right; } -#title_header { +#title-header { + display: inline-block; position: relative; - width: 600px; left: 0.25em; } -#image_container { +#image-container { float: left; position: relative; width: 64px; height: 64px; } -#image_background { +#image-background { width: inherit; height: inherit; } -#image_background>img { +#image-background>img { width: inherit; height: inherit; } -#image_overlay { +#image-overlay { position: absolute; width: inherit; height: inherit; @@ -522,7 +615,7 @@ ul.breadcrumb li a:hover { top: 10px; } -#image_overlay>img { +#image-overlay>img { width: inherit; height: inherit; } @@ -535,56 +628,90 @@ ul.breadcrumb li a:hover { 60em number from margins and button sizes */ @media only screen and (max-width: 60em) { - .admin_content { + .admin-content { display: inline; } } -.admin_content { +.admin-content { width: 100%; text-align: center; } -.admin_button { - margin: auto; - width: 100%; -} - -.admin_button, -.admin_button:focus, -.admin_button:hover, -.admin_button:active { +.admin-button, +.admin-button:focus, +.admin-button:hover, +.admin-button:active { color: #000000; } -.admin_button { +.admin-button { display:inline-block; box-sizing: border-box; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); width: 10em; + height: 10em; margin: 3em; padding: 1em; text-align: center; text-decoration: none; - background: white; - color: black; + /* use separate border values to inherit the theme color */ border-radius: 3px; - height: 10em; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); + border-width: 1px; + border-style: solid; + font-weight: bold; } -.admin_button>img { +.admin-button>img { height: 60%; padding: 5%; } +.button-link { + border: none; + outline: none; + background: none; + cursor: pointer; + color: #0000EE; + padding: 0; + text-decoration: underline; + font-family: inherit; + font-size: inherit; +} + +.img-error { + width: 24px; + height: 24px; + vertical-align: middle; +} + +.error div { + text-align: center; +} + +table.request-details { + text-align: left; + width: 60%; + margin-left:auto; + margin-right:auto; +} + +table.request-details td:first-child { + white-space: nowrap; +} + +table.request-details thead tbody td th { + text-align: left; +} + /*---Service tabs---*/ -.tabs_panel { - background: #ddd; +.tabs-panel { + background: #DDDDDD; padding: 1px; } -.current_tab_panel { +.current-tab-panel { background: white; padding: 1em; margin: 0; @@ -592,7 +719,8 @@ ul.breadcrumb li a:hover { min-height: 10em; } -a.current_tab:link, a.current_tab:visited { +a.current-tab:link, +a.current-tab:visited { background-color: white; color: black; padding: 1em 1em 1.4em 1em; @@ -602,7 +730,8 @@ a.current_tab:link, a.current_tab:visited { font-weight: bold; } -a.tab:link, a.tab:visited { +a.tab:link, +a.tab:visited { padding: 1em; text-align: center; text-decoration: none; @@ -611,27 +740,33 @@ a.tab:link, a.tab:visited { /*---service details---*/ -table.service_details { +table.service-details { table-layout: fixed; } -/*---field_form---*/ +/*---field form---*/ -.new_item_form { +.new-item-form { margin: auto; width: 30%; min-width: 500px; } -table.fields_table th, table.fields_table td { +table.fields-table { + width: auto; +} + +table.fields-table th, +table.fields-table td { border: 0; + white-space: nowrap; } -table.fields_table td.centered { +table.fields-table td.centered { text-align: center; } -table.fields_table input[type="radio"] { +table.fields-table input[type="radio"] { margin-left: 2em; } @@ -639,9 +774,9 @@ table.fields_table input[type="radio"] { support different browsers using an higher level container ---*/ -select.equal_width, -option.equal_width, -input.equal_width { +select.equal-width, +option.equal-width, +input.equal-width { width: 250px; /*-webkit-box-sizing: border-box;*/ -moz-box-sizing: border-box; @@ -649,7 +784,7 @@ input.equal_width { float: left; } -div.input_container { +div.input-container { width: 100%; height:100%; -webkit-box-sizing: border-box; diff --git a/magpie/ui/home/static/themes/blue.css b/magpie/ui/home/static/themes/blue.css new file mode 100644 index 000000000..dffb24676 --- /dev/null +++ b/magpie/ui/home/static/themes/blue.css @@ -0,0 +1,19 @@ +.theme { + border: 1px solid #2C548F; + background-color: #4C81AF; + color: white; +} + +a.tab:hover, +a.tab:active, +.img-button:hover, +.img-button:focus, +.admin-button:hover, +.admin-button:active, +input[type="submit"]:hover, +input[type="submit"]:focus, +input[type="button"]:hover, +input[type="button"]:focus { + background-color: #5CA3BF; + text-decoration: none; +} diff --git a/magpie/ui/home/static/themes/green.css b/magpie/ui/home/static/themes/green.css new file mode 100644 index 000000000..a933aad68 --- /dev/null +++ b/magpie/ui/home/static/themes/green.css @@ -0,0 +1,19 @@ +.theme { + border: 1px solid #2C8F30; + background-color: #4CAF50; + color: white; +} + +a.tab:hover, +a.tab:active, +.img-button:hover, +.img-button:focus, +.admin-button:hover, +.admin-button:active, +input[type="submit"]:hover, +input[type="submit"]:focus, +input[type="button"]:hover, +input[type="button"]:focus { + background-color: #5CBF60; + text-decoration: none; +} diff --git a/magpie/ui/home/static/users.png b/magpie/ui/home/static/users.png index a2bf9a52a..2c0284ec8 100644 Binary files a/magpie/ui/home/static/users.png and b/magpie/ui/home/static/users.png differ diff --git a/magpie/ui/home/static/warning_exclamation.png b/magpie/ui/home/static/warning_exclamation.png deleted file mode 100644 index 08accf26e..000000000 Binary files a/magpie/ui/home/static/warning_exclamation.png and /dev/null differ diff --git a/magpie/ui/home/static/warning_exclamation_orange.png b/magpie/ui/home/static/warning_exclamation_orange.png deleted file mode 100644 index 4fe4ea20f..000000000 Binary files a/magpie/ui/home/static/warning_exclamation_orange.png and /dev/null differ diff --git a/magpie/ui/home/templates/error.mako b/magpie/ui/home/templates/error.mako new file mode 100644 index 000000000..959f6e651 --- /dev/null +++ b/magpie/ui/home/templates/error.mako @@ -0,0 +1,44 @@ +<%inherit file="ui.home:templates/template.mako"/> + +<%block name="breadcrumb"> +
  • Home
  • + + +
    +
    + ERROR + + ${error_title} +
    + ${error_detail} +
    + %if error_request: +
    +
    + () +
    + + + + + + + %for error_field, error_value in error_request.items(): + + %endfor + + + +
    + %endif +
    +
    diff --git a/magpie/ui/home/templates/home.mako b/magpie/ui/home/templates/home.mako index 598156374..6f3d3ed19 100644 --- a/magpie/ui/home/templates/home.mako +++ b/magpie/ui/home/templates/home.mako @@ -4,17 +4,19 @@
  • Home
  • -
    - - + diff --git a/magpie/ui/home/templates/template.mako b/magpie/ui/home/templates/template.mako index f74e867cc..0a443d180 100644 --- a/magpie/ui/home/templates/template.mako +++ b/magpie/ui/home/templates/template.mako @@ -1,11 +1,20 @@ - + - Magpie Administration + Magpie - - + + + + + + + +