From d0987ad34c3e5ac93ba57a14d98ee1d97cc22a39 Mon Sep 17 00:00:00 2001 From: Julien Maupetit Date: Wed, 5 Jan 2022 16:01:35 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(project)=20add=20acl-proxy=20applicat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need fine-grained control over dashboard permissions to ensure users cannot access variable values they are not expected to. As it appears that we cannot forbid dashboard input variable values passed via a GET request, we've designed a FastAPI-based application that proxy dashboards requests and check input values given grafana logged user. The acl-proxy application is used as a companion of our grafana instance along with the Caddy2 web proxy using an x-accel-redirect workflow. --- .circleci/config.yml | 9 +- .gitignore | 21 ++ CHANGELOG.md | 4 + Makefile | 64 +++- bin/pytest | 6 + docker-compose.yml | 28 ++ docker/files/etc/acl/logging.json | 37 ++ docker/files/etc/caddy/Caddyfile | 12 + env.d/acl | 15 + src/acl/.pylintrc | 544 ++++++++++++++++++++++++++++++ src/acl/Dockerfile | 63 ++++ src/acl/README.md | 0 src/acl/acl/__init__.py | 0 src/acl/acl/edx.py | 35 ++ src/acl/acl/exceptions.py | 9 + src/acl/acl/grafana.py | 95 ++++++ src/acl/acl/main.py | 98 ++++++ src/acl/acl/settings.py | 58 ++++ src/acl/setup.cfg | 86 +++++ src/acl/setup.py | 4 + src/acl/tests/__init__.py | 0 src/acl/tests/conftest.py | 91 +++++ src/acl/tests/test_edx.py | 39 +++ src/acl/tests/test_grafana.py | 130 +++++++ src/acl/tests/test_main.py | 157 +++++++++ 25 files changed, 1593 insertions(+), 12 deletions(-) create mode 100755 bin/pytest create mode 100644 docker/files/etc/acl/logging.json create mode 100644 docker/files/etc/caddy/Caddyfile create mode 100644 env.d/acl create mode 100644 src/acl/.pylintrc create mode 100644 src/acl/Dockerfile create mode 100644 src/acl/README.md create mode 100644 src/acl/acl/__init__.py create mode 100644 src/acl/acl/edx.py create mode 100644 src/acl/acl/exceptions.py create mode 100644 src/acl/acl/grafana.py create mode 100644 src/acl/acl/main.py create mode 100644 src/acl/acl/settings.py create mode 100644 src/acl/setup.cfg create mode 100644 src/acl/setup.py create mode 100644 src/acl/tests/__init__.py create mode 100644 src/acl/tests/conftest.py create mode 100644 src/acl/tests/test_edx.py create mode 100644 src/acl/tests/test_grafana.py create mode 100644 src/acl/tests/test_main.py diff --git a/.circleci/config.yml b/.circleci/config.yml index a81d8c3..1b3e28d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -53,8 +53,6 @@ jobs: working_directory: ~/fun steps: - checkout - - setup_remote_docker: - docker_layer_caching: true - run: name: Build development image command: make build @@ -66,7 +64,6 @@ jobs: compile: machine: image: ubuntu-2004:202104-01 - docker_layer_caching: true working_directory: ~/fun steps: - checkout @@ -85,7 +82,6 @@ jobs: lint: machine: image: ubuntu-2004:202104-01 - docker_layer_caching: true working_directory: ~/fun steps: - checkout @@ -104,7 +100,6 @@ jobs: plugins: machine: image: ubuntu-2004:202104-01 - docker_layer_caching: true working_directory: ~/fun steps: - checkout @@ -153,7 +148,7 @@ jobs: name: Build release archive command: | mkdir releases - tar cvzf releases/postie-${RELEASE}.tgz -C ~/fun var/lib/grafana + tar cvzf releases/potsie-${RELEASE}.tgz -C ~/fun var/lib/grafana - run: name: Get release changes command: | @@ -196,7 +191,7 @@ workflows: branches: ignore: main tags: - only: /(?!^v).*/ + only: /.*/ - lint-changelog: filters: branches: diff --git a/.gitignore b/.gitignore index 3f9cb83..af61462 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Mac Os +.DS_Store + # Ignore compiled objects var/ @@ -5,3 +8,21 @@ var/ node_modules/ dist/ coverage/ + +# == Python +# Packaging +build +dist +*.egg-info + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Unit test / coverage reports +htmlcov/ +.coverage +.cache +.pytest_cache +nosetests.xml +coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3719ba7..a2d762a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to ## [Unreleased] +### Added + +- ACL proxy application + ## [0.3.0] - 2022-01-27 ### Added diff --git a/Makefile b/Makefile index a92e5fa..129550e 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ DOCKER_GID = $(shell id -g) DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID) COMPOSE = DOCKER_USER=$(DOCKER_USER) docker-compose COMPOSE_RUN = $(COMPOSE) run --rm +COMPOSE_RUN_ACL = $(COMPOSE_RUN) acl # -- Node COMPOSE_RUN_NODE = $(COMPOSE_RUN) node @@ -53,10 +54,22 @@ bootstrap: \ bootstrap: ## bootstrap the application .PHONY: bootstrap -build: ## build potsie development image - @$(COMPOSE) build app +build: \ + build-app \ + build-acl +build: ## build development images .PHONY: build +build-app: ## build potsie development image + @echo "Building grafana dashboards..." + @$(COMPOSE) build app +.PHONY: build-app + +build-acl: ## build acl development image + @echo "Building acl companion app..." + @$(COMPOSE) build acl +.PHONY: build-acl + clean: \ down clean: ## remove project files and containers (warning: it removes the database container) @@ -93,12 +106,47 @@ format: ## format Jsonnet sources and libraries bin/jsonnetfmt -i $(sources) $(libraries) .PHONY: format -lint: ## lint Jsonnet sources and libraries +lint-black: ## lint back-end python sources with black + @echo 'lint:black started…' + @$(COMPOSE_RUN_ACL) black acl tests +.PHONY: lint-black + +lint-flake8: ## lint back-end python sources with flake8 + @echo 'lint:flake8 started…' + @$(COMPOSE_RUN_ACL) flake8 +.PHONY: lint-flake8 + +lint-isort: ## automatically re-arrange python imports in back-end code base + @echo 'lint:isort started…' + @$(COMPOSE_RUN_ACL) isort --atomic . +.PHONY: lint-isort + +lint-pylint: ## lint back-end python sources with pylint + @echo 'lint:pylint started…' + @$(COMPOSE_RUN_ACL) pylint acl tests +.PHONY: lint-pylint + +lint-bandit: ## lint back-end python sources with bandit + @echo 'lint:bandit started…' + @$(COMPOSE_RUN_ACL) bandit -qr acl +.PHONY: lint-bandit + +lint-jsonnet: ## lint Jsonnet sources and libraries bin/jsonnet-lint $(sources) $(libraries) +.PHONY: lint-jsonnet + +lint: \ + lint-isort \ + lint-black \ + lint-flake8 \ + lint-pylint \ + lint-bandit \ + lint-jsonnet +lint: ## lint all sources .PHONY: lint logs: ## display grafana logs (follow mode) - @$(COMPOSE) logs -f grafana + @$(COMPOSE) logs -f caddy acl grafana .PHONY: logs plugins: ## download, build and install plugins @@ -121,7 +169,7 @@ run: ## start the development server @$(COMPOSE) up -d postgresql @echo "Wait for database to be up..." @$(WAIT_DB) - @$(COMPOSE) up -d grafana + @$(COMPOSE) up -d caddy @echo "Wait for grafana to be up..." @$(WAIT_GRAFANA) .PHONY: run @@ -134,6 +182,12 @@ stop: ## stop the development server @$(COMPOSE) stop .PHONY: stop +test: \ + run +test: ## run acl tests + bin/pytest +.PHONY: test + update: ## update jsonnet bundles @$(COMPOSE_RUN) jb update $(MAKE) build diff --git a/bin/pytest b/bin/pytest new file mode 100755 index 0000000..bf9a9d5 --- /dev/null +++ b/bin/pytest @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +declare DOCKER_USER +DOCKER_USER="$(id -u):$(id -g)" + +DOCKER_USER=${DOCKER_USER} docker-compose run --rm acl pytest "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 784882f..8783ae8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,34 @@ services: - elasticsearch - postgresql + caddy: + image: caddy:2.4.6 + restart: unless-stopped + ports: + - "8090:8090" + volumes: + - ./docker/files/etc/caddy/Caddyfile:/etc/caddy/Caddyfile + depends_on: + - grafana + - acl + + acl: + build: + context: src/acl + target: development + image: potsie-acl-proxy:development + ports: + - 8000:8000 + volumes: + - ./src/acl:/app + - ./docker/files/etc/acl/logging.json:/etc/acl/logging.json + env_file: + - env.d/acl + logging: + options: + max-size: "200k" + user: "${DOCKER_USER:-1000}" + app: build: context: . diff --git a/docker/files/etc/acl/logging.json b/docker/files/etc/acl/logging.json new file mode 100644 index 0000000..f1ba1ce --- /dev/null +++ b/docker/files/etc/acl/logging.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s %(name)s:%(funcName)s >> %(message)s", + "use_colors": true + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": "%(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s" + } + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr" + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout" + } + }, + "loggers": { + "uvicorn": { "handlers": ["default"], "level": "INFO" }, + "uvicorn.error": { "level": "INFO" }, + "uvicorn.access": { + "handlers": ["access"], + "level": "INFO", + "propagate": false + }, + "acl": {"handlers": ["default"], "level": "DEBUG"} + } +} diff --git a/docker/files/etc/caddy/Caddyfile b/docker/files/etc/caddy/Caddyfile new file mode 100644 index 0000000..6fae5b2 --- /dev/null +++ b/docker/files/etc/caddy/Caddyfile @@ -0,0 +1,12 @@ +localhost:8090 + +reverse_proxy * grafana:3000 + +reverse_proxy /d/* acl:8000 { + + @accel header X-Accel-Redirect * + handle_response @accel { + rewrite {http.reverse_proxy.header.X-Accel-Redirect} + reverse_proxy grafana:3000 + } +} diff --git a/env.d/acl b/env.d/acl new file mode 100644 index 0000000..8ebea75 --- /dev/null +++ b/env.d/acl @@ -0,0 +1,15 @@ +EDX_DATABASE_HOST=edx_mysql +EDX_DATABASE_NAME=edxapp +EDX_DATABASE_PORT=3306 +EDX_DATABASE_USER_NAME=edxapp_user +EDX_DATABASE_USER_PASSWORD=funfunfun +EDX_DATASOURCE_NAME=edx_app +GRAFANA_ROOT_URL=http://grafana:3000 +# Required for aiomysql +USERNAME=acl +# Grafana +GRAFANA_DATABASE_HOST=postgresql +GRAFANA_DATABASE_NAME=potsie +GRAFANA_DATABASE_PORT=5432 +GRAFANA_DATABASE_USER_NAME=fun +GRAFANA_DATABASE_USER_PASSWORD=pass diff --git a/src/acl/.pylintrc b/src/acl/.pylintrc new file mode 100644 index 0000000..0ad4bfb --- /dev/null +++ b/src/acl/.pylintrc @@ -0,0 +1,544 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore= + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=no + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=apply-builtin, + backtick, + bad-continuation + bad-inline-option, + bad-python3-import, + basestring-builtin, + buffer-builtin, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + deprecated-itertools-function, + deprecated-pragma, + deprecated-str-translate-call, + deprecated-string-function, + deprecated-types-field, + dict-items-not-iterating, + dict-iter-method, + dict-keys-not-iterating, + dict-values-not-iterating, + dict-view-method, + div-method, + django-not-configured, + eq-without-hash, + exception-message-attribute, + execfile-builtin, + file-builtin, + file-ignored, + filter-builtin-not-iterating, + getslice-method, + hex-method, + idiv-method, + import-star-module-level, + indexing-exception, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + locally-enabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + non-ascii-bytes-literal, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + raw-checker-failed, + rdiv-method, + reduce-builtin, + reload-builtin, + round-builtin, + setslice-method, + standarderror-builtin, + suppressed-message, + sys-max-int, + unichr-builtin, + unicode-builtin, + unpacking-in-except, + useless-suppression, + using-cmp-argument, + xrange-builtin, + zip-builtin-not-iterating, + +# Enable the message, report, category or checker with the given id(s). You can +# 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 + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# 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=optparse.Values,sys.exit + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,responses, + Course,Organization,Page,Person,PersonTitle,Category + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=yes + +# Minimum lines number of a similarity. +# First implementations of CMS wizards have common fields we do not want to factorize for now +min-similarity-lines=35 + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + cm, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=8 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=15 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/src/acl/Dockerfile b/src/acl/Dockerfile new file mode 100644 index 0000000..950126f --- /dev/null +++ b/src/acl/Dockerfile @@ -0,0 +1,63 @@ +# -- Base image -- +FROM python:3.9-slim as base + +# Upgrade pip to its latest release to speed up dependencies installation +RUN pip install --upgrade pip + +# Upgrade system packages to install security updates +RUN apt-get update && \ + apt-get -y upgrade && \ + rm -rf /var/lib/apt/lists/* + + +# -- Builder -- +FROM base as builder + +WORKDIR /build + +COPY . /build/ + +RUN apt-get update && \ + apt-get install -y gcc libc6-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN python setup.py install + + +# -- Core -- +FROM base as core + +RUN apt-get update && \ + apt-get install -y jq && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/local /usr/local + +WORKDIR /app + + +# -- Development -- +FROM core as development + +# Copy all sources, not only runtime-required files +COPY . /app/ + +# Uninstall acl and re-install it in editable mode along with development +# dependencies +RUN pip uninstall -y potsie-acl-proxy +RUN pip install -e .[dev] + +# Un-privileged user running the application +USER ${DOCKER_USER:-1000} + +CMD ["uvicorn", "acl.main:app", \ + "--reload", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--log-config", "/etc/acl/logging.json"] + +# -- Production -- +FROM core as production + +# Un-privileged user running the application +USER ${DOCKER_USER:-1000} diff --git a/src/acl/README.md b/src/acl/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/acl/acl/__init__.py b/src/acl/acl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/acl/acl/edx.py b/src/acl/acl/edx.py new file mode 100644 index 0000000..5f0a585 --- /dev/null +++ b/src/acl/acl/edx.py @@ -0,0 +1,35 @@ +"""EDX database client.""" +import logging + +from email_validator import EmailNotValidError, validate_email + +from .exceptions import EdxException + +logger = logging.getLogger(__name__) + + +async def user_courses(database, email): + """Get logged user courses.""" + + if email is None: + raise EdxException("An email is required to get user courses") + + # Validate input email so that no SQL injection attack can be made + try: + valid = validate_email(email) + except EmailNotValidError as error: + raise EdxException(f"Invalid input email {email}") from error + + edx_course_keys_sql_request = ( # nosec + "SELECT DISTINCT `student_courseaccessrole`.`course_id` " + "FROM `student_courseaccessrole` WHERE (" + " `student_courseaccessrole`.`user_id` = (" + " SELECT id from auth_user " + f' WHERE email="{valid.email}" ' + ' AND `student_courseaccessrole`.`role` IN ("staff", "instructor")' + " )" + ")" + ) + logger.debug("Edx course keys SQL request: %s", edx_course_keys_sql_request) + + return await database.fetch_all(query=edx_course_keys_sql_request) diff --git a/src/acl/acl/exceptions.py b/src/acl/acl/exceptions.py new file mode 100644 index 0000000..c97f768 --- /dev/null +++ b/src/acl/acl/exceptions.py @@ -0,0 +1,9 @@ +"""ACL exceptions.""" + + +class EdxException(Exception): + """Raised when an Edx database request fails.""" + + +class GrafanaException(Exception): + """Raised when a Grafana API request fails.""" diff --git a/src/acl/acl/grafana.py b/src/acl/acl/grafana.py new file mode 100644 index 0000000..4b68881 --- /dev/null +++ b/src/acl/acl/grafana.py @@ -0,0 +1,95 @@ +"""ACL Grafana client.""" +import logging +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Union +from urllib.parse import urljoin + +import aiohttp +from pydantic import BaseModel, EmailStr, FileUrl, ValidationError + +from .exceptions import GrafanaException + +logger = logging.getLogger(__name__) + + +class User(BaseModel): + """Grafana User""" + + id: int + email: EmailStr + name: str + login: str + theme: str + orgId: int + isGrafanaAdmin: bool + isDisabled: bool + isExternal: bool + authLabels: List[str] = None + updatedAt: datetime + createdAt: datetime + avatarUrl: Optional[Union[FileUrl, Path]] + + +async def perform_request( + session, grafana_base_url, endpoint, grafana_session, method="GET", payload=None +): + """Perform asynchronous request against Grafana API.""" + + url = urljoin(grafana_base_url, endpoint) + + if grafana_session is None: + raise GrafanaException( + "A grafana session is required to impersonate user requests." + ) + + request_kwargs = {} + if payload is not None: + if method.upper() == "GET": + request_kwargs.update({"params": payload}) + else: + request_kwargs.update({"json": payload}) + + logger.debug("%s %s %s", method.upper(), url, request_kwargs) + + async with getattr(session, method.lower())( + url, + headers={ + "Cookie": f"grafana_session={grafana_session}", + "content-type": "application/json", + }, + **request_kwargs, + ) as response: + + try: + response.raise_for_status() + except aiohttp.client_exceptions.ClientResponseError as error: + logger.debug("exception: %s", error) + raise GrafanaException( + "Grafana request failed. Check logs for details." + ) from error + + try: + return await response.json() + except aiohttp.ContentTypeError as error: + logger.debug("response.text: %s", await response.text()) + logger.debug("exception: %s", error) + raise GrafanaException("Unexpected response content type.") from error + + +async def current_user(session, grafana_base_url, grafana_session): + """Get logged user informations.""" + + response = await perform_request( + session, grafana_base_url, "/api/user", grafana_session + ) + logger.debug("Grafana user response: %s", response) + try: + user = User(**response) + except ValidationError as error: + logger.debug("Raw user response: %s", response) + raise GrafanaException("Invalid request user.") from error + + logger.debug("User: %s", user) + + return user diff --git a/src/acl/acl/main.py b/src/acl/acl/main.py new file mode 100644 index 0000000..fe4619a --- /dev/null +++ b/src/acl/acl/main.py @@ -0,0 +1,98 @@ +"""ACL server application.""" +import logging +from typing import Optional + +import aiohttp +import databases +from fastapi import Cookie, FastAPI, HTTPException, Query, Response, status + +from . import edx, grafana +from .exceptions import EdxException, GrafanaException +from .settings import Settings + +logger = logging.getLogger(__name__) + +# pylint: disable=invalid-name +settings = Settings() +logger.debug("Database url: %s", settings.EDX_DATABASE_URL) +edx_database = databases.Database(settings.EDX_DATABASE_URL) +http_requests_session = None +app = FastAPI() + + +# pylint: disable=invalid-name,global-statement +@app.on_event("startup") +async def startup(): + """Application startup event handling.""" + # Prevent aiohttp warning: + # + # the aiohttp.ClientSession object should be created within an async + # function + global http_requests_session + + http_requests_session = aiohttp.ClientSession() + await edx_database.connect() + + +@app.on_event("shutdown") +async def shutdown(): + """Application shutdown event handling.""" + + await http_requests_session.close() + await edx_database.disconnect() + + +@app.get("/{request_path:path}") +async def proxy( + request_path: str, + course_key: Optional[str] = Query(None, alias="var-COURSE_KEY"), + school: Optional[str] = Query(None, alias="var-SCHOOL"), + course: Optional[str] = Query(None, alias="var-COURSE"), + session: Optional[str] = Query(None, alias="var-SESSION"), + grafana_session: str = Cookie(None), +): + """Look for authorizations of the current user.""" + + x_accel_redirect = f"/{request_path}" + allowed = Response( + headers={"X-Accel-Redirect": f"{x_accel_redirect}"}, + ) + forbidden = Response( + content="You are not allowed to view this.", + status_code=status.HTTP_403_FORBIDDEN, + media_type="text/html", + ) + + try: + user = await grafana.current_user( + http_requests_session, settings.GRAFANA_ROOT_URL, grafana_session + ) + except GrafanaException as error: + raise HTTPException( + status_code=401, + detail="Cannot get grafana user. See logs for details", + ) from error + + try: + user_course_keys = [ + key + for row in await edx.user_courses(edx_database, user.email) + for key in row + ] + except EdxException as error: + raise HTTPException( + status_code=400, + detail="Cannot get courses from edx. See logs for details", + ) from error + logger.debug("User course key: %s", user_course_keys) + + if course_key is not None and course_key not in user_course_keys: + return forbidden + + if ( + all((school, course, session)) + and f"course-v1:{school}+{course}+{session}" not in user_course_keys + ): + return forbidden + + return allowed diff --git a/src/acl/acl/settings.py b/src/acl/acl/settings.py new file mode 100644 index 0000000..a55c3fc --- /dev/null +++ b/src/acl/acl/settings.py @@ -0,0 +1,58 @@ +"""ACL Settings.""" + +from pydantic import AnyHttpUrl, AnyUrl, BaseSettings, PostgresDsn, validator + + +class Settings(BaseSettings): + """ACL web application settings.""" + + EDX_DATABASE_HOST: str + EDX_DATABASE_NAME: str + EDX_DATABASE_PORT: int + EDX_DATABASE_USER_NAME: str + EDX_DATABASE_USER_PASSWORD: str + EDX_DATABASE_URL: AnyUrl = None + EDX_DATASOURCE_NAME: str + GRAFANA_ROOT_URL: AnyHttpUrl + + @validator("EDX_DATABASE_URL", pre=True, always=True) + @classmethod + def default_edx_database_url(cls, value, *, values): + """Set EDX_DATABASE_URL setting from separated settings if not already set.""" + + return value or ( + "mysql://" + f"{values['EDX_DATABASE_USER_NAME']}:" + f"{values['EDX_DATABASE_USER_PASSWORD']}@" + f"{values['EDX_DATABASE_HOST']}:" + f"{values['EDX_DATABASE_PORT']}/" + f"{values['EDX_DATABASE_NAME']}" + ) + + +class TestSettings(Settings): + """ "ACL web application tests settings.""" + + GRAFANA_DATABASE_HOST: str + GRAFANA_DATABASE_NAME: str + GRAFANA_DATABASE_PORT: int + GRAFANA_DATABASE_USER_NAME: str + GRAFANA_DATABASE_USER_PASSWORD: str + GRAFANA_DATABASE_URL: PostgresDsn = None + ACL_ROOT_URL: AnyHttpUrl = "http://acl:8000" + + @validator("GRAFANA_DATABASE_URL", pre=True, always=True) + @classmethod + def default_grafana_database_url(cls, value, *, values): + """Set GRAFANA_DATABASE_URL setting from separated settings + if not already set. + """ + + return value or ( + "postgresql://" + f"{values['GRAFANA_DATABASE_USER_NAME']}:" + f"{values['GRAFANA_DATABASE_USER_PASSWORD']}@" + f"{values['GRAFANA_DATABASE_HOST']}:" + f"{values['GRAFANA_DATABASE_PORT']}/" + f"{values['GRAFANA_DATABASE_NAME']}" + ) diff --git a/src/acl/setup.cfg b/src/acl/setup.cfg new file mode 100644 index 0000000..658003d --- /dev/null +++ b/src/acl/setup.cfg @@ -0,0 +1,86 @@ +;; +;; ACL package +;; +[metadata] +name = potsie-acl-proxy +version = 0.3.0 +description = An ACL proxy for Grafana +long_description = file:README.md +long_description_content_type = text/markdown +author = Open FUN (France Universite Numerique) +author_email = fun.dev@fun-mooc.fr +url = https://openfun.github.io/potsie/ +license = MIT +keywords = ACL, Grafana, Analytics, xAPI, LRS +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Natural Language :: English + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.9 + +[options] +include_package_data = True +install_requires = + aiohttp==3.8.1 + databases[mysql]==0.5.4 + fastapi==0.70.1 + pydantic[email]==1.9.0 + uvicorn[standard]==0.16.0 +package_dir = + =. +packages = find: +zip_safe = True +python_requires = >= 3.9 + +[options.extras_require] +dev = + bandit==1.7.1 + black==21.10b0 + databases[postgresql]==0.5.4 + flake8==4.0.1 + isort==5.10.1 + pylint==2.12.2 + pytest==6.2.5 + pytest-asyncio==0.17.2 + pytest-cov==3.0.0 + requests==2.27.1 +ci = + twine==3.7.1 + +[options.packages.find] +where = . + +[wheel] +universal = 1 + +;; +;; Third-party packages configuration +;; +[flake8] +max-line-length = 88 +extend-ignore = E203 +exclude = + .git, + .venv, + build, + venv, + __pycache__, + node_modules, + */migrations/* + +[isort] +known_potsie_acl_proxy= +sections=FUTURE,STDLIB,THIRDPARTY,POTSIE_ACL_PROXY,FIRSTPARTY,LOCALFOLDER +skip_glob=venv +profile=black + +[tool:pytest] +addopts = -v --cov-report term-missing --cov-config=.coveragerc --cov=acl +asyncio_mode = auto +python_files = + test_*.py + tests.py +testpaths = + tests diff --git a/src/acl/setup.py b/src/acl/setup.py new file mode 100644 index 0000000..c823345 --- /dev/null +++ b/src/acl/setup.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +from setuptools import setup + +setup() diff --git a/src/acl/tests/__init__.py b/src/acl/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/acl/tests/conftest.py b/src/acl/tests/conftest.py new file mode 100644 index 0000000..e155aed --- /dev/null +++ b/src/acl/tests/conftest.py @@ -0,0 +1,91 @@ +"""ACL tests configuration.""" +# pylint: disable=redefined-outer-name + +import aiohttp +import pytest +import pytest_asyncio +from databases import Database +from fastapi.testclient import TestClient + +from acl.settings import TestSettings + + +@pytest.fixture +def settings(): + """ACL application settings.""" + + return TestSettings() + + +@pytest.fixture +def test_client(): + """HTTP test client.""" + # pylint: disable=import-outside-toplevel + from acl.main import app + + with TestClient(app) as client: + yield client + + +@pytest_asyncio.fixture +async def http_requests_session(): + """HTTP requests session fixture.""" + + session = aiohttp.ClientSession() + yield session + await session.close() + + +@pytest_asyncio.fixture +async def grafana_http_requests_session_admin(settings, http_requests_session): + """Grafana-authenticated HTTP requests session fixture for the admin user.""" + + async with http_requests_session.post( + f"{settings.GRAFANA_ROOT_URL}/login", + json={"user": "admin", "password": "pass"}, + ): + yield http_requests_session + + +@pytest_asyncio.fixture +async def grafana_http_requests_session_teacher(settings, http_requests_session): + """Grafana-authenticated HTTP requests session fixture for the teacher user.""" + + async with http_requests_session.post( + f"{settings.GRAFANA_ROOT_URL}/login", + json={"user": "teacher", "password": "funfunfun"}, + ): + yield http_requests_session + + +@pytest_asyncio.fixture +async def grafana_session_teacher(settings, grafana_http_requests_session_teacher): + """Shortcut to get grafana_session cookie value.""" + + return ( + grafana_http_requests_session_teacher.cookie_jar.filter_cookies( + settings.GRAFANA_ROOT_URL + ) + .get("grafana_session") + .value + ) + + +@pytest_asyncio.fixture +async def mysql_database(settings): + """Mysql database fixture.""" + + database = Database(settings.EDX_DATABASE_URL, force_rollback=True) + await database.connect() + yield database + await database.disconnect() + + +@pytest_asyncio.fixture +async def grafana_database(settings): + """Grafana database fixture.""" + + database = Database(settings.GRAFANA_DATABASE_URL, force_rollback=True) + await database.connect() + yield database + await database.disconnect() diff --git a/src/acl/tests/test_edx.py b/src/acl/tests/test_edx.py new file mode 100644 index 0000000..f11b015 --- /dev/null +++ b/src/acl/tests/test_edx.py @@ -0,0 +1,39 @@ +"""Tests for ACL edx module.""" + +import pytest + +from acl.edx import user_courses +from acl.exceptions import EdxException + + +@pytest.mark.asyncio +async def test_user_courses_email_verification(mysql_database): + """Test user_courses function with SQL injection attacks.""" + + with pytest.raises(EdxException, match="An email is required to get user courses"): + await user_courses(mysql_database, None) + + with pytest.raises(EdxException, match="Invalid input email admin@localhost"): + await user_courses(mysql_database, "admin@localhost") + + with pytest.raises(EdxException, match="Invalid input email foo"): + await user_courses(mysql_database, "foo") + + with pytest.raises(EdxException, match="Invalid input email "): + await user_courses(mysql_database, "") + + with pytest.raises( + EdxException, match='Invalid input email foo@localhost" OR 1 == 1 --];' + ): + await user_courses(mysql_database, 'foo@localhost" OR 1 == 1 --];') + + +@pytest.mark.asyncio +async def test_user_courses(mysql_database): + """Test user_courses fetching.""" + + assert await user_courses(mysql_database, "teacher@example.org") == [ + ("course-v1:FUN-MOOC+00001+session01",), + ("course-v1:FUN-MOOC+00002+session01",), + ] + assert await user_courses(mysql_database, "student@example.org") == [] diff --git a/src/acl/tests/test_grafana.py b/src/acl/tests/test_grafana.py new file mode 100644 index 0000000..e3aaa25 --- /dev/null +++ b/src/acl/tests/test_grafana.py @@ -0,0 +1,130 @@ +"""Tests for ACL grafana module.""" + +import pytest + +from acl import exceptions, grafana + + +def _get_grafana_session(grafana_http_requests_session, grafana_root_url): + """Shortcut to get grafana_session cookie value.""" + + return ( + grafana_http_requests_session.cookie_jar.filter_cookies(grafana_root_url) + .get("grafana_session") + .value + ) + + +@pytest.mark.asyncio +async def test_perform_request_without_session(settings, http_requests_session): + """Test grafana HTTP API requests without grafana session.""" + + with pytest.raises( + exceptions.GrafanaException, + match="A grafana session is required to impersonate user requests.", + ): + await grafana.perform_request( + http_requests_session, settings.GRAFANA_ROOT_URL, "/api/user", None + ) + + # We use a non existing fake session here. + with pytest.raises( + exceptions.GrafanaException, + match="Grafana request failed. Check logs for details.", + ): + await grafana.perform_request( + http_requests_session, settings.GRAFANA_ROOT_URL, "/api/user", "fakesession" + ) + + # Trying to access the root url performs a redirection to /login with a 200 + # response but with a wrong content-type (HTML vs JSON). + with pytest.raises( + exceptions.GrafanaException, + match="Unexpected response content type.", + ): + await grafana.perform_request( + http_requests_session, settings.GRAFANA_ROOT_URL, "/", "fakesession" + ) + + +# pylint: disable=unused-argument +@pytest.mark.asyncio +async def test_perform_request_with_session( + settings, grafana_http_requests_session_admin, grafana_database +): + """Test grafana HTTP API requests with an opened grafana session.""" + + # Perform a GET request with request parameters + response = await grafana.perform_request( + grafana_http_requests_session_admin, + settings.GRAFANA_ROOT_URL, + "/api/users/lookup", + _get_grafana_session( + grafana_http_requests_session_admin, settings.GRAFANA_ROOT_URL + ), + payload={"loginOrEmail": "teacher"}, + ) + user = grafana.User(**response) + assert user.email == "teacher@example.org" + + # Now create a new folder + response = await grafana.perform_request( + grafana_http_requests_session_admin, + settings.GRAFANA_ROOT_URL, + "/api/folders", + _get_grafana_session( + grafana_http_requests_session_admin, settings.GRAFANA_ROOT_URL + ), + method="POST", + payload={"uid": "1234", "title": "Foo folder"}, + ) + assert response.get("url") == "/dashboards/f/1234/foo-folder" + + # Delete this folder + response = await grafana.perform_request( + grafana_http_requests_session_admin, + settings.GRAFANA_ROOT_URL, + "/api/folders/1234", + _get_grafana_session( + grafana_http_requests_session_admin, settings.GRAFANA_ROOT_URL + ), + method="DELETE", + ) + assert response.get("message") == "Folder Foo folder deleted" + + +@pytest.mark.asyncio +async def test_current_user(settings, grafana_http_requests_session_teacher): + """Test grafana current_user.""" + + user = await grafana.current_user( + grafana_http_requests_session_teacher, + settings.GRAFANA_ROOT_URL, + _get_grafana_session( + grafana_http_requests_session_teacher, settings.GRAFANA_ROOT_URL + ), + ) + assert user.email == "teacher@example.org" + + +@pytest.mark.asyncio +async def test_current_user_with_model_validation_error( + settings, grafana_http_requests_session_teacher, monkeypatch +): + """Test grafana current_user with invalid user response.""" + + class FakeUserModel(grafana.User): + """A fake user model to test model validation error.""" + + fakeField: str + + monkeypatch.setattr(grafana, "User", FakeUserModel) + + with pytest.raises(exceptions.GrafanaException, match="Invalid request user."): + await grafana.current_user( + grafana_http_requests_session_teacher, + settings.GRAFANA_ROOT_URL, + _get_grafana_session( + grafana_http_requests_session_teacher, settings.GRAFANA_ROOT_URL + ), + ) diff --git a/src/acl/tests/test_main.py b/src/acl/tests/test_main.py new file mode 100644 index 0000000..5028d5d --- /dev/null +++ b/src/acl/tests/test_main.py @@ -0,0 +1,157 @@ +"""Tests for ACL main (server) module.""" + +from pydantic import BaseModel + +from acl import edx, grafana + + +def test_proxy_without_grafana_session(settings, test_client): + """Test proxy view without grafana session.""" + + response = test_client.get(f"{settings.ACL_ROOT_URL}/test/proxy") + + assert response.status_code == 401 + assert "X-Accel-Redirect" not in response.headers + assert response.json() == { + "detail": "Cannot get grafana user. See logs for details" + } + + +def test_proxy_with_bad_user_email( + settings, test_client, grafana_session_teacher, monkeypatch +): + """Test proxy view with an open teacher grafana session but an invalid user + email. + """ + + class BadEmailUser(BaseModel): + """User model with a bad email address.""" + + email: str + + # pylint: disable=unused-argument + async def __current_user(session, grafana_base_url, grafana_session): + """Mock grafana.current_user response with a non-standard email.""" + return BadEmailUser(email="teacher@localhost") + + monkeypatch.setattr(grafana, "current_user", __current_user) + + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + ) + + assert response.status_code == 400 + assert "X-Accel-Redirect" not in response.headers + assert response.json() == { + "detail": "Cannot get courses from edx. See logs for details" + } + + +def test_proxy_with_teacher_grafana_session( + settings, test_client, grafana_session_teacher +): + """Test proxy view with an open teacher grafana session""" + + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + ) + + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/test/proxy" + + response = test_client.get( + f"{settings.ACL_ROOT_URL}/any/path/should/respond", + cookies={"grafana_session": grafana_session_teacher}, + ) + + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/any/path/should/respond" + + +def test_proxy_with_no_courses_account( + settings, test_client, grafana_session_teacher, monkeypatch +): + + """Test proxy view with an open teacher grafana session for a teacher not + associated with courses in edx. + """ + + # pylint: disable=unused-argument + async def __user_courses(database, email): + """User is not a registered teacher in edx.""" + return [] + + monkeypatch.setattr(edx, "user_courses", __user_courses) + + # If no course key has been given as a request query, allow proxying + # request. + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + ) + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/test/proxy" + + # Even with partial course key parameters + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + params={"var-SCHOOL": "foo"}, + ) + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/test/proxy" + + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + params={"var-COURSE": "bar"}, + ) + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/test/proxy" + + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + params={"var-SESSION": "baz"}, + ) + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/test/proxy" + + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + params={ + "var-SCHOOL": "foo", + "var-COURSE": "bar", + }, + ) + assert response.status_code == 200 + assert response.headers["X-Accel-Redirect"] == "/test/proxy" + + # With a course key in the request, we check that the user is allowed to + # view course-related data. + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + params={"var-COURSE_KEY": "foo"}, + ) + assert response.status_code == 403 + assert "X-Accel-Redirect" not in response.headers + assert response.text == "You are not allowed to view this." + + # With a schoo/course/session keys in the request, we check that the user + # is allowed to view course-related data. + response = test_client.get( + f"{settings.ACL_ROOT_URL}/test/proxy", + cookies={"grafana_session": grafana_session_teacher}, + params={ + "var-SCHOOL": "foo", + "var-COURSE": "bar", + "var-SESSION": "baz", + }, + ) + assert response.status_code == 403 + assert "X-Accel-Redirect" not in response.headers + assert response.text == "You are not allowed to view this."