diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index e2ab2c3..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,37 +0,0 @@ -version: 2 -jobs: - build: - docker: - - image: circleci/python:3.6.1 - - working_directory: ~/repo - - steps: - - checkout - - restore_cache: - keys: - - v1-dependencies-{{ checksum "requirements.txt" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: - name: install dependencies - command: | - python3 -m venv venv - . venv/bin/activate - pip install -r requirements.txt - - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum "requirements.txt" }} - - - run: - name: run tests - command: | - . venv/bin/activate - python3 -m unittest discover - - - store_artifacts: - path: test-reports - destination: test-reports \ No newline at end of file diff --git a/.github/workflows/bump.yaml b/.github/workflows/bump.yaml new file mode 100644 index 0000000..e562346 --- /dev/null +++ b/.github/workflows/bump.yaml @@ -0,0 +1,32 @@ +name: Bump version + +on: + workflow_run: + workflows: [ "Lint" ] + branches: [ master ] + types: + - completed + +jobs: + tag-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.PAT_TOKEN }} + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: determine-version + run: | + VERSION=$(npx semantic-release --branches master --dry-run | { grep -i 'the next release version is' || test $? = 1; } | sed -E 's/.* ([[:digit:].]+)$/\1/') + echo "VERSION=$VERSION" >> $GITHUB_ENV + id: version + - uses: rickstaa/action-create-tag@v1 + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + with: + tag: v${{ env.VERSION }} + message: "Releasing v${{ env.VERSION }}" + github_token: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/daily.yaml b/.github/workflows/daily.yaml new file mode 100644 index 0000000..7ddc622 --- /dev/null +++ b/.github/workflows/daily.yaml @@ -0,0 +1,27 @@ +name: Daily check + +on: + schedule: + - cron: '0 4 * * *' + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - uses: docker-practice/actions-setup-docker@master + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + - name: Run tests + run: | + tox -e tests diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..2cade19 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,90 @@ +name: Lint + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + check-commits: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: webiny/action-conventional-commits@v1.0.3 + + check-linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + - name: Check linting, formatting + run: | + tox -e check + + check-docs: + runs-on: ubuntu-latest + needs: + - check-commits + - check-linting + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + - name: Check documentation build + run: | + tox -e docs + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + needs: + - check-commits + - check-linting + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - uses: docker-practice/actions-setup-docker@master + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + - name: Run tests + run: | + tox -e tests + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + - name: Run build + run: | + tox -e build diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..9519829 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,33 @@ +name: Publish + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox wheel twine + - name: Apply the tag version + run: | + version=${{ github.ref_name }} + sed -i 's/__version__ = .*/__version__ = "'${version:1}'"/' keycloak/_version.py + - name: Run build + run: | + tox -e build + - name: Publish to PyPi + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + run: | + twine upload -u $TWINE_USERNAME -p $TWINE_PASSWORD dist/* diff --git a/.gitignore b/.gitignore index 7ea9902..4c8d46d 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,5 @@ ENV/ .idea/ main.py main2.py -s3air-authz-config.json \ No newline at end of file +s3air-authz-config.json +.vscode \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..806a12c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v1.2.0 + hooks: + - id: conventional-pre-commit + stages: [ commit-msg ] + args: [ ] # optional: list of Conventional Commits types to allow diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..7aa6ce5 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 + +build: + os: "ubuntu-20.04" + tools: + python: "3.10" + +python: + install: + - requirements: docs-requirements.txt diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..c89e61e --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,8 @@ +{ + "plugins": ["@semantic-release/commit-analyzer"], + "verifyConditions": false, + "npmPublish": false, + "publish": false, + "fail": false, + "success": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c8891db..4492c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,45 +1,44 @@ -Changelog -============ +# Changelog All notable changes to this project will be documented in this file. ## [0.5.0] - 2017-08-21 -* Basic functions for Keycloak API (well_know, token, userinfo, logout, certs, -entitlement, instropect) +- Basic functions for Keycloak API (well_know, token, userinfo, logout, certs, + entitlement, instropect) ## [0.6.0] - 2017-08-23 -* Added load authorization settings +- Added load authorization settings ## [0.7.0] - 2017-08-23 -* Added polices +- Added polices ## [0.8.0] - 2017-08-23 -* Added permissions +- Added permissions ## [0.9.0] - 2017-09-05 -* Added functions for Admin Keycloak API +- Added functions for Admin Keycloak API ## [0.10.0] - 2017-10-23 -* Updated libraries versions -* Updated Docs +- Updated libraries versions +- Updated Docs ## [0.11.0] - 2017-12-12 -* Changed Instropect RPT +- Changed Instropect RPT ## [0.12.0] - 2018-01-25 -* Add groups functions -* Add Admin Tasks for user and client role management -* Function to trigger user sync from provider +- Add groups functions +- Add Admin Tasks for user and client role management +- Function to trigger user sync from provider ## [0.12.1] - 2018-08-04 -* Add get_idps -* Rework group functions +- Add get_idps +- Rework group functions diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..853ebe5 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @ryshoooo @marcospereirampj diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..683f7ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing + +Welcome to the Python Keycloak contributing guidelines. We are all more than happy to receive +any contributions to the repository and want to thank you in advance for your contributions! +This document outlines the process and the guidelines on how contributions work for this repository. + +## Setting up the dev environment + +The development environment is mainly up to the developer. Our recommendations are to create a python +virtual environment and install the necessary requirements. Example + +```sh +python -m venv venv +source venv/bin/activate +python -m pip install -U pip +python -m pip install -r requirements.txt +python -m pip install -r dev-requirements.txt +``` + +## Running checks and tests + +We're utilizing `tox` for most of the testing workflows. However we also have an external dependency on `docker`. +We're using docker to spin up a local keycloak instance which we run our test cases against. This is to avoid +a lot of unnecessary mocking and yet have immediate feedback from the actual Keycloak instance. All of the setup +is done for you with the tox environments, all you need is to have both tox and docker installed +(`tox` is included in the `dev-requirements.txt`). + +To run the unit tests, simply run + +```sh +tox -e tests +``` + +The project is also adhering to strict linting (flake8) and formatting (black + isort). You can always check that +your code changes adhere to the format by running + +```sh +tox -e check +``` + +If the check fails, you'll see an error message specifying what went wrong. To simplify things, you can also run + +```sh +tox -e apply-check +``` + +which will apply isort and black formatting for you in the repository. The flake8 problems however need to be resolved +manually by the developer. + +Additionally we require that the documentation pages are built without warnings. This check is also run via tox, using +the command + +```sh +tox -e docs +``` + +The check is also run in the CICD pipelines. We require that the documentation pages built from the code docstrings +do not create visually "bad" pages. + +## Conventional commits + +Commits to this project must adhere to the [Conventional Commits +specification](https://www.conventionalcommits.org/en/v1.0.0/) that will allow +us to automate version bumps and changelog entry creation. + +After cloning this repository, you must install the pre-commit hook for +conventional commits (this is included in the `dev-requirements.txt`) + +```sh +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install pre-commit +pre-commit install --install-hooks -t pre-commit -t pre-push -t commit-msg +``` + +## How to contribute + +1. Fork this repository, develop and test your changes +2. Make sure that your changes do not decrease the test coverage +3. Make sure you're commits follow the conventional commits +4. Submit a pull request + +## How to release + +The CICD pipelines are set up for the repository. When a PR is merged, a new version of the library +will be automatically deployed to the PyPi server, meaning you'll be able to see your changes immediately. diff --git a/MANIFEST.in b/MANIFEST.in index 1aba38f..acf84af 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,4 @@ include LICENSE +include requirements.txt +include dev-requirements.txt +include docs-requirements.txt diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..d2f6981 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,5 @@ +tox +pytest +pytest-cov +wheel +pre-commit diff --git a/docs-requirements.txt b/docs-requirements.txt new file mode 100644 index 0000000..343bf89 --- /dev/null +++ b/docs-requirements.txt @@ -0,0 +1,9 @@ +mock +alabaster +commonmark +recommonmark +sphinx +sphinx-rtd-theme +readthedocs-sphinx-ext +m2r2 +sphinx-autoapi diff --git a/docs/source/conf.py b/docs/source/conf.py index 166a8f4..23e9bbf 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -32,37 +32,37 @@ import sphinx_rtd_theme # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.viewcode", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'python-keycloak' -copyright = '2017, Marcos Pereira' -author = 'Marcos Pereira' +project = "python-keycloak" +copyright = "2017, Marcos Pereira" +author = "Marcos Pereira" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.27.1' +version = "0.27.1" # The full version, including alpha/beta/rc tags. -release = '0.27.1' +release = "0.27.1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -74,13 +74,13 @@ language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] add_function_parentheses = False add_module_names = True # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True @@ -91,7 +91,7 @@ todo_include_todos = True # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme @@ -103,7 +103,7 @@ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # 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_static_path = ["_static"] html_use_smartypants = False @@ -116,7 +116,7 @@ html_show_copyright = True # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -#html_sidebars = { +# html_sidebars = { # '**': [ # 'about.html', # 'navigation.html', @@ -124,13 +124,13 @@ html_show_copyright = True # 'searchbox.html', # 'donate.html', # ] -#} +# } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = 'python-keycloakdoc' +htmlhelp_basename = "python-keycloakdoc" # -- Options for LaTeX output --------------------------------------------- @@ -139,15 +139,12 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -157,8 +154,13 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'python-keycloak.tex', 'python-keycloak Documentation', - 'Marcos Pereira', 'manual'), + ( + master_doc, + "python-keycloak.tex", + "python-keycloak Documentation", + "Marcos Pereira", + "manual", + ) ] @@ -166,10 +168,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'python-keycloak', 'python-keycloak Documentation', - [author], 1) -] +man_pages = [(master_doc, "python-keycloak", "python-keycloak Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -178,10 +177,13 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'python-keycloak', 'python-keycloak Documentation', - author, 'python-keycloak', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "python-keycloak", + "python-keycloak Documentation", + author, + "python-keycloak", + "One line description of project.", + "Miscellaneous", + ) ] - - - diff --git a/keycloak/_version.py b/keycloak/_version.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/keycloak/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/keycloak/authorization/__init__.py b/keycloak/authorization/__init__.py index 219687b..dad1078 100644 --- a/keycloak/authorization/__init__.py +++ b/keycloak/authorization/__init__.py @@ -55,39 +55,44 @@ class Authorization: :param data: keycloak authorization data (dict) :return: """ - for pol in data['policies']: - if pol['type'] == 'role': - policy = Policy(name=pol['name'], - type=pol['type'], - logic=pol['logic'], - decision_strategy=pol['decisionStrategy']) - - config_roles = json.loads(pol['config']['roles']) + for pol in data["policies"]: + if pol["type"] == "role": + policy = Policy( + name=pol["name"], + type=pol["type"], + logic=pol["logic"], + decision_strategy=pol["decisionStrategy"], + ) + + config_roles = json.loads(pol["config"]["roles"]) for role in config_roles: - policy.add_role(Role(name=role['id'], - required=role['required'])) + policy.add_role(Role(name=role["id"], required=role["required"])) self.policies[policy.name] = policy - if pol['type'] == 'scope': - permission = Permission(name=pol['name'], - type=pol['type'], - logic=pol['logic'], - decision_strategy=pol['decisionStrategy']) + if pol["type"] == "scope": + permission = Permission( + name=pol["name"], + type=pol["type"], + logic=pol["logic"], + decision_strategy=pol["decisionStrategy"], + ) - permission.scopes = ast.literal_eval(pol['config']['scopes']) + permission.scopes = ast.literal_eval(pol["config"]["scopes"]) - for policy_name in ast.literal_eval(pol['config']['applyPolicies']): + for policy_name in ast.literal_eval(pol["config"]["applyPolicies"]): self.policies[policy_name].add_permission(permission) - if pol['type'] == 'resource': - permission = Permission(name=pol['name'], - type=pol['type'], - logic=pol['logic'], - decision_strategy=pol['decisionStrategy']) + if pol["type"] == "resource": + permission = Permission( + name=pol["name"], + type=pol["type"], + logic=pol["logic"], + decision_strategy=pol["decisionStrategy"], + ) - permission.resources = ast.literal_eval(pol['config'].get('resources', "[]")) + permission.resources = ast.literal_eval(pol["config"].get("resources", "[]")) - for policy_name in ast.literal_eval(pol['config']['applyPolicies']): + for policy_name in ast.literal_eval(pol["config"]["applyPolicies"]): if self.policies.get(policy_name) is not None: self.policies[policy_name].add_permission(permission) diff --git a/keycloak/authorization/policy.py b/keycloak/authorization/policy.py index 9f688f7..4fbe913 100644 --- a/keycloak/authorization/policy.py +++ b/keycloak/authorization/policy.py @@ -98,9 +98,10 @@ class Policy: :param role: keycloak role. :return: """ - if self.type != 'role': + if self.type != "role": raise KeycloakAuthorizationConfigError( - "Can't add role. Policy type is different of role") + "Can't add role. Policy type is different of role" + ) self._roles.append(role) def add_permission(self, permission): diff --git a/keycloak/connection.py b/keycloak/connection.py index bdecfce..8ef45b1 100644 --- a/keycloak/connection.py +++ b/keycloak/connection.py @@ -29,11 +29,11 @@ except ImportError: import requests from requests.adapters import HTTPAdapter -from .exceptions import (KeycloakConnectionError) +from .exceptions import KeycloakConnectionError class ConnectionManager(object): - """ Represents a simple server connection. + """Represents a simple server connection. Args: base_url (str): The server URL. headers (dict): The header parameters of the requests to the server. @@ -52,15 +52,15 @@ class ConnectionManager(object): # retry once to reset connection with Keycloak after tomcat's ConnectionTimeout # see https://github.com/marcospereirampj/python-keycloak/issues/36 - for protocol in ('https://', 'http://'): + for protocol in ("https://", "http://"): adapter = HTTPAdapter(max_retries=1) # adds POST to retry whitelist allowed_methods = set(adapter.max_retries.allowed_methods) - allowed_methods.add('POST') + allowed_methods.add("POST") adapter.max_retries.allowed_methods = frozenset(allowed_methods) self._s.mount(protocol, adapter) - + if proxies: self._s.proxies.update(proxies) @@ -69,7 +69,7 @@ class ConnectionManager(object): @property def base_url(self): - """ Return base url in use for requests to the server. """ + """Return base url in use for requests to the server.""" return self._base_url @base_url.setter @@ -79,7 +79,7 @@ class ConnectionManager(object): @property def timeout(self): - """ Return timeout in use for request to the server. """ + """Return timeout in use for request to the server.""" return self._timeout @timeout.setter @@ -89,7 +89,7 @@ class ConnectionManager(object): @property def verify(self): - """ Return verify in use for request to the server. """ + """Return verify in use for request to the server.""" return self._verify @verify.setter @@ -99,7 +99,7 @@ class ConnectionManager(object): @property def headers(self): - """ Return header request to the server. """ + """Return header request to the server.""" return self._headers @headers.setter @@ -108,7 +108,7 @@ class ConnectionManager(object): self._headers = value def param_headers(self, key): - """ Return a specific header parameter. + """Return a specific header parameter. :arg key (str): Header parameters key. :return: @@ -117,11 +117,11 @@ class ConnectionManager(object): return self.headers.get(key) def clean_headers(self): - """ Clear header parameters. """ + """Clear header parameters.""" self.headers = {} def exist_param_headers(self, key): - """ Check if the parameter exists in the header. + """Check if the parameter exists in the header. :arg key (str): Header parameters key. :return: @@ -130,7 +130,7 @@ class ConnectionManager(object): return self.param_headers(key) is not None def add_param_headers(self, key, value): - """ Add a single parameter inside the header. + """Add a single parameter inside the header. :arg key (str): Header parameters key. value (str): Value to be added. @@ -138,14 +138,14 @@ class ConnectionManager(object): self.headers[key] = value def del_param_headers(self, key): - """ Remove a specific parameter. + """Remove a specific parameter. :arg key (str): Key of the header parameters. """ self.headers.pop(key, None) def raw_get(self, path, **kwargs): - """ Submit get request to the path. + """Submit get request to the path. :arg path (str): Path for request. :return @@ -155,17 +155,18 @@ class ConnectionManager(object): """ try: - return self._s.get(urljoin(self.base_url, path), - params=kwargs, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) + return self._s.get( + urljoin(self.base_url, path), + params=kwargs, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) + raise KeycloakConnectionError("Can't connect to server (%s)" % e) def raw_post(self, path, data, **kwargs): - """ Submit post request to the path. + """Submit post request to the path. :arg path (str): Path for request. data (dict): Payload for request. @@ -175,18 +176,19 @@ class ConnectionManager(object): HttpError: Can't connect to server. """ try: - return self._s.post(urljoin(self.base_url, path), - params=kwargs, - data=data, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) + return self._s.post( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) + raise KeycloakConnectionError("Can't connect to server (%s)" % e) def raw_put(self, path, data, **kwargs): - """ Submit put request to the path. + """Submit put request to the path. :arg path (str): Path for request. data (dict): Payload for request. @@ -196,18 +198,19 @@ class ConnectionManager(object): HttpError: Can't connect to server. """ try: - return self._s.put(urljoin(self.base_url, path), - params=kwargs, - data=data, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) + return self._s.put( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) + raise KeycloakConnectionError("Can't connect to server (%s)" % e) def raw_delete(self, path, data={}, **kwargs): - """ Submit delete request to the path. + """Submit delete request to the path. :arg path (str): Path for request. @@ -218,12 +221,13 @@ class ConnectionManager(object): HttpError: Can't connect to server. """ try: - return self._s.delete(urljoin(self.base_url, path), - params=kwargs, - data=data, - headers=self.headers, - timeout=self.timeout, - verify=self.verify) + return self._s.delete( + urljoin(self.base_url, path), + params=kwargs, + data=data, + headers=self.headers, + timeout=self.timeout, + verify=self.verify, + ) except Exception as e: - raise KeycloakConnectionError( - "Can't connect to server (%s)" % e) + raise KeycloakConnectionError("Can't connect to server (%s)" % e) diff --git a/keycloak/exceptions.py b/keycloak/exceptions.py index 67da62a..a9c1b2b 100644 --- a/keycloak/exceptions.py +++ b/keycloak/exceptions.py @@ -25,8 +25,7 @@ import requests class KeycloakError(Exception): - def __init__(self, error_message="", response_code=None, - response_body=None): + def __init__(self, error_message="", response_code=None, response_body=None): Exception.__init__(self, error_message) @@ -56,10 +55,23 @@ class KeycloakOperationError(KeycloakError): class KeycloakDeprecationError(KeycloakError): pass + class KeycloakGetError(KeycloakOperationError): pass +class KeycloakPostError(KeycloakOperationError): + pass + + +class KeycloakPutError(KeycloakOperationError): + pass + + +class KeycloakDeleteError(KeycloakOperationError): + pass + + class KeycloakSecretNotFound(KeycloakOperationError): pass @@ -90,10 +102,10 @@ def raise_error_from_response(response, error, expected_codes=None, skip_exists= return response.content if skip_exists and response.status_code == 409: - return {"Already exists"} + return {"msg": "Already exists"} try: - message = response.json()['message'] + message = response.json()["message"] except (KeyError, ValueError): message = response.content @@ -103,6 +115,6 @@ def raise_error_from_response(response, error, expected_codes=None, skip_exists= if response.status_code == 401: error = KeycloakAuthenticationError - raise error(error_message=message, - response_code=response.status_code, - response_body=response.content) + raise error( + error_message=message, response_code=response.status_code, response_body=response.content + ) diff --git a/keycloak/keycloak_admin.py b/keycloak/keycloak_admin.py index 41ecab8..41f4567 100644 --- a/keycloak/keycloak_admin.py +++ b/keycloak/keycloak_admin.py @@ -29,33 +29,91 @@ from builtins import isinstance from typing import Iterable from .connection import ConnectionManager -from .exceptions import raise_error_from_response, KeycloakGetError +from .exceptions import KeycloakGetError, raise_error_from_response from .keycloak_openid import KeycloakOpenID - -from .urls_patterns import URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS, URL_ADMIN_CLIENT_AUTHZ_POLICIES, \ - URL_ADMIN_CLIENT_AUTHZ_SCOPES, URL_ADMIN_SERVER_INFO, URL_ADMIN_CLIENT_AUTHZ_RESOURCES, URL_ADMIN_CLIENT_ROLES, \ - URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY, URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION, \ - URL_ADMIN_GET_SESSIONS, URL_ADMIN_RESET_PASSWORD, URL_ADMIN_SEND_UPDATE_ACCOUNT, URL_ADMIN_GROUPS_REALM_ROLES, \ - URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE, URL_ADMIN_CLIENT_INSTALLATION_PROVIDER, \ - URL_ADMIN_REALM_ROLES_ROLE_BY_NAME, URL_ADMIN_GROUPS_CLIENT_ROLES, \ - URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, URL_ADMIN_USER_GROUP, URL_ADMIN_REALM_ROLES, URL_ADMIN_GROUP_CHILD, \ - URL_ADMIN_USER_CONSENTS, URL_ADMIN_SEND_VERIFY_EMAIL, URL_ADMIN_CLIENT, URL_ADMIN_USER, URL_ADMIN_CLIENT_ROLE, \ - URL_ADMIN_USER_GROUPS, URL_ADMIN_CLIENTS, URL_ADMIN_FLOWS_EXECUTIONS, URL_ADMIN_GROUPS, URL_ADMIN_USER_CLIENT_ROLES, \ - URL_ADMIN_REALMS, URL_ADMIN_USERS_COUNT, URL_ADMIN_FLOWS, URL_ADMIN_GROUP, URL_ADMIN_CLIENT_AUTHZ_SETTINGS, \ - URL_ADMIN_GROUP_MEMBERS, URL_ADMIN_USER_STORAGE, URL_ADMIN_GROUP_PERMISSIONS, URL_ADMIN_IDPS, URL_ADMIN_IDP, \ - URL_ADMIN_IDP_MAPPERS, URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, URL_ADMIN_USERS, URL_ADMIN_CLIENT_SCOPES, \ - URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER, URL_ADMIN_CLIENT_SCOPE, URL_ADMIN_CLIENT_SECRETS, \ - URL_ADMIN_USER_REALM_ROLES, URL_ADMIN_USER_REALM_ROLES_AVAILABLE, URL_ADMIN_USER_REALM_ROLES_COMPOSITE, \ - URL_ADMIN_REALM, URL_ADMIN_COMPONENTS, URL_ADMIN_COMPONENT, URL_ADMIN_KEYS, \ - URL_ADMIN_USER_FEDERATED_IDENTITY, URL_ADMIN_USER_FEDERATED_IDENTITIES, URL_ADMIN_CLIENT_ROLE_MEMBERS, \ - URL_ADMIN_REALM_ROLES_MEMBERS, URL_ADMIN_CLIENT_PROTOCOL_MAPPER, URL_ADMIN_CLIENT_SCOPES_MAPPERS, \ - URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION, URL_ADMIN_FLOWS_EXECUTIONS_FLOW, URL_ADMIN_FLOWS_COPY, \ - URL_ADMIN_FLOWS_ALIAS, URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER, URL_ADMIN_AUTHENTICATOR_CONFIG, \ - URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE, URL_ADMIN_CLIENT_ALL_SESSIONS, URL_ADMIN_EVENTS, \ - URL_ADMIN_REALM_EXPORT, URL_ADMIN_DELETE_USER_ROLE, URL_ADMIN_USER_LOGOUT, URL_ADMIN_FLOWS_EXECUTION, \ - URL_ADMIN_FLOW, URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES, URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE, \ - URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES, URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE, \ - URL_ADMIN_USER_CREDENTIALS, URL_ADMIN_USER_CREDENTIAL, URL_ADMIN_CLIENT_PROTOCOL_MAPPERS +from .urls_patterns import ( + URL_ADMIN_AUTHENTICATOR_CONFIG, + URL_ADMIN_CLIENT, + URL_ADMIN_CLIENT_ALL_SESSIONS, + URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS, + URL_ADMIN_CLIENT_AUTHZ_POLICIES, + URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION, + URL_ADMIN_CLIENT_AUTHZ_RESOURCES, + URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY, + URL_ADMIN_CLIENT_AUTHZ_SCOPES, + URL_ADMIN_CLIENT_AUTHZ_SETTINGS, + URL_ADMIN_CLIENT_INSTALLATION_PROVIDER, + URL_ADMIN_CLIENT_PROTOCOL_MAPPER, + URL_ADMIN_CLIENT_PROTOCOL_MAPPERS, + URL_ADMIN_CLIENT_ROLE, + URL_ADMIN_CLIENT_ROLE_MEMBERS, + URL_ADMIN_CLIENT_ROLES, + URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE, + URL_ADMIN_CLIENT_SCOPE, + URL_ADMIN_CLIENT_SCOPES, + URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER, + URL_ADMIN_CLIENT_SCOPES_MAPPERS, + URL_ADMIN_CLIENT_SECRETS, + URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER, + URL_ADMIN_CLIENTS, + URL_ADMIN_COMPONENT, + URL_ADMIN_COMPONENTS, + URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE, + URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES, + URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE, + URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES, + URL_ADMIN_DELETE_USER_ROLE, + URL_ADMIN_EVENTS, + URL_ADMIN_FLOW, + URL_ADMIN_FLOWS, + URL_ADMIN_FLOWS_ALIAS, + URL_ADMIN_FLOWS_COPY, + URL_ADMIN_FLOWS_EXECUTION, + URL_ADMIN_FLOWS_EXECUTIONS, + URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION, + URL_ADMIN_FLOWS_EXECUTIONS_FLOW, + URL_ADMIN_GET_SESSIONS, + URL_ADMIN_GROUP, + URL_ADMIN_GROUP_CHILD, + URL_ADMIN_GROUP_MEMBERS, + URL_ADMIN_GROUP_PERMISSIONS, + URL_ADMIN_GROUPS, + URL_ADMIN_GROUPS_CLIENT_ROLES, + URL_ADMIN_GROUPS_REALM_ROLES, + URL_ADMIN_IDP, + URL_ADMIN_IDP_MAPPERS, + URL_ADMIN_IDPS, + URL_ADMIN_KEYS, + URL_ADMIN_REALM, + URL_ADMIN_REALM_EXPORT, + URL_ADMIN_REALM_ROLES, + URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE, + URL_ADMIN_REALM_ROLES_MEMBERS, + URL_ADMIN_REALM_ROLES_ROLE_BY_NAME, + URL_ADMIN_REALMS, + URL_ADMIN_RESET_PASSWORD, + URL_ADMIN_SEND_UPDATE_ACCOUNT, + URL_ADMIN_SEND_VERIFY_EMAIL, + URL_ADMIN_SERVER_INFO, + URL_ADMIN_USER, + URL_ADMIN_USER_CLIENT_ROLES, + URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, + URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, + URL_ADMIN_USER_CONSENTS, + URL_ADMIN_USER_CREDENTIAL, + URL_ADMIN_USER_CREDENTIALS, + URL_ADMIN_USER_FEDERATED_IDENTITIES, + URL_ADMIN_USER_FEDERATED_IDENTITY, + URL_ADMIN_USER_GROUP, + URL_ADMIN_USER_GROUPS, + URL_ADMIN_USER_LOGOUT, + URL_ADMIN_USER_REALM_ROLES, + URL_ADMIN_USER_REALM_ROLES_AVAILABLE, + URL_ADMIN_USER_REALM_ROLES_COMPOSITE, + URL_ADMIN_USER_STORAGE, + URL_ADMIN_USERS, + URL_ADMIN_USERS_COUNT, +) class KeycloakAdmin: @@ -75,8 +133,19 @@ class KeycloakAdmin: _custom_headers = None _user_realm_name = None - def __init__(self, server_url, username=None, password=None, realm_name='master', client_id='admin-cli', verify=True, - client_secret_key=None, custom_headers=None, user_realm_name=None, auto_refresh_token=None): + def __init__( + self, + server_url, + username=None, + password=None, + realm_name="master", + client_id="admin-cli", + verify=True, + client_secret_key=None, + custom_headers=None, + user_realm_name=None, + auto_refresh_token=None, + ): """ :param server_url: Keycloak server url @@ -198,40 +267,46 @@ class KeycloakAdmin: @auto_refresh_token.setter def auto_refresh_token(self, value): - allowed_methods = {'get', 'post', 'put', 'delete'} + allowed_methods = {"get", "post", "put", "delete"} if not isinstance(value, Iterable): - raise TypeError('Expected a list of strings among {allowed}'.format(allowed=allowed_methods)) + raise TypeError( + "Expected a list of strings among {allowed}".format(allowed=allowed_methods) + ) if not all(method in allowed_methods for method in value): - raise TypeError('Unexpected method in auto_refresh_token, accepted methods are {allowed}'.format(allowed=allowed_methods)) + raise TypeError( + "Unexpected method in auto_refresh_token, accepted methods are {allowed}".format( + allowed=allowed_methods + ) + ) self._auto_refresh_token = value def __fetch_all(self, url, query=None): - '''Wrapper function to paginate GET requests + """Wrapper function to paginate GET requests :param url: The url on which the query is executed :param query: Existing query parameters (optional) :return: Combined results of paginated queries - ''' + """ results = [] # initalize query if it was called with None if not query: query = {} page = 0 - query['max'] = self.PAGE_SIZE + query["max"] = self.PAGE_SIZE # fetch until we can while True: - query['first'] = page*self.PAGE_SIZE + query["first"] = page * self.PAGE_SIZE partial_results = raise_error_from_response( - self.raw_get(url, **query), - KeycloakGetError) + self.raw_get(url, **query), KeycloakGetError + ) if not partial_results: break results.extend(partial_results) - if len(partial_results) < query['max']: + if len(partial_results) < query["max"]: break page += 1 return results @@ -239,9 +314,7 @@ class KeycloakAdmin: def __fetch_paginated(self, url, query=None): query = query or {} - return raise_error_from_response( - self.raw_get(url, **query), - KeycloakGetError) + return raise_error_from_response(self.raw_get(url, **query), KeycloakGetError) def import_realm(self, payload): """ @@ -255,8 +328,7 @@ class KeycloakAdmin: :return: RealmRepresentation """ - data_raw = self.raw_post(URL_ADMIN_REALMS, - data=json.dumps(payload)) + data_raw = self.raw_post(URL_ADMIN_REALMS, data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) def export_realm(self, export_clients=False, export_groups_and_role=False): @@ -271,7 +343,11 @@ class KeycloakAdmin: :return: realm configurations JSON """ - params_path = {"realm-name": self.realm_name, "export-clients": export_clients, "export-groups-and-roles": export_groups_and_role } + params_path = { + "realm-name": self.realm_name, + "export-clients": export_clients, + "export-groups-and-roles": export_groups_and_role, + } data_raw = self.raw_post(URL_ADMIN_REALM_EXPORT.format(**params_path), data="") return raise_error_from_response(data_raw, KeycloakGetError) @@ -296,9 +372,10 @@ class KeycloakAdmin: :return: Keycloak server response (RealmRepresentation) """ - data_raw = self.raw_post(URL_ADMIN_REALMS, - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post(URL_ADMIN_REALMS, data=json.dumps(payload)) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def update_realm(self, realm_name, payload): """ @@ -314,8 +391,7 @@ class KeycloakAdmin: """ params_path = {"realm-name": realm_name} - data_raw = self.raw_put(URL_ADMIN_REALM.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put(URL_ADMIN_REALM.format(**params_path), data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_realm(self, realm_name): @@ -359,8 +435,7 @@ class KeycloakAdmin: :param: payload: IdentityProviderRepresentation """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_IDPS.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post(URL_ADMIN_IDPS.format(**params_path), data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) def add_mapper_to_idp(self, idp_alias, payload): @@ -374,8 +449,9 @@ class KeycloakAdmin: :param: payload: IdentityProviderMapperRepresentation """ params_path = {"realm-name": self.realm_name, "idp-alias": idp_alias} - data_raw = self.raw_post(URL_ADMIN_IDP_MAPPERS.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_IDP_MAPPERS.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) def get_idps(self): @@ -416,16 +492,15 @@ class KeycloakAdmin: params_path = {"realm-name": self.realm_name} if exist_ok: - exists = self.get_user_id(username=payload['username']) + exists = self.get_user_id(username=payload["username"]) if exists is not None: return str(exists) - data_raw = self.raw_post(URL_ADMIN_USERS.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post(URL_ADMIN_USERS.format(**params_path), data=json.dumps(payload)) raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) - _last_slash_idx = data_raw.headers['Location'].rindex('/') - return data_raw.headers['Location'][_last_slash_idx + 1:] + _last_slash_idx = data_raw.headers["Location"].rindex("/") + return data_raw.headers["Location"][_last_slash_idx + 1 :] def users_count(self): """ @@ -490,8 +565,7 @@ class KeycloakAdmin: :return: Http response """ params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.raw_put(URL_ADMIN_USER.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put(URL_ADMIN_USER.format(**params_path), data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_user(self, user_id): @@ -522,8 +596,9 @@ class KeycloakAdmin: """ payload = {"type": "password", "temporary": temporary, "value": password} params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.raw_put(URL_ADMIN_RESET_PASSWORD.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_RESET_PASSWORD.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_credentials(self, user_id): @@ -551,7 +626,11 @@ class KeycloakAdmin: :param: credential_id: credential id :return: Keycloak server response (ClientRepresentation) """ - params_path = {"realm-name": self.realm_name, "id": user_id, "credential_id": credential_id} + params_path = { + "realm-name": self.realm_name, + "id": user_id, + "credential_id": credential_id, + } data_raw = self.raw_get(URL_ADMIN_USER_CREDENTIAL.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) @@ -566,7 +645,11 @@ class KeycloakAdmin: :param: credential_id: credential id :return: Keycloak server response (ClientRepresentation) """ - params_path = {"realm-name": self.realm_name, "id": user_id, "credential_id": credential_id} + params_path = { + "realm-name": self.realm_name, + "id": user_id, + "credential_id": credential_id, + } data_raw = self.raw_delete(URL_ADMIN_USER_CREDENTIAL.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) @@ -615,9 +698,15 @@ class KeycloakAdmin: :param provider_username: username specified by the provider :return: """ - payload = {"identityProvider": provider_id, "userId": provider_userid, "userName": provider_username} + payload = { + "identityProvider": provider_id, + "userId": provider_userid, + "userName": provider_username, + } params_path = {"realm-name": self.realm_name, "id": user_id, "provider": provider_id} - data_raw = self.raw_post(URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path), data=json.dumps(payload) + ) def delete_user_social_login(self, user_id, provider_id): @@ -631,7 +720,9 @@ class KeycloakAdmin: data_raw = self.raw_delete(URL_ADMIN_USER_FEDERATED_IDENTITY.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) - def send_update_account(self, user_id, payload, client_id=None, lifespan=None, redirect_uri=None): + def send_update_account( + self, user_id, payload, client_id=None, lifespan=None, redirect_uri=None + ): """ Send an update account email to the user. An email contains a link the user can click to perform a set of required actions. @@ -646,8 +737,11 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": user_id} params_query = {"client_id": client_id, "lifespan": lifespan, "redirect_uri": redirect_uri} - data_raw = self.raw_put(URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), - data=json.dumps(payload), **params_query) + data_raw = self.raw_put( + URL_ADMIN_SEND_UPDATE_ACCOUNT.format(**params_path), + data=json.dumps(payload), + **params_query + ) return raise_error_from_response(data_raw, KeycloakGetError) def send_verify_email(self, user_id, client_id=None, redirect_uri=None): @@ -663,8 +757,9 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": user_id} params_query = {"client_id": client_id, "redirect_uri": redirect_uri} - data_raw = self.raw_put(URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), - data={}, **params_query) + data_raw = self.raw_put( + URL_ADMIN_SEND_VERIFY_EMAIL.format(**params_path), data={}, **params_query + ) return raise_error_from_response(data_raw, KeycloakGetError) def get_sessions(self, user_id): @@ -740,7 +835,7 @@ class KeycloakAdmin: """ for subgroup in group["subGroups"]: - if subgroup['path'] == path: + if subgroup["path"] == path: return subgroup elif subgroup["subGroups"]: for subgroup in group["subGroups"]: @@ -787,11 +882,11 @@ class KeycloakAdmin: # TODO: Review this code is necessary for group in groups: - if group['path'] == path: + if group["path"] == path: return group elif search_in_subgroups and group["subGroups"]: for group in group["subGroups"]: - if group['path'] == path: + if group["path"] == path: return group res = self.get_subgroups(group, path) if res != None: @@ -814,14 +909,18 @@ class KeycloakAdmin: if parent is None: params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_GROUPS.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_GROUPS.format(**params_path), data=json.dumps(payload) + ) else: - params_path = {"realm-name": self.realm_name, "id": parent, } - data_raw = self.raw_post(URL_ADMIN_GROUP_CHILD.format(**params_path), - data=json.dumps(payload)) + params_path = {"realm-name": self.realm_name, "id": parent} + data_raw = self.raw_post( + URL_ADMIN_GROUP_CHILD.format(**params_path), data=json.dumps(payload) + ) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def update_group(self, group_id, payload): """ @@ -837,8 +936,7 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.raw_put(URL_ADMIN_GROUP.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put(URL_ADMIN_GROUP.format(**params_path), data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def group_set_permissions(self, group_id, enabled=True): @@ -851,8 +949,10 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.raw_put(URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), - data=json.dumps({"enabled": enabled})) + data_raw = self.raw_put( + URL_ADMIN_GROUP_PERMISSIONS.format(**params_path), + data=json.dumps({"enabled": enabled}), + ) return raise_error_from_response(data_raw, KeycloakGetError) def group_user_add(self, user_id, group_id): @@ -935,7 +1035,7 @@ class KeycloakAdmin: clients = self.get_clients() for client in clients: - if client_name == client.get('name') or client_name == client.get('clientId'): + if client_name == client.get("name") or client_name == client.get("clientId"): return client["id"] return None @@ -965,12 +1065,14 @@ class KeycloakAdmin: :return: Keycloak server response """ - params_path = {"realm-name": self.realm_name, - "id": client_id} + params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.raw_post(URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_CLIENT_AUTHZ_RESOURCES.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def get_client_authz_resources(self, client_id): """ @@ -1008,12 +1110,15 @@ class KeycloakAdmin: :return: Keycloak server response """ - params_path = {"realm-name": self.realm_name, - "id": client_id} + params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.raw_post(URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def create_client_authz_resource_based_permission(self, client_id, payload, skip_exists=False): """ @@ -1039,12 +1144,15 @@ class KeycloakAdmin: :return: Keycloak server response """ - params_path = {"realm-name": self.realm_name, - "id": client_id} + params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.raw_post(URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def get_client_authz_scopes(self, client_id): """ @@ -1110,9 +1218,10 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_CLIENTS.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post(URL_ADMIN_CLIENTS.format(**params_path), data=json.dumps(payload)) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def update_client(self, client_id, payload): """ @@ -1124,8 +1233,7 @@ class KeycloakAdmin: :return: Http response """ params_path = {"realm-name": self.realm_name, "id": client_id} - data_raw = self.raw_put(URL_ADMIN_CLIENT.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put(URL_ADMIN_CLIENT.format(**params_path), data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_client(self, client_id): @@ -1182,7 +1290,7 @@ class KeycloakAdmin: :param query: Additional Query parameters (see https://www.keycloak.org/docs-api/11.0/rest-api/index.html#_roles_resource) :return: Keycloak Server Response (UserRepresentation) """ - params_path = {"realm-name": self.realm_name, "role-name":role_name} + params_path = {"realm-name": self.realm_name, "role-name": role_name} return self.__fetch_all(URL_ADMIN_REALM_ROLES_MEMBERS.format(**params_path), query) def get_client_roles(self, client_id): @@ -1250,9 +1358,12 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": client_role_id} - data_raw = self.raw_post(URL_ADMIN_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_CLIENT_ROLES.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def add_composite_client_roles_to_role(self, client_role_id, role_name, roles): """ @@ -1266,8 +1377,10 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": client_role_id, "role-name": role_name} - data_raw = self.raw_post(URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_CLIENT_ROLES_COMPOSITE_CLIENT_ROLE.format(**params_path), + data=json.dumps(payload), + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_client_role(self, client_role_id, role_name): @@ -1296,8 +1409,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": user_id, "client-id": client_id} - data_raw = self.raw_post(URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_client_role_members(self, client_id, role_name, **query): @@ -1308,9 +1422,8 @@ class KeycloakAdmin: :param query: Additional query parameters ( see https://www.keycloak.org/docs-api/11.0/rest-api/index.html#_clients_resource) :return: Keycloak server response (UserRepresentation) """ - params_path = {"realm-name": self.realm_name, "id":client_id, "role-name":role_name} - return self.__fetch_all(URL_ADMIN_CLIENT_ROLE_MEMBERS.format(**params_path) , query) - + params_path = {"realm-name": self.realm_name, "id": client_id, "role-name": role_name} + return self.__fetch_all(URL_ADMIN_CLIENT_ROLE_MEMBERS.format(**params_path), query) def create_realm_role(self, payload, skip_exists=False): """ @@ -1322,9 +1435,12 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_REALM_ROLES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_REALM_ROLES.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def get_realm_role(self, role_name): """ @@ -1348,8 +1464,9 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "role-name": role_name} - data_raw = self.raw_put(URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_realm_role(self, role_name): @@ -1360,8 +1477,7 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "role-name": role_name} - data_raw = self.raw_delete( - URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path)) + data_raw = self.raw_delete(URL_ADMIN_REALM_ROLES_ROLE_BY_NAME.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def add_composite_realm_roles_to_role(self, role_name, roles): @@ -1377,9 +1493,9 @@ class KeycloakAdmin: params_path = {"realm-name": self.realm_name, "role-name": role_name} data_raw = self.raw_post( URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, - expected_codes=[204]) + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def remove_composite_realm_roles_to_role(self, role_name, roles): """ @@ -1394,9 +1510,9 @@ class KeycloakAdmin: params_path = {"realm-name": self.realm_name, "role-name": role_name} data_raw = self.raw_delete( URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, - expected_codes=[204]) + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_composite_realm_roles_of_role(self, role_name): """ @@ -1407,8 +1523,7 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "role-name": role_name} - data_raw = self.raw_get( - URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path)) + data_raw = self.raw_get(URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) def assign_realm_roles(self, user_id, roles): @@ -1422,8 +1537,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.raw_post(URL_ADMIN_USER_REALM_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_USER_REALM_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_realm_roles_of_user(self, user_id, roles): @@ -1437,8 +1553,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": user_id} - data_raw = self.raw_delete(URL_ADMIN_USER_REALM_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_delete( + URL_ADMIN_USER_REALM_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_realm_roles_of_user(self, user_id): @@ -1484,8 +1601,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.raw_post(URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_group_realm_roles(self, group_id, roles): @@ -1499,8 +1617,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": group_id} - data_raw = self.raw_delete(URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_delete( + URL_ADMIN_GROUPS_REALM_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_group_realm_roles(self, group_id): @@ -1526,8 +1645,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": group_id, "client-id": client_id} - data_raw = self.raw_post(URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_group_client_roles(self, group_id, client_id): @@ -1555,8 +1675,9 @@ class KeycloakAdmin: payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": group_id, "client-id": client_id} - data_raw = self.raw_delete(URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_delete( + URL_ADMIN_GROUPS_CLIENT_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_client_roles_of_user(self, user_id, client_id): @@ -1577,7 +1698,9 @@ class KeycloakAdmin: :param client_id: id of client (not client-id) :return: Keycloak server response (array RoleRepresentation) """ - return self._get_client_roles_of_user(URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, user_id, client_id) + return self._get_client_roles_of_user( + URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE, user_id, client_id + ) def get_composite_client_roles_of_user(self, user_id, client_id): """ @@ -1587,7 +1710,9 @@ class KeycloakAdmin: :param client_id: id of client (not client-id) :return: Keycloak server response (array RoleRepresentation) """ - return self._get_client_roles_of_user(URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, user_id, client_id) + return self._get_client_roles_of_user( + URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE, user_id, client_id + ) def _get_client_roles_of_user(self, client_level_role_mapping_url, user_id, client_id): params_path = {"realm-name": self.realm_name, "id": user_id, "client-id": client_id} @@ -1605,8 +1730,9 @@ class KeycloakAdmin: """ payload = roles if isinstance(roles, list) else [roles] params_path = {"realm-name": self.realm_name, "id": user_id, "client-id": client_id} - data_raw = self.raw_delete(URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_delete( + URL_ADMIN_USER_CLIENT_ROLES.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_authentication_flows(self): @@ -1649,9 +1775,10 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_FLOWS.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post(URL_ADMIN_FLOWS.format(**params_path), data=json.dumps(payload)) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def copy_authentication_flow(self, payload, flow_alias): """ @@ -1663,8 +1790,9 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "flow-alias": flow_alias} - data_raw = self.raw_post(URL_ADMIN_FLOWS_COPY.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_FLOWS_COPY.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) def delete_authentication_flow(self, flow_id): @@ -1705,9 +1833,10 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "flow-alias": flow_alias} - data_raw = self.raw_put(URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[202,204]) + data_raw = self.raw_put( + URL_ADMIN_FLOWS_EXECUTIONS.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[202, 204]) def get_authentication_flow_execution(self, execution_id): """ @@ -1736,8 +1865,9 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "flow-alias": flow_alias} - data_raw = self.raw_post(URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) def delete_authentication_flow_execution(self, execution_id): @@ -1768,9 +1898,12 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "flow-alias": flow_alias} - data_raw = self.raw_post(URL_ADMIN_FLOWS_EXECUTIONS_FLOW.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_FLOWS_EXECUTIONS_FLOW.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def get_authenticator_config(self, config_id): """ @@ -1795,8 +1928,9 @@ class KeycloakAdmin: :return: Response(json) """ params_path = {"realm-name": self.realm_name, "id": config_id} - data_raw = self.raw_put(URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_AUTHENTICATOR_CONFIG.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_authenticator_config(self, config_id): @@ -1821,12 +1955,13 @@ class KeycloakAdmin: :param action: Action can be "triggerFullSync" or "triggerChangedUsersSync" :return: """ - data = {'action': action} + data = {"action": action} params_query = {"action": action} params_path = {"realm-name": self.realm_name, "id": storage_id} - data_raw = self.raw_post(URL_ADMIN_USER_STORAGE.format(**params_path), - data=json.dumps(data), **params_query) + data_raw = self.raw_post( + URL_ADMIN_USER_STORAGE.format(**params_path), data=json.dumps(data), **params_query + ) return raise_error_from_response(data_raw, KeycloakGetError) def get_client_scopes(self): @@ -1866,9 +2001,12 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_CLIENT_SCOPES.format(**params_path), - data=json.dumps(payload)) - return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists) + data_raw = self.raw_post( + URL_ADMIN_CLIENT_SCOPES.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response( + data_raw, KeycloakGetError, expected_codes=[201], skip_exists=skip_exists + ) def update_client_scope(self, client_scope_id, payload): """ @@ -1882,8 +2020,9 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "scope-id": client_scope_id} - data_raw = self.raw_put(URL_ADMIN_CLIENT_SCOPE.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_CLIENT_SCOPE.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def add_mapper_to_client_scope(self, client_scope_id, payload): @@ -1899,7 +2038,8 @@ class KeycloakAdmin: params_path = {"realm-name": self.realm_name, "scope-id": client_scope_id} data_raw = self.raw_post( - URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), data=json.dumps(payload)) + URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) @@ -1913,11 +2053,13 @@ class KeycloakAdmin: :return: Keycloak server Response """ - params_path = {"realm-name": self.realm_name, "scope-id": client_scope_id, - "protocol-mapper-id": protocol_mppaer_id} + params_path = { + "realm-name": self.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mppaer_id, + } - data_raw = self.raw_delete( - URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path)) + data_raw = self.raw_delete(URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) @@ -1933,11 +2075,15 @@ class KeycloakAdmin: :return: Keycloak server Response """ - params_path = {"realm-name": self.realm_name, "scope-id": client_scope_id, - "protocol-mapper-id": protocol_mapper_id} + params_path = { + "realm-name": self.realm_name, + "scope-id": client_scope_id, + "protocol-mapper-id": protocol_mapper_id, + } data_raw = self.raw_put( - URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), data=json.dumps(payload)) + URL_ADMIN_CLIENT_SCOPES_MAPPERS.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) @@ -1951,7 +2097,6 @@ class KeycloakAdmin: data_raw = self.raw_get(URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) - def delete_default_default_client_scope(self, scope_id): """ Delete default default client scope @@ -1963,7 +2108,6 @@ class KeycloakAdmin: data_raw = self.raw_delete(URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) - def add_default_default_client_scope(self, scope_id): """ Add default default client scope @@ -1973,10 +2117,11 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": scope_id} payload = {"realm": self.realm_name, "clientScopeId": scope_id} - data_raw = self.raw_put(URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPE.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) - def get_default_optional_client_scopes(self): """ Return list of default optional client scopes @@ -1987,7 +2132,6 @@ class KeycloakAdmin: data_raw = self.raw_get(URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPES.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) - def delete_default_optional_client_scope(self, scope_id): """ Delete default optional client scope @@ -1999,7 +2143,6 @@ class KeycloakAdmin: data_raw = self.raw_delete(URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) - def add_default_optional_client_scope(self, scope_id): """ Add default optional client scope @@ -2009,10 +2152,11 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name, "id": scope_id} payload = {"realm": self.realm_name, "clientScopeId": scope_id} - data_raw = self.raw_put(URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_DEFAULT_OPTIONAL_CLIENT_SCOPE.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) - def add_mapper_to_client(self, client_id, payload): """ Add a mapper to a client @@ -2026,28 +2170,30 @@ class KeycloakAdmin: params_path = {"realm-name": self.realm_name, "id": client_id} data_raw = self.raw_post( - URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), data=json.dumps(payload)) + URL_ADMIN_CLIENT_PROTOCOL_MAPPERS.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) - + def update_client_mapper(self, client_id, mapper_id, payload): """ Update client mapper :param client_id: The id of the client :param client_mapper_id: The id of the mapper to be deleted :param payload: ProtocolMapperRepresentation - :return: Keycloak server response + :return: Keycloak server response """ params_path = { "realm-name": self.realm_name, - "id": self.client_id, + "id": self.client_id, "protocol-mapper-id": mapper_id, } data_raw = self.raw_put( - URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), data=json.dumps(payload)) - + URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path), data=json.dumps(payload) + ) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def remove_client_mapper(self, client_id, client_mapper_id): @@ -2062,14 +2208,13 @@ class KeycloakAdmin: params_path = { "realm-name": self.realm_name, "id": client_id, - "protocol-mapper-id": client_mapper_id + "protocol-mapper-id": client_mapper_id, } - data_raw = self.raw_delete( - URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path)) - + data_raw = self.raw_delete(URL_ADMIN_CLIENT_PROTOCOL_MAPPER.format(**params_path)) + return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) - + def generate_client_secrets(self, client_id): """ @@ -2109,8 +2254,7 @@ class KeycloakAdmin: :return: components list """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_get(URL_ADMIN_COMPONENTS.format(**params_path), - data=None, **query) + data_raw = self.raw_get(URL_ADMIN_COMPONENTS.format(**params_path), data=None, **query) return raise_error_from_response(data_raw, KeycloakGetError) def create_component(self, payload): @@ -2126,8 +2270,9 @@ class KeycloakAdmin: """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_post(URL_ADMIN_COMPONENTS.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_post( + URL_ADMIN_COMPONENTS.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[201]) def get_component(self, component_id): @@ -2156,8 +2301,9 @@ class KeycloakAdmin: :return: Http response """ params_path = {"realm-name": self.realm_name, "component-id": component_id} - data_raw = self.raw_put(URL_ADMIN_COMPONENT.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put( + URL_ADMIN_COMPONENT.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def delete_component(self, component_id): @@ -2182,8 +2328,7 @@ class KeycloakAdmin: :return: keys list """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_get(URL_ADMIN_KEYS.format(**params_path), - data=None) + data_raw = self.raw_get(URL_ADMIN_KEYS.format(**params_path), data=None) return raise_error_from_response(data_raw, KeycloakGetError) def get_events(self, query=None): @@ -2196,8 +2341,7 @@ class KeycloakAdmin: :return: events list """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_get(URL_ADMIN_EVENTS.format(**params_path), - data=None, **query) + data_raw = self.raw_get(URL_ADMIN_EVENTS.format(**params_path), data=None, **query) return raise_error_from_response(data_raw, KeycloakGetError) def set_events(self, payload): @@ -2210,8 +2354,7 @@ class KeycloakAdmin: :return: Http response """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_put(URL_ADMIN_EVENTS.format(**params_path), - data=json.dumps(payload)) + data_raw = self.raw_put(URL_ADMIN_EVENTS.format(**params_path), data=json.dumps(payload)) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def raw_get(self, *args, **kwargs): @@ -2222,7 +2365,7 @@ class KeycloakAdmin: and try *get* once more. """ r = self.connection.raw_get(*args, **kwargs) - if 'get' in self.auto_refresh_token and r.status_code == 401: + if "get" in self.auto_refresh_token and r.status_code == 401: self.refresh_token() return self.connection.raw_get(*args, **kwargs) return r @@ -2235,7 +2378,7 @@ class KeycloakAdmin: and try *post* once more. """ r = self.connection.raw_post(*args, **kwargs) - if 'post' in self.auto_refresh_token and r.status_code == 401: + if "post" in self.auto_refresh_token and r.status_code == 401: self.refresh_token() return self.connection.raw_post(*args, **kwargs) return r @@ -2248,7 +2391,7 @@ class KeycloakAdmin: and try *put* once more. """ r = self.connection.raw_put(*args, **kwargs) - if 'put' in self.auto_refresh_token and r.status_code == 401: + if "put" in self.auto_refresh_token and r.status_code == 401: self.refresh_token() return self.connection.raw_put(*args, **kwargs) return r @@ -2261,7 +2404,7 @@ class KeycloakAdmin: and try *delete* once more. """ r = self.connection.raw_delete(*args, **kwargs) - if 'delete' in self.auto_refresh_token and r.status_code == 401: + if "delete" in self.auto_refresh_token and r.status_code == 401: self.refresh_token() return self.connection.raw_delete(*args, **kwargs) return r @@ -2273,11 +2416,15 @@ class KeycloakAdmin: token_realm_name = self.realm_name else: token_realm_name = "master" - - self.keycloak_openid = KeycloakOpenID(server_url=self.server_url, client_id=self.client_id, - realm_name=token_realm_name, verify=self.verify, - client_secret_key=self.client_secret_key, - custom_headers=self.custom_headers) + + self.keycloak_openid = KeycloakOpenID( + server_url=self.server_url, + client_id=self.client_id, + realm_name=token_realm_name, + verify=self.verify, + client_secret_key=self.client_secret_key, + custom_headers=self.custom_headers, + ) grant_type = ["password"] if self.client_secret_key: @@ -2286,11 +2433,13 @@ class KeycloakAdmin: self.realm_name = self.user_realm_name if self.username and self.password: - self._token = self.keycloak_openid.token(self.username, self.password, grant_type=grant_type) + self._token = self.keycloak_openid.token( + self.username, self.password, grant_type=grant_type + ) headers = { - 'Authorization': 'Bearer ' + self.token.get('access_token'), - 'Content-Type': 'application/json' + "Authorization": "Bearer " + self.token.get("access_token"), + "Content-Type": "application/json", } else: self._token = None @@ -2300,13 +2449,12 @@ class KeycloakAdmin: # merge custom headers to main headers headers.update(self.custom_headers) - self._connection = ConnectionManager(base_url=self.server_url, - headers=headers, - timeout=60, - verify=self.verify) + self._connection = ConnectionManager( + base_url=self.server_url, headers=headers, timeout=60, verify=self.verify + ) def refresh_token(self): - refresh_token = self.token.get('refresh_token', None) + refresh_token = self.token.get("refresh_token", None) if refresh_token is None: self.get_token() else: @@ -2314,16 +2462,18 @@ class KeycloakAdmin: self.token = self.keycloak_openid.refresh_token(refresh_token) except KeycloakGetError as e: list_errors = [ - b'Refresh token expired', - b'Token is not active', - b'Session not active' + b"Refresh token expired", + b"Token is not active", + b"Session not active", ] if e.response_code == 400 and any(err in e.response_body for err in list_errors): - self.get_token() + self.get_token() else: raise - - self.connection.add_param_headers('Authorization', 'Bearer ' + self.token.get('access_token')) + + self.connection.add_param_headers( + "Authorization", "Bearer " + self.token.get("access_token") + ) def get_client_all_sessions(self, client_id): """ @@ -2346,9 +2496,10 @@ class KeycloakAdmin: DELETE admin/realms/{realm-name}/users/{id}/role-mappings/realm """ - params_path = {"realm-name": self.realm_name, "id": str(user_id) } - data_raw = self.raw_delete(URL_ADMIN_DELETE_USER_ROLE.format(**params_path), - data=json.dumps(payload)) + params_path = {"realm-name": self.realm_name, "id": str(user_id)} + data_raw = self.raw_delete( + URL_ADMIN_DELETE_USER_ROLE.format(**params_path), data=json.dumps(payload) + ) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) def get_client_sessions_stats(self): @@ -2360,8 +2511,5 @@ class KeycloakAdmin: :return: Dict of clients and session count """ params_path = {"realm-name": self.realm_name} - data_raw = self.raw_get( - self.URL_ADMIN_CLIENT_SESSION_STATS.format(**params_path) - ) + data_raw = self.raw_get(self.URL_ADMIN_CLIENT_SESSION_STATS.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) - diff --git a/keycloak/keycloak_openid.py b/keycloak/keycloak_openid.py index 1d6ed28..d9c068f 100644 --- a/keycloak/keycloak_openid.py +++ b/keycloak/keycloak_openid.py @@ -27,24 +27,38 @@ from jose import jwt from .authorization import Authorization from .connection import ConnectionManager -from .exceptions import raise_error_from_response, KeycloakGetError, \ - KeycloakRPTNotFound, KeycloakAuthorizationConfigError, KeycloakInvalidTokenError, KeycloakDeprecationError +from .exceptions import ( + KeycloakAuthorizationConfigError, + KeycloakDeprecationError, + KeycloakGetError, + KeycloakInvalidTokenError, + KeycloakRPTNotFound, + raise_error_from_response, +) from .urls_patterns import ( - URL_REALM, URL_AUTH, + URL_CERTS, + URL_ENTITLEMENT, + URL_INTROSPECT, + URL_LOGOUT, + URL_REALM, URL_TOKEN, URL_USERINFO, URL_WELL_KNOWN, - URL_LOGOUT, - URL_CERTS, - URL_ENTITLEMENT, - URL_INTROSPECT ) class KeycloakOpenID: - - def __init__(self, server_url, realm_name, client_id, client_secret_key=None, verify=True, custom_headers=None, proxies=None): + def __init__( + self, + server_url, + realm_name, + client_id, + client_secret_key=None, + verify=True, + custom_headers=None, + proxies=None, + ): """ :param server_url: Keycloak server url @@ -62,11 +76,9 @@ class KeycloakOpenID: if custom_headers is not None: # merge custom headers to main headers headers.update(custom_headers) - self._connection = ConnectionManager(base_url=server_url, - headers=headers, - timeout=60, - verify=verify, - proxies=proxies) + self._connection = ConnectionManager( + base_url=server_url, headers=headers, timeout=60, verify=verify, proxies=proxies + ) self._authorization = Authorization() @@ -138,7 +150,7 @@ class KeycloakOpenID: :param kwargs: :return: """ - if method_token_info == 'introspect': + if method_token_info == "introspect": token_info = self.introspect(token) else: token_info = self.decode_token(token, **kwargs) @@ -146,11 +158,11 @@ class KeycloakOpenID: return token_info def well_know(self): - """ The most important endpoint to understand is the well-known configuration - endpoint. It lists endpoints and other configuration options relevant to - the OpenID Connect implementation in Keycloak. + """The most important endpoint to understand is the well-known configuration + endpoint. It lists endpoints and other configuration options relevant to + the OpenID Connect implementation in Keycloak. - :return It lists endpoints and other configuration options relevant. + :return It lists endpoints and other configuration options relevant. """ params_path = {"realm-name": self.realm_name} @@ -165,12 +177,23 @@ class KeycloakOpenID: :return: """ - params_path = {"authorization-endpoint": self.well_know()['authorization_endpoint'], - "client-id": self.client_id, - "redirect-uri": redirect_uri} + params_path = { + "authorization-endpoint": self.well_know()["authorization_endpoint"], + "client-id": self.client_id, + "redirect-uri": redirect_uri, + } return URL_AUTH.format(**params_path) - def token(self, username="", password="", grant_type=["password"], code="", redirect_uri="", totp=None, **extra): + def token( + self, + username="", + password="", + grant_type=["password"], + code="", + redirect_uri="", + totp=None, + **extra + ): """ The token endpoint is used to obtain tokens. Tokens can either be obtained by exchanging an authorization code or by supplying credentials directly depending on @@ -188,9 +211,14 @@ class KeycloakOpenID: :return: """ params_path = {"realm-name": self.realm_name} - payload = {"username": username, "password": password, - "client_id": self.client_id, "grant_type": grant_type, - "code": code, "redirect_uri": redirect_uri} + payload = { + "username": username, + "password": password, + "client_id": self.client_id, + "grant_type": grant_type, + "code": code, + "redirect_uri": redirect_uri, + } if extra: payload.update(extra) @@ -198,8 +226,7 @@ class KeycloakOpenID: payload["totp"] = totp payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), - data=payload) + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) return raise_error_from_response(data_raw, KeycloakGetError) def refresh_token(self, refresh_token, grant_type=["refresh_token"]): @@ -216,10 +243,13 @@ class KeycloakOpenID: :return: """ params_path = {"realm-name": self.realm_name} - payload = {"client_id": self.client_id, "grant_type": grant_type, "refresh_token": refresh_token} + payload = { + "client_id": self.client_id, + "grant_type": grant_type, + "refresh_token": refresh_token, + } payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), - data=payload) + data_raw = self.connection.raw_post(URL_TOKEN.format(**params_path), data=payload) return raise_error_from_response(data_raw, KeycloakGetError) def userinfo(self, token): @@ -250,8 +280,7 @@ class KeycloakOpenID: payload = {"client_id": self.client_id, "refresh_token": refresh_token} payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), - data=payload) + data_raw = self.connection.raw_post(URL_LOGOUT.format(**params_path), data=payload) return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[204]) @@ -268,7 +297,7 @@ class KeycloakOpenID: params_path = {"realm-name": self.realm_name} data_raw = self.connection.raw_get(URL_CERTS.format(**params_path)) return raise_error_from_response(data_raw, KeycloakGetError) - + def public_key(self): """ The public key is exposed by the realm page directly. @@ -277,8 +306,7 @@ class KeycloakOpenID: """ params_path = {"realm-name": self.realm_name} data_raw = self.connection.raw_get(URL_REALM.format(**params_path)) - return raise_error_from_response(data_raw, KeycloakGetError)['public_key'] - + return raise_error_from_response(data_raw, KeycloakGetError)["public_key"] def entitlement(self, token, resource_server_id): """ @@ -293,8 +321,8 @@ class KeycloakOpenID: self.connection.add_param_headers("Authorization", "Bearer " + token) params_path = {"realm-name": self.realm_name, "resource-server-id": resource_server_id} data_raw = self.connection.raw_get(URL_ENTITLEMENT.format(**params_path)) - - if data_raw.status_code == 404: + + if data_raw.status_code == 404: return raise_error_from_response(data_raw, KeycloakDeprecationError) return raise_error_from_response(data_raw, KeycloakGetError) @@ -316,7 +344,7 @@ class KeycloakOpenID: payload = {"client_id": self.client_id, "token": token} - if token_type_hint == 'requesting_party_token': + if token_type_hint == "requesting_party_token": if rpt: payload.update({"token": rpt, "token_type_hint": token_type_hint}) self.connection.add_param_headers("Authorization", "Bearer " + token) @@ -325,12 +353,11 @@ class KeycloakOpenID: payload = self._add_secret_key(payload) - data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), - data=payload) + data_raw = self.connection.raw_post(URL_INTROSPECT.format(**params_path), data=payload) return raise_error_from_response(data_raw, KeycloakGetError) - def decode_token(self, token, key, algorithms=['RS256'], **kwargs): + def decode_token(self, token, key, algorithms=["RS256"], **kwargs): """ A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data structure that represents a cryptographic key. This specification @@ -347,8 +374,7 @@ class KeycloakOpenID: :return: """ - return jwt.decode(token, key, algorithms=algorithms, - audience=self.client_id, **kwargs) + return jwt.decode(token, key, algorithms=algorithms, audience=self.client_id, **kwargs) def load_authorization_config(self, path): """ @@ -357,12 +383,12 @@ class KeycloakOpenID: :param path: settings file (json) :return: """ - authorization_file = open(path, 'r') + authorization_file = open(path, "r") authorization_json = json.loads(authorization_file.read()) self.authorization.load_config(authorization_json) authorization_file.close() - def get_policies(self, token, method_token_info='introspect', **kwargs): + def get_policies(self, token, method_token_info="introspect", **kwargs): """ Get policies by user token @@ -377,12 +403,10 @@ class KeycloakOpenID: token_info = self._token_info(token, method_token_info, **kwargs) - if method_token_info == 'introspect' and not token_info['active']: - raise KeycloakInvalidTokenError( - "Token expired or invalid." - ) + if method_token_info == "introspect" and not token_info["active"]: + raise KeycloakInvalidTokenError("Token expired or invalid.") - user_resources = token_info['resource_access'].get(self.client_id) + user_resources = token_info["resource_access"].get(self.client_id) if not user_resources: return None @@ -390,13 +414,13 @@ class KeycloakOpenID: policies = [] for policy_name, policy in self.authorization.policies.items(): - for role in user_resources['roles']: + for role in user_resources["roles"]: if self._build_name_role(role) in policy.roles: policies.append(policy) return list(set(policies)) - def get_permissions(self, token, method_token_info='introspect', **kwargs): + def get_permissions(self, token, method_token_info="introspect", **kwargs): """ Get permission by user token @@ -413,12 +437,10 @@ class KeycloakOpenID: token_info = self._token_info(token, method_token_info, **kwargs) - if method_token_info == 'introspect' and not token_info['active']: - raise KeycloakInvalidTokenError( - "Token expired or invalid." - ) + if method_token_info == "introspect" and not token_info["active"]: + raise KeycloakInvalidTokenError("Token expired or invalid.") - user_resources = token_info['resource_access'].get(self.client_id) + user_resources = token_info["resource_access"].get(self.client_id) if not user_resources: return None @@ -426,7 +448,7 @@ class KeycloakOpenID: permissions = [] for policy_name, policy in self.authorization.policies.items(): - for role in user_resources['roles']: + for role in user_resources["roles"]: if self._build_name_role(role) in policy.roles: permissions += policy.permissions diff --git a/keycloak/tests/test_connection.py b/keycloak/tests/test_connection.py deleted file mode 100644 index cb98feb..0000000 --- a/keycloak/tests/test_connection.py +++ /dev/null @@ -1,191 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2017 Marcos Pereira -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program. If not, see . -from unittest import mock - -from httmock import urlmatch, response, HTTMock, all_requests - -from keycloak import KeycloakAdmin, KeycloakOpenID -from ..connection import ConnectionManager - -try: - import unittest -except ImportError: - import unittest2 as unittest - - -class TestConnection(unittest.TestCase): - - def setUp(self): - self._conn = ConnectionManager( - base_url="http://localhost/", - headers={}, - timeout=60) - - @all_requests - def response_content_success(self, url, request): - headers = {'content-type': 'application/json'} - content = b'response_ok' - return response(200, content, headers, None, 5, request) - - def test_raw_get(self): - with HTTMock(self.response_content_success): - resp = self._conn.raw_get("/known_path") - self.assertEqual(resp.content, b'response_ok') - self.assertEqual(resp.status_code, 200) - - def test_raw_post(self): - @urlmatch(path="/known_path", method="post") - def response_post_success(url, request): - headers = {'content-type': 'application/json'} - content = 'response'.encode("utf-8") - return response(201, content, headers, None, 5, request) - - with HTTMock(response_post_success): - resp = self._conn.raw_post("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, b'response') - self.assertEqual(resp.status_code, 201) - - def test_raw_put(self): - @urlmatch(netloc="localhost", path="/known_path", method="put") - def response_put_success(url, request): - headers = {'content-type': 'application/json'} - content = 'response'.encode("utf-8") - return response(200, content, headers, None, 5, request) - - with HTTMock(response_put_success): - resp = self._conn.raw_put("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, b'response') - self.assertEqual(resp.status_code, 200) - - def test_raw_get_fail(self): - @urlmatch(netloc="localhost", path="/known_path", method="get") - def response_get_fail(url, request): - headers = {'content-type': 'application/json'} - content = "404 page not found".encode("utf-8") - return response(404, content, headers, None, 5, request) - - with HTTMock(response_get_fail): - resp = self._conn.raw_get("/known_path") - - self.assertEqual(resp.content, b"404 page not found") - self.assertEqual(resp.status_code, 404) - - def test_raw_post_fail(self): - @urlmatch(netloc="localhost", path="/known_path", method="post") - def response_post_fail(url, request): - headers = {'content-type': 'application/json'} - content = str(["Start can't be blank"]).encode("utf-8") - return response(404, content, headers, None, 5, request) - - with HTTMock(response_post_fail): - resp = self._conn.raw_post("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, str(["Start can't be blank"]).encode("utf-8")) - self.assertEqual(resp.status_code, 404) - - def test_raw_put_fail(self): - @urlmatch(netloc="localhost", path="/known_path", method="put") - def response_put_fail(url, request): - headers = {'content-type': 'application/json'} - content = str(["Start can't be blank"]).encode("utf-8") - return response(404, content, headers, None, 5, request) - - with HTTMock(response_put_fail): - resp = self._conn.raw_put("/known_path", - {'field': 'value'}) - self.assertEqual(resp.content, str(["Start can't be blank"]).encode("utf-8")) - self.assertEqual(resp.status_code, 404) - - def test_add_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertEqual(self._conn.headers, - {"test": "value"}) - - def test_del_param_headers(self): - self._conn.add_param_headers("test", "value") - self._conn.del_param_headers("test") - self.assertEqual(self._conn.headers, {}) - - def test_clean_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertEqual(self._conn.headers, - {"test": "value"}) - self._conn.clean_headers() - self.assertEqual(self._conn.headers, {}) - - def test_exist_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertTrue(self._conn.exist_param_headers("test")) - self.assertFalse(self._conn.exist_param_headers("test_no")) - - def test_get_param_headers(self): - self._conn.add_param_headers("test", "value") - self.assertTrue(self._conn.exist_param_headers("test")) - self.assertFalse(self._conn.exist_param_headers("test_no")) - - def test_get_headers(self): - self._conn.add_param_headers("test", "value") - self.assertEqual(self._conn.headers, - {"test": "value"}) - - def test_KeycloakAdmin_custom_header(self): - - class FakeToken: - @staticmethod - def get(string_val): - return "faketoken" - - fake_token = FakeToken() - - with mock.patch.object(KeycloakOpenID, "__init__", return_value=None) as mock_keycloak_open_id: - with mock.patch("keycloak.keycloak_openid.KeycloakOpenID.token", return_value=fake_token): - with mock.patch("keycloak.connection.ConnectionManager.__init__", return_value=None) as mock_connection_manager: - with mock.patch("keycloak.connection.ConnectionManager.__del__", return_value=None) as mock_connection_manager_delete: - server_url = "https://localhost/auth/" - username = "admin" - password = "secret" - realm_name = "master" - - headers = { - 'Custom': 'test-custom-header' - } - KeycloakAdmin(server_url=server_url, - username=username, - password=password, - realm_name=realm_name, - verify=False, - custom_headers=headers) - - mock_keycloak_open_id.assert_called_with(server_url=server_url, - realm_name=realm_name, - client_id='admin-cli', - client_secret_key=None, - verify=False, - custom_headers=headers) - - expected_header = {'Authorization': 'Bearer faketoken', - 'Content-Type': 'application/json', - 'Custom': 'test-custom-header' - } - - mock_connection_manager.assert_called_with(base_url=server_url, - headers=expected_header, - timeout=60, - verify=False) - mock_connection_manager_delete.assert_called_once_with() diff --git a/keycloak/urls_patterns.py b/keycloak/urls_patterns.py index d7dd16a..43699eb 100644 --- a/keycloak/urls_patterns.py +++ b/keycloak/urls_patterns.py @@ -30,7 +30,9 @@ URL_LOGOUT = "realms/{realm-name}/protocol/openid-connect/logout" URL_CERTS = "realms/{realm-name}/protocol/openid-connect/certs" URL_INTROSPECT = "realms/{realm-name}/protocol/openid-connect/token/introspect" URL_ENTITLEMENT = "realms/{realm-name}/authz/entitlement/{resource-server-id}" -URL_AUTH = "{authorization-endpoint}?client_id={client-id}&response_type=code&redirect_uri={redirect-uri}" +URL_AUTH = ( + "{authorization-endpoint}?client_id={client-id}&response_type=code&redirect_uri={redirect-uri}" +) # ADMIN URLS URL_ADMIN_USERS = "admin/realms/{realm-name}/users" @@ -41,14 +43,26 @@ URL_ADMIN_SEND_UPDATE_ACCOUNT = "admin/realms/{realm-name}/users/{id}/execute-ac URL_ADMIN_SEND_VERIFY_EMAIL = "admin/realms/{realm-name}/users/{id}/send-verify-email" URL_ADMIN_RESET_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password" URL_ADMIN_GET_SESSIONS = "admin/realms/{realm-name}/users/{id}/sessions" -URL_ADMIN_USER_CLIENT_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}" +URL_ADMIN_USER_CLIENT_ROLES = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}" +) URL_ADMIN_USER_REALM_ROLES = "admin/realms/{realm-name}/users/{id}/role-mappings/realm" -URL_ADMIN_USER_REALM_ROLES_AVAILABLE = "admin/realms/{realm-name}/users/{id}/role-mappings/realm/available" -URL_ADMIN_USER_REALM_ROLES_COMPOSITE = "admin/realms/{realm-name}/users/{id}/role-mappings/realm/composite" +URL_ADMIN_USER_REALM_ROLES_AVAILABLE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/realm/available" +) +URL_ADMIN_USER_REALM_ROLES_COMPOSITE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/realm/composite" +) URL_ADMIN_GROUPS_REALM_ROLES = "admin/realms/{realm-name}/groups/{id}/role-mappings/realm" -URL_ADMIN_GROUPS_CLIENT_ROLES = "admin/realms/{realm-name}/groups/{id}/role-mappings/clients/{client-id}" -URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/available" -URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE = "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/composite" +URL_ADMIN_GROUPS_CLIENT_ROLES = ( + "admin/realms/{realm-name}/groups/{id}/role-mappings/clients/{client-id}" +) +URL_ADMIN_USER_CLIENT_ROLES_AVAILABLE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/available" +) +URL_ADMIN_USER_CLIENT_ROLES_COMPOSITE = ( + "admin/realms/{realm-name}/users/{id}/role-mappings/clients/{client-id}/composite" +) URL_ADMIN_USER_GROUP = "admin/realms/{realm-name}/users/{id}/groups/{group-id}" URL_ADMIN_USER_GROUPS = "admin/realms/{realm-name}/users/{id}/groups" URL_ADMIN_USER_PASSWORD = "admin/realms/{realm-name}/users/{id}/reset-password" @@ -79,8 +93,12 @@ URL_ADMIN_CLIENT_AUTHZ_RESOURCES = URL_ADMIN_CLIENT + "/authz/resource-server/re URL_ADMIN_CLIENT_AUTHZ_SCOPES = URL_ADMIN_CLIENT + "/authz/resource-server/scope?max=-1" URL_ADMIN_CLIENT_AUTHZ_PERMISSIONS = URL_ADMIN_CLIENT + "/authz/resource-server/permission?max=-1" URL_ADMIN_CLIENT_AUTHZ_POLICIES = URL_ADMIN_CLIENT + "/authz/resource-server/policy?max=-1" -URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY = URL_ADMIN_CLIENT + "/authz/resource-server/policy/role?max=-1" -URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION = URL_ADMIN_CLIENT + "/authz/resource-server/permission/resource?max=-1" +URL_ADMIN_CLIENT_AUTHZ_ROLE_BASED_POLICY = ( + URL_ADMIN_CLIENT + "/authz/resource-server/policy/role?max=-1" +) +URL_ADMIN_CLIENT_AUTHZ_RESOURCE_BASED_PERMISSION = ( + URL_ADMIN_CLIENT + "/authz/resource-server/permission/resource?max=-1" +) URL_ADMIN_CLIENT_SERVICE_ACCOUNT_USER = URL_ADMIN_CLIENT + "/service-account-user" URL_ADMIN_CLIENT_CERTS = URL_ADMIN_CLIENT + "/certificates/{attr}" @@ -101,7 +119,9 @@ URL_ADMIN_IDPS = "admin/realms/{realm-name}/identity-provider/instances" URL_ADMIN_IDP_MAPPERS = "admin/realms/{realm-name}/identity-provider/instances/{idp-alias}/mappers" URL_ADMIN_IDP = "admin/realms//{realm-name}/identity-provider/instances/{alias}" URL_ADMIN_REALM_ROLES_ROLE_BY_NAME = "admin/realms/{realm-name}/roles/{role-name}" -URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE = "admin/realms/{realm-name}/roles/{role-name}/composites" +URL_ADMIN_REALM_ROLES_COMPOSITE_REALM_ROLE = ( + "admin/realms/{realm-name}/roles/{role-name}/composites" +) URL_ADMIN_REALM_EXPORT = "admin/realms/{realm-name}/partial-export?exportClients={export-clients}&exportGroupsAndRoles={export-groups-and-roles}" URL_ADMIN_DEFAULT_DEFAULT_CLIENT_SCOPES = URL_ADMIN_REALM + "/default-default-client-scopes" @@ -113,10 +133,16 @@ URL_ADMIN_FLOWS = "admin/realms/{realm-name}/authentication/flows" URL_ADMIN_FLOW = URL_ADMIN_FLOWS + "/{id}" URL_ADMIN_FLOWS_ALIAS = "admin/realms/{realm-name}/authentication/flows/{flow-id}" URL_ADMIN_FLOWS_COPY = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/copy" -URL_ADMIN_FLOWS_EXECUTIONS = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions" +URL_ADMIN_FLOWS_EXECUTIONS = ( + "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions" +) URL_ADMIN_FLOWS_EXECUTION = "admin/realms/{realm-name}/authentication/executions/{id}" -URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/execution" -URL_ADMIN_FLOWS_EXECUTIONS_FLOW = "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/flow" +URL_ADMIN_FLOWS_EXECUTIONS_EXECUTION = ( + "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/execution" +) +URL_ADMIN_FLOWS_EXECUTIONS_FLOW = ( + "admin/realms/{realm-name}/authentication/flows/{flow-alias}/executions/flow" +) URL_ADMIN_AUTHENTICATOR_CONFIG = "admin/realms/{realm-name}/authentication/config/{id}" URL_ADMIN_COMPONENTS = "admin/realms/{realm-name}/components" @@ -124,10 +150,11 @@ URL_ADMIN_COMPONENT = "admin/realms/{realm-name}/components/{component-id}" URL_ADMIN_KEYS = "admin/realms/{realm-name}/keys" URL_ADMIN_USER_FEDERATED_IDENTITIES = "admin/realms/{realm-name}/users/{id}/federated-identity" -URL_ADMIN_USER_FEDERATED_IDENTITY = "admin/realms/{realm-name}/users/{id}/federated-identity/{provider}" +URL_ADMIN_USER_FEDERATED_IDENTITY = ( + "admin/realms/{realm-name}/users/{id}/federated-identity/{provider}" +) -URL_ADMIN_EVENTS = 'admin/realms/{realm-name}/events' +URL_ADMIN_EVENTS = "admin/realms/{realm-name}/events" URL_ADMIN_DELETE_USER_ROLE = "admin/realms/{realm-name}/users/{id}/role-mappings/realm" URL_ADMIN_CLIENT_SESSION_STATS = "admin/realms/{realm-name}/client-session-stats" - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f947450 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.black] +line-length = 99 + +[tool.isort] +line_length = 99 +profile = "black" diff --git a/requirements.txt b/requirements.txt index a353c7f..5474982 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,3 @@ requests>=2.20.0 -httmock>=1.2.5 python-jose>=1.4.0 -twine==1.13.0 -jose~=1.0.0 -setuptools~=54.2.0 -urllib3>=1.26.5 \ No newline at end of file +urllib3>=1.26.0 diff --git a/setup.py b/setup.py index 0ffc5aa..8f3b7fc 100644 --- a/setup.py +++ b/setup.py @@ -1,31 +1,56 @@ # -*- coding: utf-8 -*- - +import re from setuptools import setup with open("README.md", "r") as fh: long_description = fh.read() +with open("requirements.txt", "r") as fh: + reqs = fh.read().split("\n") + +with open("dev-requirements.txt", "r") as fh: + dev_reqs = fh.read().split("\n") + +with open("docs-requirements.txt", "r") as fh: + docs_reqs = fh.read().split("\n") + + +VERSIONFILE = "keycloak/_version.py" +verstrline = open(VERSIONFILE, "rt").read() +VSRE = r"^__version__ = ['\"]([^'\"]*)['\"]" +mo = re.search(VSRE, verstrline, re.M) +if mo: + verstr = mo.group(1) +else: + raise RuntimeError("Unable to find version string in %s." % (VERSIONFILE,)) + setup( - name='python-keycloak', - version='0.27.1', - url='https://github.com/marcospereirampj/python-keycloak', - license='The MIT License', - author='Marcos Pereira', - author_email='marcospereira.mpj@gmail.com', - keywords='keycloak openid', - description='python-keycloak is a Python package providing access to the Keycloak API.', + name="python-keycloak", + version=verstr, + url="https://github.com/marcospereirampj/python-keycloak", + license="The MIT License", + author="Marcos Pereira, Richard Nemeth", + author_email="marcospereira.mpj@gmail.com; ryshoooo@gmail.com", + keywords="keycloak openid oidc", + description="python-keycloak is a Python package providing access to the Keycloak API.", long_description=long_description, long_description_content_type="text/markdown", - packages=['keycloak', 'keycloak.authorization', 'keycloak.tests'], - install_requires=['requests>=2.20.0', 'python-jose>=1.4.0'], - tests_require=['httmock>=1.2.5'], + packages=["keycloak"], + install_requires=reqs, + tests_require=dev_reqs, + extras_require={"docs": docs_reqs}, + python_requires=">=3.7", + project_urls={ + "Documentation": "https://python-keycloak.readthedocs.io/en/latest/", + "Issue tracker": "https://github.com/marcospereirampj/python-keycloak/issues", + }, classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: MIT License', - 'Development Status :: 3 - Alpha', - 'Operating System :: MacOS', - 'Operating System :: Unix', - 'Operating System :: Microsoft :: Windows', - 'Topic :: Utilities' - ] + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Operating System :: MacOS", + "Operating System :: Unix", + "Operating System :: Microsoft :: Windows", + "Topic :: Utilities", + ], ) diff --git a/test_keycloak_init.sh b/test_keycloak_init.sh new file mode 100755 index 0000000..bee8830 --- /dev/null +++ b/test_keycloak_init.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +CMD_ARGS=$1 +KEYCLOAK_DOCKER_IMAGE="quay.io/keycloak/keycloak:latest" + +echo "${CMD_ARGS}" + +function keycloak_stop() { + docker stop unittest_keycloak &> /dev/null + docker rm unittest_keycloak &> /dev/null +} + +function keycloak_start() { + echo "Starting keycloak docker container" + docker run -d --name unittest_keycloak -e KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN}" -e KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD}" -p "${KEYCLOAK_PORT}:8080" "${KEYCLOAK_DOCKER_IMAGE}" start-dev + SECONDS=0 + until curl localhost:$KEYCLOAK_PORT; do + sleep 5; + if [ ${SECONDS} -gt 180 ]; then + echo "Timeout exceeded"; + exit 1; + fi + done +} + +# Ensuring that keycloak is stopped in case of CTRL-C +trap keycloak_stop err exit + +keycloak_stop # In case it did not shut down correctly last time. +keycloak_start + +eval ${CMD_ARGS} +RETURN_VALUE=$? + +exit ${RETURN_VALUE} diff --git a/keycloak/tests/__init__.py b/tests/__init__.py similarity index 100% rename from keycloak/tests/__init__.py rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b9c266a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +import os +import uuid + +import pytest + +from keycloak import KeycloakAdmin + + +@pytest.fixture +def env(): + class KeycloakTestEnv(object): + KEYCLOAK_HOST = os.environ["KEYCLOAK_HOST"] + KEYCLOAK_PORT = os.environ["KEYCLOAK_PORT"] + KEYCLOAK_ADMIN = os.environ["KEYCLOAK_ADMIN"] + KEYCLOAK_ADMIN_PASSWORD = os.environ["KEYCLOAK_ADMIN_PASSWORD"] + + return KeycloakTestEnv() + + +@pytest.fixture +def admin(env): + return KeycloakAdmin( + server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", + username=env.KEYCLOAK_ADMIN, + password=env.KEYCLOAK_ADMIN_PASSWORD, + ) + + +@pytest.fixture +def realm(admin: KeycloakAdmin) -> str: + realm_name = str(uuid.uuid4()) + admin.create_realm(payload={"realm": realm_name}) + yield realm_name + admin.delete_realm(realm_name=realm_name) + + +@pytest.fixture +def user(admin: KeycloakAdmin, realm: str) -> str: + admin.realm_name = realm + username = str(uuid.uuid4()) + user_id = admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) + yield user_id + admin.delete_user(user_id=user_id) + + +@pytest.fixture +def group(admin: KeycloakAdmin, realm: str) -> str: + admin.realm_name = realm + group_name = str(uuid.uuid4()) + group_id = admin.create_group(payload={"name": group_name}) + yield group_id + admin.delete_group(group_id=group_id) + + +@pytest.fixture +def client(admin: KeycloakAdmin, realm: str) -> str: + admin.realm_name = realm + client = str(uuid.uuid4()) + client_id = admin.create_client(payload={"name": client, "clientId": client}) + yield client_id + admin.delete_client(client_id=client_id) diff --git a/tests/test_keycloak_admin.py b/tests/test_keycloak_admin.py new file mode 100644 index 0000000..29d3b15 --- /dev/null +++ b/tests/test_keycloak_admin.py @@ -0,0 +1,1201 @@ +import pytest + +import keycloak +from keycloak import KeycloakAdmin +from keycloak.connection import ConnectionManager +from keycloak.exceptions import ( + KeycloakDeleteError, + KeycloakGetError, + KeycloakPostError, + KeycloakPutError, +) + + +def test_keycloak_version(): + assert keycloak.__version__, keycloak.__version__ + + +def test_keycloak_admin_bad_init(env): + with pytest.raises(TypeError) as err: + KeycloakAdmin( + server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", + username=env.KEYCLOAK_ADMIN, + password=env.KEYCLOAK_ADMIN_PASSWORD, + auto_refresh_token=1, + ) + assert err.match("Expected a list of strings") + + with pytest.raises(TypeError) as err: + KeycloakAdmin( + server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", + username=env.KEYCLOAK_ADMIN, + password=env.KEYCLOAK_ADMIN_PASSWORD, + auto_refresh_token=["patch"], + ) + assert err.match("Unexpected method in auto_refresh_token") + + +def test_keycloak_admin_init(env): + admin = KeycloakAdmin( + server_url=f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", + username=env.KEYCLOAK_ADMIN, + password=env.KEYCLOAK_ADMIN_PASSWORD, + ) + assert admin.server_url == f"http://{env.KEYCLOAK_HOST}:{env.KEYCLOAK_PORT}", admin.server_url + assert admin.realm_name == "master", admin.realm_name + assert isinstance(admin.connection, ConnectionManager), type(admin.connection) + assert admin.client_id == "admin-cli", admin.client_id + assert admin.client_secret_key is None, admin.client_secret_key + assert admin.verify, admin.verify + assert admin.username == env.KEYCLOAK_ADMIN, admin.username + assert admin.password == env.KEYCLOAK_ADMIN_PASSWORD, admin.password + assert admin.totp is None, admin.totp + assert admin.token is not None, admin.token + assert admin.auto_refresh_token == list(), admin.auto_refresh_token + assert admin.user_realm_name is None, admin.user_realm_name + assert admin.custom_headers is None, admin.custom_headers + + +def test_realms(admin: KeycloakAdmin): + # Get realms + realms = admin.get_realms() + assert len(realms) == 1, realms + assert "master" == realms[0]["realm"] + + # Create a test realm + res = admin.create_realm(payload={"realm": "test"}) + assert res == b"", res + + # Create the same realm, should fail + with pytest.raises(KeycloakPostError) as err: + res = admin.create_realm(payload={"realm": "test"}) + assert err.match('409: b\'{"errorMessage":"Conflict detected. See logs for details"}\'') + + # Create the same realm, skip_exists true + res = admin.create_realm(payload={"realm": "test"}, skip_exists=True) + assert res == {"msg": "Already exists"}, res + + # Get a single realm + res = admin.get_realm(realm_name="test") + assert res["realm"] == "test" + + # Get non-existing realm + with pytest.raises(KeycloakGetError) as err: + admin.get_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found."}\'') + + # Update realm + res = admin.update_realm(realm_name="test", payload={"accountTheme": "test"}) + assert res == dict(), res + + # Check that the update worked + res = admin.get_realm(realm_name="test") + assert res["realm"] == "test" + assert res["accountTheme"] == "test" + + # Update wrong payload + with pytest.raises(KeycloakPutError) as err: + admin.update_realm(realm_name="test", payload={"wrong": "payload"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + # Check that get realms returns both realms + realms = admin.get_realms() + realm_names = [x["realm"] for x in realms] + assert len(realms) == 2, realms + assert "master" in realm_names, realm_names + assert "test" in realm_names, realm_names + + # Delete the realm + res = admin.delete_realm(realm_name="test") + assert res == dict(), res + + # Check that the realm does not exist anymore + with pytest.raises(KeycloakGetError) as err: + admin.get_realm(realm_name="test") + assert err.match('404: b\'{"error":"Realm not found."}\'') + + # Delete non-existing realm + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm(realm_name="non-existent") + assert err.match('404: b\'{"error":"Realm not found."}\'') + + +def test_import_export_realms(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + realm_export = admin.export_realm(export_clients=True, export_groups_and_role=True) + assert realm_export != dict(), realm_export + + admin.delete_realm(realm_name=realm) + admin.realm_name = "master" + res = admin.import_realm(payload=realm_export) + assert res == b"", res + + # Test bad import + with pytest.raises(KeycloakPostError) as err: + admin.import_realm(payload=dict()) + assert err.match('500: b\'{"error":"unknown_error"}\'') + + +def test_users(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + # Check no users present + users = admin.get_users() + assert users == list(), users + + # Test create user + user_id = admin.create_user(payload={"username": "test", "email": "test@test.test"}) + assert user_id is not None, user_id + + # Test create the same user + with pytest.raises(KeycloakPostError) as err: + admin.create_user(payload={"username": "test", "email": "test@test.test"}) + assert err.match('409: b\'{"errorMessage":"User exists with same username"}\'') + + # Test create the same user, exists_ok true + user_id_2 = admin.create_user( + payload={"username": "test", "email": "test@test.test"}, exist_ok=True + ) + assert user_id == user_id_2 + + # Test get user + user = admin.get_user(user_id=user_id) + assert user["username"] == "test", user["username"] + assert user["email"] == "test@test.test", user["email"] + + # Test update user + res = admin.update_user(user_id=user_id, payload={"firstName": "Test"}) + assert res == dict(), res + user = admin.get_user(user_id=user_id) + assert user["firstName"] == "Test" + + # Test update user fail + with pytest.raises(KeycloakPutError) as err: + admin.update_user(user_id=user_id, payload={"wrong": "payload"}) + assert err.match('400: b\'{"error":"Unrecognized field') + + # Test get users again + users = admin.get_users() + usernames = [x["username"] for x in users] + assert "test" in usernames + + # Test users counts + count = admin.users_count() + assert count == 1, count + + # Test user groups + groups = admin.get_user_groups(user_id=user["id"]) + assert len(groups) == 0 + + # Test user groups bad id + with pytest.raises(KeycloakGetError) as err: + admin.get_user_groups(user_id="does-not-exist") + assert err.match('404: b\'{"error":"User not found"}\'') + + # Test logout + res = admin.user_logout(user_id=user["id"]) + assert res == dict(), res + + # Test logout fail + with pytest.raises(KeycloakPostError) as err: + admin.user_logout(user_id="non-existent-id") + assert err.match('404: b\'{"error":"User not found"}\'') + + # Test consents + res = admin.user_consents(user_id=user["id"]) + assert len(res) == 0, res + + # Test consents fail + with pytest.raises(KeycloakGetError) as err: + admin.user_consents(user_id="non-existent-id") + assert err.match('404: b\'{"error":"User not found"}\'') + + # Test delete user + res = admin.delete_user(user_id=user_id) + assert res == dict(), res + with pytest.raises(KeycloakGetError) as err: + admin.get_user(user_id=user_id) + err.match('404: b\'{"error":"User not found"}\'') + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_user(user_id="non-existent-id") + assert err.match('404: b\'{"error":"User not found"}\'') + + +def test_users_pagination(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + for ind in range(admin.PAGE_SIZE + 50): + username = f"user_{ind}" + admin.create_user(payload={"username": username, "email": f"{username}@test.test"}) + + users = admin.get_users() + assert len(users) == admin.PAGE_SIZE + 50, len(users) + + users = admin.get_users(query={"first": 100}) + assert len(users) == 50, len(users) + + users = admin.get_users(query={"max": 20}) + assert len(users) == 20, len(users) + + +def test_idps(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + # Create IDP + res = admin.create_idp( + payload=dict( + providerId="github", alias="github", config=dict(clientId="test", clientSecret="test") + ) + ) + assert res == b"", res + + # Test create idp fail + with pytest.raises(KeycloakPostError) as err: + admin.create_idp(payload={"providerId": "does-not-exist", "alias": "something"}) + assert err.match("Invalid identity provider id"), err + + # Test listing + idps = admin.get_idps() + assert len(idps) == 1 + assert "github" == idps[0]["alias"] + + # Test adding a mapper + res = admin.add_mapper_to_idp( + idp_alias="github", + payload={ + "identityProviderAlias": "github", + "identityProviderMapper": "github-user-attribute-mapper", + "name": "test", + }, + ) + assert res == b"", res + + # Test mapper fail + with pytest.raises(KeycloakPostError) as err: + admin.add_mapper_to_idp(idp_alias="does-no-texist", payload=dict()) + assert err.match('404: b\'{"error":"HTTP 404 Not Found"}\'') + + # Test delete + res = admin.delete_idp(idp_alias="github") + assert res == dict(), res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_idp(idp_alias="does-not-exist") + assert err.match('404: b\'{"error":"HTTP 404 Not Found"}\'') + + +def test_user_credentials(admin: KeycloakAdmin, user: str): + res = admin.set_user_password(user_id=user, password="booya", temporary=True) + assert res == dict(), res + + # Test user password set fail + with pytest.raises(KeycloakPutError) as err: + admin.set_user_password(user_id="does-not-exist", password="") + assert err.match('404: b\'{"error":"User not found"}\'') + + credentials = admin.get_credentials(user_id=user) + assert len(credentials) == 1 + assert credentials[0]["type"] == "password", credentials + + # Test get credentials fail + with pytest.raises(KeycloakGetError) as err: + admin.get_credentials(user_id="does-not-exist") + assert err.match('404: b\'{"error":"User not found"}\'') + + res = admin.delete_credential(user_id=user, credential_id=credentials[0]["id"]) + assert res == dict(), res + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_credential(user_id=user, credential_id="does-not-exist") + assert err.match('404: b\'{"error":"Credential not found"}\'') + + +def test_social_logins(admin: KeycloakAdmin, user: str): + res = admin.add_user_social_login( + user_id=user, provider_id="gitlab", provider_userid="test", provider_username="test" + ) + assert res == dict(), res + admin.add_user_social_login( + user_id=user, provider_id="github", provider_userid="test", provider_username="test" + ) + assert res == dict(), res + + # Test add social login fail + with pytest.raises(KeycloakPostError) as err: + admin.add_user_social_login( + user_id="does-not-exist", + provider_id="does-not-exist", + provider_userid="test", + provider_username="test", + ) + assert err.match('404: b\'{"error":"User not found"}\'') + + res = admin.get_user_social_logins(user_id=user) + assert res == list(), res + + # Test get social logins fail + with pytest.raises(KeycloakGetError) as err: + admin.get_user_social_logins(user_id="does-not-exist") + assert err.match('404: b\'{"error":"User not found"}\'') + + res = admin.delete_user_social_login(user_id=user, provider_id="gitlab") + assert res == {}, res + + res = admin.delete_user_social_login(user_id=user, provider_id="github") + assert res == {}, res + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_user_social_login(user_id=user, provider_id="instagram") + assert err.match('404: b\'{"error":"Link not found"}\''), err + + +def test_server_info(admin: KeycloakAdmin): + info = admin.get_server_info() + assert set(info.keys()) == { + "systemInfo", + "memoryInfo", + "profileInfo", + "themes", + "socialProviders", + "identityProviders", + "providers", + "protocolMapperTypes", + "builtinProtocolMappers", + "clientInstallations", + "componentTypes", + "passwordPolicies", + "enums", + }, info.keys() + + +def test_groups(admin: KeycloakAdmin, user: str): + # Test get groups + groups = admin.get_groups() + assert len(groups) == 0 + + # Test create group + group_id = admin.create_group(payload={"name": "main-group"}) + assert group_id is not None, group_id + + # Test create subgroups + subgroup_id_1 = admin.create_group(payload={"name": "subgroup-1"}, parent=group_id) + subgroup_id_2 = admin.create_group(payload={"name": "subgroup-2"}, parent=group_id) + + # Test create group fail + with pytest.raises(KeycloakPostError) as err: + admin.create_group(payload={"name": "subgroup-1"}, parent=group_id) + assert err.match('409: b\'{"error":"unknown_error"}\''), err + + # Test skip exists OK + subgroup_id_1_eq = admin.create_group( + payload={"name": "subgroup-1"}, parent=group_id, skip_exists=True + ) + assert subgroup_id_1_eq is None + + # Test get groups again + groups = admin.get_groups() + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get groups query + groups = admin.get_groups(query={"max": 10}) + assert len(groups) == 1, groups + assert len(groups[0]["subGroups"]) == 2, groups["subGroups"] + assert groups[0]["id"] == group_id + assert {x["id"] for x in groups[0]["subGroups"]} == {subgroup_id_1, subgroup_id_2} + + # Test get group + res = admin.get_group(group_id=subgroup_id_1) + assert res["id"] == subgroup_id_1, res + assert res["name"] == "subgroup-1" + assert res["path"] == "/main-group/subgroup-1" + + # Test get group fail + with pytest.raises(KeycloakGetError) as err: + admin.get_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id"}\''), err + + # Create 1 more subgroup + subsubgroup_id_1 = admin.create_group(payload={"name": "subsubgroup-1"}, parent=subgroup_id_2) + main_group = admin.get_group(group_id=group_id) + + # Test nested searches + res = admin.get_subgroups(group=main_group, path="/main-group/subgroup-2/subsubgroup-1") + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + # Test empty search + res = admin.get_subgroups(group=main_group, path="/none") + assert res is None, res + + # Test get group by path + res = admin.get_group_by_path(path="/main-group/subgroup-1") + assert res is None, res + + res = admin.get_group_by_path(path="/main-group/subgroup-1", search_in_subgroups=True) + assert res is not None, res + assert res["id"] == subgroup_id_1, res + + res = admin.get_group_by_path( + path="/main-group/subgroup-2/subsubgroup-1/test", search_in_subgroups=True + ) + assert res is None, res + + res = admin.get_group_by_path( + path="/main-group/subgroup-2/subsubgroup-1", search_in_subgroups=True + ) + assert res is not None, res + assert res["id"] == subsubgroup_id_1 + + res = admin.get_group_by_path(path="/main-group") + assert res is not None, res + assert res["id"] == group_id, res + + # Test group members + res = admin.get_group_members(group_id=subgroup_id_2) + assert len(res) == 0, res + + # Test fail group members + with pytest.raises(KeycloakGetError) as err: + admin.get_group_members(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id"}\'') + + res = admin.group_user_add(user_id=user, group_id=subgroup_id_2) + assert res == dict(), res + + res = admin.get_group_members(group_id=subgroup_id_2) + assert len(res) == 1, res + assert res[0]["id"] == user + + # Test get group members query + res = admin.get_group_members(group_id=subgroup_id_2, query={"max": 10}) + assert len(res) == 1, res + assert res[0]["id"] == user + + with pytest.raises(KeycloakDeleteError) as err: + admin.group_user_remove(user_id="does-not-exist", group_id=subgroup_id_2) + assert err.match('404: b\'{"error":"User not found"}\''), err + + res = admin.group_user_remove(user_id=user, group_id=subgroup_id_2) + assert res == dict(), res + + # Test set permissions + res = admin.group_set_permissions(group_id=subgroup_id_2, enabled=True) + assert res["enabled"], res + res = admin.group_set_permissions(group_id=subgroup_id_2, enabled=False) + assert not res["enabled"], res + with pytest.raises(KeycloakPutError) as err: + admin.group_set_permissions(group_id=subgroup_id_2, enabled="blah") + assert err.match('500: b\'{"error":"unknown_error"}\''), err + + # Test update group + res = admin.update_group(group_id=subgroup_id_2, payload={"name": "new-subgroup-2"}) + assert res == dict(), res + assert admin.get_group(group_id=subgroup_id_2)["name"] == "new-subgroup-2" + + # test update fail + with pytest.raises(KeycloakPutError) as err: + admin.update_group(group_id="does-not-exist", payload=dict()) + assert err.match('404: b\'{"error":"Could not find group by id"}\''), err + + # Test delete + res = admin.delete_group(group_id=group_id) + assert res == dict(), res + assert len(admin.get_groups()) == 0 + + # Test delete fail + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_group(group_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find group by id"}\''), err + + +def test_clients(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + # Test get clients + clients = admin.get_clients() + assert len(clients) == 6, clients + assert {x["name"] for x in clients} == set( + [ + "${client_admin-cli}", + "${client_security-admin-console}", + "${client_account-console}", + "${client_broker}", + "${client_account}", + "${client_realm-management}", + ] + ), clients + + # Test create client + client_id = admin.create_client(payload={"name": "test-client", "clientId": "test-client"}) + assert client_id, client_id + + with pytest.raises(KeycloakPostError) as err: + admin.create_client(payload={"name": "test-client", "clientId": "test-client"}) + assert err.match('409: b\'{"errorMessage":"Client test-client already exists"}\''), err + + client_id_2 = admin.create_client( + payload={"name": "test-client", "clientId": "test-client"}, skip_exists=True + ) + assert client_id == client_id_2, client_id_2 + + # Test get client + res = admin.get_client(client_id=client_id) + assert res["clientId"] == "test-client", res + assert res["name"] == "test-client", res + assert res["id"] == client_id, res + + with pytest.raises(KeycloakGetError) as err: + admin.get_client(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client"}\'') + assert len(admin.get_clients()) == 7 + + # Test get client id + assert admin.get_client_id(client_name="test-client") == client_id + assert admin.get_client_id(client_name="does-not-exist") is None + + # Test update client + res = admin.update_client(client_id=client_id, payload={"name": "test-client-change"}) + assert res == dict(), res + + with pytest.raises(KeycloakPutError) as err: + admin.update_client(client_id="does-not-exist", payload={"name": "test-client-change"}) + assert err.match('404: b\'{"error":"Could not find client"}\'') + + # Test authz + auth_client_id = admin.create_client( + payload={ + "name": "authz-client", + "clientId": "authz-client", + "authorizationServicesEnabled": True, + "serviceAccountsEnabled": True, + } + ) + res = admin.get_client_authz_settings(client_id=auth_client_id) + assert res["allowRemoteResourceManagement"] + assert res["decisionStrategy"] == "UNANIMOUS" + assert len(res["policies"]) >= 0 + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_settings(client_id=client_id) + assert err.match('500: b\'{"error":"HTTP 500 Internal Server Error"}\'') + + # Authz resources + res = admin.get_client_authz_resources(client_id=auth_client_id) + assert len(res) == 1 + assert res[0]["name"] == "Default Resource" + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_resources(client_id=client_id) + assert err.match('500: b\'{"error":"unknown_error"}\'') + + res = admin.create_client_authz_resource( + client_id=auth_client_id, payload={"name": "test-resource"} + ) + assert res["name"] == "test-resource", res + test_resource_id = res["_id"] + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_resource( + client_id=auth_client_id, payload={"name": "test-resource"} + ) + assert err.match('409: b\'{"error":"invalid_request"') + assert admin.create_client_authz_resource( + client_id=auth_client_id, payload={"name": "test-resource"}, skip_exists=True + ) == {"msg": "Already exists"} + + res = admin.get_client_authz_resources(client_id=auth_client_id) + assert len(res) == 2 + assert {x["name"] for x in res} == {"Default Resource", "test-resource"} + + # Authz policies + res = admin.get_client_authz_policies(client_id=auth_client_id) + assert len(res) == 1, res + assert res[0]["name"] == "Default Policy" + assert len(admin.get_client_authz_policies(client_id=client_id)) == 1 + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_policies(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client"}\'') + + role_id = admin.get_realm_role(role_name="offline_access")["id"] + res = admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert res["name"] == "test-authz-rb-policy", res + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert admin.create_client_authz_role_based_policy( + client_id=auth_client_id, + payload={"name": "test-authz-rb-policy", "roles": [{"id": role_id}]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(admin.get_client_authz_policies(client_id=auth_client_id)) == 2 + + # Test authz permissions + res = admin.get_client_authz_permissions(client_id=auth_client_id) + assert len(res) == 1, res + assert res[0]["name"] == "Default Permission" + assert len(admin.get_client_authz_permissions(client_id=client_id)) == 1 + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_permissions(client_id="does-not-exist") + assert err.match('404: b\'{"error":"Could not find client"}\'') + + res = admin.create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert res, res + assert res["name"] == "test-permission-rb" + assert res["resources"] == [test_resource_id] + + with pytest.raises(KeycloakPostError) as err: + admin.create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + ) + assert err.match('409: b\'{"error":"Policy with name') + assert admin.create_client_authz_resource_based_permission( + client_id=auth_client_id, + payload={"name": "test-permission-rb", "resources": [test_resource_id]}, + skip_exists=True, + ) == {"msg": "Already exists"} + assert len(admin.get_client_authz_permissions(client_id=auth_client_id)) == 2 + + # Test authz scopes + res = admin.get_client_authz_scopes(client_id=auth_client_id) + assert len(res) == 0, res + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_authz_scopes(client_id=client_id) + assert err.match('500: b\'{"error":"unknown_error"}\'') + + # Test service account user + res = admin.get_client_service_account_user(client_id=auth_client_id) + assert res["username"] == "service-account-authz-client", res + + with pytest.raises(KeycloakGetError) as err: + admin.get_client_service_account_user(client_id=client_id) + assert err.match('400: b\'{"error":"unknown_error"}\'') + + # Test delete client + res = admin.delete_client(client_id=auth_client_id) + assert res == dict(), res + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client(client_id=auth_client_id) + assert err.match('404: b\'{"error":"Could not find client"}\'') + + +def test_realm_roles(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + # Test get realm roles + roles = admin.get_realm_roles() + assert len(roles) == 3, roles + role_names = [x["name"] for x in roles] + assert "uma_authorization" in role_names, role_names + assert "offline_access" in role_names, role_names + + # Test empty members + with pytest.raises(KeycloakGetError) as err: + admin.get_realm_role_members(role_name="does-not-exist") + assert err.match('404: b\'{"error":"Could not find role"}\'') + members = admin.get_realm_role_members(role_name="offline_access") + assert members == list(), members + + # Test create realm role + role_id = admin.create_realm_role(payload={"name": "test-realm-role"}) + assert role_id, role_id + with pytest.raises(KeycloakPostError) as err: + admin.create_realm_role(payload={"name": "test-realm-role"}) + assert err.match('409: b\'{"errorMessage":"Role with name test-realm-role already exists"}\'') + role_id_2 = admin.create_realm_role(payload={"name": "test-realm-role"}, skip_exists=True) + assert role_id == role_id_2 + + # Test update realm role + res = admin.update_realm_role( + role_name="test-realm-role", payload={"name": "test-realm-role-update"} + ) + assert res == dict(), res + with pytest.raises(KeycloakPutError) as err: + admin.update_realm_role( + role_name="test-realm-role", payload={"name": "test-realm-role-update"} + ) + assert err.match('404: b\'{"error":"Could not find role"}\''), err + + # Test realm role user assignment + user_id = admin.create_user(payload={"username": "role-testing", "email": "test@test.test"}) + with pytest.raises(KeycloakPostError) as err: + admin.assign_realm_roles(user_id=user_id, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.assign_realm_roles( + user_id=user_id, + roles=[ + admin.get_realm_role(role_name="offline_access"), + admin.get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == dict(), res + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] for x in admin.get_realm_role_members(role_name="offline_access") + ] + assert admin.get_user(user_id=user_id)["username"] in [ + x["username"] for x in admin.get_realm_role_members(role_name="test-realm-role-update") + ] + + roles = admin.get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 3 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_roles_of_user(user_id=user_id, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.delete_realm_roles_of_user( + user_id=user_id, roles=[admin.get_realm_role(role_name="offline_access")] + ) + assert res == dict(), res + assert admin.get_realm_role_members(role_name="offline_access") == list() + roles = admin.get_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" not in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + roles = admin.get_available_realm_roles_of_user(user_id=user_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "uma_authorization" in [x["name"] for x in roles] + + # Test realm role group assignment + group_id = admin.create_group(payload={"name": "test-group"}) + with pytest.raises(KeycloakPostError) as err: + admin.assign_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.assign_group_realm_roles( + group_id=group_id, + roles=[ + admin.get_realm_role(role_name="offline_access"), + admin.get_realm_role(role_name="test-realm-role-update"), + ], + ) + assert res == dict(), res + + roles = admin.get_group_realm_roles(group_id=group_id) + assert len(roles) == 2 + assert "offline_access" in [x["name"] for x in roles] + assert "test-realm-role-update" in [x["name"] for x in roles] + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_group_realm_roles(group_id=group_id, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.delete_group_realm_roles( + group_id=group_id, roles=[admin.get_realm_role(role_name="offline_access")] + ) + assert res == dict(), res + roles = admin.get_group_realm_roles(group_id=group_id) + assert len(roles) == 1 + assert "test-realm-role-update" in [x["name"] for x in roles] + + # Test composite realm roles + composite_role = admin.create_realm_role(payload={"name": "test-composite-role"}) + with pytest.raises(KeycloakPostError) as err: + admin.add_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.add_composite_realm_roles_to_role( + role_name=composite_role, roles=[admin.get_realm_role(role_name="test-realm-role-update")] + ) + assert res == dict(), res + + res = admin.get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 1 + assert "test-realm-role-update" in res[0]["name"] + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_realm_roles_of_role(role_name="bad") + assert err.match('404: b\'{"error":"Could not find role"}\'') + + res = admin.get_composite_realm_roles_of_user(user_id=user_id) + assert len(res) == 4 + assert "offline_access" in {x["name"] for x in res} + assert "test-realm-role-update" in {x["name"] for x in res} + assert "uma_authorization" in {x["name"] for x in res} + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_realm_roles_of_user(user_id="bad") + assert err.match('404: b\'{"error":"User not found"}\'') + + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_composite_realm_roles_to_role(role_name=composite_role, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.remove_composite_realm_roles_to_role( + role_name=composite_role, roles=[admin.get_realm_role(role_name="test-realm-role-update")] + ) + assert res == dict(), res + + res = admin.get_composite_realm_roles_of_role(role_name=composite_role) + assert len(res) == 0 + + # Test delete realm role + res = admin.delete_realm_role(role_name=composite_role) + assert res == dict(), res + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_realm_role(role_name=composite_role) + assert err.match('404: b\'{"error":"Could not find role"}\'') + + +def test_client_roles(admin: KeycloakAdmin, client: str): + # Test get client roles + res = admin.get_client_roles(client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_client_roles(client_id="bad") + assert err.match('404: b\'{"error":"Could not find client"}\'') + + # Test create client role + client_role_id = admin.create_client_role( + client_role_id=client, payload={"name": "client-role-test"} + ) + with pytest.raises(KeycloakPostError) as err: + admin.create_client_role(client_role_id=client, payload={"name": "client-role-test"}) + assert err.match('409: b\'{"errorMessage":"Role with name client-role-test already exists"}\'') + client_role_id_2 = admin.create_client_role( + client_role_id=client, payload={"name": "client-role-test"}, skip_exists=True + ) + assert client_role_id == client_role_id_2 + + # Test get client role + res = admin.get_client_role(client_id=client, role_name="client-role-test") + assert res["name"] == client_role_id + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role(client_id=client, role_name="bad") + assert err.match('404: b\'{"error":"Could not find role"}\'') + + res_ = admin.get_client_role_id(client_id=client, role_name="client-role-test") + assert res_ == res["id"] + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role_id(client_id=client, role_name="bad") + assert err.match('404: b\'{"error":"Could not find role"}\'') + assert len(admin.get_client_roles(client_id=client)) == 1 + + # Test update client role + res = admin.update_client_role( + client_role_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert res == dict() + with pytest.raises(KeycloakPutError) as err: + res = admin.update_client_role( + client_role_id=client, + role_name="client-role-test", + payload={"name": "client-role-test-update"}, + ) + assert err.match('404: b\'{"error":"Could not find role"}\'') + + # Test user with client role + res = admin.get_client_role_members(client_id=client, role_name="client-role-test-update") + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role_members(client_id=client, role_name="bad") + assert err.match('404: b\'{"error":"Could not find role"}\'') + + user_id = admin.create_user(payload={"username": "test", "email": "test@test.test"}) + with pytest.raises(KeycloakPostError) as err: + admin.assign_client_role(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.assign_client_role( + user_id=user_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert res == dict() + assert ( + len(admin.get_client_role_members(client_id=client, role_name="client-role-test-update")) + == 1 + ) + + roles = admin.get_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + admin.get_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match('404: b\'{"error":"Client not found"}\'') + + roles = admin.get_composite_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 1, roles + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match('404: b\'{"error":"Client not found"}\'') + + roles = admin.get_available_client_roles_of_user(user_id=user_id, client_id=client) + assert len(roles) == 0, roles + with pytest.raises(KeycloakGetError) as err: + admin.get_composite_client_roles_of_user(user_id=user_id, client_id="bad") + assert err.match('404: b\'{"error":"Client not found"}\'') + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client_roles_of_user(user_id=user_id, client_id=client, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + admin.delete_client_roles_of_user( + user_id=user_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert len(admin.get_client_roles_of_user(user_id=user_id, client_id=client)) == 0 + + # Test groups and client roles + res = admin.get_client_role_groups(client_id=client, role_name="client-role-test-update") + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_client_role_groups(client_id=client, role_name="bad") + assert err.match('404: b\'{"error":"Could not find role"}\'') + + group_id = admin.create_group(payload={"name": "test-group"}) + res = admin.get_group_client_roles(group_id=group_id, client_id=client) + assert len(res) == 0 + with pytest.raises(KeycloakGetError) as err: + admin.get_group_client_roles(group_id=group_id, client_id="bad") + assert err.match('404: b\'{"error":"Client not found"}\'') + + with pytest.raises(KeycloakPostError) as err: + admin.assign_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.assign_group_client_roles( + group_id=group_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert res == dict() + assert ( + len(admin.get_client_role_groups(client_id=client, role_name="client-role-test-update")) + == 1 + ) + assert len(admin.get_group_client_roles(group_id=group_id, client_id=client)) == 1 + + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_group_client_roles(group_id=group_id, client_id=client, roles=["bad"]) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.delete_group_client_roles( + group_id=group_id, + client_id=client, + roles=[admin.get_client_role(client_id=client, role_name="client-role-test-update")], + ) + assert res == dict() + + # Test composite client roles + with pytest.raises(KeycloakPostError) as err: + admin.add_composite_client_roles_to_role( + client_role_id=client, role_name="client-role-test-update", roles=["bad"] + ) + assert err.match('500: b\'{"error":"unknown_error"}\'') + res = admin.add_composite_client_roles_to_role( + client_role_id=client, + role_name="client-role-test-update", + roles=[admin.get_realm_role(role_name="offline_access")], + ) + assert res == dict() + assert admin.get_client_role(client_id=client, role_name="client-role-test-update")[ + "composite" + ] + + # Test delete of client role + res = admin.delete_client_role(client_role_id=client, role_name="client-role-test-update") + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_client_role(client_role_id=client, role_name="client-role-test-update") + assert err.match('404: b\'{"error":"Could not find role"}\'') + + +def test_email(admin: KeycloakAdmin, user: str): + # Emails will fail as we don't have SMTP test setup + with pytest.raises(KeycloakPutError) as err: + admin.send_update_account(user_id=user, payload=dict()) + assert err.match('500: b\'{"error":"unknown_error"}\'') + + admin.update_user(user_id=user, payload={"enabled": True}) + with pytest.raises(KeycloakPutError) as err: + admin.send_verify_email(user_id=user) + assert err.match('500: b\'{"errorMessage":"Failed to send execute actions email"}\'') + + +def test_get_sessions(admin: KeycloakAdmin): + sessions = admin.get_sessions(user_id=admin.get_user_id(username=admin.username)) + assert len(sessions) >= 1 + with pytest.raises(KeycloakGetError) as err: + admin.get_sessions(user_id="bad") + assert err.match('404: b\'{"error":"User not found"}\'') + + +def test_get_client_installation_provider(admin: KeycloakAdmin, client: str): + with pytest.raises(KeycloakGetError) as err: + admin.get_client_installation_provider(client_id=client, provider_id="bad") + assert err.match('404: b\'{"error":"Unknown Provider"}\'') + + installation = admin.get_client_installation_provider( + client_id=client, provider_id="keycloak-oidc-keycloak-json" + ) + assert set(installation.keys()) == { + "auth-server-url", + "confidential-port", + "credentials", + "realm", + "resource", + "ssl-required", + } + + +def test_auth_flows(admin: KeycloakAdmin, realm: str): + admin.realm_name = realm + + res = admin.get_authentication_flows() + assert len(res) == 8, res + assert set(res[0].keys()) == { + "alias", + "authenticationExecutions", + "builtIn", + "description", + "id", + "providerId", + "topLevel", + } + assert {x["alias"] for x in res} == { + "reset credentials", + "browser", + "http challenge", + "registration", + "docker auth", + "direct grant", + "first broker login", + "clients", + } + + with pytest.raises(KeycloakGetError) as err: + admin.get_authentication_flow_for_id(flow_id="bad") + assert err.match('404: b\'{"error":"Could not find flow with id"}\'') + browser_flow_id = [x for x in res if x["alias"] == "browser"][0]["id"] + res = admin.get_authentication_flow_for_id(flow_id=browser_flow_id) + assert res["alias"] == "browser" + + # Test copying + with pytest.raises(KeycloakPostError) as err: + admin.copy_authentication_flow(payload=dict(), flow_alias="bad") + assert err.match("404: b''") + + res = admin.copy_authentication_flow(payload={"newName": "test-browser"}, flow_alias="browser") + assert res == b"", res + assert len(admin.get_authentication_flows()) == 9 + + # Test create + res = admin.create_authentication_flow( + payload={"alias": "test-create", "providerId": "basic-flow"} + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + admin.create_authentication_flow(payload={"alias": "test-create", "builtIn": False}) + assert err.match('409: b\'{"errorMessage":"Flow test-create already exists"}\'') + assert admin.create_authentication_flow( + payload={"alias": "test-create"}, skip_exists=True + ) == {"msg": "Already exists"} + + # Test flow executions + res = admin.get_authentication_flow_executions(flow_alias="browser") + assert len(res) == 8, res + with pytest.raises(KeycloakGetError) as err: + admin.get_authentication_flow_executions(flow_alias="bad") + assert err.match("404: b''") + exec_id = res[0]["id"] + + res = admin.get_authentication_flow_execution(execution_id=exec_id) + assert set(res.keys()) == { + "alternative", + "authenticator", + "authenticatorFlow", + "conditional", + "disabled", + "enabled", + "id", + "parentFlow", + "priority", + "required", + "requirement", + }, res + with pytest.raises(KeycloakGetError) as err: + admin.get_authentication_flow_execution(execution_id="bad") + assert err.match('404: b\'{"error":"Illegal execution"}\'') + + with pytest.raises(KeycloakPostError) as err: + admin.create_authentication_flow_execution(payload=dict(), flow_alias="browser") + assert err.match('400: b\'{"error":"It is illegal to add execution to a built in flow"}\'') + + res = admin.create_authentication_flow_execution( + payload={"provider": "auth-cookie"}, flow_alias="test-create" + ) + assert res == b"" + assert len(admin.get_authentication_flow_executions(flow_alias="test-create")) == 1 + + with pytest.raises(KeycloakPutError) as err: + admin.update_authentication_flow_executions( + payload={"required": "yes"}, flow_alias="test-create" + ) + assert err.match('400: b\'{"error":"Unrecognized field') + payload = admin.get_authentication_flow_executions(flow_alias="test-create")[0] + payload["displayName"] = "test" + res = admin.update_authentication_flow_executions(payload=payload, flow_alias="test-create") + assert res + + exec_id = admin.get_authentication_flow_executions(flow_alias="test-create")[0]["id"] + res = admin.delete_authentication_flow_execution(execution_id=exec_id) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_authentication_flow_execution(execution_id=exec_id) + assert err.match('404: b\'{"error":"Illegal execution"}\'') + + # Test subflows + res = admin.create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-browser", + ) + assert res == b"" + with pytest.raises(KeycloakPostError) as err: + admin.create_authentication_flow_subflow( + payload={"alias": "test-subflow", "providerId": "basic-flow"}, + flow_alias="test-browser", + ) + assert err.match('409: b\'{"errorMessage":"New flow alias name already exists"}\'') + res = admin.create_authentication_flow_subflow( + payload={ + "alias": "test-subflow", + "provider": "basic-flow", + "type": "something", + "description": "something", + }, + flow_alias="test-create", + skip_exists=True, + ) + assert res == {"msg": "Already exists"} + + # Test delete auth flow + flow_id = [x for x in admin.get_authentication_flows() if x["alias"] == "test-browser"][0][ + "id" + ] + res = admin.delete_authentication_flow(flow_id=flow_id) + assert res == dict() + with pytest.raises(KeycloakDeleteError) as err: + admin.delete_authentication_flow(flow_id=flow_id) + assert err.match('404: b\'{"error":"Could not find flow with id"}\'') diff --git a/tests/test_urls_patterns.py b/tests/test_urls_patterns.py new file mode 100644 index 0000000..6fa5a87 --- /dev/null +++ b/tests/test_urls_patterns.py @@ -0,0 +1,26 @@ +from keycloak import urls_patterns + + +def test_correctness_of_patterns(): + """Test that there are no duplicate url patterns.""" + + # Test that the patterns are present + urls = [x for x in dir(urls_patterns) if not x.startswith("__")] + assert len(urls) >= 0 + + # Test that all patterns start with URL_ + for url in urls: + assert url.startswith("URL_"), f"The url pattern {url} does not begin with URL_" + + # Test that the patterns have unique names + seen_urls = list() + for url in urls: + assert url not in seen_urls, f"The url pattern {url} is present twice." + seen_urls.append(url) + + # Test that the pattern values are unique + seen_url_values = list() + for url in urls: + url_value = urls_patterns.__dict__[url] + assert url_value not in seen_url_values, f"The url {url} has a duplicate value {url_value}" + seen_url_values.append(url_value) diff --git a/tox.env b/tox.env new file mode 100644 index 0000000..49cea83 --- /dev/null +++ b/tox.env @@ -0,0 +1,4 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_HOST={env:KEYCLOAK_HOST:localhost} +KEYCLOAK_PORT=8080 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2726f66 --- /dev/null +++ b/tox.ini @@ -0,0 +1,48 @@ +[tox] +envlist = check, apply-check, docs, tests, build + +[testenv] +install_command = pip install {opts} {packages} + +[testenv:check] +deps = + black + isort + flake8 +commands = + black --check --diff keycloak tests docs + isort -c --df keycloak tests docs + flake8 keycloak tests docs + +[testenv:apply-check] +deps = + black + isort + flake8 +commands = + black -C keycloak tests docs + black keycloak tests docs + isort keycloak tests docs + +[testenv:docs] +deps = + .[docs] +commands = + python -m sphinx -T -E -W -b html -d _build/doctrees -D language=en ./docs/source _build/html + +[testenv:tests] +setenv = file|tox.env +deps = + -rrequirements.txt + -rdev-requirements.txt +commands = + ./test_keycloak_init.sh "pytest -vv --cov=keycloak --cov-report term-missing {posargs}" + +[testenv:build] +deps = + -rdev-requirements.txt +commands = + python setup.py sdist bdist_wheel + +[flake8] +max-line-length = 99