├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_lib_ldap.py │ ├── test_lib_ldap_converters.py │ ├── test_lib_ldap_result.py │ ├── test_web_resource_base.py │ ├── utils.py │ ├── test_web_base_routes.py │ ├── test_web_resource_v1_me.py │ ├── test_web_util_ipa.py │ ├── test_web_namespace.py │ ├── test_web_app.py │ ├── test_web_utils_pagination.py │ ├── test_web_resource_v1_certs.py │ ├── test_web_extension_ipacfg.py │ ├── test_web_resource_v1_users.py │ ├── test_web_resource_v1_search.py │ ├── test_web_resource_v1_groups.py │ └── test_lib_ldap_client.py ├── fixtures │ ├── fasjson.env │ ├── ipa.ca.crt │ └── ipa.default.conf └── conftest.py ├── fasjson ├── lib │ ├── __init__.py │ └── ldap │ │ ├── __init__.py │ │ ├── converters.py │ │ ├── models.py │ │ └── client.py ├── web │ ├── __init__.py │ ├── apis │ │ ├── __init__.py │ │ ├── errors.py │ │ ├── v1.py │ │ └── base.py │ ├── resources │ │ ├── __init__.py │ │ ├── me.py │ │ ├── base.py │ │ ├── certs.py │ │ ├── users.py │ │ ├── groups.py │ │ └── search.py │ ├── extensions │ │ ├── __init__.py │ │ └── flask_ipacfg.py │ ├── utils │ │ ├── __init__.py │ │ ├── request_parsing.py │ │ ├── ipa.py │ │ └── pagination.py │ ├── base_routes.py │ ├── defaults.cfg │ └── app.py └── __init__.py ├── devel ├── ansible │ ├── roles │ │ ├── fasjson │ │ │ ├── files │ │ │ │ ├── bashrc │ │ │ │ ├── fasjson.wsgi │ │ │ │ ├── tmpfiles.conf │ │ │ │ └── systemd-service.conf │ │ │ ├── templates │ │ │ │ ├── setup-fasjson-service.sh │ │ │ │ └── httpd.conf │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── cert │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── apache │ │ │ ├── handlers │ │ │ │ └── main.yml │ │ │ ├── files │ │ │ │ └── redirect-to-ssl.conf │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── ipa-client │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── ipa-keytab │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── freeipa │ │ │ └── tasks │ │ │ │ └── main.yml │ │ └── common │ │ │ └── tasks │ │ │ └── main.yml │ ├── vars.yml │ ├── freeipa.yml │ └── fasjson.yml └── run-liccheck.sh ├── news ├── +py39-py314.feature.md ├── _template.md.j2 └── get-authors.py ├── docs ├── api_v1.rst ├── _static │ └── custom.css ├── index.rst ├── make-requirements.sh ├── requirements.txt ├── utils │ └── export_swagger.py ├── installation.rst ├── usage.rst ├── conf.py └── release_notes.md ├── .github ├── renovate.json ├── ISSUE_TEMPLATE │ ├── user-story.md │ └── bug-report.md ├── pull-request-template.md └── workflows │ ├── label-when-deployed.yaml │ └── main.yml ├── deploy ├── wsgi.py ├── start.sh └── httpd.conf ├── .dockerignore ├── .gitignore ├── README.md ├── .readthedocs.yml ├── Vagrantfile ├── .pre-commit-config.yaml ├── Dockerfile ├── tox.ini ├── fasjson.spec └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fasjson/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fasjson/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fasjson/web/apis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fasjson/web/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fasjson/web/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/files/bashrc: -------------------------------------------------------------------------------- 1 | # .bashrc 2 | cd /home/vagrant/fasjson 3 | -------------------------------------------------------------------------------- /news/+py39-py314.feature.md: -------------------------------------------------------------------------------- 1 | Drop support for Python 3.9, add support for Python 3.14 2 | -------------------------------------------------------------------------------- /devel/ansible/vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ipa_admin_user: admin 3 | ipa_admin_password: password 4 | -------------------------------------------------------------------------------- /tests/fixtures/fasjson.env: -------------------------------------------------------------------------------- 1 | KRB5CCNAME=/tmp/krb5cc-httpd 2 | GSS_USE_PROXY=yes 3 | GSS_NAME=admin@EXAMPLE.TEST 4 | -------------------------------------------------------------------------------- /tests/fixtures/ipa.ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | dummy and invalid cert 3 | -----END CERTIFICATE----- 4 | -------------------------------------------------------------------------------- /devel/ansible/freeipa.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: freeipa 3 | become: true 4 | become_method: sudo 5 | roles: 6 | - freeipa 7 | -------------------------------------------------------------------------------- /devel/ansible/roles/cert/defaults/main.yml: -------------------------------------------------------------------------------- 1 | krb_service: HTTP 2 | cert_hostname: "{{ ansible_fqdn }}" 3 | cert_basename: server 4 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/files/fasjson.wsgi: -------------------------------------------------------------------------------- 1 | from fasjson.web.app import create_app 2 | 3 | 4 | application = create_app() 5 | -------------------------------------------------------------------------------- /docs/api_v1.rst: -------------------------------------------------------------------------------- 1 | API v1 2 | ====== 3 | 4 | This API is mounted on the ``/v1/`` URL prefix. 5 | 6 | .. openapi:: _api/api_v1.json 7 | -------------------------------------------------------------------------------- /devel/ansible/roles/apache/handlers/main.yml: -------------------------------------------------------------------------------- 1 | - name: restart httpd 2 | systemd: 3 | name: httpd.service 4 | state: restarted 5 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | #api-v1 dl { 2 | margin-top: 2em; 3 | padding-bottom: 1em; 4 | border-bottom: 1px dotted gray; 5 | } 6 | -------------------------------------------------------------------------------- /fasjson/lib/ldap/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import LDAP 2 | 3 | 4 | def get_client(uri, basedn, login, **kwargs): 5 | return LDAP(uri, basedn, **kwargs) 6 | -------------------------------------------------------------------------------- /devel/ansible/roles/apache/files/redirect-to-ssl.conf: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | RewriteCond %{HTTPS} off 3 | RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] 4 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/files/tmpfiles.conf: -------------------------------------------------------------------------------- 1 | # 2 | # /usr/lib/tmpfiles.d/fasjson.conf 3 | # 4 | 5 | d /run/fasjson 0770 apache apache 6 | d /run/fasjson/ccaches 0770 apache apache 7 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>fedora-infra/shared:renovate-config"], 4 | "ignorePaths": ["Dockerfile"] 5 | } 6 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/files/systemd-service.conf: -------------------------------------------------------------------------------- 1 | # 2 | # /usr/lib/systemd/system/httpd.service.d/fasjson.conf 3 | # 4 | 5 | [Service] 6 | Environment=KRB5CCNAME=/tmp/krb5cc-httpd 7 | Environment=GSS_USE_PROXY=yes 8 | -------------------------------------------------------------------------------- /deploy/wsgi.py: -------------------------------------------------------------------------------- 1 | from werkzeug.middleware.proxy_fix import ProxyFix 2 | 3 | from fasjson.web.app import create_app 4 | 5 | 6 | application = create_app() 7 | application.wsgi_app = ProxyFix(application.wsgi_app, x_proto=1, x_host=1) 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | __pycache__ 4 | *.pyc 5 | *.pyo 6 | 7 | # test related 8 | htmlcov/ 9 | .tox/ 10 | .coverage 11 | .coverage.* 12 | .cache 13 | 14 | # docs related 15 | docs/_source 16 | docs/_build 17 | docs/_api 18 | -------------------------------------------------------------------------------- /devel/ansible/roles/ipa-client/defaults/main.yml: -------------------------------------------------------------------------------- 1 | ipa_admin_user: admin 2 | ipa_admin_password: password 3 | krb_master_password: "{{ ipa_admin_password }}" 4 | krb_realm: "{{ ansible_domain | upper }}" 5 | python_exec: python 6 | python_run_dir: null 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/user-story.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: User Story 3 | about: Create a user story for FasJson 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Acceptance Criteria 11 | 12 | * 13 | 14 | 15 | ## Definition of Done 16 | -------------------------------------------------------------------------------- /devel/ansible/roles/apache/defaults/main.yml: -------------------------------------------------------------------------------- 1 | krb_service: HTTP 2 | keytab_directory: /etc/httpd/conf 3 | keytab_path: "{{ keytab_directory }}/{{ krb_service }}.keytab" 4 | ipa_admin_user: admin 5 | ipa_admin_password: password 6 | krb_realm: "{{ ansible_domain | upper }}" 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug for FasJson 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Expected Behavior 11 | 12 | 13 | ## Actual Behavior 14 | 15 | 16 | ## Steps to Reproduce the Problem 17 | 18 | * 19 | -------------------------------------------------------------------------------- /tests/fixtures/ipa.default.conf: -------------------------------------------------------------------------------- 1 | #File modified by ipa-client-install 2 | 3 | [global] 4 | basedn = dc=example,dc=test 5 | realm = EXAMPLE.TEST 6 | domain = example.test 7 | server = ipa.example.test 8 | host = fasjson.example.test 9 | xmlrpc_uri = https://ipa.example.test/ipa/xml 10 | enable_ra = True 11 | -------------------------------------------------------------------------------- /fasjson/web/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import g 2 | 3 | from fasjson.lib.ldap.models import UserModel as LDAPUserModel 4 | 5 | 6 | def maybe_anonymize(user): 7 | if user.get("is_private", False) and g.username != user["username"]: 8 | user = LDAPUserModel.anonymize(user) 9 | return user 10 | -------------------------------------------------------------------------------- /deploy/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf /httpdir/* 3 | mkdir /httpdir/run/ /httpdir/run/ccaches/ 4 | ln -s /etc/httpd/modules /httpdir/modules 5 | truncate --size=0 /httpdir/access.log /httpdir/error.log 6 | tail -qf /httpdir/access.log /httpdir/error.log & 7 | exec httpd -f /opt/fasjson/deploy/httpd.conf -DFOREGROUND -DNO_DETACH 8 | -------------------------------------------------------------------------------- /devel/ansible/roles/ipa-keytab/defaults/main.yml: -------------------------------------------------------------------------------- 1 | krb_service: HTTP 2 | krb_host_fqdn: "{{ ansible_fqdn }}" 3 | keytab_directory: /etc 4 | keytab_path: "{{ keytab_directory }}/{{ krb_service }}.keytab" 5 | keytab_owner: root 6 | keytab_group: root 7 | ipa_admin_user: admin 8 | ipa_admin_password: password 9 | krb_realm: "{{ ansible_domain | upper }}" 10 | -------------------------------------------------------------------------------- /tests/unit/test_lib_ldap.py: -------------------------------------------------------------------------------- 1 | from fasjson.lib.ldap import get_client 2 | 3 | 4 | def test_get_client(mocker): 5 | LDAP = mocker.patch("fasjson.lib.ldap.LDAP") 6 | LDAP.return_value = object() 7 | client = get_client("ldap://", "dc=example,dc=test", "dummy") 8 | LDAP.assert_called_once_with("ldap://", "dc=example,dc=test") 9 | assert client == LDAP.return_value 10 | -------------------------------------------------------------------------------- /devel/ansible/fasjson.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: fasjson 3 | become: true 4 | become_method: sudo 5 | vars_files: 6 | - vars.yml 7 | roles: 8 | - common 9 | - ipa-client 10 | - role: ipa-keytab 11 | krb_service: HTTP 12 | keytab_path: /etc/httpd/conf/fasjson.keytab 13 | keytab_owner: apache 14 | keytab_group: apache 15 | - apache 16 | - fasjson 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | venv_*/ 3 | /build/ 4 | /dist/ 5 | *.egg-info/ 6 | __pycache__ 7 | *.pyc 8 | *.pyo 9 | .tox 10 | *.rpm 11 | *.tar.gz 12 | .vagrant 13 | MANIFEST 14 | .*_cache 15 | 16 | # test related 17 | htmlcov/ 18 | .tox/ 19 | .coverage 20 | .coverage.* 21 | .cache 22 | nosetests.xml 23 | coverage.xml 24 | 25 | # docs related 26 | docs/_source 27 | docs/_build 28 | docs/_api 29 | 30 | .vscode/ 31 | -------------------------------------------------------------------------------- /.github/pull-request-template.md: -------------------------------------------------------------------------------- 1 | 4 | Fixes # 5 | 6 | 9 | ## Proposed Changes 10 | 11 | * 12 | 13 | 16 | ## Verification Steps 17 | 18 | * 19 | 20 | 23 | ## Additional Notes 24 | -------------------------------------------------------------------------------- /fasjson/web/apis/errors.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restx import abort, Api, Resource 3 | 4 | 5 | blueprint = Blueprint("errors", __name__, url_prefix="/errors") 6 | api = Api(blueprint, title="Webserver errors", doc=False, add_specs=False) 7 | 8 | 9 | @api.route("/") 10 | class Error(Resource): 11 | """Generate JSON on Apache-generated errors (or whichever webserver is used).""" 12 | 13 | def get(self, code): 14 | abort(code) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fedora Account System / IPA JSON gateway 2 | 3 | A JSON gateway to query FreeIPA, built for the Fedora Account System. 4 | 5 | The documentation is available at https://fasjson.readthedocs.io/ 6 | 7 | ![Tests & build status](https://github.com/fedora-infra/fasjson/actions/workflows/main.yml/badge.svg?branch=develop) 8 | ![Documentation](https://readthedocs.org/projects/fasjson/badge/?version=latest) 9 | 10 | 11 | ## TODO 12 | 13 | - documentation 14 | - HTTPS 15 | -------------------------------------------------------------------------------- /fasjson/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from importlib.metadata import PackageNotFoundError, version 3 | except ImportError: 4 | from importlib_metadata import PackageNotFoundError, version 5 | 6 | 7 | # Set the version 8 | try: 9 | __version__ = version("fasjson") 10 | except PackageNotFoundError: 11 | import os 12 | 13 | import toml 14 | 15 | pyproject = toml.load(os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")) 16 | __version__ = pyproject["tool"]["poetry"]["version"] 17 | -------------------------------------------------------------------------------- /fasjson/web/apis/v1.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from ..resources.certs import api_v1 as certs 4 | from ..resources.groups import api_v1 as groups 5 | from ..resources.me import api_v1 as me 6 | from ..resources.search import api_v1 as search 7 | from ..resources.users import api_v1 as users 8 | from .base import FasJsonApi 9 | 10 | 11 | blueprint = Blueprint("v1", __name__, url_prefix="/v1") 12 | api = FasJsonApi(blueprint, version="1.0") 13 | 14 | api.add_namespace(me) 15 | api.add_namespace(users) 16 | api.add_namespace(groups) 17 | api.add_namespace(certs) 18 | api.add_namespace(search) 19 | -------------------------------------------------------------------------------- /tests/unit/test_lib_ldap_converters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fasjson.lib.ldap import converters 4 | 5 | 6 | def test_bool_true(): 7 | c = converters.BoolConverter("locked") 8 | assert c.from_ldap([b"true"]) is True 9 | 10 | 11 | def test_bool_wrong_value(): 12 | c = converters.BoolConverter("locked") 13 | with pytest.raises(ValueError): 14 | assert c.from_ldap([b"maybe"]) 15 | 16 | 17 | def test_binary(): 18 | c = converters.BinaryConverter("userCertificate") 19 | assert c.ldap_name == "userCertificate;binary" 20 | assert c.from_ldap([b"dummy"]) == "ZHVtbXk=" 21 | -------------------------------------------------------------------------------- /devel/ansible/roles/cert/tasks/main.yml: -------------------------------------------------------------------------------- 1 | 2 | 3 | - name: Generate and get SSL cert 4 | shell: ipa-getcert request -f /etc/pki/tls/certs/{{ cert_basename }}.pem -k /etc/pki/tls/private/{{ cert_basename }}.key -K {{ krb_service }}/{{ cert_hostname }} -N {{ cert_hostname }} 5 | args: 6 | creates: /etc/pki/tls/certs/{{ cert_basename }}.pem 7 | 8 | - name: Check the cert is there 9 | wait_for: 10 | path: /etc/pki/tls/certs/{{ cert_basename }}.pem 11 | state: present 12 | 13 | - name: Check the key is there 14 | wait_for: 15 | path: /etc/pki/tls/private/{{ cert_basename }}.key 16 | state: present 17 | -------------------------------------------------------------------------------- /fasjson/web/utils/request_parsing.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | 4 | def add_exact_arguments(parser, model): 5 | for argument in parser.args: 6 | if argument.name in ("page_size", "page_number"): 7 | continue 8 | if "__" in argument.name: 9 | continue 10 | if argument.name in model.always_exact_match: 11 | argument.help = f"{argument.help} (exact match)" 12 | continue 13 | new_argument = deepcopy(argument) 14 | new_argument.name = f"{argument.name}__exact" 15 | new_argument.help = f"{argument.help} (exact match)" 16 | parser.add_argument(new_argument) 17 | -------------------------------------------------------------------------------- /devel/ansible/roles/freeipa/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install RPM packages 3 | dnf: 4 | name: 5 | - git 6 | - vim 7 | - freeipa-server 8 | state: present 9 | 10 | - name: install freeipa server 11 | shell: ipa-server-install -a adminPassw0rd! --hostname=ipa.example.test -r EXAMPLE.TEST -p adminPassw0rd! -n example.test -U 12 | args: 13 | creates: /var/lib/ipa/sysrestore/sysrestore.state 14 | 15 | - name: get freeipa-fas 16 | git: 17 | repo: https://github.com/fedora-infra/freeipa-fas.git 18 | dest: /root/freeipa-fas 19 | 20 | - name: install freeipa-fas 21 | shell: ./install.sh 22 | args: 23 | chdir: /root/freeipa-fas/ 24 | creates: /usr/share/ipa/updates/99-fas.update 25 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | apt_packages: 12 | - libkrb5-dev 13 | tools: 14 | python: "3.11" 15 | 16 | # Build documentation in the docs/ directory with Sphinx 17 | sphinx: 18 | configuration: docs/conf.py 19 | 20 | # Optionally build your docs in additional formats such as PDF and ePub 21 | formats: all 22 | 23 | # Optionally set the version of Python and requirements required to build your docs 24 | python: 25 | install: 26 | - requirements: docs/requirements.txt 27 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | FASJSON's documentation 3 | ======================= 4 | 5 | .. image:: https://github.com/fedora-infra/fasjson/actions/workflows/main.yml/badge.svg?branch=develop 6 | 7 | .. image:: https://readthedocs.org/projects/fasjson/badge/?version=latest 8 | 9 | 10 | .. Release Notes 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | :caption: Release Notes 15 | 16 | release_notes 17 | 18 | 19 | .. User Guide 20 | 21 | .. toctree:: 22 | :maxdepth: 2 23 | :caption: User Guide 24 | 25 | installation 26 | usage 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | :caption: Module Documentation 31 | 32 | _source/modules 33 | 34 | .. toctree:: 35 | :maxdepth: 2 36 | :caption: API Documentation 37 | 38 | api_v1 39 | -------------------------------------------------------------------------------- /tests/unit/test_lib_ldap_result.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fasjson.lib.ldap.client import LDAPResult 4 | 5 | 6 | @pytest.fixture 7 | def ldap_result(): 8 | return LDAPResult( 9 | items=[{"name": "dummy-1"}, {"name": "dummy-2"}], 10 | total=4, 11 | page_size=0, 12 | page_number=1, 13 | ) 14 | 15 | 16 | def test_ldap_result_repr(ldap_result): 17 | assert repr(ldap_result) == "" 18 | 19 | 20 | def test_ldap_result_cmp(ldap_result): 21 | result_1 = ldap_result 22 | result_2 = LDAPResult(items=ldap_result.items) 23 | assert result_1 != result_2 24 | 25 | 26 | def test_ldap_result_cmp_invalid(ldap_result): 27 | with pytest.raises(ValueError): 28 | assert ldap_result == ["a", "b", "c"] 29 | -------------------------------------------------------------------------------- /.github/workflows/label-when-deployed.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Contributors to the Fedora Project 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: Apply labels when deployed 6 | 7 | on: 8 | push: 9 | branches: 10 | - staging 11 | - stable 12 | 13 | jobs: 14 | label: 15 | name: Apply labels 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Staging deployment 20 | uses: fedora-infra/label-when-in-branch@v1 21 | with: 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | branch: staging 24 | label: deployed:staging 25 | - name: Production deployment 26 | uses: fedora-infra/label-when-in-branch@v1 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | branch: stable 30 | label: deployed:prod 31 | -------------------------------------------------------------------------------- /docs/make-requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | here=$(realpath $(dirname $0)) 6 | output="${here}/requirements.txt" 7 | excluded="gssapi python-ldap requests-kerberos pykerberos winkerberos dataclasses jsonschema" 8 | 9 | set -x 10 | 11 | poetry export -f requirements.txt --without-hashes -o "${output}" 12 | 13 | # Remove the python version markers 14 | sed -i -e "s/ ; .*$//" "${output}" 15 | 16 | # Remove some modules because ReadTheDocs does not install C-based modules 17 | for exclude in ${excluded}; do 18 | sed -i -e "/^${exclude}==/d" "${output}" 19 | done 20 | 21 | # Add toml to parse the version in conf.py 22 | echo toml >> "${output}" 23 | # Add pytest because it is imported in the source code 24 | echo pytest >> "${output}" 25 | # Add Sphinx dependencies 26 | echo -e "sphinx\nsphinxcontrib-napoleon\nsphinxcontrib-openapi\nmyst-parser" >> "${output}" 27 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==10.0.1 2 | attrs==25.4.0 3 | blinker==1.9.0 4 | certifi==2025.11.12 5 | cffi==2.0.0 6 | charset-normalizer==3.4.4 7 | click==8.3.1 8 | colorama==0.4.6 9 | cryptography==46.0.3 10 | decorator==5.2.1 11 | dnspython==2.8.0 12 | flask-healthz==1.0.1 13 | flask-mod-auth-gssapi==1.1.1 14 | flask-restx==1.3.2 15 | flask==3.1.2 16 | idna==3.11 17 | importlib-resources==6.5.2 18 | itsdangerous==2.2.0 19 | jinja2==3.1.6 20 | jsonschema-specifications==2025.9.1 21 | krb5==0.9.0 22 | markupsafe==3.0.3 23 | pyasn1-modules==0.4.2 24 | pyasn1==0.6.1 25 | pycparser==2.23 26 | pyspnego==0.12.0 27 | python-freeipa==1.0.10 28 | referencing==0.37.0 29 | requests-gssapi==1.4.0 30 | requests==2.32.5 31 | rpds-py==0.30.0 32 | sspilib==0.5.0 33 | typing-extensions==4.15.0 34 | urllib3==2.6.2 35 | werkzeug==3.1.4 36 | toml 37 | pytest 38 | sphinx 39 | sphinxcontrib-napoleon 40 | sphinxcontrib-openapi 41 | myst-parser 42 | -------------------------------------------------------------------------------- /devel/run-liccheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # SPDX-FileCopyrightText: Contributors to the Fedora Project 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | 8 | STRATEGY_URL=https://raw.githubusercontent.com/fedora-infra/shared/main/liccheck-strategy.ini 9 | 10 | trap 'rm -f "$TMPFILE $STRATEGY_TMPFILE"' EXIT 11 | 12 | set -e 13 | set -x 14 | 15 | TMPFILE=$(mktemp -t requirements-XXXXXX.txt) 16 | STRATEGY_TMPFILE=$(mktemp -t liccheck-strategy-XXXXXX.ini) 17 | 18 | curl -o $STRATEGY_TMPFILE $STRATEGY_URL 19 | 20 | poetry export --with dev --without-hashes -f requirements.txt -o $TMPFILE 21 | 22 | # Use pip freeze instead of poetry when it fails 23 | # poetry run pip freeze --exclude-editable --isolated > $TMPFILE 24 | 25 | # Liccheck requires setuptools: https://github.com/dhatim/python-license-check/issues/117 26 | poetry run pip install setuptools 27 | 28 | poetry run liccheck -r $TMPFILE -s $STRATEGY_TMPFILE 29 | -------------------------------------------------------------------------------- /fasjson/web/resources/me.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from flask_restx import fields, Resource 3 | 4 | from fasjson.web.utils.ipa import ldap_client 5 | 6 | from .base import Namespace 7 | 8 | 9 | api_v1 = Namespace("me", description="Information about the connected user") 10 | 11 | MeModel = api_v1.model( 12 | "Me", 13 | { 14 | "dn": fields.String, 15 | "username": fields.String, 16 | "service": fields.String, 17 | "uri": fields.String, 18 | }, 19 | ) 20 | 21 | 22 | @api_v1.route("/") 23 | class Me(Resource): 24 | @api_v1.doc("whoami") 25 | @api_v1.marshal_with(MeModel) 26 | def get(self): 27 | """Fetch the connected user""" 28 | client = ldap_client() 29 | result = client.whoami() 30 | if "username" in result: 31 | result["uri"] = url_for("v1.users_user", username=result["username"], _external=True) 32 | return result 33 | -------------------------------------------------------------------------------- /devel/ansible/roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install common packages 3 | dnf: 4 | name: 5 | - bash-completion 6 | - tmux 7 | - vim-enhanced 8 | - git 9 | - python3 10 | state: present 11 | 12 | - name: Determine Python version 13 | command: 14 | argv: 15 | - python3 16 | - -c 17 | - "from sys import version_info as vi; print(f'{vi[0]}.{vi[1]}')" 18 | register: _python3_version_result 19 | changed_when: False 20 | 21 | - name: Prepare the facts dir 22 | file: 23 | path: /etc/ansible/facts.d 24 | state: directory 25 | 26 | - name: Set Python version fact 27 | ini_file: 28 | path: /etc/ansible/facts.d/python.fact 29 | section: py3 30 | option: version 31 | value: "{{ _python3_version_result.stdout | trim }}" 32 | register: fact_ini 33 | 34 | - name: Re-read facts after adding custom fact 35 | ansible.builtin.setup: 36 | filter: ansible_local 37 | when: fact_ini.changed 38 | -------------------------------------------------------------------------------- /fasjson/web/base_routes.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import ldap 4 | from flask import current_app, jsonify, url_for 5 | from flask_healthz import HealthError 6 | 7 | 8 | def root(): 9 | blueprints = sorted( 10 | [name for name in current_app.blueprints if re.match("^v[0-9]+$", name)], 11 | key=lambda name: int(name[1:]), 12 | ) 13 | apis = [ 14 | { 15 | "version": int(name[1:]), 16 | "uri": url_for(f"{name}.root", _external=True), 17 | "specs": url_for(f"{name}.specs", _external=True), 18 | "docs": url_for(f"{name}.doc", _external=True), 19 | } 20 | for name in blueprints 21 | ] 22 | return jsonify({"message": "Welcome to FASJSON", "apis": apis}) 23 | 24 | 25 | def readiness(): 26 | """Readiness Health Check""" 27 | try: 28 | client = ldap.initialize(current_app.config["FASJSON_LDAP_URI"]) 29 | client.simple_bind_s() 30 | except ldap.SERVER_DOWN as e: 31 | raise HealthError("LDAP server is down") from e 32 | -------------------------------------------------------------------------------- /docs/utils/export_swagger.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | # this is a script we run in tox when generating the docs. 4 | # it saves a copy of the openapi / swagger spec so we can use 5 | # it in sphinx with the sphinxcontrib-openapi plugin 6 | 7 | import json 8 | import os 9 | from importlib import import_module 10 | 11 | from fasjson.web.app import create_app 12 | 13 | 14 | directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "_api") 15 | 16 | 17 | def _generate_spec(api_version): 18 | api_module = import_module(f"fasjson.web.apis.v{api_version}") 19 | try: 20 | os.makedirs(directory) 21 | except OSError: 22 | pass 23 | output_path = os.path.join(directory, f"api_v{api_version}.json") 24 | with open(output_path, "w") as f: 25 | f.write(json.dumps(api_module.api.__schema__)) 26 | 27 | 28 | def run(): 29 | app = create_app({"TESTING": True}) 30 | with app.test_request_context(): 31 | _generate_spec(1) 32 | 33 | 34 | if __name__ == "__main__": 35 | run() 36 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | ENV['VAGRANT_NO_PARALLEL'] = 'yes' 5 | 6 | Vagrant.configure(2) do |config| 7 | config.hostmanager.enabled = true 8 | config.hostmanager.manage_host = true 9 | config.hostmanager.manage_guest = true 10 | 11 | config.vm.define "fasjson" do |fasjson| 12 | fasjson.vm.box_url = "https://download.fedoraproject.org/pub/fedora/linux/releases/38/Cloud/x86_64/images/Fedora-Cloud-Base-Vagrant-38-1.6.x86_64.vagrant-libvirt.box" 13 | fasjson.vm.box = "f38-cloud-libvirt" 14 | fasjson.vm.hostname = "fasjson-dev.tinystage.test" 15 | 16 | fasjson.vm.synced_folder ".", "/vagrant", disabled: true 17 | fasjson.vm.synced_folder ".", "/home/vagrant/fasjson", type: "sshfs" 18 | 19 | fasjson.vm.provider :libvirt do |libvirt| 20 | libvirt.cpus = 2 21 | libvirt.memory = 2048 22 | end 23 | 24 | fasjson.vm.provision "ansible" do |ansible| 25 | ansible.playbook = "devel/ansible/fasjson.yml" 26 | ansible.verbose = true 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # Generic hooks 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v6.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | # https://black.readthedocs.io/en/stable/integrations/source_version_control.html 12 | - repo: https://github.com/psf/black 13 | rev: 25.12.0 14 | hooks: 15 | - id: black 16 | 17 | # ruff 18 | - repo: https://github.com/charliermarsh/ruff-pre-commit 19 | # ruff version. 20 | rev: v0.14.9 21 | hooks: 22 | - id: ruff 23 | 24 | - repo: https://github.com/myint/rstcheck 25 | rev: v6.2.5 26 | hooks: 27 | - id: rstcheck 28 | additional_dependencies: [sphinx, toml, myst-parser] 29 | 30 | - repo: local 31 | hooks: 32 | - id: liccheck 33 | name: liccheck 34 | entry: ./devel/run-liccheck.sh 35 | files: "(pyproject.toml|poetry.lock)" 36 | pass_filenames: false 37 | language: script 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/fedora/fedora:40 2 | LABEL \ 3 | name="fasjson" \ 4 | vendor="Fedora Infrastructure" \ 5 | license="GPLv3+" 6 | ENV HOME=/opt 7 | RUN dnf install -y \ 8 | openldap-clients \ 9 | vim \ 10 | git \ 11 | ipa-client \ 12 | gcc \ 13 | redhat-rpm-config \ 14 | python-devel \ 15 | krb5-devel \ 16 | openldap-devel \ 17 | httpd \ 18 | mod_auth_gssapi \ 19 | mod_session \ 20 | policycoreutils-python-utils \ 21 | poetry \ 22 | python3-mod_wsgi \ 23 | python3-pip && \ 24 | dnf autoremove -y && \ 25 | dnf clean all -y 26 | RUN python3 -m venv /opt/venv 27 | RUN poetry config virtualenvs.create false 28 | COPY ./ /opt/fasjson 29 | ENV VIRTUAL_ENV=/opt/venv 30 | RUN cd /opt/fasjson && poetry install --only main 31 | RUN rm -f /etc/krb5.conf && ln -sf /etc/krb5/krb5.conf /etc/krb5.conf && \ 32 | rm -f /etc/openldap/ldap.conf && ln -sf /etc/ipa/ldap.conf /etc/openldap/ldap.conf 33 | EXPOSE 8080 34 | ENTRYPOINT bash /opt/fasjson/deploy/start.sh 35 | -------------------------------------------------------------------------------- /tests/unit/test_web_resource_base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask_restx import fields 3 | 4 | from fasjson.web.resources.base import Namespace 5 | 6 | 7 | @pytest.fixture 8 | def api(): 9 | return Namespace("test") 10 | 11 | 12 | def test_marshal_with_field(api): 13 | class TestResource: 14 | @api.marshal_with(fields.Boolean()) 15 | def get(self): 16 | return True 17 | 18 | resource = TestResource() 19 | assert resource.get() == {"result": True} 20 | 21 | 22 | def test_marshal_with_field_class(api): 23 | class TestResource: 24 | @api.marshal_with(fields.Boolean) 25 | def get(self): 26 | return True 27 | 28 | resource = TestResource() 29 | assert resource.get() == {"result": True} 30 | 31 | 32 | def test_marshal_with_field_tuple_response(api): 33 | headers = dict() 34 | 35 | class TestResource: 36 | @api.marshal_with(fields.Boolean()) 37 | def get(self): 38 | return True, 200, headers 39 | 40 | resource = TestResource() 41 | assert resource.get() == ({"result": True}, 200, headers) 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = checks,docs,{py310,py311,py312,py313,py314}-unit 3 | isolated_build = true 4 | 5 | [testenv] 6 | passenv = HOME 7 | sitepackages = false 8 | skip_install = true 9 | allowlist_externals = 10 | poetry 11 | commands_pre = 12 | poetry install 13 | commands = 14 | unit: poetry run pytest -vv --cov --cov-report=html --cov-report=xml --cov-report=term-missing tests/unit {posargs} 15 | 16 | [testenv:docs] 17 | changedir = docs 18 | deps = 19 | -rdocs/requirements.txt 20 | allowlist_externals = 21 | {[testenv]allowlist_externals} 22 | rm 23 | mkdir 24 | commands= 25 | rm -rf _build 26 | rm -rf _source 27 | rm -rf _api 28 | mkdir _api 29 | poetry run sphinx-build -W -b html -d {envtmpdir}/doctrees . _build/html 30 | 31 | [testenv:checks] 32 | allowlist_externals = 33 | {[testenv]allowlist_externals} 34 | pre-commit 35 | git 36 | commands = 37 | pre-commit run --all-files 38 | 39 | 40 | # We're using Ruff now, but we leave this line in place for contributors whose 41 | # editor still only runs flake8. 42 | [flake8] 43 | max-line-length = 100 44 | -------------------------------------------------------------------------------- /tests/unit/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | 4 | def get_user_ldap_data(name): 5 | return { 6 | "certificates": None, 7 | "creation": datetime(2020, 3, 9, 10, 32, 3, tzinfo=timezone.utc), 8 | "givenname": "", 9 | "gpgkeyids": None, 10 | "sshpubkeys": None, 11 | "locked": False, 12 | "username": name, 13 | "emails": [f"{name}@example.test"], 14 | "surname": name, 15 | "human_name": name, 16 | "is_private": False, 17 | "github_username": name, 18 | "gitlab_username": name, 19 | "pronouns": ["they/them/theirs"], 20 | "rhbzemail": f"{name}@rhbz_example.test", 21 | "website": f"{name}.example.com", 22 | "rssurl": f"{name}.example.com/feed", 23 | "websites": [f"{name}.example.com"], 24 | "rssurls": [f"{name}.example.com/feed"], 25 | } 26 | 27 | 28 | def get_user_api_output(name): 29 | data = get_user_ldap_data(name) 30 | data["creation"] = data["creation"].isoformat() 31 | data["ircnicks"] = data["locale"] = data["timezone"] = None 32 | data["uri"] = f"http://localhost/v1/users/{name}/" 33 | return data 34 | -------------------------------------------------------------------------------- /devel/ansible/roles/ipa-keytab/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: kinit 3 | shell: echo "{{ ipa_admin_password }}" | kinit {{ ipa_admin_user }}@{{ krb_realm }} 4 | 5 | - name: Create the service in IPA 6 | command: ipa service-add --force {{ krb_service | upper }}/{{ krb_host_fqdn }} 7 | register: service_add_result 8 | changed_when: "'Added service' in service_add_result.stdout" 9 | failed_when: "not ('Added service' in service_add_result.stdout or 'already exists' in service_add_result.stderr)" 10 | 11 | - name: Allow the host to manage the virtual service 12 | shell: ipa service-add-host --hosts={{ ansible_fqdn }} {{ krb_service | upper }}/{{ krb_host_fqdn }} 13 | when: krb_host_fqdn != ansible_fqdn 14 | register: result 15 | changed_when: '"Number of members added 1" in result.stdout' 16 | failed_when: '(ansible_fqdn + ": This entry is already a member") not in result.stdout and result.rc != 0' 17 | 18 | - name: Get service keytab 19 | shell: ipa-getkeytab -p {{ krb_service | upper }}/{{ krb_host_fqdn }}@{{ krb_realm }} -k {{ keytab_path }} 20 | args: 21 | creates: "{{ keytab_path }}" 22 | 23 | - name: Set the correct permissions on keytab 24 | file: 25 | path: "{{ keytab_path }}" 26 | owner: "{{ keytab_owner }}" 27 | group: "{{ keytab_group }}" 28 | mode: 0640 29 | -------------------------------------------------------------------------------- /devel/ansible/roles/ipa-client/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install RPM packages 3 | dnf: 4 | name: 5 | - ipa-client 6 | state: present 7 | 8 | - name: Install python-certifi 9 | dnf: 10 | name: 11 | - python3-certifi 12 | state: present 13 | when: "python_exec in ('python', 'python3')" 14 | 15 | - name: Enroll system as IPA client 16 | shell: | 17 | ipa-client-install \ 18 | --hostname {{ ansible_fqdn }} \ 19 | --domain {{ ansible_domain }} \ 20 | --realm {{ krb_realm }} \ 21 | --server ipa.{{ ansible_domain }} \ 22 | -p {{ ipa_admin_user }} \ 23 | -w {{ ipa_admin_password }} \ 24 | -U -N --force-join 25 | args: 26 | creates: /etc/ipa/default.conf 27 | 28 | # Add Tinystage's root CA to certifi's bundle 29 | 30 | - name: Find where certifi's CA bundle is located 31 | command: 32 | cmd: "{{ python_exec }} -c 'import certifi; print(certifi.where())'" 33 | chdir: "{{ python_run_dir }}" 34 | register: _ca_bundle_path 35 | changed_when: False 36 | 37 | - name: Get the content of the CA cert 38 | slurp: 39 | src: /etc/ipa/ca.crt 40 | register: ca_crt 41 | 42 | - name: Put tinystage root CA in the list of CA's for certifi 43 | blockinfile: 44 | block: "{{ ca_crt.content | b64decode }}" 45 | path: "{{ _ca_bundle_path.stdout }}" 46 | -------------------------------------------------------------------------------- /fasjson/lib/ldap/converters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from base64 import b64encode 3 | 4 | 5 | class Converter: 6 | def __init__(self, ldap_name, multivalued=False): 7 | self.ldap_name = ldap_name 8 | self.multivalued = multivalued 9 | 10 | def from_ldap(self, value): 11 | value = [self.decode(v) for v in value] 12 | if not self.multivalued: 13 | value = value[0] 14 | return value 15 | 16 | def decode(self, value): 17 | return value.decode("utf-8") 18 | 19 | 20 | class BoolConverter(Converter): 21 | def decode(self, value): 22 | value = super().decode(value).upper() 23 | if value == "TRUE": 24 | return True 25 | if value == "FALSE": 26 | return False 27 | raise ValueError(value) 28 | 29 | 30 | class GeneralTimeConverter(Converter): 31 | gentime_fmt = "%Y%m%d%H%M%SZ" 32 | 33 | def decode(self, value): 34 | value = super().decode(value) 35 | return datetime.datetime.strptime(value, self.gentime_fmt) 36 | 37 | 38 | class BinaryConverter(Converter): 39 | def __init__(self, *args, **kwargs): 40 | super().__init__(*args, **kwargs) 41 | self.ldap_name = f"{self.ldap_name};binary" 42 | 43 | def decode(self, value): 44 | return b64encode(value).decode("ascii") 45 | -------------------------------------------------------------------------------- /devel/ansible/roles/apache/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install RPM packages 3 | dnf: 4 | name: 5 | - httpd 6 | - krb5-workstation 7 | - sqlite 8 | - openssl 9 | - mod_ssl 10 | state: present 11 | 12 | - name: kinit for Apache 13 | shell: echo "{{ ipa_admin_password }}" | kinit {{ ipa_admin_user }}@{{ krb_realm }} 14 | 15 | - name: Get the certificates 16 | import_role: 17 | name: cert 18 | vars: 19 | cert_hostname: "{{ ansible_fqdn }}" 20 | cert_basename: server 21 | 22 | - name: configure SSLCertificateFile in ssl.conf 23 | lineinfile: 24 | path: /etc/httpd/conf.d/ssl.conf 25 | regexp: "SSLCertificateFile \/etc\/pki\/tls\/certs\/localhost.crt" 26 | line: SSLCertificateFile /etc/pki/tls/certs/server.pem 27 | notify: restart httpd 28 | 29 | - name: configure SSLCertificateKeyFile in ssl.conf 30 | lineinfile: 31 | path: /etc/httpd/conf.d/ssl.conf 32 | regexp: "SSLCertificateKeyFile \/etc\/pki\/tls\/private\/localhost.key" 33 | line: SSLCertificateKeyFile /etc/pki/tls/private/server.key 34 | notify: restart httpd 35 | 36 | - name: set default client keytab in krb5.conf 37 | lineinfile: 38 | path: /etc/krb5.conf 39 | insertafter: 'default_ccache_name.*' 40 | line: ' default_client_keytab_name = FILE:{{ keytab_path }}' 41 | 42 | - name: redirect to https 43 | copy: 44 | src: redirect-to-ssl.conf 45 | dest: /etc/httpd/conf.d/redirect-to-ssl.conf 46 | notify: restart httpd 47 | -------------------------------------------------------------------------------- /fasjson/web/defaults.cfg: -------------------------------------------------------------------------------- 1 | # This turns all 404 error messages into something that supposes you've mistyped the URL, while it's 2 | # most often that the requested object does not exist. Turn it off. 3 | RESTX_ERROR_404_HELP = False 4 | 5 | # Show all request parsing errors 6 | # https://flask-restx.readthedocs.io/en/latest/parsing.html#error-handling 7 | BUNDLE_ERRORS = True 8 | 9 | HEALTHZ = { 10 | "live": lambda: None, 11 | "ready": "fasjson.web.base_routes.readiness", 12 | } 13 | 14 | # The ID of the Certificate Profile to use in IPA 15 | CERTIFICATE_PROFILE = None 16 | 17 | # Ask to re-authenticate (and invalidate the mod_auth_gssapi session) when the delegated credentials 18 | # are expired. 19 | # https://github.com/gssapi/mod_auth_gssapi/issues/316 20 | MOD_AUTH_GSSAPI_SESSION_HEADER = "IPASESSION" 21 | 22 | # LOGGING = { 23 | # "version": 1, 24 | # "formatters": { 25 | # "default": { 26 | # "format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s", 27 | # } 28 | # }, 29 | # "handlers": { 30 | # "wsgi": { 31 | # "class": "logging.StreamHandler", 32 | # "stream": "ext://flask.logging.wsgi_errors_stream", 33 | # "formatter": "default", 34 | # } 35 | # }, 36 | # "fasjson": { 37 | # "level": "DEBUG", 38 | # "handlers": ["wsgi"], 39 | # "propagate": False, 40 | # }, 41 | # "root": {"level": "INFO", "handlers": ["wsgi"]}, 42 | # } 43 | -------------------------------------------------------------------------------- /tests/unit/test_web_base_routes.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import ldap 4 | 5 | 6 | def test_root_anonymous(anon_client): 7 | rv = anon_client.get("/") 8 | body = json.loads(rv.data) 9 | 10 | assert rv.status_code == 200 11 | expected = { 12 | "apis": [ 13 | { 14 | "docs": "http://localhost/docs/v1/", 15 | "specs": "http://localhost/specs/v1.json", 16 | "uri": "http://localhost/v1/", 17 | "version": 1, 18 | } 19 | ], 20 | "message": "Welcome to FASJSON", 21 | } 22 | assert body == expected 23 | 24 | 25 | def test_live_success(anon_client): 26 | rv = anon_client.get("/healthz/live") 27 | assert 200 == rv.status_code 28 | assert b"OK" in rv.data 29 | 30 | 31 | def test_ready_success(anon_client, mocker): 32 | bind_mock = mocker.patch("ldap.ldapobject.SimpleLDAPObject.simple_bind_s") 33 | # a successful return value is None, so we don't want simple_bind_s to complain 34 | bind_mock.return_value = None 35 | rv = anon_client.get("/healthz/ready") 36 | 37 | assert 200 == rv.status_code 38 | assert b"OK" in rv.data 39 | 40 | 41 | def test_ready_error(anon_client, mocker): 42 | mocker.patch( 43 | "ldap.ldapobject.SimpleLDAPObject.simple_bind_s", 44 | side_effect=ldap.SERVER_DOWN, 45 | ) 46 | rv = anon_client.get("/healthz/ready") 47 | 48 | assert 503 == rv.status_code 49 | assert b"LDAP server is down" in rv.data 50 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/templates/setup-fasjson-service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | PRINCIPAL="{{ krb_service }}/{{ ansible_fqdn }}" 6 | DELEGATION="fasjson-delegation" 7 | 8 | ipa service-find $PRINCIPAL &> /dev/null || ipa service-add $PRINCIPAL --force 9 | 10 | # Create delegation rule 11 | 12 | ipa servicedelegationrule-find $DELEGATION &> /dev/null || ipa servicedelegationrule-add $DELEGATION 13 | 14 | ipa servicedelegationrule-show $DELEGATION | grep "Member principals:" | grep -qs $PRINCIPAL || ( 15 | ipa servicedelegationrule-add-member --principals=$PRINCIPAL $DELEGATION 16 | ) 17 | 18 | # Delegate for LDAP 19 | 20 | ipa servicedelegationrule-show $DELEGATION | grep "Allowed Target:" | grep -qs ipa-ldap-delegation-targets || ( 21 | ipa servicedelegationrule-add-target --servicedelegationtargets=ipa-ldap-delegation-targets $DELEGATION 22 | ) 23 | 24 | # Delegate for HTTP 25 | 26 | ipa servicedelegationtarget-find ipa-http-delegation-targets &> /dev/null || ipa servicedelegationtarget-add ipa-http-delegation-targets 27 | 28 | ipa servicedelegationtarget-show ipa-http-delegation-targets | grep "Member principals:" | grep -qs HTTP/ipa.{{ ansible_domain }} || ( 29 | ipa servicedelegationtarget-add-member ipa-http-delegation-targets --principals=HTTP/ipa.{{ ansible_domain }}@{{ ansible_domain | upper }} 30 | ) 31 | 32 | ipa servicedelegationrule-show $DELEGATION | grep "Allowed Target:" | grep -qs ipa-http-delegation-targets || ( 33 | ipa servicedelegationrule-add-target --servicedelegationtargets=ipa-http-delegation-targets $DELEGATION 34 | ) 35 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | 4 | Install dependencies:: 5 | 6 | dnf install ipa-client httpd mod_auth_gssapi mod_session python3-mod_wsgi python3-poetry 7 | 8 | Install WSGI app:: 9 | 10 | poetry config virtualenvs.create false 11 | poetry install 12 | cp ansible/roles/fasjson/files/fasjson.wsgi /srv/ 13 | 14 | Enroll the system as an IPA client:: 15 | 16 | $ ipa-client-install 17 | 18 | Get service keytab for HTTPd:: 19 | 20 | ipa service-add HTTP/$(hostname) 21 | ipa servicedelegationrule-add-member --principals=HTTP/$(hostname) fasjson-delegation 22 | ipa-getkeytab -p HTTP/$(hostname) -k /var/lib/gssproxy/httpd.keytab 23 | chown root:root /var/lib/gssproxy/httpd.keytab 24 | chmod 640 /var/lib/gssproxy/httpd.keytab 25 | 26 | Configure GSSProxy for Apache:: 27 | 28 | cp ansible/roles/fasjson/files/config/gssproxy-fasjson.conf /etc/gssproxy/99-fasjson.conf 29 | systemctl enable gssproxy.service 30 | systemctl restart gssproxy.service 31 | 32 | Configure temporary files:: 33 | 34 | cp ansible/roles/fasjson/files/config/tmpfiles-fasjson.conf /etc/tmpfiles.d/fasjson.conf 35 | systemd-tmpfiles --create 36 | 37 | Tune SELinux Policy:: 38 | 39 | setsebool -P httpd_can_connect_ldap=on 40 | 41 | Configure Apache:: 42 | 43 | mkdir mkdir -p /etc/systemd/system/httpd.service.d 44 | cp ansible/roles/fasjson/files/config/systemd-httpd-service-fasjson.conf /etc/systemd/system/httpd.service.d/fasjson.conf 45 | cp ansible/roles/fasjson/files/config/httpd-fasjson.conf /etc/httpd/conf.d/fasjson.conf 46 | systemctl daemon-reload 47 | systemctl enable httpd.service 48 | systemctl restart httpd.service 49 | -------------------------------------------------------------------------------- /news/_template.md.j2: -------------------------------------------------------------------------------- 1 | {% macro reference(value) -%} 2 | {%- if value.startswith("PR") -%} 3 | PR #{{ value[2:] }} 4 | {%- elif value.startswith("C") -%} 5 | [{{ value[1:] }}](https://github.com/fedora-infra/fasjson/commits/{{ value[1:] }}) 6 | {%- else -%} 7 | #{{ value }} 8 | {%- endif -%} 9 | {%- endmacro -%} 10 | 11 | {{- top_line -}} 12 | 13 | Released on {{ versiondata.date }}. 14 | 15 | {% for section, _ in sections.items() -%} 16 | {%- if section -%} 17 | ## {{section}} 18 | {%- endif -%} 19 | 20 | {%- if sections[section] -%} 21 | {%- for category, val in definitions.items() if category in sections[section] and category != "author" -%} 22 | ### {{ definitions[category]['name'] }} 23 | 24 | {% if definitions[category]['showcontent'] -%} 25 | {%- for text, values in sections[section][category].items() %} 26 | - {{ text }} 27 | {%- if values %} 28 | {% if "\n - " in text or '\n * ' in text %} 29 | 30 | 31 | ( 32 | {%- else %} 33 | ( 34 | {%- endif -%} 35 | {%- for issue in values %} 36 | {{ reference(issue) }}{% if not loop.last %}, {% endif %} 37 | {%- endfor %} 38 | ) 39 | {% else %} 40 | 41 | {% endif %} 42 | {% endfor -%} 43 | {%- else -%} 44 | - {{ sections[section][category]['']|sort|join(', ') }} 45 | 46 | {% endif -%} 47 | {%- if sections[section][category]|length == 0 %} 48 | No significant changes. 49 | 50 | {% else -%} 51 | {%- endif %} 52 | 53 | {% endfor -%} 54 | {% if sections[section]["author"] -%} 55 | ### {{definitions['author']["name"]}} 56 | 57 | Many thanks to the contributors of bug reports, pull requests, and pull request 58 | reviews for this release: 59 | 60 | {% for text, values in sections[section]["author"].items() -%} 61 | - {{ text }} 62 | {% endfor -%} 63 | {%- endif %} 64 | 65 | {% else -%} 66 | No significant changes. 67 | 68 | {% endif %} 69 | {%- endfor +%} 70 | -------------------------------------------------------------------------------- /tests/unit/test_web_resource_v1_me.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def mock_ldap_client(mock_ipa_client): 8 | yield partial(mock_ipa_client, "fasjson.web.resources.me", "ldap") 9 | 10 | 11 | def test_me_user_success(client, gss_user, mock_ldap_client): 12 | r = { 13 | "dn": "uid=dummy,cn=users,cn=accounts,dc=example,dc=test", 14 | "username": "dummy", 15 | } 16 | mock_ldap_client(whoami=lambda: r) 17 | 18 | rv = client.get("/v1/me/") 19 | expected = { 20 | "result": { 21 | "dn": r["dn"], 22 | "username": "dummy", 23 | "service": None, 24 | "uri": "http://localhost/v1/users/dummy/", 25 | } 26 | } 27 | 28 | assert 200 == rv.status_code 29 | assert expected == rv.get_json() 30 | 31 | 32 | def test_me_service_success(client, gss_user, mock_ldap_client): 33 | r = { 34 | "dn": ( 35 | "krbprincipalname=test/fasjson.example.test@example.test," 36 | "cn=services,cn=accounts,dc=example,dc=test" 37 | ), 38 | "service": "test/fasjson.example.test", 39 | } 40 | mock_ldap_client(whoami=lambda: r) 41 | 42 | rv = client.get("/v1/me/") 43 | expected = { 44 | "result": { 45 | "dn": r["dn"], 46 | "username": None, 47 | "service": "test/fasjson.example.test", 48 | "uri": None, 49 | } 50 | } 51 | 52 | assert 200 == rv.status_code 53 | assert expected == rv.get_json() 54 | 55 | 56 | def test_me_error(client, gss_env): 57 | rv = client.get("/v1/me/") 58 | res = rv.get_json() 59 | assert 403 == rv.status_code 60 | assert "message" in res 61 | assert res["message"].startswith("Invalid credentials") 62 | assert res["message"].endswith("Minor (2529639107): No credentials cache found)") 63 | -------------------------------------------------------------------------------- /news/get-authors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This script browses through git commit history (starting at latest tag), collects all authors of 5 | commits and creates fragment for `towncrier`_ tool. 6 | 7 | It's meant to be run during the release process, before generating the release notes. 8 | 9 | Example:: 10 | 11 | $ python get_authors.py 12 | 13 | .. _towncrier: https://github.com/hawkowl/towncrier/ 14 | 15 | Authors: 16 | Aurelien Bompard 17 | Michal Konecny 18 | """ 19 | 20 | import os 21 | from argparse import ArgumentParser 22 | from subprocess import check_output 23 | 24 | 25 | EXCLUDE = ["Weblate (bot)", "renovate[bot]"] 26 | 27 | last_tag = check_output("git tag | sort -n | tail -n 1", shell=True, text=True).strip() 28 | 29 | args_parser = ArgumentParser() 30 | args_parser.add_argument( 31 | "until", 32 | nargs="?", 33 | default="HEAD", 34 | help="Consider all commits until this one (default: %(default)s).", 35 | ) 36 | args_parser.add_argument( 37 | "since", 38 | nargs="?", 39 | default=last_tag, 40 | help="Consider all commits since this one (default: %(default)s).", 41 | ) 42 | args = args_parser.parse_args() 43 | 44 | authors = {} 45 | log_range = args.since + ".." + args.until 46 | output = check_output(["git", "log", log_range, "--format=%ae\t%an"], text=True) 47 | for line in output.splitlines(): 48 | email, fullname = line.split("\t") 49 | email = email.split("@")[0].replace(".", "") 50 | if email in authors: 51 | continue 52 | authors[email] = fullname 53 | 54 | for nick, fullname in authors.items(): 55 | if fullname in EXCLUDE or fullname.endswith("[bot]"): 56 | continue 57 | filename = f"{nick}.author" 58 | if os.path.exists(filename): 59 | continue 60 | print(f"Adding author {fullname} ({nick})") 61 | with open(filename, "w") as f: 62 | f.write(fullname) 63 | f.write("\n") 64 | -------------------------------------------------------------------------------- /tests/unit/test_web_util_ipa.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import g 3 | from werkzeug.exceptions import Unauthorized 4 | 5 | from fasjson.web.utils.ipa import ldap_client, rpc_client 6 | 7 | 8 | def test_ldap_client(mocker, gss_user, app): 9 | get_client = mocker.patch("fasjson.web.utils.ipa.get_client") 10 | with app.test_request_context("/v1/me/"): 11 | app.preprocess_request() 12 | g.gss_creds = object() 13 | g.username = "dummy" 14 | ldap_client() 15 | get_client.assert_called_with( 16 | "ldap://ipa.example.test", 17 | basedn="dc=example,dc=test", 18 | login="dummy", 19 | timeout=30, 20 | ) 21 | 22 | 23 | def test_ldap_client_anon(mocker, gss_user, app): 24 | get_client = mocker.patch("fasjson.web.utils.ipa.get_client") 25 | with app.test_request_context("/v1/me/"): 26 | app.preprocess_request() 27 | g.gss_creds = None 28 | with pytest.raises(Unauthorized): 29 | ldap_client() 30 | get_client.assert_not_called() 31 | 32 | 33 | def test_rpc_client(mocker, gss_user, app): 34 | ClientMeta = mocker.patch("fasjson.web.utils.ipa.ClientMeta") 35 | client = mocker.Mock() 36 | ClientMeta.return_value = client 37 | with app.test_request_context("/v1/me/"): 38 | app.preprocess_request() 39 | g.gss_creds = object() 40 | rpc_client() 41 | ClientMeta.assert_called_once_with( 42 | "ipa.example.test", verify_ssl=app.config["FASJSON_IPA_CA_CERT_PATH"] 43 | ) 44 | client.login_kerberos.assert_called_once_with() 45 | 46 | 47 | def test_rpc_client_anon(mocker, gss_user, app): 48 | ClientMeta = mocker.patch("fasjson.web.utils.ipa.ClientMeta") 49 | with app.test_request_context("/v1/me/"): 50 | app.preprocess_request() 51 | g.gss_creds = None 52 | with pytest.raises(Unauthorized): 53 | rpc_client() 54 | ClientMeta.assert_not_called() 55 | -------------------------------------------------------------------------------- /fasjson/web/utils/ipa.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, g, request 2 | from flask_restx import abort, fields, Mask 3 | from python_freeipa import ClientMeta 4 | 5 | from fasjson.lib.ldap import converters, get_client 6 | 7 | 8 | def ldap_client(): 9 | if g.gss_creds is None or g.username is None: 10 | abort(401) 11 | return get_client( 12 | current_app.config["FASJSON_LDAP_URI"], 13 | basedn=current_app.config["FASJSON_IPA_BASEDN"], 14 | login=g.username, 15 | timeout=current_app.config.get("FASJSON_LDAP_TIMEOUT", 30), 16 | ) 17 | 18 | 19 | def rpc_client(): 20 | if g.gss_creds is None: 21 | abort(401) 22 | client = ClientMeta( 23 | current_app.config["FASJSON_IPA_SERVER"], 24 | verify_ssl=current_app.config["FASJSON_IPA_CA_CERT_PATH"], 25 | ) 26 | client.login_kerberos() 27 | return client 28 | 29 | 30 | def get_fields_from_ldap_model(ldap_model, endpoint, field_args=None): 31 | field_args = field_args or {} 32 | result = {} 33 | 34 | for attr, ldap_converter in ldap_model.fields.items(): 35 | if attr in ldap_model.hidden_fields: 36 | continue 37 | 38 | if isinstance(ldap_converter, converters.BoolConverter): 39 | field = fields.Boolean 40 | elif isinstance(ldap_converter, converters.GeneralTimeConverter): 41 | field = fields.DateTime 42 | else: 43 | field = fields.String 44 | 45 | field = field(**field_args.get(attr, {})) 46 | 47 | if ldap_converter.multivalued: 48 | field = fields.List(field) 49 | 50 | result[attr] = field 51 | 52 | result["uri"] = fields.Url(endpoint, absolute=True) 53 | 54 | return result 55 | 56 | 57 | def get_attrs_from_mask(model): 58 | mask_header = current_app.config["RESTX_MASK_HEADER"] 59 | mask = request.headers.get(mask_header) 60 | if mask is None: 61 | return None 62 | return list(Mask(mask).keys()) 63 | -------------------------------------------------------------------------------- /tests/unit/test_web_namespace.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import ldap 4 | from python_freeipa.exceptions import BadRequest 5 | 6 | 7 | def test_schema(client, gss_user): 8 | rv = client.get("/specs/v1.json") 9 | assert rv.status_code == 200 10 | body = json.loads(rv.data) 11 | assert body["basePath"] == "/v1" 12 | assert body["info"]["title"] == "FAS-JSON" 13 | assert body["info"]["version"] == "1.0" 14 | assert body["swagger"] == "2.0" 15 | 16 | 17 | def test_ldap_local_error(client, gss_user, mocker): 18 | mocker.patch("fasjson.web.resources.me.ldap_client", side_effect=ldap.LOCAL_ERROR) 19 | rv = client.get("/v1/me/") 20 | assert rv.status_code == 500 21 | body = json.loads(rv.data) 22 | assert body["message"] == "LDAP local error" 23 | assert body["source"] == "LDAP" 24 | 25 | 26 | def test_ldap_server_error(client, gss_user, mocker): 27 | mocker.patch("fasjson.web.resources.me.ldap_client", side_effect=ldap.SERVER_DOWN) 28 | rv = client.get("/v1/me/") 29 | assert rv.status_code == 500 30 | body = json.loads(rv.data) 31 | assert body["message"] == "LDAP server is down" 32 | assert body["source"] == "LDAP" 33 | 34 | 35 | def test_rpc_bad_request(client, gss_user, mocker): 36 | mocker.patch( 37 | "fasjson.web.resources.certs.rpc_client", 38 | side_effect=BadRequest(message="dummy message", code=42), 39 | ) 40 | rv = client.get("/v1/certs/1/") 41 | assert rv.status_code == 400 42 | body = json.loads(rv.data) 43 | assert body["message"] == "dummy message" 44 | assert body["code"] == 42 45 | assert body["source"] == "RPC" 46 | 47 | 48 | def test_rpc_bad_request_no_code(client, gss_user, mocker): 49 | mocker.patch( 50 | "fasjson.web.resources.certs.rpc_client", 51 | side_effect=BadRequest(message="dummy message"), 52 | ) 53 | rv = client.get("/v1/certs/1/") 54 | assert rv.status_code == 400 55 | body = json.loads(rv.data) 56 | assert body["message"] == "dummy message" 57 | assert body["code"] is None 58 | assert body["source"] == "RPC" 59 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/templates/httpd.conf: -------------------------------------------------------------------------------- 1 | # 2 | # /etc/httpd/conf.d/fasjson.conf 3 | # 4 | 5 | WSGISocketPrefix /run/httpd/wsgi 6 | WSGIProcessGroup fasjson 7 | WSGIApplicationGroup %{GLOBAL} 8 | WSGIDaemonProcess fasjson processes=4 threads=1 \ 9 | display-name=%{GROUP} python-home=/srv/venv \ 10 | maximum-requests=500 socket-timeout=2147483647 \ 11 | lang=C.UTF-8 locale=C.UTF-8 12 | # WSGIImportScript /srv/fasjson.wsgi \ 13 | # process-group=fasjson application-group=fasjson 14 | WSGIScriptAlias /fasjson /srv/fasjson.wsgi 15 | WSGIScriptReloading Off 16 | 17 | # 18 | # AllowOverride None 19 | # # Allow open access: 20 | # Require all granted 21 | # 22 | 23 | 24 | 25 | 26 | ServerName {{ ansible_fqdn }} 27 | ErrorLog logs/error_log 28 | TransferLog logs/access_log 29 | LogLevel info 30 | 31 | SSLEngine on 32 | SSLCipherSuite PROFILE=SYSTEM 33 | SSLProxyCipherSuite PROFILE=SYSTEM 34 | SSLCertificateFile /etc/pki/tls/certs/server.pem 35 | SSLCertificateKeyFile /etc/pki/tls/private/server.key 36 | 37 | 38 | Require all granted 39 | ErrorDocument 401 /fasjson/errors/401 40 | ErrorDocument 403 /fasjson/errors/403 41 | ErrorDocument 500 /fasjson/errors/500 42 | 43 | 44 | 45 | AuthType GSSAPI 46 | AuthName "Kerberos Login" 47 | GssapiUseSessions On 48 | Session On 49 | SessionCookieName fasjson_session path=/fasjson;httponly;secure; 50 | SessionHeader FASJSONSESSION 51 | GssapiSessionKey file:/run/fasjson/session.key 52 | GssapiCredStore keytab:/etc/httpd/conf/fasjson.keytab 53 | GssapiCredStore client_keytab:/etc/httpd/conf/fasjson.keytab 54 | GssapiCredStore ccache:FILE:/run/fasjson/krb5ccache 55 | GssapiImpersonate On 56 | GssapiDelegCcacheDir /run/fasjson/ccaches 57 | GssapiDelegCcachePerms mode:0660 58 | GssapiUseS4U2Proxy on 59 | GssapiAllowedMech krb5 60 | 61 | Require valid-user 62 | 63 | Header always append X-Frame-Options DENY 64 | Header always append Content-Security-Policy "frame-ancestors 'none'" 65 | Header unset Set-Cookie 66 | Header unset ETag 67 | FileETag None 68 | 69 | 70 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import types 3 | 4 | import pytest 5 | from flask.testing import FlaskClient 6 | 7 | from fasjson.web.app import create_app 8 | 9 | 10 | @pytest.fixture 11 | def test_dir(): 12 | return os.path.dirname(os.path.realpath(__file__)) 13 | 14 | 15 | @pytest.fixture 16 | def fixture_dir(test_dir): 17 | return f"{test_dir}/fixtures" 18 | 19 | 20 | @pytest.fixture 21 | def app(fixture_dir): 22 | app = create_app() 23 | app.config["FASJSON_IPA_CONFIG_PATH"] = f"{fixture_dir}/ipa.default.conf" 24 | app.config["FASJSON_IPA_CA_CERT_PATH"] = f"{fixture_dir}/ipa.ca.crt" 25 | app.config["TESTING"] = True 26 | return app 27 | 28 | 29 | @pytest.fixture 30 | def gss_env(fixture_dir): 31 | output = {} 32 | with open(f"{fixture_dir}/fasjson.env") as f: 33 | for line in f.readlines(): 34 | k, v = line.replace("\n", "").split("=") 35 | output[k] = v 36 | return output 37 | 38 | 39 | class GSSAwareClient(FlaskClient): 40 | def __init__(self, *args, **kwargs): 41 | self.gss_env = kwargs.pop("gss_env") 42 | super().__init__(*args, **kwargs) 43 | 44 | def open(self, *args, **kwargs): 45 | environ_base = self.gss_env.copy() 46 | environ_base.update(kwargs.get("environ_base", {})) 47 | kwargs["environ_base"] = environ_base 48 | return super().open(*args, **kwargs) 49 | 50 | 51 | @pytest.fixture 52 | def client(app, gss_env): 53 | app.test_client_class = GSSAwareClient 54 | with app.test_client(gss_env=gss_env) as client: 55 | yield client 56 | 57 | 58 | @pytest.fixture 59 | def anon_client(app): 60 | app.test_client_class = FlaskClient 61 | with app.test_client() as client: 62 | yield client 63 | 64 | 65 | @pytest.fixture 66 | def gss_user(gss_env, mocker): 67 | creds = mocker.patch("gssapi.Credentials") 68 | creds.return_value = types.SimpleNamespace(lifetime=10) 69 | 70 | 71 | @pytest.fixture 72 | def mock_ipa_client(mocker): 73 | def ipa_client_factory(module_path, client_type, **kwargs): 74 | client = types.SimpleNamespace(**kwargs) 75 | factory = mocker.patch(f"{module_path}.{client_type}_client") 76 | factory.return_value = client 77 | return client 78 | 79 | return ipa_client_factory 80 | -------------------------------------------------------------------------------- /fasjson/web/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging.config import dictConfig 3 | 4 | from flask import Flask 5 | from flask_healthz import healthz 6 | from flask_mod_auth_gssapi import FlaskModAuthGSSAPI 7 | from flask_restx import abort 8 | from werkzeug.exceptions import HTTPException 9 | from werkzeug.routing import BaseConverter 10 | 11 | from .apis.errors import api as api_errors 12 | from .apis.errors import blueprint as blueprint_errors 13 | from .apis.v1 import blueprint as blueprint_v1 14 | from .base_routes import root 15 | from .extensions.flask_ipacfg import IPAConfig 16 | 17 | 18 | class NameConverter(BaseConverter): 19 | """Limit what a user or group name can look like in the URLs.""" 20 | 21 | regex = "[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}" 22 | 23 | 24 | def create_app(config=None): 25 | """See https://flask.palletsprojects.com/en/1.1.x/patterns/appfactories/""" 26 | 27 | app = Flask(__name__) 28 | 29 | # Load default configuration 30 | app.config.from_pyfile("defaults.cfg") 31 | 32 | # Load the optional configuration file 33 | if "FASJSON_CONFIG_PATH" in os.environ: 34 | app.config.from_envvar("FASJSON_CONFIG_PATH") 35 | 36 | # Load the config passed as argument 37 | app.config.update(config or {}) 38 | 39 | # Logging 40 | if app.config.get("LOGGING"): 41 | dictConfig(app.config["LOGGING"]) 42 | 43 | # Extensions 44 | FlaskModAuthGSSAPI(app, abort=abort) 45 | IPAConfig(app) 46 | 47 | # URL converters 48 | app.url_map.converters["name"] = NameConverter 49 | 50 | # Register APIs 51 | # TODO: consider having only one class per resource and passing the API version from the 52 | # global g variable as described here: 53 | # https://flask.palletsprojects.com/en/1.1.x/patterns/urlprocessors/#internationalized-blueprint-urls 54 | app.register_blueprint(blueprint_v1) 55 | app.register_blueprint(healthz, url_prefix="/healthz") 56 | 57 | # Handler for webserver errors 58 | app.register_blueprint(blueprint_errors) 59 | # Make the main app's error handler use the error API's error handler in order to output JSON 60 | app.register_error_handler(HTTPException, api_errors.handle_error) 61 | 62 | # Register the root view 63 | app.add_url_rule("/", endpoint="root", view_func=root) 64 | 65 | return app 66 | -------------------------------------------------------------------------------- /fasjson/web/utils/pagination.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from flask import current_app, request 4 | from flask_restx import marshal, reqparse 5 | 6 | 7 | page_request_parser = reqparse.RequestParser() 8 | page_request_parser.add_argument("page_size", type=int, help="Page size.") 9 | page_request_parser.add_argument("page_number", type=int, default=1, help="Page number.") 10 | 11 | 12 | def add_page_data(output, result, model): 13 | """Use the pagination data from the LDAP result to add page info to the output. 14 | 15 | This function adds a dictionary with pagination info in a ``page`` key in the output dictionary. 16 | The pagination dictionary contains: 17 | 18 | * ``page_number``: the current page number 19 | * ``page_size``: the number of items per page 20 | * ``total_results``: the total number of items in the entire recordset 21 | * ``total_pages``: the number of pages available 22 | * ``next_page``: the URL to the next page if there is one. On the last page, this key 23 | is absent. 24 | 25 | If the query was not paginated, this ``page`` dictionary is not added to the output dictionary. 26 | 27 | This function does not return anything, the output dictionary is modified in-place. 28 | """ 29 | if not result.page_size: 30 | return 31 | total_pages = math.ceil(result.total / result.page_size) 32 | output["page"] = { 33 | "total_results": result.total, 34 | "page_size": result.page_size, 35 | "page_number": result.page_number, 36 | "total_pages": total_pages, 37 | } 38 | if result.page_number < total_pages: 39 | qs = request.args.copy() 40 | qs.update( 41 | { 42 | "page_size": result.page_size, 43 | "page_number": result.page_number + 1, 44 | } 45 | ) 46 | qs = "&".join(f"{k}={v}" for k, v in qs.items()) 47 | base_url = request.base_url 48 | output["page"]["next_page"] = f"{base_url}?{qs}" 49 | 50 | 51 | def paged_marshal(result, model, **kwargs): 52 | if kwargs.get("mask") is None: 53 | mask_header = current_app.config["RESTX_MASK_HEADER"] 54 | kwargs["mask"] = request.headers.get(mask_header) 55 | output = marshal(result.items, model, envelope="result", **kwargs) 56 | add_page_data(output, result, model) 57 | return output 58 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ----- 3 | 4 | :: 5 | 6 | $ kinit 7 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/groups/ 8 | {"result": [{"name": "test-group", "uri": "http://$(hostname)/fasjson/v1/groups/test-group/"}]} 9 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/groups/admins/ 10 | {"result": {"name": "test-group", "uri": "http://fasjson.example.test/fasjson/v1/groups/test-group/"}} 11 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/groups/admins/sponsors/ 12 | {"result": [{"username": "admin", [...]}, {"username": "user123", [...]}]} 13 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/groups/admins/members/ 14 | {"result": [{"username": "admin", [...]}, {"username": "user123", [...]}]} 15 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/users/admin/ 16 | {"result": {"username": "admin", "surname": "Administrator", "givenname": "", "emails": ["admin@$(domain)"], "ircnicks": null, "locale": "fr_FR", "timezone": null, "gpgkeyids": null, "creation": "2020-04-23T10:16:35", "locked": false, "uri": "http://$(hostname)/fasjson/v1/users/admin/"}} 17 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/users/admin/groups/ 18 | {"result": [{"name": "test-group", "uri": "http://$(hostname)/fasjson/v1/groups/test-group/"}]} 19 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/search/users/?username=admin&ircnick=admin&surname=admin&givenname=admin&email=admin@example.test 20 | {"result": [{"username": "admin", [...]}, {"username": "badminton", [...]}]} 21 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/search/users/?group=firstgroup&group=othergroup 22 | {"result": [{"username": "admin", [...]}, {"username": "badminton", [...]}]} 23 | $ curl --negotiate -u : http://$(hostname)/fasjson/v1/me/ 24 | {"result": {"dn": "uid=admin,cn=users,cn=accounts,dc=$(domain)", "username": "admin", "uri": "http://$(hostname)/fasjson/v1/users/admin/"}} 25 | 26 | There is an interactive autogenerated documentation that can be reached by opening https://fasjson.fedoraproject.org/docs/v1/. 27 | Please note however that at the moment the example ``curl`` commands displayed by this documentation lack the necessary 28 | ``-u : --negotiate`` options, you'll have to remember to add them. 29 | 30 | 31 | Searching for users 32 | ~~~~~~~~~~~~~~~~~~~ 33 | 34 | Endpoint: ``/v1/search/users/`` 35 | 36 | Users can be search by many user attributes, refer to the autogenerated documentation for details. 37 | They can also be searched by group names with the ``group`` search term. If multiple ``group`` search 38 | terms are given, the resulting users will be the members of **all** the groups. 39 | -------------------------------------------------------------------------------- /fasjson/web/resources/base.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask_restx import Namespace as RestXNamespace 4 | from flask_restx.utils import merge, unpack 5 | 6 | from ..utils.pagination import paged_marshal 7 | 8 | 9 | class Namespace(RestXNamespace): 10 | def marshal_with(self, fields, *args, **kwargs): 11 | if not isinstance(fields, dict): 12 | return self.marshal_with_field(fields, *args, **kwargs) 13 | kwargs.setdefault("envelope", "result") 14 | return super().marshal_with(fields, *args, **kwargs) 15 | 16 | def paged_marshal_with(self, model, description=None, **marshal_kwargs): 17 | """ 18 | A decorator to call paged_marshal. See Namespace.marshal_with for reference. 19 | """ 20 | 21 | def decorator(func): 22 | full_marshal_kwargs = marshal_kwargs.copy() 23 | full_marshal_kwargs["envelope"] = "result" 24 | doc = { 25 | "responses": {"200": (description, [model], full_marshal_kwargs)}, 26 | # Mask values can't be determined outside app context 27 | "__mask__": marshal_kwargs.get("mask", True), 28 | } 29 | func.__apidoc__ = merge(getattr(func, "__apidoc__", {}), doc) 30 | 31 | @wraps(func) 32 | def wrapper(*args, **kwargs): 33 | result = func(*args, **kwargs) 34 | return paged_marshal(result, model, ordered=self.ordered, **marshal_kwargs) 35 | 36 | return wrapper 37 | 38 | return decorator 39 | 40 | def marshal_with_field(self, field, *args, description=None): 41 | # Sadly we can't use flask_restx.marshalling.marshal_with_field because it does not 42 | # support envelopes. 43 | if isinstance(field, type): 44 | field = field() 45 | 46 | def decorator(func): 47 | doc = { 48 | "responses": {"200": (description, field, {"envelope": "result"})}, 49 | } 50 | func.__apidoc__ = merge(getattr(func, "__apidoc__", {}), doc) 51 | 52 | @wraps(func) 53 | def wrapper(*args, **kwargs): 54 | result = func(*args, **kwargs) 55 | if isinstance(result, tuple): 56 | data, code, headers = unpack(result) 57 | return {"result": field.format(data)}, code, headers 58 | return {"result": field.format(result)} 59 | 60 | return wrapper 61 | 62 | return decorator 63 | 64 | def add_resource(self, resource, *urls, **kwargs): 65 | """Each API endpoint can return 401 if authentication is not successful.""" 66 | self.response(401, "Unauthorized. You need to be logged in.")(resource) 67 | return super().add_resource(resource, *urls, **kwargs) 68 | -------------------------------------------------------------------------------- /tests/unit/test_web_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import types 4 | 5 | import pytest 6 | from flask_restx import abort 7 | 8 | from fasjson.web.app import create_app 9 | 10 | 11 | def test_app_gss_forbidden_error(client): 12 | rv = client.get("/") 13 | assert rv.status_code == 403 14 | body = json.loads(rv.data) 15 | assert "message" in body 16 | assert body["message"].startswith("Invalid credentials") 17 | assert body["message"].endswith("Minor (2529639107): No credentials cache found)") 18 | 19 | 20 | def test_app_default_unauthorized_error(client, mocker): 21 | creds_factory = mocker.patch("gssapi.Credentials") 22 | creds_factory.return_value = types.SimpleNamespace(lifetime=0) 23 | rv = client.get("/") 24 | assert rv.status_code == 401 25 | assert "WWW-Authenticate" in rv.headers 26 | assert rv.headers.get("WWW-Authenticate") == "Negotiate" 27 | assert rv.headers["Content-Type"] == "application/json" 28 | body = json.loads(rv.data) 29 | assert body == {"message": "Re-authentication is necessary."} 30 | 31 | 32 | def test_app_default_notfound_error(client, gss_user): 33 | rv = client.get("/notfound") 34 | body = json.loads(rv.data) 35 | 36 | assert rv.status_code == 404 37 | assert body.get("message") is not None 38 | 39 | 40 | def test_app_default_internal_error(client, gss_user): 41 | @client.application.route("/500") 42 | def fivehundred(): 43 | x = [] 44 | return x[10] 45 | 46 | # Don't catch the exception in the testing framework 47 | client.application.config["TESTING"] = False 48 | 49 | rv = client.get("/500") 50 | body = json.loads(rv.data) 51 | 52 | assert rv.status_code == 500 53 | assert body.get("message") is not None 54 | 55 | 56 | def test_app_registered_error(client, gss_user): 57 | @client.application.route("/403") 58 | def forbidden(): 59 | abort(403, "forbidden", foo="bar") 60 | 61 | rv = client.get("/403") 62 | body = json.loads(rv.data) 63 | 64 | assert rv.status_code == 403 65 | assert body == {"foo": "bar", "message": "forbidden"} 66 | 67 | 68 | def test_webserver_error(anon_client): 69 | for code in (401, 403, 500): 70 | rv = anon_client.get(f"/errors/{code}") 71 | assert rv.status_code == code 72 | body = json.loads(rv.data) 73 | assert "message" in body 74 | 75 | 76 | @pytest.fixture 77 | def temp_config(tmpdir): 78 | config_path = os.path.join(tmpdir, "testing.cfg") 79 | with open(config_path, "w") as config_file: 80 | config_file.write("DUMMY = 'dummy'\n") 81 | os.environ["FASJSON_CONFIG_PATH"] = config_path 82 | yield 83 | del os.environ["FASJSON_CONFIG_PATH"] 84 | 85 | 86 | def test_configuration_file(temp_config, app): 87 | with app.test_request_context("/"): 88 | assert app.config.get("DUMMY") == "dummy" 89 | 90 | 91 | def test_logging_config(mocker): 92 | dictConfig = mocker.patch("fasjson.web.app.dictConfig") 93 | logging_config = { 94 | "version": 1, 95 | "root": {"level": "DEBUG", "handlers": []}, 96 | } 97 | create_app(config={"LOGGING": logging_config}) 98 | dictConfig.assert_called_with(logging_config) 99 | -------------------------------------------------------------------------------- /fasjson.spec: -------------------------------------------------------------------------------- 1 | %global debug_package %{nil} 2 | %global ipa_version 4.8.0 3 | 4 | Name: fasjson 5 | Version: 0.0.1 6 | Release: 1%{?dist} 7 | Summary: JSON REST API for Fedora Account System 8 | 9 | BuildArch: noarch 10 | 11 | License: GPL 12 | URL: https://github.com/fedora-infra/fasjson 13 | Source0: https://github.com/fedora-infra/fasjson/archive/%{version}.tar.gz 14 | 15 | BuildRequires: python3-devel 16 | BuildRequires: python3-setuptools 17 | BuildRequires: systemd 18 | 19 | Requires: python3-fasjson 20 | Requires: gssproxy 21 | Requires: httpd 22 | Requires: mod_auth_gssapi 23 | Requires: mod_session 24 | Requires: python3-mod_wsgi 25 | %if 0%{?rhel} 26 | Conflicts: ipa-server 27 | Requires: ipa-client >= %{ipa_version} 28 | %else 29 | Conflicts: freeipa-server 30 | Requires: freeipa-client >= %{ipa_version} 31 | %endif 32 | %{?systemd_requires} 33 | 34 | 35 | %description 36 | JSON REST API for Fedora Account System 37 | 38 | 39 | %package -n python3-fasjson 40 | Summary: FAS JSON REST API server implementation 41 | Requires: python3-dns 42 | Requires: python3-flask 43 | Requires: python3-gssapi 44 | Requires: python3-ldap 45 | 46 | 47 | %description -n python3-fasjson 48 | Python 3 flask app for fasjson 49 | 50 | 51 | %prep 52 | %autosetup 53 | 54 | 55 | %build 56 | %py3_build 57 | touch debugfiles.list 58 | 59 | 60 | %install 61 | rm -rf $RPM_BUILD_ROOT 62 | %py3_install 63 | 64 | %__mkdir_p %{buildroot}%{_usr}/share/fasjson 65 | cp fasjson.wsgi %{buildroot}%{_usr}/share/fasjson 66 | 67 | %__mkdir_p %{buildroot}%{_sysconfdir}/gssproxy 68 | cp config/gssproxy-fasjson.conf %{buildroot}%{_sysconfdir}/gssproxy/99-fasjson.conf 69 | 70 | %__mkdir_p %{buildroot}%{_unitdir}/httpd.service.d 71 | cp config/systemd-httpd-service-fasjson.conf %{buildroot}/%{_unitdir}/httpd.service.d/fasjson.conf 72 | 73 | %__mkdir_p %{buildroot}%{_sysconfdir}/httpd/conf.d 74 | cp config/httpd-fasjson.conf %{buildroot}%{_sysconfdir}/httpd/conf.d/fasjson.conf 75 | 76 | %__mkdir_p %{buildroot}%{_tmpfilesdir} 77 | cp config/tmpfiles-fasjson.conf %{buildroot}%{_tmpfilesdir}/fasjson.conf 78 | 79 | 80 | %post 81 | %tmpfiles_create %{_tmpfilesdir}/fasjson.conf 82 | %systemd_post gssproxy.service httpd.service 83 | 84 | 85 | %preun 86 | %systemd_preun gssproxy.service httpd.service 87 | 88 | 89 | %postun 90 | %systemd_postun gssproxy.service httpd.service 91 | 92 | 93 | %posttrans 94 | systemctl daemon-reload 95 | systemctl enable --now gssproxy.service 96 | systemctl restart gssproxy.service 97 | systemctl try-restart httpd.service 98 | 99 | 100 | %files 101 | %config %{_sysconfdir}/gssproxy/99-fasjson.conf 102 | %config %{_unitdir}/httpd.service.d/fasjson.conf 103 | %config(noreplace) %{_sysconfdir}/httpd/conf.d/fasjson.conf 104 | %config %{_tmpfilesdir}/fasjson.conf 105 | %dir %{_usr}/share/fasjson 106 | %{_usr}/share/fasjson/fasjson.wsgi 107 | 108 | 109 | %files -n python3-fasjson 110 | %license COPYING 111 | %doc README.md 112 | %{python3_sitelib}/fasjson 113 | %{python3_sitelib}/fasjson*.egg-info 114 | 115 | 116 | %changelog 117 | * Tue Nov 19 2019 Christian Heimes - 0.0.1-1 118 | - Initial release 119 | -------------------------------------------------------------------------------- /fasjson/web/apis/base.py: -------------------------------------------------------------------------------- 1 | import ldap 2 | from flask_restx import Api 3 | from flask_restx.api import SwaggerView 4 | from python_freeipa.exceptions import BadRequest 5 | 6 | 7 | def handle_ldap_local_error(error): 8 | """When an LDAP local error occurs, return a 500 status code. 9 | 10 | Args: 11 | error (ldap.LOCAL_ERROR): the exception that was raised 12 | 13 | Returns: 14 | dict: a description of the error 15 | """ 16 | return ( 17 | { 18 | "message": "LDAP local error", 19 | "details": str(error), 20 | "source": "LDAP", 21 | }, 22 | 500, 23 | ) 24 | 25 | 26 | def handle_ldap_server_error(error): 27 | """When the LDAP server is down, return a 500 status code. 28 | 29 | Args: 30 | error (ldap.SERVER_DOWN): the exception that was raised 31 | 32 | Returns: 33 | dict: a description of the error 34 | """ 35 | return {"message": "LDAP server is down", "source": "LDAP"}, 500 36 | 37 | 38 | def handle_rpc_error(error): 39 | """When a JSON-RPC error occurs, return a 400 status code. 40 | 41 | Warning, the exception does not always have a ``code`` attribute. 42 | 43 | Args: 44 | error (python_freeipa.exceptions.BadRequest): the exception that was raised 45 | 46 | Returns: 47 | dict: a description of the error 48 | """ 49 | return ( 50 | { 51 | "message": error.message, 52 | "code": getattr(error, "code", None), 53 | "source": "RPC", 54 | }, 55 | 400, 56 | ) 57 | 58 | 59 | API_DEFAULTS = { 60 | "title": "FAS-JSON", 61 | "description": "The Fedora Accounts System JSON API", 62 | "license": "GPLv3", 63 | "license_url": "https://www.gnu.org/licenses/gpl-3.0.html", 64 | # We add our own route for specs and docs 65 | "add_specs": False, 66 | "doc": False, 67 | } 68 | 69 | 70 | class FasJsonApi(Api): 71 | def init_app(self, app, **kwargs): 72 | for key, value in API_DEFAULTS.items(): 73 | kwargs.setdefault(key, value) 74 | 75 | super().init_app(app, **kwargs) 76 | 77 | self.errorhandler(ldap.LOCAL_ERROR)(handle_ldap_local_error) 78 | self.errorhandler(ldap.SERVER_DOWN)(handle_ldap_server_error) 79 | self.errorhandler(BadRequest)(handle_rpc_error) 80 | self.blueprint.record_once(self._on_blueprint_registration) 81 | 82 | def _on_blueprint_registration(self, state): 83 | # Add URL rules on the top level app 84 | self._register_specs_top(state.app) 85 | self._register_doc_top(state.app) 86 | 87 | def _register_specs_top(self, top_level_app): 88 | endpoint = f"{self.blueprint.name}.specs" 89 | top_level_app.add_url_rule( 90 | f"/specs/{self.blueprint.name}.json", 91 | endpoint=endpoint, 92 | view_func=SwaggerView.as_view(endpoint, self, [self]), 93 | ) 94 | 95 | def _register_doc_top(self, top_level_app): 96 | top_level_app.add_url_rule( 97 | f"/docs/{self.blueprint.name}/", 98 | f"{self.blueprint.name}.doc", 99 | self.render_doc, 100 | ) 101 | -------------------------------------------------------------------------------- /tests/unit/test_web_utils_pagination.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fasjson.lib.ldap.client import LDAPResult 4 | from fasjson.web.resources.groups import GroupModel 5 | from fasjson.web.utils.pagination import add_page_data, paged_marshal 6 | 7 | 8 | @pytest.fixture 9 | def ldap_result(): 10 | return LDAPResult( 11 | items=[ 12 | { 13 | "groupname": "group1", 14 | "description": "the group1 group", 15 | "mailing_list": "group1@groups.com", 16 | "url": "www.group1.com", 17 | "irc": ["#group1"], 18 | "discussion_url": "https://discussion.test/group1", 19 | } 20 | ], 21 | total=2, 22 | page_number=1, 23 | page_size=1, 24 | ) 25 | 26 | 27 | @pytest.fixture 28 | def page_output(): 29 | return { 30 | "total_results": 2, 31 | "page_size": 1, 32 | "page_number": 1, 33 | "total_pages": 2, 34 | "next_page": "http://localhost/?page_size=1&page_number=2", 35 | } 36 | 37 | 38 | def test_paged_marshal(app, ldap_result, page_output): 39 | with app.test_request_context("/"): 40 | output = paged_marshal(ldap_result, GroupModel) 41 | 42 | expected = { 43 | "groupname": "group1", 44 | "description": "the group1 group", 45 | "mailing_list": "group1@groups.com", 46 | "url": "www.group1.com", 47 | "irc": ["#group1"], 48 | "uri": "http://localhost/v1/groups/group1/", 49 | "discussion_url": "https://discussion.test/group1", 50 | } 51 | 52 | assert output == { 53 | "result": [expected], 54 | "page": page_output, 55 | } 56 | 57 | 58 | def test_paged_marshal_with_mask_header(app, ldap_result, page_output): 59 | with app.test_request_context("/", headers={"X-Fields": "{groupname,mailing_list}"}): 60 | output = paged_marshal(ldap_result, GroupModel) 61 | 62 | expected = { 63 | "groupname": "group1", 64 | "mailing_list": "group1@groups.com", 65 | } 66 | assert output == { 67 | "result": [expected], 68 | "page": page_output, 69 | } 70 | 71 | 72 | def test_paged_marshal_with_mask_arg(app, ldap_result, page_output): 73 | with app.test_request_context("/"): 74 | output = paged_marshal(ldap_result, GroupModel, mask="{groupname,mailing_list}") 75 | 76 | expected = { 77 | "groupname": "group1", 78 | "mailing_list": "group1@groups.com", 79 | } 80 | assert output == { 81 | "result": [expected], 82 | "page": page_output, 83 | } 84 | 85 | 86 | def test_add_page_data_last_page(app, ldap_result): 87 | ldap_result.page_number = 2 88 | output = {} 89 | with app.test_request_context("/"): 90 | add_page_data(output, ldap_result, GroupModel) 91 | 92 | assert output["page"] == { 93 | "total_results": 2, 94 | "page_size": 1, 95 | "page_number": 2, 96 | "total_pages": 2, 97 | } 98 | 99 | 100 | def test_add_page_data_existing_qs(app, ldap_result): 101 | with app.test_request_context("/?foo=bar"): 102 | output = paged_marshal(ldap_result, GroupModel) 103 | 104 | expected_next_page = "http://localhost/?foo=bar&page_size=1&page_number=2" 105 | assert output["page"]["next_page"] == expected_next_page 106 | -------------------------------------------------------------------------------- /fasjson/web/resources/certs.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from flask_restx import fields, reqparse, Resource 3 | from python_freeipa.exceptions import BadRequest 4 | 5 | from fasjson.web.utils.ipa import rpc_client 6 | 7 | from .base import Namespace 8 | 9 | 10 | api_v1 = Namespace("certs", description="Certificates related operations") 11 | 12 | 13 | class Base64Dict(fields.String): 14 | """IPA returns this weird structure in certificate chains""" 15 | 16 | def output(self, key, obj, ordered=False, **kwargs): 17 | return super().output("__base64__", obj, ordered=ordered, **kwargs) 18 | 19 | 20 | CertModel = api_v1.model( 21 | "Cert", 22 | { 23 | "cacn": fields.String(), 24 | "certificate": fields.String(), 25 | "certificate_chain": fields.List(Base64Dict()), 26 | "issuer": fields.String(), 27 | "revoked": fields.Boolean(), 28 | "san_other": fields.List(fields.String()), 29 | "san_other_kpn": fields.List(fields.String()), 30 | "san_other_upn": fields.List(fields.String()), 31 | "serial_number": fields.Integer(), 32 | "serial_number_hex": fields.String(), 33 | "sha1_fingerprint": fields.String(), 34 | "sha256_fingerprint": fields.String(), 35 | "subject": fields.String(), 36 | "valid_not_after": fields.DateTime(dt_format="rfc822"), 37 | "valid_not_before": fields.DateTime(dt_format="rfc822"), 38 | "uri": fields.Url("v1.certs_cert", absolute=True), 39 | }, 40 | ) 41 | 42 | 43 | create_request_parser = reqparse.RequestParser() 44 | create_request_parser.add_argument("user", required=True, help="User name.") 45 | create_request_parser.add_argument("csr", required=True, help="Certificate Signing Request.") 46 | create_request_parser.add_argument("profile", required=False, help="Certificate Profile.") 47 | 48 | 49 | @api_v1.route("/") 50 | @api_v1.response(400, "The CSR could not be signed") 51 | class Certs(Resource): 52 | @api_v1.doc("sign_csr") 53 | @api_v1.expect(create_request_parser) 54 | @api_v1.marshal_with(CertModel) 55 | def post(self): 56 | """Send a CSR and get a signed certificate in return""" 57 | args = create_request_parser.parse_args() 58 | client = rpc_client() 59 | profile = args["profile"] or current_app.config["CERTIFICATE_PROFILE"] 60 | result = client.cert_request(args["csr"], o_principal=args["user"], o_profile_id=profile) 61 | return result["result"] 62 | 63 | 64 | @api_v1.route("//") 65 | @api_v1.param("serial_number", "The certificate's serial number") 66 | @api_v1.response(404, "Certificate not found") 67 | class Cert(Resource): 68 | @api_v1.doc("get_cert") 69 | @api_v1.marshal_with(CertModel) 70 | def get(self, serial_number): 71 | """Fetch a certificate given its serial number 72 | 73 | Certificates are also present on users' results, but this method gives more details. 74 | """ 75 | client = rpc_client() 76 | try: 77 | result = client.cert_show(serial_number) 78 | except BadRequest as e: 79 | if e.code == 4301: 80 | api_v1.abort( 81 | 404, 82 | "Certificate not found", 83 | serial_number=serial_number, 84 | server_message=e.message, 85 | ) 86 | else: 87 | raise 88 | return result["result"] 89 | -------------------------------------------------------------------------------- /deploy/httpd.conf: -------------------------------------------------------------------------------- 1 | Listen 0.0.0.0:8080 2 | ServerRoot "/httpdir" 3 | PidFile "/httpdir/httpd.pid" 4 | LoadModule authn_file_module modules/mod_authn_file.so 5 | LoadModule authn_anon_module modules/mod_authn_anon.so 6 | LoadModule authz_user_module modules/mod_authz_user.so 7 | LoadModule authz_host_module modules/mod_authz_host.so 8 | LoadModule include_module modules/mod_include.so 9 | LoadModule log_config_module modules/mod_log_config.so 10 | LoadModule env_module modules/mod_env.so 11 | LoadModule ext_filter_module modules/mod_ext_filter.so 12 | LoadModule expires_module modules/mod_expires.so 13 | LoadModule headers_module modules/mod_headers.so 14 | LoadModule mime_module modules/mod_mime.so 15 | LoadModule status_module modules/mod_status.so 16 | LoadModule negotiation_module modules/mod_negotiation.so 17 | LoadModule dir_module modules/mod_dir.so 18 | LoadModule alias_module modules/mod_alias.so 19 | LoadModule rewrite_module modules/mod_rewrite.so 20 | LoadModule version_module modules/mod_version.so 21 | LoadModule wsgi_module modules/mod_wsgi_python3.so 22 | LoadModule authn_core_module modules/mod_authn_core.so 23 | LoadModule authz_core_module modules/mod_authz_core.so 24 | LoadModule unixd_module modules/mod_unixd.so 25 | LoadModule mpm_event_module modules/mod_mpm_event.so 26 | LoadModule request_module modules/mod_request.so 27 | LoadModule auth_gssapi_module modules/mod_auth_gssapi.so 28 | LoadModule session_module modules/mod_session.so 29 | LoadModule session_cookie_module modules/mod_session_cookie.so 30 | LoadModule session_dbd_module modules/mod_session_dbd.so 31 | LoadModule auth_form_module modules/mod_auth_form.so 32 | LoadModule setenvif_module modules/mod_setenvif.so 33 | 34 | StartServers 20 35 | ServerLimit 100 36 | MaxRequestsPerChild 2000 37 | MaxRequestWorkers 100 38 | TypesConfig /etc/mime.types 39 | AddDefaultCharset UTF-8 40 | CoreDumpDirectory /tmp 41 | 42 | # Logging. Don't log OpenShift's probes 43 | SetEnvIf Request_URI "^/healthz/" dontlog 44 | LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined 45 | CustomLog /httpdir/access.log combined env=!dontlog 46 | ErrorLog /httpdir/error.log 47 | LogLevel info 48 | 49 | WSGISocketPrefix run/wsgi 50 | WSGIPythonHome /opt/venv 51 | WSGIDaemonProcess fasjson processes=4 threads=1 maximum-requests=500 \ 52 | display-name=%{GROUP} socket-timeout=2147483647 \ 53 | lang=C.UTF-8 locale=C.UTF-8 home=/httpdir 54 | WSGIImportScript /opt/fasjson/deploy/wsgi.py \ 55 | process-group=fasjson application-group=fasjson 56 | WSGIScriptAlias / /opt/fasjson/deploy/wsgi.py 57 | WSGIScriptReloading Off 58 | WSGIRestrictStdout Off 59 | WSGIRestrictSignal Off 60 | #WSGIPythonOptimize 1 # This causes the ldap module to fail 61 | 62 | 63 | WSGIProcessGroup fasjson 64 | WSGIApplicationGroup fasjson 65 | 66 | Require all granted 67 | ErrorDocument 401 /errors/401 68 | ErrorDocument 403 /errors/403 69 | ErrorDocument 404 /errors/404 70 | ErrorDocument 500 /errors/500 71 | 72 | 73 | 74 | AuthType GSSAPI 75 | AuthName "Kerberos Login" 76 | GssapiUseSessions On 77 | Session On 78 | SessionCookieName ipa_session path=/;httponly;secure; 79 | SessionHeader IPASESSION 80 | GssapiSessionKey file:/etc/fasjson-secret/session.key 81 | 82 | GssapiCredStore keytab:/etc/keytabs/http 83 | GssapiCredStore client_keytab:/etc/keytabs/http 84 | GssapiCredStore ccache:FILE:/httpdir/httpd.ccache 85 | GssapiDelegCcacheDir /httpdir/run/ccaches 86 | GssapiDelegCcachePerms mode:0660 87 | GssapiUseS4U2Proxy on 88 | GssapiAllowedMech krb5 89 | 90 | Require valid-user 91 | 92 | Header always append X-Frame-Options DENY 93 | Header always append Content-Security-Policy "frame-ancestors 'none'" 94 | Header unset Set-Cookie 95 | Header unset ETag 96 | FileETag None 97 | 98 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fasjson" 3 | description = "fasjson makes it possible for applications to talk to the fedora account system." 4 | license = "MIT" 5 | version = "1.7.0" 6 | readme = "README.md" 7 | authors = [{name = "Fedora Infrastructure", email = "admin@fedoraproject.org"}] 8 | requires-python = ">=3.10.0,<4.0.0" 9 | dependencies = [ 10 | "Flask (>=2.2.0,<4.0.0)", 11 | "python-ldap (>=3.2.0,<4.0.0)", 12 | "dnspython (>=2.1.0,<3.0.0)", 13 | "flask-restx (>=1.2.0,<2.0.0)", 14 | "flask-healthz (>=1.0.0,<2.0.0)", 15 | "python-freeipa (>=1.0.5,<2.0.0)", 16 | "requests-kerberos (>=0.12.0,<1.0.0)", 17 | "flask-mod-auth-gssapi (>=0.2.0,<2.0.0)", 18 | "requests-gssapi (>=1.2.3,<2.0.0)", 19 | ] 20 | 21 | [project.urls] 22 | homepage = "https://github.com/fedora-infra/fasjson" 23 | repository = "https://github.com/fedora-infra/fasjson" 24 | 25 | [tool.poetry.group.dev.dependencies] 26 | flake8 = "*" 27 | pytest = "*" 28 | pytest-mock = "*" 29 | pytest-cov = "*" 30 | liccheck = "*" 31 | bandit = "*" 32 | black = "*" 33 | isort = "*" 34 | safety = "*" 35 | pre-commit = "*" 36 | towncrier = "*" 37 | myst-parser = "*" 38 | 39 | 40 | [tool.black] 41 | line-length = 100 42 | 43 | 44 | [tool.ruff] 45 | line-length = 100 46 | 47 | [tool.ruff.lint] 48 | select = ["E", "F", "W", "I", "UP", "S", "B", "RUF"] 49 | ignore = ["RUF012"] 50 | 51 | [tool.ruff.lint.isort] 52 | lines-after-imports = 2 53 | order-by-type = false 54 | 55 | [tool.ruff.lint.per-file-ignores] 56 | "tests/*" = ["S101", "E501"] 57 | "news/get-authors.py" = ["S602", "S603", "S607"] 58 | 59 | 60 | [tool.coverage.run] 61 | branch = true 62 | source = ["fasjson"] 63 | 64 | [tool.coverage.paths] 65 | source = ["fasjson"] 66 | 67 | [tool.coverage.report] 68 | fail_under = 100 69 | exclude_lines = [ 70 | "pragma: no cover", 71 | "if __name__ == .__main__.:", 72 | # Don't complain if tests don't hit defensive assertion code 73 | "raise AssertionError", 74 | "raise NotImplementedError", 75 | "\\.\\.\\.", 76 | ] 77 | omit = [ 78 | "fasjson/__init__.py", 79 | "tests/*", 80 | ] 81 | 82 | 83 | [tool.rstcheck] 84 | report_level = "WARNING" 85 | ignore_directives = [ 86 | "openapi", 87 | ] 88 | ignore_roles = ["pr", "issue"] 89 | ignore_messages = "Duplicate implicit target name" 90 | 91 | 92 | [tool.towncrier] 93 | package = "fasjson" 94 | filename = "docs/release_notes.md" 95 | directory = "news/" 96 | start_string = "\n" 97 | underlines = ["", "", ""] 98 | template = "news/_template.md.j2" 99 | title_format = "## v{version}" 100 | issue_format = "{issue}" 101 | 102 | [[tool.towncrier.type]] 103 | directory = "bic" 104 | name = "Backwards Incompatible Changes" 105 | showcontent = true 106 | 107 | [[tool.towncrier.type]] 108 | directory = "dependency" 109 | name = "Dependency Changes" 110 | showcontent = true 111 | 112 | [[tool.towncrier.type]] 113 | directory = "feature" 114 | name = "Features" 115 | showcontent = true 116 | 117 | [[tool.towncrier.type]] 118 | directory = "bug" 119 | name = "Bug Fixes" 120 | showcontent = true 121 | 122 | [[tool.towncrier.type]] 123 | directory = "dev" 124 | name = "Development Improvements" 125 | showcontent = true 126 | 127 | [[tool.towncrier.type]] 128 | directory = "docs" 129 | name = "Documentation Improvements" 130 | showcontent = true 131 | 132 | [[tool.towncrier.type]] 133 | directory = "other" 134 | name = "Other Changes" 135 | showcontent = true 136 | 137 | [[tool.towncrier.type]] 138 | directory = "author" 139 | name = "Contributors" 140 | showcontent = true 141 | 142 | [tool.pytest.ini_options] 143 | testpaths = [ 144 | "tests/unit", 145 | ] 146 | 147 | [build-system] 148 | requires = ["poetry-core>=1.0.0"] 149 | build-backend = "poetry.core.masonry.api" 150 | -------------------------------------------------------------------------------- /fasjson/web/resources/users.py: -------------------------------------------------------------------------------- 1 | from flask_restx import fields, Resource 2 | 3 | from fasjson.lib.ldap.models import GroupModel as LDAPGroupModel 4 | from fasjson.lib.ldap.models import UserModel as LDAPUserModel 5 | from fasjson.web.utils import maybe_anonymize 6 | from fasjson.web.utils.ipa import ( 7 | get_attrs_from_mask, 8 | get_fields_from_ldap_model, 9 | ldap_client, 10 | ) 11 | from fasjson.web.utils.pagination import page_request_parser 12 | 13 | from .base import Namespace 14 | 15 | 16 | api_v1 = Namespace("users", description="Users related operations") 17 | 18 | UserModel = api_v1.model( 19 | "User", 20 | get_fields_from_ldap_model(LDAPUserModel, "v1.users_user", {"locked": {"default": False}}), 21 | ) 22 | 23 | 24 | @api_v1.route("/") 25 | class UserList(Resource): 26 | @api_v1.doc("list_users") 27 | @api_v1.expect(page_request_parser) 28 | @api_v1.paged_marshal_with(UserModel) 29 | def get(self): 30 | """List all users""" 31 | args = page_request_parser.parse_args() 32 | client = ldap_client() 33 | result = client.get_users( 34 | attrs=get_attrs_from_mask(UserModel), 35 | page_size=args.page_size, 36 | page_number=args.page_number, 37 | ) 38 | result.items = [maybe_anonymize(user) for user in result.items] 39 | return result 40 | 41 | 42 | @api_v1.route("//") 43 | @api_v1.param("username", "The user name") 44 | @api_v1.response(404, "User not found") 45 | class User(Resource): 46 | @api_v1.doc("get_user") 47 | @api_v1.marshal_with(UserModel) 48 | def get(self, username): 49 | """Fetch a user given their name""" 50 | client = ldap_client() 51 | res = client.get_user(username, attrs=get_attrs_from_mask(UserModel)) 52 | if res is None: 53 | api_v1.abort(404, "User not found", name=username) 54 | res = maybe_anonymize(res) 55 | return res 56 | 57 | 58 | UserGroupsModel = api_v1.model( 59 | "UserGroup", 60 | get_fields_from_ldap_model(LDAPGroupModel, "v1.groups_group"), 61 | mask="{groupname,uri}", 62 | ) 63 | 64 | 65 | @api_v1.route("//groups/") 66 | @api_v1.param("username", "The user name") 67 | @api_v1.response(404, "User not found") 68 | class UserGroups(Resource): 69 | @api_v1.doc("list_user_groups") 70 | @api_v1.expect(page_request_parser) 71 | @api_v1.paged_marshal_with(UserGroupsModel) 72 | def get(self, username): 73 | """Fetch a user's groups given their username""" 74 | args = page_request_parser.parse_args() 75 | client = ldap_client() 76 | user = client.get_user(username) 77 | if user is None: 78 | api_v1.abort(404, "User does not exist", name=username) 79 | return client.get_user_groups( 80 | username=username, 81 | attrs=get_attrs_from_mask(UserGroupsModel), 82 | page_size=args.page_size, 83 | page_number=args.page_number, 84 | ) 85 | 86 | 87 | UserAgreementsModel = api_v1.model( 88 | "UserAgreement", 89 | {"name": fields.String()}, 90 | ) 91 | 92 | 93 | @api_v1.route("//agreements/") 94 | @api_v1.param("username", "The user name") 95 | @api_v1.response(404, "User not found") 96 | class UserAgreements(Resource): 97 | @api_v1.doc("list_user_agreements") 98 | @api_v1.expect(page_request_parser) 99 | @api_v1.paged_marshal_with(UserAgreementsModel) 100 | def get(self, username): 101 | """Fetch a user's agreements given their username""" 102 | args = page_request_parser.parse_args() 103 | client = ldap_client() 104 | user = client.get_user(username) 105 | if user is None: 106 | api_v1.abort(404, "User does not exist", name=username) 107 | return client.get_user_agreements( 108 | username=username, 109 | page_size=args.page_size, 110 | page_number=args.page_number, 111 | ) 112 | -------------------------------------------------------------------------------- /devel/ansible/roles/fasjson/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install RPM packages 3 | dnf: 4 | name: 5 | - httpd 6 | - mod_auth_gssapi 7 | - mod_session 8 | - mod_ssl 9 | - python3-mod_wsgi 10 | - python3-dns 11 | - python3-flask 12 | - python3-gssapi 13 | - python3-ldap 14 | - python3-pip 15 | - python3-wheel 16 | - python3-devel 17 | - openldap-devel 18 | - krb5-devel 19 | - poetry 20 | - python3-tox 21 | - "@Development Tools" 22 | - vim 23 | - tmux 24 | - libffi-devel 25 | - openldap-devel 26 | state: present 27 | 28 | - name: Allow apache to see /srv 29 | sefcontext: 30 | target: "/srv(/.*)?" 31 | setype: httpd_sys_content_t 32 | 33 | - name: Allow apache to exec binary Python modules 34 | sefcontext: 35 | target: '/srv/venv/lib(64)?/.*\.so[0-9.]*' 36 | setype: httpd_sys_script_exec_t 37 | 38 | - name: Allow apache to access the source code dir 39 | file: 40 | name: /home/vagrant 41 | mode: 0755 42 | 43 | - name: Create a directory for the virtualenv where Apache can see it 44 | file: 45 | name: /srv/venv 46 | state: directory 47 | mode: 0755 48 | owner: vagrant 49 | group: vagrant 50 | 51 | # - name: Create the virtualenv and install poetry 52 | # pip: 53 | # #name: poetry 54 | # virtualenv: /srv/venv 55 | # become: yes 56 | # become_user: vagrant 57 | 58 | - name: Create the virtualenv 59 | command: python -m venv /srv/venv 60 | become: yes 61 | become_user: vagrant 62 | 63 | # - name: Tell poetry not to create a virtualenv 64 | # shell: /srv/venv/bin/poetry config virtualenvs.create false 65 | # args: 66 | # chdir: /home/vagrant/fasjson/ 67 | # become: yes 68 | # become_user: vagrant 69 | 70 | # https://github.com/python-ldap/python-ldap/issues/432#issuecomment-974799221 71 | - name: Workaround for python-ldap and openldap 2.5+ 72 | copy: 73 | dest: /usr/lib64/libldap_r.so 74 | content: INPUT ( libldap.so ) 75 | 76 | - name: Install python dependencies with poetry 77 | shell: poetry install 78 | args: 79 | chdir: /home/vagrant/fasjson/ 80 | environment: 81 | VIRTUAL_ENV: /srv/venv 82 | become: yes 83 | become_user: vagrant 84 | 85 | - name: Restore SELinux contexts 86 | command: restorecon -irv /srv/ 87 | 88 | - name: Install the .bashrc 89 | copy: 90 | src: bashrc 91 | dest: /home/vagrant/.bashrc 92 | mode: 0644 93 | owner: vagrant 94 | group: vagrant 95 | 96 | - name: Copy wsgi 97 | copy: 98 | src: fasjson.wsgi 99 | dest: /srv/fasjson.wsgi 100 | mode: 0644 101 | owner: vagrant 102 | group: vagrant 103 | 104 | - name: Copy the service setup script 105 | template: 106 | src: setup-fasjson-service.sh 107 | dest: /srv/setup-fasjson-service.sh 108 | mode: 0755 109 | 110 | - name: kinit 111 | shell: echo "{{ ipa_admin_password }}" | kinit {{ ipa_admin_user }}@{{ krb_realm }} 112 | 113 | - name: Create the service in IPA 114 | command: bash /srv/setup-fasjson-service.sh 115 | 116 | - name: Configure temporary files 117 | copy: 118 | src: tmpfiles.conf 119 | dest: /etc/tmpfiles.d/fasjson.conf 120 | 121 | - name: Create temporary file 122 | shell: systemd-tmpfiles --create 123 | 124 | - name: Tune SELinux Policy 125 | seboolean: 126 | name: "{{ item }}" 127 | state: yes 128 | persistent: yes 129 | with_items: 130 | - httpd_can_connect_ldap 131 | - httpd_can_network_connect 132 | - httpd_use_fusefs 133 | - httpd_read_user_content 134 | 135 | - name: Create Apache service 136 | file: 137 | path: /etc/systemd/system/httpd.service.d 138 | state: directory 139 | mode: 0755 140 | 141 | - name: Copy configs for Apache 142 | copy: 143 | src: "{{ item.src }}" 144 | dest: "{{ item.dest }}" 145 | with_items: 146 | - src: "systemd-service.conf" 147 | dest: "/etc/systemd/system/httpd.service.d/fasjson.conf" 148 | 149 | - name: Copy templated configs for Apache 150 | template: 151 | src: "{{ item.src }}" 152 | dest: "{{ item.dest }}" 153 | with_items: 154 | - src: "httpd.conf" 155 | dest: "/etc/httpd/conf.d/fasjson.conf" 156 | 157 | - name: Enable and restart Apache 158 | systemd: 159 | state: restarted 160 | name: httpd 161 | enabled: yes 162 | daemon_reload: yes 163 | -------------------------------------------------------------------------------- /fasjson/web/resources/groups.py: -------------------------------------------------------------------------------- 1 | from flask_restx import fields, Resource 2 | 3 | from fasjson.lib.ldap.models import GroupModel as LDAPGroupModel 4 | from fasjson.lib.ldap.models import UserModel as LDAPUserModel 5 | from fasjson.web.utils.ipa import ( 6 | get_attrs_from_mask, 7 | get_fields_from_ldap_model, 8 | ldap_client, 9 | ) 10 | from fasjson.web.utils.pagination import page_request_parser 11 | 12 | from .base import Namespace 13 | 14 | 15 | api_v1 = Namespace("groups", description="Groups related operations") 16 | 17 | GroupModel = api_v1.model( 18 | "Group", 19 | get_fields_from_ldap_model(LDAPGroupModel, "v1.groups_group"), 20 | ) 21 | 22 | MemberModel = api_v1.model( 23 | "Member", 24 | get_fields_from_ldap_model(LDAPUserModel, "v1.users_user"), 25 | mask="{username,uri}", 26 | ) 27 | 28 | SponsorModel = api_v1.model( 29 | "Sponsor", 30 | get_fields_from_ldap_model(LDAPUserModel, "v1.users_user"), 31 | mask="{username,uri}", 32 | ) 33 | 34 | 35 | @api_v1.route("/") 36 | class GroupList(Resource): 37 | @api_v1.doc("list_groups") 38 | @api_v1.expect(page_request_parser) 39 | @api_v1.paged_marshal_with(GroupModel) 40 | def get(self): 41 | """List all groups""" 42 | args = page_request_parser.parse_args() 43 | client = ldap_client() 44 | result = client.get_groups( 45 | attrs=get_attrs_from_mask(GroupModel), 46 | page_size=args.page_size, 47 | page_number=args.page_number, 48 | ) 49 | return result 50 | 51 | 52 | @api_v1.route("//") 53 | @api_v1.param("groupname", "The group name") 54 | @api_v1.response(404, "Group not found") 55 | class Group(Resource): 56 | @api_v1.doc("get_group") 57 | @api_v1.marshal_with(GroupModel) 58 | def get(self, groupname): 59 | """Fetch a group given their name""" 60 | client = ldap_client() 61 | res = client.get_group(groupname, attrs=get_attrs_from_mask(GroupModel)) 62 | if res is None: 63 | api_v1.abort(404, "Group not found", groupname=groupname) 64 | return res 65 | 66 | 67 | @api_v1.route("//members/") 68 | @api_v1.param("groupname", "The group name") 69 | @api_v1.response(404, "Group not found") 70 | class GroupMembers(Resource): 71 | @api_v1.doc("list_group_members") 72 | @api_v1.expect(page_request_parser) 73 | @api_v1.paged_marshal_with(MemberModel) 74 | def get(self, groupname): 75 | """Fetch group members given the group name""" 76 | args = page_request_parser.parse_args() 77 | client = ldap_client() 78 | 79 | group = client.get_group(groupname) 80 | if group is None: 81 | api_v1.abort(404, "Group not found", groupname=groupname) 82 | 83 | return client.get_group_members( 84 | groupname, 85 | attrs=get_attrs_from_mask(MemberModel), 86 | page_size=args.page_size, 87 | page_number=args.page_number, 88 | ) 89 | 90 | 91 | @api_v1.route("//sponsors/") 92 | @api_v1.param("groupname", "The group name") 93 | @api_v1.response(404, "Group not found") 94 | class GroupSponsors(Resource): 95 | @api_v1.doc("list_group_sponsors") 96 | @api_v1.marshal_with(SponsorModel) 97 | def get(self, groupname): 98 | """Fetch group sponsors given the group name""" 99 | client = ldap_client() 100 | 101 | group = client.get_group(groupname) 102 | if group is None: 103 | api_v1.abort(404, "Group not found", groupname=groupname) 104 | 105 | return client.get_group_sponsors(groupname, attrs=get_attrs_from_mask(SponsorModel)) 106 | 107 | 108 | @api_v1.route("//is-member/") 109 | @api_v1.param("groupname", "The group name") 110 | @api_v1.param("username", "The user name") 111 | @api_v1.response(404, "Group not found") 112 | class IsMember(Resource): 113 | @api_v1.doc("check_membership") 114 | @api_v1.marshal_with(fields.Boolean()) 115 | def get(self, groupname, username): 116 | """Check whether a user is a member of the group""" 117 | client = ldap_client() 118 | 119 | group = client.get_group(groupname) 120 | if group is None: 121 | api_v1.abort(404, "Group not found", groupname=groupname) 122 | 123 | result = client.check_membership(groupname, username) 124 | return result 125 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | import os 8 | import re 9 | import sys 10 | 11 | 12 | topdir = os.path.abspath("../") 13 | sys.path.insert(0, topdir) 14 | 15 | import fasjson # NOQA 16 | 17 | 18 | # Set the full version, including alpha/beta/rc tags 19 | release = fasjson.__version__ 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = "fasjson" 25 | copyright = "2020, Red Hat, Inc" 26 | author = "Fedora Infrastructure" 27 | 28 | # The short X.Y version 29 | version = ".".join(release.split(".")[:2]) 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.intersphinx", 40 | "sphinx.ext.extlinks", 41 | "sphinx.ext.viewcode", 42 | "sphinx.ext.napoleon", 43 | "sphinxcontrib.openapi", 44 | "myst_parser", 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ["_templates"] 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | # This pattern also affects html_static_path and html_extra_path. 53 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 54 | 55 | # Explcitely set the master doc 56 | # https://github.com/readthedocs/readthedocs.org/issues/2569 57 | master_doc = "index" 58 | 59 | 60 | # -- Options for HTML output ------------------------------------------------- 61 | 62 | # The theme to use for HTML and HTML Help pages. See the documentation for 63 | # a list of builtin themes. 64 | # 65 | html_theme = "alabaster" 66 | 67 | 68 | # Theme options are theme-specific and customize the look and feel of a theme 69 | # further. For a list of options available for each theme, see the 70 | # documentation. 71 | html_theme_options = { 72 | "github_user": "fedora-infra", 73 | "github_repo": "fasjson", 74 | "page_width": "1040px", 75 | "show_related": True, 76 | "sidebar_collapse": True, 77 | "caption_font_size": "140%", 78 | } 79 | 80 | # Add any paths that contain custom static files (such as style sheets) here, 81 | # relative to this directory. They are copied after the builtin static files, 82 | # so a file named "default.css" will overwrite the builtin "default.css". 83 | html_static_path = ["_static"] 84 | 85 | 86 | # -- Extension configuration ------------------------------------------------- 87 | 88 | autodoc_mock_imports = ["gssapi", "requests_gssapi"] 89 | 90 | source_suffix = { 91 | ".rst": "restructuredtext", 92 | ".md": "markdown", 93 | } 94 | 95 | myst_enable_extensions = [ 96 | "colon_fence", 97 | ] 98 | myst_heading_anchors = 3 99 | 100 | 101 | # -- Options for intersphinx extension --------------------------------------- 102 | 103 | # Example configuration for intersphinx: refer to the Python standard library. 104 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 105 | 106 | 107 | # -- Misc ----- 108 | 109 | 110 | def export_swagger(_): 111 | # Mock C-based modules 112 | from unittest.mock import Mock 113 | 114 | sys.modules["ldap"] = Mock() 115 | sys.modules["ldap.filter"] = Mock() 116 | sys.modules["ldap.controls.pagedresults"] = Mock() 117 | sys.modules["gssapi"] = Mock() 118 | # Run the exporter 119 | sys.path.insert(0, os.path.join(topdir, "docs", "utils")) 120 | import export_swagger 121 | 122 | export_swagger.run() 123 | sys.path.pop(0) 124 | 125 | 126 | def run_apidoc(_): 127 | from sphinx.ext import apidoc 128 | 129 | apidoc.main( 130 | [ 131 | "-f", 132 | "-o", 133 | os.path.join(topdir, "docs", "_source"), 134 | os.path.join(topdir, "fasjson"), 135 | ] 136 | ) 137 | 138 | 139 | github_url = "https://github.com/fedora-infra/fasjson" 140 | 141 | 142 | def changelog_github_links(app, docname, source): 143 | if docname != "release_notes": 144 | return 145 | github_issue_re = re.compile(r"#(\d+)") 146 | for docnr, doc in enumerate(source): 147 | source[docnr] = github_issue_re.sub(r"[#\1](" + github_url + r"/issues/\1)", doc) 148 | 149 | 150 | def setup(app): 151 | app.connect("builder-inited", run_apidoc) 152 | app.connect("builder-inited", export_swagger) 153 | app.connect("source-read", changelog_github_links) 154 | -------------------------------------------------------------------------------- /fasjson/web/resources/search.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Resource 2 | from flask_restx.inputs import datetime_from_iso8601 3 | 4 | from fasjson.lib.ldap.models import UserModel as LDAPUserModel 5 | from fasjson.web.utils import maybe_anonymize 6 | from fasjson.web.utils.ipa import get_attrs_from_mask, ldap_client 7 | from fasjson.web.utils.pagination import page_request_parser 8 | from fasjson.web.utils.request_parsing import add_exact_arguments 9 | 10 | from .base import Namespace 11 | from .users import UserModel 12 | 13 | 14 | search_request_parser = page_request_parser.copy() 15 | search_request_parser.add_argument("email", help="The email to search for") 16 | search_request_parser.add_argument("email__exact", help="DEPRECATED: use email") 17 | search_request_parser.add_argument("username", help="The username to search for") 18 | search_request_parser.add_argument("ircnick", help="The ircnick to search for") 19 | search_request_parser.add_argument("givenname", help="The first name to search for") 20 | search_request_parser.add_argument("surname", help="The surname to search for") 21 | search_request_parser.add_argument("human_name", help="The full human name to search for") 22 | search_request_parser.add_argument("github_username", help="The username in GitHub.com") 23 | search_request_parser.add_argument("gitlab_username", help="The username in GitLab.com") 24 | search_request_parser.add_argument( 25 | "creation__before", 26 | help="Search for users created before this date", 27 | type=datetime_from_iso8601, 28 | ) 29 | search_request_parser.add_argument("rhbzemail", help="The bugzilla email to search for") 30 | search_request_parser.add_argument("website", help="The website URLs to search for") 31 | search_request_parser.add_argument("rssurl", help="The RSS URLs to search for") 32 | search_request_parser.add_argument( 33 | "group", action="append", help="Users must be a member of this group" 34 | ) 35 | add_exact_arguments(search_request_parser, LDAPUserModel) 36 | 37 | 38 | api_v1 = Namespace("search", description="Search related operations") 39 | 40 | 41 | @api_v1.route("/users/") 42 | class SearchUsers(Resource): 43 | @api_v1.doc("search") 44 | @api_v1.expect(search_request_parser) 45 | @api_v1.response(400, "Validation Error") 46 | @api_v1.paged_marshal_with(UserModel) 47 | def get(self): 48 | """Fetch users given a search term""" 49 | search_args = search_request_parser.parse_args() 50 | page_number, page_size = self._parse_page_args(search_args) 51 | self._validate_search_args(search_args) 52 | 53 | client = ldap_client() 54 | result = client.search_users( 55 | attrs=get_attrs_from_mask(UserModel), 56 | page_size=page_size, 57 | page_number=page_number, 58 | **search_args, 59 | ) 60 | result.items = [maybe_anonymize(user) for user in result.items] 61 | return result 62 | 63 | def _parse_page_args(self, search_args): 64 | """ 65 | A simple function to remove and return the page size and number from the search args. 66 | Page size: 67 | - is between 1 and 40 68 | - is a number 69 | - defaults to 40 if not provided 70 | """ 71 | page_number = search_args.pop("page_number") 72 | page_size = search_args.pop("page_size") 73 | page_size = 40 if page_size is None else page_size 74 | 75 | if page_size > 40 or page_size == 0: 76 | api_v1.abort( 77 | 400, 78 | "Page size must be between 1 and 40 when searching.", 79 | page_size=page_size, 80 | ) 81 | return page_number, page_size 82 | 83 | def _validate_search_args(self, search_args): 84 | """ 85 | A simple function to validate the provided arguments. 86 | Checks: 87 | - At least one search term must be provided 88 | - All provided search terms must be greater than 3 characters in length if we are 89 | doing a substring match 90 | """ 91 | if not any(search_args.values()): 92 | api_v1.abort(400, "At least one search term must be provided.") 93 | for search_term, search_value in search_args.items(): 94 | if search_term == "creation__before": 95 | continue # It's a datetime already 96 | if search_term == "group": 97 | continue # These may be smaller than 3 chars, and will be matched exactly anyway. 98 | # For substring matches, we want at least 3 chars 99 | if ( 100 | search_value 101 | and not search_term.endswith("__exact") 102 | and search_term not in LDAPUserModel.always_exact_match 103 | and len(search_value) < 3 104 | ): 105 | api_v1.abort( 106 | 400, 107 | "Search term must be at least 3 characters long.", 108 | search_term=search_term, 109 | search_value=search_value, 110 | ) 111 | -------------------------------------------------------------------------------- /docs/release_notes.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the changes for the upcoming release can be found in [the `news` directory](https://github.com/fedora-infra/fasjson/tree/develop/news/). 8 | 9 | 10 | 11 | ## v1.7.0 12 | 13 | Released on 2025-11-14. 14 | 15 | ### Features 16 | 17 | - Support presence match in the search endpoint. For example, use `ircnick=*` to get users who have defined an IRC nick. (#presence-match) 18 | - Add the possibility to search users by GitLab usernames (#645) 19 | - Make `rssurl` and `website` multivalued fields (#719) 20 | - Add a multivalued `group` search term for users, to select only those in the specified groups 21 | - Allow searching for RSS URLs and websites 22 | 23 | ### Bug Fixes 24 | 25 | - Fixup `get_user_groups()` after 6698d1c8 26 | 27 | ### Development Improvements 28 | 29 | - Move the tests out of the main python package (#734) 30 | - Make the Vagrant VM use Tinystage instead of its own instance of FreeIPA 31 | - Switch to ruff instead of flake8+isort+bandit+pyupgrade 32 | 33 | ### Other Changes 34 | 35 | - Note that the `email__exact` search term is deprecated since email searches are never substring-based, so just use the `email` term (#6698d1c) 36 | 37 | ### Contributors 38 | 39 | Many thanks to the contributors of bug reports, pull requests, and pull request 40 | reviews for this release: 41 | 42 | - Aurélien Bompard 43 | - Pedro Moura 44 | - Ryan Lerch 45 | 46 | ## v1.6.0 47 | 48 | Released on 2023-09-21. 49 | This is a feature release that adds the `rssurl` field. 50 | 51 | ### Features 52 | 53 | - Add the rssurl field (#558). 54 | 55 | ### Bug Fixes 56 | 57 | - Don't crash when a user has no groups (#399). 58 | - Don't crash when group is a sponsor of a group (#444). 59 | 60 | ### Contributors 61 | 62 | Many thanks to the contributors of bug reports, pull requests, and pull request 63 | reviews for this release: 64 | 65 | - Aurélien Bompard 66 | - Lenka Segura 67 | - Pedro Moura 68 | 69 | 70 | ## v1.5.0 71 | 72 | Released on 2022-08-29. 73 | This is a feature release that adds `rhbzemail` field searching. 74 | 75 | ### Features 76 | 77 | - Allow search by rhbzemail field (#370). 78 | 79 | ### Contributors 80 | 81 | Many thanks to the contributors of bug reports, pull requests, and pull request 82 | reviews for this release: 83 | 84 | - Aurélien Bompard 85 | - Michal Konečný 86 | 87 | 88 | ## v1.4.0 89 | 90 | Released on 2022-06-30. 91 | This is a feature release that adds exact value search and GitHub username 92 | search. 93 | 94 | ### Features 95 | 96 | - Allow searching for the exact value (#266). 97 | - Add a way to search users with GitHub usernames (#348). 98 | 99 | The `creation_before` search term has been renamed to `creation__before` 100 | for coherence. This is technically a backwards incompatible change, but the 101 | term has only been added a few days ago and not advertised, so I'm fairly 102 | confident noone uses it yet. 103 | 104 | 105 | ## v1.3.0 106 | 107 | This is a feature release that adds user search terms. 108 | 109 | ### Features 110 | 111 | - Add the `human_name` and `creation_before` search terms for users (#343). 112 | 113 | ### Development Improvements 114 | 115 | - Upgrade the Vagrant VM to F36 (#343). 116 | 117 | ### Contributors 118 | 119 | Many thanks to the contributors of bug reports, pull requests, and pull request 120 | reviews for this release: 121 | 122 | - Aurélien Bompard 123 | - Stephen Coady 124 | 125 | 126 | ## v1.2.1 127 | 128 | Released on 2022-05-16. 129 | 130 | ### Development Improvements 131 | 132 | - Allow users to set bugzilla email using fasRHBZEmail attribute (#288). 133 | - Update dependencies 134 | - Drop support for Python < 3.9 135 | 136 | ### Contributors 137 | 138 | Many thanks to the contributors of bug reports, pull requests, and pull request 139 | reviews for this release: 140 | 141 | - Aurélien Bompard 142 | - Pedro Moura 143 | - Stephen Coady 144 | 145 | 146 | ## v1.2.0 147 | 148 | Released on 2021-11-05. 149 | 150 | ### Features 151 | 152 | - Add some more user fields: `github_username`, `gitlab_username`, 153 | `website`, and `pronouns` (#213). 154 | 155 | ### Bug Fixes 156 | 157 | - Respect user's privacy setting on the search endpoint (#257). 158 | 159 | 160 | ## v1.1.0 161 | 162 | This is a feature release. 163 | 164 | ### Features 165 | 166 | - Field mask support: request more or less object attributes with a HTTP header 167 | (#144). 168 | - Expose users' SSH keys (#186). 169 | 170 | ### Bug Fixes 171 | 172 | - Display indirect groups as well (#188). 173 | 174 | ### Contributors 175 | 176 | Many thanks to the contributors of bug reports, pull requests, and pull request 177 | reviews for this release: 178 | 179 | - Aurélien Bompard 180 | 181 | 182 | ## v1.0.0 183 | 184 | This is a the first stable release, as deployed in production in the Fedora 185 | infrastructure on March 24th 2021. 186 | 187 | ### Contributors 188 | 189 | Many thanks to the contributors of bug reports, pull requests, and pull request 190 | reviews for this release: 191 | 192 | - Aurélien Bompard 193 | - Stephen Coady 194 | -------------------------------------------------------------------------------- /tests/unit/test_web_resource_v1_certs.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | from python_freeipa.exceptions import BadRequest 5 | 6 | 7 | @pytest.fixture 8 | def mock_rpc_client(mock_ipa_client): 9 | yield partial(mock_ipa_client, "fasjson.web.resources.certs", "rpc") 10 | 11 | 12 | def _get_cert_rpc_data(cert_id): 13 | return { 14 | "result": { 15 | "certificate": "dummmy+cert/+=", 16 | "serial_number": cert_id, 17 | "serial_number_hex": "0xC", 18 | "subject": "CN=dummy,O=EXAMPLE.TEST", 19 | "issuer": "CN=Certificate Authority,O=EXAMPLE.TEST", 20 | "valid_not_before": "Tue May 05 06:22:53 2020 UTC", 21 | "valid_not_after": "Fri May 06 06:22:53 2022 UTC", 22 | "sha1_fingerprint": "8d:8d:41:6a:ae:8d:95:c5:5f:19:85:6c:16:cc:2f:d0:b0:82:42:c7", 23 | "sha256_fingerprint": ( 24 | "c4:d7:c8:47:2e:41:16:57:b6:5d:d7:94:ae:d1:a4:66:97:b1:e9:7f:04:" 25 | "8f:1f:c3:fb:44:e8:89:30:3f:1a:30" 26 | ), 27 | "revoked": False, 28 | "owner_user": ["dummy"], 29 | "cacn": "ipa", 30 | "certificate_chain": [ 31 | {"__base64__": "dummmy+cert/+="}, 32 | {"__base64__": "dummmy+ca+cert"}, 33 | ], 34 | }, 35 | "value": cert_id, 36 | "summary": None, 37 | } 38 | 39 | 40 | def _get_cert_api_output(cert_id): 41 | return { 42 | "cacn": "ipa", 43 | "certificate": "dummmy+cert/+=", 44 | "certificate_chain": ["dummmy+cert/+=", "dummmy+ca+cert"], 45 | "issuer": "CN=Certificate Authority,O=EXAMPLE.TEST", 46 | "revoked": False, 47 | "san_other": None, 48 | "san_other_kpn": None, 49 | "san_other_upn": None, 50 | "serial_number": cert_id, 51 | "serial_number_hex": "0xC", 52 | "sha1_fingerprint": "8d:8d:41:6a:ae:8d:95:c5:5f:19:85:6c:16:cc:2f:d0:b0:82:42:c7", 53 | "sha256_fingerprint": ( 54 | "c4:d7:c8:47:2e:41:16:57:b6:5d:d7:94:ae:d1:a4:66:97:b1:e9:7f:04:8f:" 55 | "1f:c3:fb:44:e8:89:30:3f:1a:30" 56 | ), 57 | "subject": "CN=dummy,O=EXAMPLE.TEST", 58 | "valid_not_after": "Fri, 06 May 2022 06:22:53 -0000", 59 | "valid_not_before": "Tue, 05 May 2020 06:22:53 -0000", 60 | "uri": f"http://localhost/v1/certs/{cert_id}/", 61 | } 62 | 63 | 64 | def test_cert_success(client, gss_user, mock_rpc_client): 65 | data = _get_cert_rpc_data(42) 66 | mock_rpc_client(cert_show=lambda cert_id: data) 67 | 68 | rv = client.get("/v1/certs/42/") 69 | 70 | expected = _get_cert_api_output(42) 71 | assert 200 == rv.status_code 72 | assert rv.get_json() == {"result": expected} 73 | 74 | 75 | def test_cert_404(client, gss_user, mock_rpc_client, mocker): 76 | mock_rpc_client( 77 | cert_show=mocker.Mock(side_effect=BadRequest(message="Error message", code=4301)) 78 | ) 79 | rv = client.get("/v1/certs/42/") 80 | assert 404 == rv.status_code 81 | assert rv.get_json() == { 82 | "message": "Certificate not found", 83 | "serial_number": 42, 84 | "server_message": "Error message", 85 | } 86 | 87 | 88 | def test_cert_error(client, gss_user, mock_rpc_client, mocker): 89 | mock_rpc_client( 90 | cert_show=mocker.Mock(side_effect=BadRequest(message="Error message", code=4242)) 91 | ) 92 | rv = client.get("/v1/certs/42/") 93 | assert 400 == rv.status_code 94 | assert rv.get_json() == { 95 | "message": "Error message", 96 | "code": 4242, 97 | "source": "RPC", 98 | } 99 | 100 | 101 | def test_cert_post_success(client, gss_user, mock_rpc_client, mocker): 102 | data = _get_cert_rpc_data(42) 103 | rpc_client = mock_rpc_client(cert_request=mocker.Mock(return_value=data)) 104 | rv = client.post("/v1/certs/", data={"csr": "dummy-csr", "user": "dummy"}) 105 | expected = _get_cert_api_output(42) 106 | assert 200 == rv.status_code 107 | assert rv.get_json() == {"result": expected} 108 | rpc_client.cert_request.assert_called_once_with( 109 | "dummy-csr", o_principal="dummy", o_profile_id=None 110 | ) 111 | 112 | 113 | def test_cert_post_with_profile_id(client, gss_user, mock_rpc_client, mocker): 114 | data = _get_cert_rpc_data(42) 115 | rpc_client = mock_rpc_client(cert_request=mocker.Mock(return_value=data)) 116 | rv = client.post( 117 | "/v1/certs/", 118 | data={"csr": "dummy-csr", "user": "dummy", "profile": "userCerts"}, 119 | ) 120 | expected = _get_cert_api_output(42) 121 | assert 200 == rv.status_code 122 | assert rv.get_json() == {"result": expected} 123 | rpc_client.cert_request.assert_called_once_with( 124 | "dummy-csr", o_principal="dummy", o_profile_id="userCerts" 125 | ) 126 | 127 | 128 | def test_cert_post_with_configured_profile_id(app, client, gss_user, mock_rpc_client, mocker): 129 | app.config["CERTIFICATE_PROFILE"] = "cert-profile" 130 | data = _get_cert_rpc_data(42) 131 | rpc_client = mock_rpc_client(cert_request=mocker.Mock(return_value=data)) 132 | rv = client.post("/v1/certs/", data={"csr": "dummy-csr", "user": "dummy"}) 133 | expected = _get_cert_api_output(42) 134 | assert 200 == rv.status_code 135 | assert rv.get_json() == {"result": expected} 136 | rpc_client.cert_request.assert_called_once_with( 137 | "dummy-csr", o_principal="dummy", o_profile_id="cert-profile" 138 | ) 139 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test & Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - stable 7 | - develop 8 | tags: 9 | pull_request: 10 | branches: 11 | - stable 12 | - develop 13 | 14 | jobs: 15 | 16 | checks: 17 | name: Checks 18 | runs-on: ubuntu-latest 19 | container: fedorapython/fedora-python-tox:latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - name: Install dependencies 24 | run: | 25 | dnf install -y pre-commit git krb5-devel libpq-devel openldap-devel python-distlib 26 | - name: Install Python dependencies 27 | run: pip install poetry poetry-plugin-export 28 | 29 | - name: Mark the working directory as safe for Git 30 | run: git config --global --add safe.directory $PWD 31 | 32 | - name: Install the project 33 | run: poetry install 34 | 35 | - name: Run pre-commit checks 36 | run: pre-commit run --all-files 37 | 38 | 39 | docs: 40 | name: Documentation 41 | runs-on: ubuntu-latest 42 | container: fedorapython/fedora-python-tox:latest 43 | steps: 44 | - uses: actions/checkout@v6 45 | 46 | - name: Install dependencies 47 | run: | 48 | dnf install -y pre-commit git krb5-devel libpq-devel openldap-devel python-distlib 49 | - name: Install Python dependencies 50 | run: pip install poetry poetry-plugin-export 51 | 52 | - name: Build the docs 53 | run: tox -e docs 54 | 55 | # - name: Save the docs 56 | # uses: actions/upload-artifact@v2 57 | # with: 58 | # name: docs 59 | # path: {{ cookiecutter.pkg_name }}/docs/_build/html 60 | 61 | 62 | unit-tests: 63 | name: Unit tests 64 | runs-on: ubuntu-latest 65 | container: fedorapython/fedora-python-tox:latest 66 | steps: 67 | - uses: actions/checkout@v6 68 | 69 | - name: Install dependencies 70 | run: | 71 | dnf install -y pre-commit git krb5-devel libpq-devel openldap-devel python-distlib 72 | - name: Install Python dependencies 73 | run: pip install poetry poetry-plugin-export 74 | 75 | - name: Run the tests 76 | run: tox -e ${{ matrix.pyver }}-unit 77 | 78 | strategy: 79 | matrix: 80 | pyver: 81 | - py310 82 | - py311 83 | - py312 84 | - py313 85 | - py314 86 | 87 | 88 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 89 | build: 90 | name: Build distribution 📦 91 | runs-on: ubuntu-latest 92 | needs: 93 | - checks 94 | - docs 95 | - unit-tests 96 | outputs: 97 | release-notes: ${{ steps.extract-changelog.outputs.markdown }} 98 | 99 | steps: 100 | 101 | - uses: actions/checkout@v6 102 | - name: Set up Python 103 | uses: actions/setup-python@v6 104 | with: 105 | python-version: "3.x" 106 | 107 | - name: Install pypa/build 108 | run: python3 -m pip install build --user 109 | 110 | - name: Build a binary wheel and a source tarball 111 | run: python3 -m build 112 | 113 | - name: Store the distribution packages 114 | uses: actions/upload-artifact@v6 115 | with: 116 | name: python-package-distributions 117 | path: dist/ 118 | 119 | - name: Extract changelog section 120 | id: extract-changelog 121 | uses: sean0x42/markdown-extract@v2 122 | with: 123 | file: docs/release_notes.md 124 | pattern: 'v[[:digits:]rc.-]+' 125 | no-print-matched-heading: true 126 | - name: Show the changelog 127 | env: 128 | CHANGELOG: ${{ steps.extract-changelog.outputs.markdown }} 129 | run: echo "$CHANGELOG" 130 | 131 | 132 | publish-to-pypi: 133 | name: Publish to PyPI 🚀 134 | if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref, 'rc') # only publish to PyPI on final tag pushes 135 | needs: 136 | - build 137 | runs-on: ubuntu-latest 138 | environment: 139 | name: pypi 140 | url: https://pypi.org/p/fasjson 141 | permissions: 142 | id-token: write # IMPORTANT: mandatory for trusted publishing 143 | 144 | steps: 145 | - name: Download all the dists 146 | uses: actions/download-artifact@v7 147 | with: 148 | name: python-package-distributions 149 | path: dist/ 150 | 151 | - name: Publish distribution to PyPI 152 | uses: pypa/gh-action-pypi-publish@release/v1 153 | 154 | 155 | github-release: 156 | name: Create a GitHub Release 📢 157 | needs: 158 | - publish-to-pypi 159 | runs-on: ubuntu-latest 160 | permissions: 161 | contents: write # IMPORTANT: mandatory for making GitHub Releases 162 | id-token: write # IMPORTANT: mandatory for sigstore 163 | 164 | steps: 165 | - name: Download all the dists 166 | uses: actions/download-artifact@v7 167 | with: 168 | name: python-package-distributions 169 | path: dist/ 170 | 171 | - name: Sign the dists with Sigstore 172 | uses: sigstore/gh-action-sigstore-python@v3.2.0 173 | with: 174 | inputs: >- 175 | ./dist/*.tar.gz 176 | ./dist/*.whl 177 | 178 | - name: Release 179 | uses: softprops/action-gh-release@v2 180 | with: 181 | files: dist/* 182 | fail_on_unmatched_files: true 183 | body: ${{ needs.build.outputs.release-notes }} 184 | -------------------------------------------------------------------------------- /fasjson/web/extensions/flask_ipacfg.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import operator 3 | import random 4 | 5 | import dns.rdatatype 6 | import dns.resolver 7 | from dns.exception import DNSException 8 | from flask import current_app 9 | 10 | 11 | class IPAConfig: 12 | prefix = "FASJSON_IPA" 13 | 14 | def __init__(self, app=None): 15 | self.app = app 16 | if app is not None: 17 | self.init_app(app) 18 | 19 | def init_app(self, app): 20 | if "FASJSON_IPA_CONFIG_PATH" not in app.config: 21 | app.config.setdefault("FASJSON_IPA_CONFIG_PATH", "/etc/ipa/default.conf") 22 | if "FASJSON_IPA_CA_CERT_PATH" not in app.config: 23 | app.config.setdefault("FASJSON_IPA_CA_CERT_PATH", "/etc/ipa/ca.crt") 24 | try: 25 | self._load_config(app) 26 | except FileNotFoundError: 27 | pass # The config will be loaded on request by _detect_ldap 28 | app.before_request(self._detect_ldap) 29 | 30 | def _load_config(self, app=None): 31 | _app = app 32 | if _app is None: 33 | _app = current_app 34 | if _app.config.get("FASJSON_IPA_CONFIG_LOADED", False): 35 | return 36 | p = configparser.ConfigParser() 37 | with open(_app.config["FASJSON_IPA_CONFIG_PATH"]) as f: 38 | p.read_file(f) 39 | 40 | _app.config.setdefault("FASJSON_IPA_BASEDN", p.get("global", "basedn")) 41 | _app.config.setdefault("FASJSON_IPA_DOMAIN", p.get("global", "domain")) 42 | _app.config.setdefault("FASJSON_IPA_REALM", p.get("global", "realm")) 43 | _app.config.setdefault("FASJSON_IPA_SERVER", p.get("global", "server", fallback=None)) 44 | _app.config.setdefault("FASJSON_IPA_CONFIG_LOADED", True) 45 | 46 | def _detect_ldap(self) -> None: 47 | # Load the config if it wasn't loaded before 48 | self._load_config() 49 | domain = current_app.config["FASJSON_IPA_DOMAIN"] 50 | servers = [] 51 | try: 52 | answers = query_srv(f"_ldap._tcp.{domain}") 53 | except DNSException: 54 | servers.append("ldap://" + current_app.config["FASJSON_IPA_SERVER"]) 55 | else: 56 | for answer in answers: 57 | server = str(answer.target).rstrip(".") 58 | servers.append(f"ldap://{server}:{answer.port}") 59 | current_app.config["FASJSON_LDAP_URI"] = " ".join(servers) 60 | 61 | 62 | def _mix_weight(records): 63 | """Weighted population sorting for records with same priority""" 64 | # trivial case 65 | if len(records) <= 1: 66 | return records 67 | 68 | # Optimization for common case: If all weights are the same (e.g. 0), 69 | # just shuffle the records, which is about four times faster. 70 | if all(rr.weight == records[0].weight for rr in records): 71 | random.shuffle(records) 72 | return records 73 | 74 | noweight = 0.01 # give records with 0 weight a small chance 75 | result = [] 76 | records = set(records) 77 | while len(records) > 1: 78 | # Compute the sum of the weights of those RRs. Then choose a 79 | # uniform random number between 0 and the sum computed (inclusive). 80 | urn = random.uniform(0, sum(rr.weight or noweight for rr in records)) # noqa: S311 81 | # Select the RR whose running sum value is the first in the selected 82 | # order which is greater than or equal to the random number selected. 83 | acc = 0.0 84 | for rr in records.copy(): 85 | acc += rr.weight or noweight 86 | if acc >= urn: 87 | records.remove(rr) 88 | result.append(rr) 89 | 90 | # randomness makes it hard to check for coverage in these next 2 lines 91 | if records: # pragma: no cover 92 | result.append(records.pop()) # pragma: no cover 93 | 94 | return result 95 | 96 | 97 | def sort_prio_weight(records): 98 | """RFC 2782 sorting algorithm for SRV and URI records 99 | 100 | RFC 2782 defines a sorting algorithms for SRV records, that is also used 101 | for sorting URI records. Records are sorted by priority and than randomly 102 | shuffled according to weight. 103 | 104 | This implementation also removes duplicate entries. 105 | """ 106 | # order records by priority 107 | records = sorted(records, key=operator.attrgetter("priority")) 108 | 109 | # remove duplicate entries 110 | uniquerecords = [] 111 | seen = set() 112 | for rr in records: 113 | # A SRV record has target and port, URI just has target. 114 | target = (rr.target, getattr(rr, "port", None)) 115 | if target not in seen: 116 | uniquerecords.append(rr) 117 | seen.add(target) 118 | 119 | # weighted randomization of entries with same priority 120 | result = [] 121 | sameprio = [] 122 | for rr in uniquerecords: 123 | # add all items with same priority in a bucket 124 | if not sameprio or sameprio[0].priority == rr.priority: 125 | sameprio.append(rr) 126 | else: 127 | # got different priority, shuffle bucket 128 | result.extend(_mix_weight(sameprio)) 129 | # start a new priority list 130 | sameprio = [rr] 131 | # add last batch of records with same priority 132 | if sameprio: 133 | result.extend(_mix_weight(sameprio)) 134 | return result 135 | 136 | 137 | def query_srv(qname, resolver=None, **kwargs): 138 | """Query SRV records and sort reply according to RFC 2782 139 | 140 | :param qname: query name, _service._proto.domain. 141 | :return: list of dns.rdtypes.IN.SRV.SRV instances 142 | """ 143 | if resolver is None: 144 | resolver = dns.resolver 145 | answer = resolver.resolve(qname, rdtype=dns.rdatatype.SRV, **kwargs) 146 | return sort_prio_weight(answer) 147 | -------------------------------------------------------------------------------- /fasjson/lib/ldap/models.py: -------------------------------------------------------------------------------- 1 | from ldap.filter import escape_filter_chars 2 | 3 | from .converters import ( 4 | BinaryConverter, 5 | BoolConverter, 6 | Converter, 7 | GeneralTimeConverter, 8 | ) 9 | 10 | 11 | class Model: 12 | primary_key = None 13 | filters = "(objectClass=*)" 14 | sub_dn = None 15 | fields = {} 16 | hidden_fields = [] 17 | # Search attributes that will never be a searched as a substring 18 | always_exact_match = [] 19 | 20 | @classmethod 21 | def get_sub_dn_for(cls, name): 22 | name = escape_filter_chars(name) 23 | return f"{cls.primary_key}={name},{cls.sub_dn}" 24 | 25 | @classmethod 26 | def get_ldap_attrs(cls): 27 | return [ 28 | converter.ldap_name 29 | for key, converter in cls.fields.items() 30 | if key not in cls.hidden_fields 31 | ] 32 | 33 | @classmethod 34 | def attr_to_ldap(cls, attr): 35 | return cls.fields[attr].ldap_name 36 | 37 | @classmethod 38 | def attrs_to_ldap(cls, attrs): 39 | if attrs is None: 40 | return None 41 | return [cls.attr_to_ldap(name) for name in attrs if name in cls.fields] 42 | 43 | @classmethod 44 | def convert_ldap_result(cls, result): 45 | new_result = {} 46 | for dest_name, converter in cls.fields.items(): 47 | try: 48 | existing_value = result[converter.ldap_name] 49 | except KeyError: 50 | continue 51 | new_result[dest_name] = converter.from_ldap(existing_value) 52 | return new_result 53 | 54 | @classmethod 55 | def get_search_attrs_map(cls): 56 | result = {} 57 | for name, converter in cls.fields.items(): 58 | if name.endswith("s") and converter.multivalued: 59 | name = name[:-1] 60 | result[name] = converter.ldap_name 61 | return result 62 | 63 | 64 | class UserModel(Model): 65 | primary_key = "uid" 66 | filters = "(&(objectClass=fasUser)(!(nsAccountLock=TRUE)))" 67 | sub_dn = "cn=users,cn=accounts" 68 | fields = { 69 | "username": Converter("uid"), 70 | "surname": Converter("sn"), 71 | "givenname": Converter("givenName"), 72 | "human_name": Converter("displayName"), 73 | "emails": Converter("mail", multivalued=True), 74 | "ircnicks": Converter("fasIRCNick", multivalued=True), 75 | "locale": Converter("fasLocale"), 76 | "timezone": Converter("fasTimeZone"), 77 | "gpgkeyids": Converter("fasGPGKeyId", multivalued=True), 78 | "sshpubkeys": Converter("ipaSshPubKey", multivalued=True), 79 | "certificates": BinaryConverter("userCertificate", multivalued=True), 80 | "creation": GeneralTimeConverter("fasCreationTime"), 81 | "is_private": BoolConverter("fasIsPrivate"), 82 | "locked": BoolConverter("nsAccountLock"), 83 | "groups": Converter("memberof", multivalued=True), 84 | "github_username": Converter("fasGitHubUsername"), 85 | "gitlab_username": Converter("fasGitLabUsername"), 86 | "pronouns": Converter("fasPronoun", multivalued=True), 87 | "rhbzemail": Converter("fasRHBZEmail"), 88 | "website": Converter("fasWebsiteURL"), 89 | "rssurl": Converter("fasRssURL"), 90 | "websites": Converter("fasWebsiteURL", multivalued=True), 91 | "rssurls": Converter("fasRssURL", multivalued=True), 92 | } 93 | hidden_fields = ["groups"] 94 | private_fields = [ 95 | "human_name", 96 | "surname", 97 | "givenname", 98 | "ircnicks", 99 | "locale", 100 | "timezone", 101 | "gpgkeyids", 102 | "github_username", 103 | "gitlab_username", 104 | "pronouns", 105 | "website", 106 | "rssurl", 107 | "websites", 108 | "rssurls", 109 | ] 110 | # Fields that do not have a SUBSTR index in the schema 111 | # https://github.com/fedora-infra/freeipa-fas/blob/develop/schema.d/89-fasschema.ldif 112 | always_exact_match = [ 113 | "email", 114 | "creation", 115 | "rhbzemail", 116 | "github_username", 117 | "gitlab_username", 118 | "website", 119 | "is_private", 120 | "rssurl", 121 | "group", 122 | ] 123 | # Fields that do not have a SUBSTR index in the schema 124 | # https://github.com/fedora-infra/freeipa-fas/blob/develop/schema.d/89-fasschema.ldif 125 | always_exact_match = [ 126 | "email", 127 | "group", 128 | "creation", 129 | "rhbzemail", 130 | "github_username", 131 | "gitlab_username", 132 | "website", 133 | "websites", 134 | "is_private", 135 | "rssurl", 136 | "rssurls", 137 | ] 138 | 139 | @classmethod 140 | def anonymize(cls, user): 141 | for attr in cls.private_fields: 142 | try: 143 | del user[attr] 144 | except KeyError: 145 | continue 146 | return user 147 | 148 | 149 | class SponsorModel(Model): 150 | primary_key = "memberManager" 151 | filters = "(&(objectClass=fasUser)(!(nsAccountLock=TRUE)))" 152 | sub_dn = "cn=users,cn=accounts" 153 | fields = { 154 | "sponsors": Converter("memberManager", multivalued=True), 155 | } 156 | 157 | 158 | class GroupModel(Model): 159 | primary_key = "cn" 160 | filters = "(objectClass=fasGroup)" 161 | sub_dn = "cn=groups,cn=accounts" 162 | fields = { 163 | "groupname": Converter("cn"), 164 | "description": Converter("description"), 165 | "mailing_list": Converter("fasmailinglist"), 166 | "url": Converter("fasurl"), 167 | "irc": Converter("fasircchannel", multivalued=True), 168 | "discussion_url": Converter("fasdiscussionurl"), 169 | } 170 | 171 | 172 | class AgreementModel(Model): 173 | primary_key = "cn" 174 | filters = "(&(objectClass=fasAgreement)(ipaEnabledFlag=TRUE))" 175 | sub_dn = "cn=fasagreements" 176 | fields = { 177 | "name": Converter("cn"), 178 | } 179 | -------------------------------------------------------------------------------- /tests/unit/test_web_extension_ipacfg.py: -------------------------------------------------------------------------------- 1 | import os 2 | from types import SimpleNamespace 3 | from unittest import mock 4 | 5 | import dns 6 | import dns.rdtypes.IN.SRV 7 | import pytest 8 | 9 | from fasjson.web.extensions.flask_ipacfg import ( 10 | _mix_weight, 11 | IPAConfig, 12 | query_srv, 13 | sort_prio_weight, 14 | ) 15 | 16 | 17 | TEST_IPACFG = """ 18 | [global] 19 | basedn = dc=testing 20 | realm = TESTING 21 | domain = testing 22 | server = ipa.testing 23 | host = fasjson.testing 24 | xmlrpc_uri = https://ipa.testing/ipa/xml 25 | enable_ra = True 26 | """ 27 | 28 | 29 | @pytest.fixture 30 | def app_with_filtered_config(app, mocker): 31 | old_config = dict(app.config) 32 | mocker.patch.dict(app.config, clear=True) 33 | for key, value in old_config.items(): 34 | if key.startswith("FASJSON_"): 35 | continue 36 | app.config[key] = value 37 | yield app 38 | app.config = old_config 39 | 40 | 41 | def _make_dns_answer(records): 42 | qname = "_ldap._tcp.example.com" 43 | mocked_records = dns.rrset.from_text_list( 44 | name=qname, 45 | rdclass="IN", 46 | rdtype="SRV", 47 | ttl=3600, 48 | text_rdatas=[ 49 | " ".join( 50 | [ 51 | str(record.get("priority", 10)), 52 | str(record.get("weight", 10)), 53 | str(record.get("port", 389)), 54 | record["name"], 55 | ] 56 | ) 57 | for record in records 58 | ], 59 | ) 60 | mocked_answer = dns.resolver.Answer( 61 | qname, 62 | dns.rdatatype.SRV, 63 | rdclass=dns.rdataclass.IN, 64 | response=SimpleNamespace( 65 | answer=dns.message.ANSWER, 66 | resolve_chaining=lambda: SimpleNamespace( 67 | canonical_name=qname, answer=mocked_records, minimum_ttl=3600 68 | ), 69 | ), 70 | ) 71 | return mocked_answer 72 | 73 | 74 | def test_ipacfg_delayed_init(mocker): 75 | init_app = mocker.patch.object(IPAConfig, "init_app") 76 | IPAConfig(None) 77 | init_app.assert_not_called() 78 | 79 | 80 | def test_ipacfg_default_paths(app_with_filtered_config): 81 | app = app_with_filtered_config 82 | IPAConfig(app) 83 | with app.test_request_context("/v1/"): 84 | try: 85 | app.preprocess_request() 86 | except FileNotFoundError: 87 | # We may be running the testsuite on a host that does not have the IPA 88 | # config files. It's fine, ignore it. 89 | if os.path.exists("/etc/ipa/default.conf"): 90 | raise 91 | assert app.config["FASJSON_IPA_CONFIG_PATH"] == "/etc/ipa/default.conf" 92 | assert app.config["FASJSON_IPA_CA_CERT_PATH"] == "/etc/ipa/ca.crt" 93 | 94 | 95 | def test_ipacfg_delayed_load(tmpdir, app_with_filtered_config): 96 | app = app_with_filtered_config 97 | config_path = os.path.join(tmpdir, "ipa.cfg") 98 | app.config["FASJSON_IPA_CONFIG_PATH"] = config_path 99 | IPAConfig(app) 100 | assert "FASJSON_IPA_CONFIG_LOADED" not in app.config 101 | with app.test_request_context("/v1/"): 102 | with open(config_path, "w") as ipacfg_file: 103 | ipacfg_file.write(TEST_IPACFG) 104 | app.preprocess_request() 105 | assert app.config["FASJSON_IPA_CONFIG_LOADED"] is True 106 | assert app.config["FASJSON_IPA_BASEDN"] == "dc=testing" 107 | assert app.config["FASJSON_IPA_DOMAIN"] == "testing" 108 | 109 | 110 | def test_default_app(app): 111 | with app.test_request_context("/v1/"): 112 | IPAConfig(app)._load_config() 113 | # This should not crash 114 | 115 | 116 | def test_already_loaded(mocker, app): 117 | with app.test_request_context("/v1/"): 118 | app.preprocess_request() 119 | assert app.config["FASJSON_IPA_CONFIG_LOADED"] is True 120 | configparser = mocker.patch("fasjson.web.extensions.flask_ipacfg.configparser") 121 | IPAConfig(app)._load_config() 122 | configparser.ConfigParser.assert_not_called() 123 | 124 | 125 | def test_detect_dns(mocker, app): 126 | ext = IPAConfig(app) 127 | mocker.patch( 128 | "fasjson.web.extensions.flask_ipacfg.query_srv", 129 | return_value=[ 130 | SimpleNamespace(target="ldap1", port=389), 131 | SimpleNamespace(target="ldap2", port=389), 132 | ], 133 | ) 134 | with app.test_request_context("/v1/"): 135 | ext._detect_ldap() 136 | expected = "ldap://ldap1:389 ldap://ldap2:389" 137 | assert app.config["FASJSON_LDAP_URI"] == expected 138 | 139 | 140 | def test_dns_query(): 141 | resolver = mock.Mock() 142 | resolver.resolve.return_value = _make_dns_answer( 143 | [ 144 | dict(name="ldap1", priority=30), 145 | dict(name="ldap2", priority=20, weight=2), 146 | dict(name="ldap3", priority=20, weight=1), 147 | dict(name="ldap4", priority=10), 148 | ] 149 | ) 150 | result = query_srv("_ldap._tcp.example.com", resolver) 151 | result_names = [str(r.target) for r in result] 152 | assert result_names[0] == "ldap4" 153 | assert result_names[3] == "ldap1" 154 | assert result_names[1:3] in [["ldap2", "ldap3"], ["ldap3", "ldap2"]] 155 | 156 | 157 | def test_dns_query_same_prio_same_weight(): 158 | names = ["ldap1", "ldap2", "ldap3"] 159 | resolver = mock.Mock() 160 | resolver.resolve.return_value = _make_dns_answer([{"name": name} for name in names]) 161 | result = query_srv("_ldap._tcp.example.com", resolver) 162 | result_names = [str(r.target) for r in result] 163 | assert set(result_names) == set(names) 164 | 165 | 166 | def test_dns_query_no_record(): 167 | resolver = mock.Mock() 168 | resolver.resolve.return_value = _make_dns_answer([]) 169 | result = query_srv("_ldap._tcp.example.com", resolver) 170 | assert len(result) == 0 171 | 172 | 173 | def test_dns_query_duplicates(): 174 | result = sort_prio_weight( 175 | [ 176 | SimpleNamespace(priority=1, target="ldap1"), 177 | SimpleNamespace(priority=1, target="ldap1"), 178 | ] 179 | ) 180 | assert len(result) == 1 181 | 182 | 183 | def test_mix_weight(): 184 | total = 100 185 | records = _make_dns_answer([{"name": f"ldap-{idx}", "weight": idx} for idx in range(total)]) 186 | result = _mix_weight(records) 187 | result_names = [str(r.target) for r in result] 188 | # Uh, I don't really know how to check for weighted randomness. 189 | # Let's check it has been shuffled 190 | assert result_names != [f"ldap-{idx}" for idx in range(total)] 191 | # And we didn't drop any item 192 | assert len(result_names) == total 193 | -------------------------------------------------------------------------------- /tests/unit/test_web_resource_v1_users.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | 5 | from fasjson.lib.ldap.client import LDAPResult 6 | 7 | from .utils import get_user_api_output, get_user_ldap_data 8 | 9 | 10 | @pytest.fixture 11 | def mock_ldap_client(mock_ipa_client): 12 | yield partial(mock_ipa_client, "fasjson.web.resources.users", "ldap") 13 | 14 | 15 | def test_user_success(client, gss_user, mock_ldap_client): 16 | data = get_user_ldap_data("dummy") 17 | mock_ldap_client(get_user=lambda u, attrs: data) 18 | 19 | rv = client.get("/v1/users/dummy/") 20 | 21 | expected = get_user_api_output("dummy") 22 | assert 200 == rv.status_code 23 | assert rv.get_json() == {"result": expected} 24 | 25 | 26 | def test_user_error(client, gss_user, mock_ldap_client): 27 | mock_ldap_client(get_user=lambda u, attrs: None) 28 | 29 | rv = client.get("/v1/users/admin/") 30 | res = rv.get_json() 31 | 32 | assert 404 == rv.status_code 33 | assert res == { 34 | "name": "admin", 35 | "message": "User not found", 36 | } 37 | 38 | 39 | def test_user_private(client, gss_user, mock_ldap_client): 40 | data = get_user_ldap_data("dummy") 41 | data["is_private"] = True 42 | mock_ldap_client(get_user=lambda u, attrs: data) 43 | 44 | rv = client.get("/v1/users/dummy/") 45 | 46 | assert 200 == rv.status_code 47 | result = rv.get_json()["result"] 48 | assert result["human_name"] is None 49 | assert result["surname"] is None 50 | assert result["givenname"] is None 51 | assert result["ircnicks"] is None 52 | assert result["gpgkeyids"] is None 53 | assert result["locale"] is None 54 | assert result["timezone"] is None 55 | 56 | 57 | def test_user_private_self(client, gss_user, mock_ldap_client): 58 | data = get_user_ldap_data("admin") 59 | data["is_private"] = True 60 | mock_ldap_client(get_user=lambda u, attrs: data) 61 | 62 | rv = client.get("/v1/users/admin/") 63 | 64 | expected = get_user_api_output("admin") 65 | expected["is_private"] = True 66 | assert 200 == rv.status_code 67 | assert rv.get_json() == {"result": expected} 68 | 69 | 70 | def test_user_no_private_info(client, gss_user, mock_ldap_client): 71 | data = get_user_ldap_data("dummy") 72 | del data["is_private"] 73 | mock_ldap_client(get_user=lambda u, attrs: data) 74 | 75 | rv = client.get("/v1/users/dummy/") 76 | 77 | expected = get_user_api_output("dummy") 78 | expected["is_private"] = None 79 | assert 200 == rv.status_code 80 | assert rv.get_json() == {"result": expected} 81 | 82 | 83 | def test_user_with_mask(client, gss_user, mock_ldap_client): 84 | data = get_user_ldap_data("dummy") 85 | mock_ldap_client(get_user=lambda u, attrs: data) 86 | 87 | rv = client.get("/v1/users/dummy/", headers={"X-Fields": "{username,human_name}"}) 88 | 89 | expected = { 90 | key: value 91 | for key, value in get_user_api_output("dummy").items() 92 | if key in ["username", "human_name"] 93 | } 94 | assert 200 == rv.status_code 95 | assert rv.get_json() == {"result": expected} 96 | 97 | 98 | def test_users_success(client, gss_user, mock_ldap_client): 99 | data = [get_user_ldap_data(f"dummy-{idx}") for idx in range(1, 10)] 100 | result = LDAPResult(items=data) 101 | mock_ldap_client( 102 | get_users=lambda attrs, page_size, page_number: result, 103 | ) 104 | 105 | rv = client.get("/v1/users/") 106 | 107 | expected = [get_user_api_output(f"dummy-{idx}") for idx in range(1, 10)] 108 | assert 200 == rv.status_code 109 | assert rv.get_json() == {"result": expected} 110 | 111 | 112 | def test_users_with_mask(client, gss_user, mock_ldap_client): 113 | data = [get_user_ldap_data(f"dummy-{idx}") for idx in range(1, 10)] 114 | result = LDAPResult(items=data) 115 | mock_ldap_client(get_users=lambda attrs, page_size, page_number: result) 116 | 117 | rv = client.get("/v1/users/", headers={"X-Fields": "{username,human_name}"}) 118 | 119 | expected = [ 120 | { 121 | key: value 122 | for key, value in get_user_api_output(f"dummy-{idx}").items() 123 | if key in ["username", "human_name"] 124 | } 125 | for idx in range(1, 10) 126 | ] 127 | assert 200 == rv.status_code 128 | assert rv.get_json() == {"result": expected} 129 | 130 | 131 | def test_user_groups_success(client, gss_user, mock_ldap_client): 132 | groups = ["group1", "group2"] 133 | result = LDAPResult(items=[{"groupname": name} for name in groups]) 134 | mock_ldap_client( 135 | get_user_groups=lambda username, attrs, page_size, page_number: result, 136 | get_user=lambda n, attrs=None: {"cn": n}, 137 | ) 138 | 139 | rv = client.get("/v1/users/dummy/groups/") 140 | assert 200 == rv.status_code 141 | assert rv.get_json() == { 142 | "result": [ 143 | {"groupname": name, "uri": f"http://localhost/v1/groups/{name}/"} for name in groups 144 | ] 145 | } 146 | 147 | 148 | def test_user_groups_with_mask(client, gss_user, mock_ldap_client): 149 | groups = ["group1", "group2"] 150 | result = LDAPResult( 151 | items=[ 152 | { 153 | "groupname": name, 154 | "irc": [f"#{name}"], 155 | "description": f"the {name} group", 156 | } 157 | for name in groups 158 | ] 159 | ) 160 | mock_ldap_client( 161 | get_user_groups=lambda username, attrs, page_size, page_number: result, 162 | get_user=lambda n, attrs=None: {"cn": n}, 163 | ) 164 | 165 | rv = client.get( 166 | "/v1/users/dummy/groups/", 167 | headers={"X-Fields": "{groupname,irc}"}, 168 | ) 169 | assert 200 == rv.status_code 170 | assert rv.get_json() == { 171 | "result": [{"groupname": name, "irc": [f"#{name}"]} for name in groups] 172 | } 173 | 174 | 175 | def test_user_groups_error(client, gss_user, mock_ldap_client): 176 | mock_ldap_client(get_user=lambda n, attrs=None: None) 177 | 178 | rv = client.get("/v1/users/dummy/groups/") 179 | 180 | expected = {"name": "dummy", "message": "User does not exist"} 181 | 182 | assert 404 == rv.status_code 183 | assert rv.get_json() == expected 184 | 185 | 186 | def test_user_agreements_success(client, gss_user, mock_ldap_client): 187 | agreements = ["agmt1", "agmt2"] 188 | result = LDAPResult(items=[{"name": name} for name in agreements]) 189 | mock_ldap_client( 190 | get_user_agreements=lambda username, page_size, page_number: result, 191 | get_user=lambda n, attrs=None: {"cn": n}, 192 | ) 193 | 194 | rv = client.get("/v1/users/dummy/agreements/") 195 | assert 200 == rv.status_code 196 | assert rv.get_json() == {"result": [{"name": name} for name in agreements]} 197 | 198 | 199 | def test_user_agreements_error(client, gss_user, mock_ldap_client): 200 | mock_ldap_client(get_user=lambda n, attrs=None: None) 201 | 202 | rv = client.get("/v1/users/dummy/agreements/") 203 | 204 | expected = {"name": "dummy", "message": "User does not exist"} 205 | 206 | assert 404 == rv.status_code 207 | assert rv.get_json() == expected 208 | -------------------------------------------------------------------------------- /tests/unit/test_web_resource_v1_search.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from fasjson.lib.ldap.client import LDAPResult 7 | from fasjson.lib.ldap.models import UserModel 8 | 9 | from .utils import get_user_api_output, get_user_ldap_data 10 | 11 | 12 | @pytest.fixture 13 | def mock_ldap_client(mock_ipa_client): 14 | yield partial(mock_ipa_client, "fasjson.web.resources.search", "ldap") 15 | 16 | 17 | @pytest.fixture() 18 | def ldap_with_search_result(mock_ldap_client, gss_user): 19 | def _mocker(num, page_size, page_number, total_results, ldap_data_factory=None): 20 | ldap_data_factory = ldap_data_factory or get_user_ldap_data 21 | data = [ldap_data_factory(f"dummy-{idx + 1}") for idx in range(0, num)] 22 | result = LDAPResult( 23 | items=data, 24 | total=total_results, 25 | page_size=page_size, 26 | page_number=page_number, 27 | ) 28 | mocked_function = mock.Mock(return_value=result) 29 | return mock_ldap_client(search_users=mocked_function) 30 | 31 | return _mocker 32 | 33 | 34 | def test_search_user_success(client, ldap_with_search_result): 35 | ldap_with_search_result(num=9, page_size=40, page_number=1, total_results=9) 36 | rv = client.get("/v1/search/users/?username=dummy") 37 | 38 | expected = [get_user_api_output(f"dummy-{idx}") for idx in range(1, 10)] 39 | page = { 40 | "total_results": 9, 41 | "page_size": 40, 42 | "page_number": 1, 43 | "total_pages": 1, 44 | } 45 | assert 200 == rv.status_code 46 | print(expected[0]) 47 | print(rv.get_json()["result"][0]) 48 | assert rv.get_json() == {"result": expected, "page": page} 49 | 50 | 51 | def test_search_user_not_found(client, ldap_with_search_result): 52 | ldap_with_search_result(num=0, page_size=40, page_number=1, total_results=0) 53 | rv = client.get("/v1/search/users/?username=somethingobscure") 54 | expected = { 55 | "result": [], 56 | "page": { 57 | "total_results": 0, 58 | "page_size": 40, 59 | "page_number": 1, 60 | "total_pages": 0, 61 | }, 62 | } 63 | assert 200 == rv.status_code 64 | assert rv.get_json() == expected 65 | 66 | 67 | def test_search_user_no_args(client, gss_user, mock_ldap_client): 68 | mock_ldap_client(search_users=lambda *a, **kw: None) 69 | 70 | rv = client.get("/v1/search/users/") 71 | 72 | expected = {"message": "At least one search term must be provided."} 73 | assert 400 == rv.status_code 74 | assert expected == rv.get_json() 75 | 76 | 77 | def test_search_user_short_search_term(client, gss_user, mock_ldap_client): 78 | mock_ldap_client(search_users=lambda *a, **kw: None) 79 | 80 | rv = client.get("/v1/search/users/?username=so") 81 | 82 | expected = { 83 | "message": "Search term must be at least 3 characters long.", 84 | "search_term": "username", 85 | "search_value": "so", 86 | } 87 | assert 400 == rv.status_code 88 | assert expected == rv.get_json() 89 | 90 | 91 | def test_search_user_page_size_too_big(client, gss_user, mock_ldap_client): 92 | mock_ldap_client(search_users=lambda *a, **kw: None) 93 | 94 | rv = client.get("/v1/search/users/?username=somethinginsignificant&page_size=123") 95 | 96 | expected = { 97 | "page_size": 123, 98 | "message": "Page size must be between 1 and 40 when searching.", 99 | } 100 | assert 400 == rv.status_code 101 | assert expected == rv.get_json() 102 | 103 | 104 | def test_search_user_page_size_zero(client, gss_user, mock_ldap_client): 105 | mock_ldap_client(search_users=lambda *a, **kw: None) 106 | 107 | rv = client.get("/v1/search/users/?username=somethinginsignificant&page_size=0") 108 | 109 | expected = { 110 | "page_size": 0, 111 | "message": "Page size must be between 1 and 40 when searching.", 112 | } 113 | assert 400 == rv.status_code 114 | assert expected == rv.get_json() 115 | 116 | 117 | def test_search_user_page_size_none(client, ldap_with_search_result): 118 | ldap_with_search_result(num=0, page_size=40, page_number=1, total_results=0) 119 | rv = client.get("/v1/search/users/?username=somethinginsignificant") 120 | 121 | expected = { 122 | "result": [], 123 | "page": { 124 | "total_results": 0, 125 | "page_size": 40, 126 | "page_number": 1, 127 | "total_pages": 0, 128 | }, 129 | } 130 | assert 200 == rv.status_code 131 | assert expected == rv.get_json() 132 | 133 | 134 | def test_search_user_outside_page_range(client, ldap_with_search_result): 135 | ldap_with_search_result(num=0, page_size=2, page_number=6, total_results=9) 136 | rv = client.get("/v1/search/users/?username=dummy&page_number=6&page_size=2") 137 | 138 | expected = { 139 | "result": [], 140 | "page": { 141 | "total_results": 9, 142 | "page_size": 2, 143 | "page_number": 6, 144 | "total_pages": 5, 145 | }, 146 | } 147 | assert 200 == rv.status_code 148 | assert expected == rv.get_json() 149 | 150 | 151 | def test_search_user_private(client, ldap_with_search_result): 152 | # Make sure the search does not return private information 153 | def get_user_ldap_data_private(name): 154 | data = get_user_ldap_data(name) 155 | data["is_private"] = True 156 | return data 157 | 158 | def get_user_api_output_private(name): 159 | data = get_user_api_output(name) 160 | data["is_private"] = True 161 | for field_name in UserModel.private_fields: 162 | data[field_name] = None 163 | return data 164 | 165 | ldap_with_search_result( 166 | num=1, 167 | page_size=10, 168 | page_number=1, 169 | total_results=1, 170 | ldap_data_factory=get_user_ldap_data_private, 171 | ) 172 | rv = client.get("/v1/search/users/?username=dummy") 173 | 174 | expected = [get_user_api_output_private("dummy-1")] 175 | page = { 176 | "total_results": 1, 177 | "page_size": 10, 178 | "page_number": 1, 179 | "total_pages": 1, 180 | } 181 | assert 200 == rv.status_code 182 | print(expected[0]) 183 | print(rv.get_json()["result"][0]) 184 | assert rv.get_json() == {"result": expected, "page": page} 185 | 186 | 187 | def test_search_json_body(client, ldap_with_search_result): 188 | ldap_with_search_result(num=0, page_size=1, page_number=1, total_results=0) 189 | rv = client.get("/v1/search/users/", json={"username": "dummy"}) 190 | 191 | assert 200 == rv.status_code 192 | page = { 193 | "total_results": 0, 194 | "page_size": 1, 195 | "page_number": 1, 196 | "total_pages": 0, 197 | } 198 | assert rv.get_json() == {"result": [], "page": page} 199 | 200 | 201 | def test_search_bad_json_body(client, ldap_with_search_result): 202 | # This should trigger the error handling in AnyJsonRequest 203 | ldap_with_search_result(num=0, page_size=1, page_number=1, total_results=0) 204 | rv = client.get("/v1/search/users/", data="bad-json", content_type="application/json") 205 | 206 | assert 400 == rv.status_code 207 | assert rv.get_json() == {"message": "At least one search term must be provided."} 208 | 209 | 210 | def test_search_user_gitlab(client, ldap_with_search_result): 211 | mocked = ldap_with_search_result(num=1, page_size=40, page_number=1, total_results=1) 212 | rv = client.get("/v1/search/users/?gitlab_username=dummy") 213 | 214 | expected = [get_user_api_output("dummy-1")] 215 | page = { 216 | "total_results": 1, 217 | "page_size": 40, 218 | "page_number": 1, 219 | "total_pages": 1, 220 | } 221 | mocked.search_users.assert_called_once() 222 | last_call_kw = mocked.search_users.call_args_list[-1][1] 223 | assert "gitlab_username" in last_call_kw 224 | assert last_call_kw["gitlab_username"] == "dummy" 225 | 226 | assert 200 == rv.status_code 227 | assert rv.get_json() == {"result": expected, "page": page} 228 | 229 | 230 | def test_search_user_by_group(client, ldap_with_search_result): 231 | mocked = ldap_with_search_result(num=1, page_size=40, page_number=1, total_results=1) 232 | rv = client.get("/v1/search/users/?group=dummy") 233 | 234 | expected = [get_user_api_output("dummy-1")] 235 | page = { 236 | "total_results": 1, 237 | "page_size": 40, 238 | "page_number": 1, 239 | "total_pages": 1, 240 | } 241 | mocked.search_users.assert_called_once() 242 | last_call_kw = mocked.search_users.call_args_list[-1][1] 243 | assert "group" in last_call_kw 244 | assert last_call_kw["group"] == ["dummy"] 245 | 246 | assert 200 == rv.status_code 247 | assert rv.get_json() == {"result": expected, "page": page} 248 | -------------------------------------------------------------------------------- /tests/unit/test_web_resource_v1_groups.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | 5 | from fasjson.lib.ldap.client import LDAPResult 6 | 7 | 8 | @pytest.fixture 9 | def mock_ldap_client(mock_ipa_client): 10 | yield partial(mock_ipa_client, "fasjson.web.resources.groups", "ldap") 11 | 12 | 13 | def test_groups_success(client, gss_user, mock_ldap_client): 14 | groups = ["group1", "group2"] 15 | result = LDAPResult( 16 | items=[ 17 | { 18 | "groupname": name, 19 | "description": f"the {name} group", 20 | "mailing_list": f"{name}@groups.com", 21 | "url": f"www.{name}.com", 22 | "irc": [f"#{name}"], 23 | "discussion_url": f"https://discussion.test/{name}", 24 | } 25 | for name in groups 26 | ] 27 | ) 28 | mock_ldap_client(get_groups=lambda attrs, page_size, page_number: result) 29 | 30 | rv = client.get("/v1/groups/") 31 | assert 200 == rv.status_code 32 | assert rv.get_json() == { 33 | "result": [ 34 | { 35 | "groupname": name, 36 | "description": f"the {name} group", 37 | "mailing_list": f"{name}@groups.com", 38 | "url": f"www.{name}.com", 39 | "irc": [f"#{name}"], 40 | "uri": f"http://localhost/v1/groups/{name}/", 41 | "discussion_url": f"https://discussion.test/{name}", 42 | } 43 | for name in groups 44 | ] 45 | } 46 | 47 | 48 | def test_groups_with_mask(client, gss_user, mock_ldap_client, mocker): 49 | groups = ["group1", "group2"] 50 | result = LDAPResult( 51 | items=[ 52 | { 53 | "groupname": name, 54 | "description": f"the {name} group", 55 | "mailing_list": f"{name}@groups.com", 56 | "url": f"www.{name}.com", 57 | "irc": [f"#{name}"], 58 | "discussion_url": f"https://discussion.test/{name}", 59 | } 60 | for name in groups 61 | ] 62 | ) 63 | mock = mock_ldap_client(get_groups=mocker.Mock(return_value=result)) 64 | 65 | rv = client.get("/v1/groups/", headers={"X-Fields": "{groupname,description,uri}"}) 66 | assert 200 == rv.status_code 67 | assert rv.get_json() == { 68 | "result": [ 69 | { 70 | "groupname": name, 71 | "description": f"the {name} group", 72 | "uri": f"http://localhost/v1/groups/{name}/", 73 | } 74 | for name in groups 75 | ] 76 | } 77 | mock.get_groups.assert_called_with( 78 | attrs=["groupname", "description", "uri"], 79 | page_size=None, 80 | page_number=1, 81 | ) 82 | 83 | 84 | def test_groups_no_groups(client, gss_user, mock_ldap_client): 85 | result = LDAPResult(items=[]) 86 | mock_ldap_client(get_groups=lambda attrs, page_size, page_number: result) 87 | rv = client.get("/v1/groups/") 88 | 89 | assert 200 == rv.status_code 90 | assert rv.get_json() == {"result": []} 91 | 92 | 93 | def test_groups_error(client, gss_user): 94 | del client.gss_env["KRB5CCNAME"] 95 | rv = client.get("/v1/groups/") 96 | 97 | assert 401 == rv.status_code 98 | 99 | 100 | def test_group_members_success(client, gss_user, mock_ldap_client): 101 | data = [{"username": "admin"}] 102 | result = LDAPResult(items=data) 103 | mock_ldap_client( 104 | get_group_members=lambda name, attrs, page_size, page_number: result, 105 | get_group=lambda n, attrs=None: {"cn": n}, 106 | ) 107 | rv = client.get("/v1/groups/admins/members/") 108 | 109 | expected = {"result": [{"username": "admin", "uri": "http://localhost/v1/users/admin/"}]} 110 | assert 200 == rv.status_code 111 | assert expected == rv.get_json() 112 | 113 | 114 | def test_group_members_error(client, gss_user, mock_ldap_client): 115 | mock_ldap_client( 116 | # get_group_members=lambda name, ps, pn: result, 117 | get_group=lambda n, attrs=None: None, 118 | ) 119 | 120 | rv = client.get("/v1/groups/editors/members/") 121 | 122 | expected = { 123 | "groupname": "editors", 124 | "message": "Group not found", 125 | } 126 | assert 404 == rv.status_code 127 | assert expected == rv.get_json() 128 | 129 | 130 | def test_group_members_with_mask(client, gss_user, mock_ldap_client): 131 | data = [ 132 | { 133 | "username": "admin", 134 | "emails": ["admin@example.test"], 135 | "timezone": "UTC", 136 | } 137 | ] 138 | result = LDAPResult(items=data) 139 | mock_ldap_client( 140 | get_group_members=lambda name, attrs, page_size, page_number: result, 141 | get_group=lambda n, attrs=None: {"cn": n}, 142 | ) 143 | rv = client.get( 144 | "/v1/groups/admins/members/", 145 | headers={"X-Fields": "{username,emails,uri}"}, 146 | ) 147 | 148 | expected = { 149 | "result": [ 150 | { 151 | "username": "admin", 152 | "emails": ["admin@example.test"], 153 | "uri": "http://localhost/v1/users/admin/", 154 | } 155 | ] 156 | } 157 | assert 200 == rv.status_code 158 | assert expected == rv.get_json() 159 | 160 | 161 | def test_group_sponsors_success(client, gss_user, mock_ldap_client): 162 | result = [{"username": "admin"}] 163 | mock_ldap_client( 164 | get_group_sponsors=lambda groupname, attrs: result, 165 | get_group=lambda n, attrs=None: {"cn": n}, 166 | ) 167 | rv = client.get("/v1/groups/admins/sponsors/") 168 | 169 | expected = {"result": [{"username": "admin", "uri": "http://localhost/v1/users/admin/"}]} 170 | assert 200 == rv.status_code 171 | assert expected == rv.get_json() 172 | 173 | 174 | def test_group_sponsors_with_mask(client, gss_user, mock_ldap_client): 175 | result = [ 176 | { 177 | "username": "admin", 178 | "emails": ["admin@example.test"], 179 | "timezone": "UTC", 180 | } 181 | ] 182 | mock_ldap_client( 183 | get_group_sponsors=lambda groupname, attrs: result, 184 | get_group=lambda n, attrs=None: {"cn": n}, 185 | ) 186 | rv = client.get( 187 | "/v1/groups/admins/sponsors/", 188 | headers={"X-Fields": "{username,emails,uri}"}, 189 | ) 190 | 191 | expected = { 192 | "result": [ 193 | { 194 | "username": "admin", 195 | "emails": ["admin@example.test"], 196 | "uri": "http://localhost/v1/users/admin/", 197 | } 198 | ] 199 | } 200 | assert 200 == rv.status_code 201 | assert expected == rv.get_json() 202 | 203 | 204 | def test_group_sponsors_error(client, gss_user, mock_ldap_client): 205 | mock_ldap_client( 206 | get_group=lambda n, attrs=None: None, 207 | ) 208 | 209 | rv = client.get("/v1/groups/editors/sponsors/") 210 | 211 | expected = { 212 | "groupname": "editors", 213 | "message": "Group not found", 214 | } 215 | assert 404 == rv.status_code 216 | assert expected == rv.get_json() 217 | 218 | 219 | def test_group_success(client, gss_user, mock_ldap_client): 220 | mock_ldap_client( 221 | get_group=lambda n, attrs: { 222 | "groupname": "dummy-group", 223 | "description": "the dummy-group", 224 | "mailing_list": "dummy-group@groups.com", 225 | "url": "www.dummy-group.com", 226 | "irc": ["#dummy-group"], 227 | "discussion_url": "https://discussion.test/dummy-group", 228 | }, 229 | ) 230 | 231 | rv = client.get("/v1/groups/dummy-group/") 232 | 233 | expected = { 234 | "groupname": "dummy-group", 235 | "description": "the dummy-group", 236 | "mailing_list": "dummy-group@groups.com", 237 | "url": "www.dummy-group.com", 238 | "irc": ["#dummy-group"], 239 | "uri": "http://localhost/v1/groups/dummy-group/", 240 | "discussion_url": "https://discussion.test/dummy-group", 241 | } 242 | assert 200 == rv.status_code 243 | assert rv.get_json() == {"result": expected} 244 | 245 | 246 | def test_group_with_mask(client, gss_user, mock_ldap_client): 247 | mock_ldap_client( 248 | get_group=lambda n, attrs: { 249 | "groupname": "dummy-group", 250 | "description": "the dummy-group", 251 | "mailing_list": "dummy-group@groups.com", 252 | "url": "www.dummy-group.com", 253 | "irc": ["#dummy-group"], 254 | }, 255 | ) 256 | 257 | rv = client.get( 258 | "/v1/groups/dummy-group/", 259 | headers={"X-Fields": "{groupname,description,uri}"}, 260 | ) 261 | 262 | expected = { 263 | "groupname": "dummy-group", 264 | "description": "the dummy-group", 265 | "uri": "http://localhost/v1/groups/dummy-group/", 266 | } 267 | assert 200 == rv.status_code 268 | assert rv.get_json() == {"result": expected} 269 | 270 | 271 | def test_group_not_found(client, gss_user, mock_ldap_client): 272 | mock_ldap_client(get_group=lambda n, attrs=None: None) 273 | rv = client.get("/v1/groups/dummy-group/") 274 | assert rv.status_code == 404 275 | rv = client.get("/v1/groups/dummy-group/is-member/anybody") 276 | assert rv.status_code == 404 277 | 278 | 279 | def test_group_is_member_true(client, gss_user, mock_ldap_client): 280 | mock_ldap_client( 281 | check_membership=lambda groupname, username: True, 282 | get_group=lambda n, attrs=None: {"cn": n}, 283 | ) 284 | 285 | rv = client.get("/v1/groups/admins/is-member/admin") 286 | assert 200 == rv.status_code 287 | assert {"result": True} == rv.get_json() 288 | 289 | 290 | def test_group_is_member_false(client, gss_user, mock_ldap_client): 291 | mock_ldap_client( 292 | check_membership=lambda groupname, username: False, 293 | get_group=lambda n, attrs=None: {"cn": n}, 294 | ) 295 | 296 | rv = client.get("/v1/groups/admins/is-member/someone-else") 297 | assert 200 == rv.status_code 298 | assert {"result": False} == rv.get_json() 299 | 300 | 301 | def test_group_starting_with_number(client, gss_user, mock_ldap_client): 302 | mock_ldap_client( 303 | get_group=lambda n, attrs: { 304 | "groupname": "3d-printing-sig", 305 | "description": "I start with a number", 306 | }, 307 | ) 308 | 309 | rv = client.get("/v1/groups/3d-printing-sig/") 310 | 311 | expected = { 312 | "groupname": "3d-printing-sig", 313 | "description": "I start with a number", 314 | "mailing_list": None, 315 | "url": None, 316 | "irc": None, 317 | "uri": "http://localhost/v1/groups/3d-printing-sig/", 318 | "discussion_url": None, 319 | } 320 | assert 200 == rv.status_code 321 | assert rv.get_json() == {"result": expected} 322 | -------------------------------------------------------------------------------- /fasjson/lib/ldap/client.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import ldap 4 | from ldap.controls.pagedresults import SimplePagedResultsControl 5 | 6 | from .models import AgreementModel, GroupModel, SponsorModel, UserModel 7 | 8 | 9 | GROUP_DN_RE = re.compile("^cn=([^,]+)") 10 | USER_DN_RE = re.compile("^uid=([^,]+)") 11 | 12 | 13 | class LDAPResult: 14 | def __init__(self, items=None, total=None, page_size=None, page_number=None): 15 | self.items = items or [] 16 | self.total = total or len(self.items) 17 | self.page_size = page_size 18 | self.page_number = page_number 19 | 20 | def __repr__(self): 21 | return f"" 22 | 23 | def __eq__(self, other): 24 | if not isinstance(other, self.__class__): 25 | raise ValueError("Unsupported operation") 26 | return all( 27 | [ 28 | getattr(self, attr) == getattr(other, attr) 29 | for attr in ["items", "total", "page_size", "page_number"] 30 | ] 31 | ) 32 | 33 | 34 | def _get_filter_string(attribute, value, substring_match): 35 | if value == "*": 36 | # Presence match 37 | return f"({attribute}=*)" 38 | 39 | value = ldap.filter.escape_filter_chars(value, 0) 40 | if substring_match: 41 | value = f"*{value}*" 42 | return f"({attribute}={value})" 43 | 44 | 45 | class LDAP: 46 | def __init__(self, uri, basedn, login="", timeout=ldap.NO_LIMIT, trace_level=0): 47 | self.basedn = basedn 48 | ldap.set_option(ldap.OPT_REFERRALS, 0) 49 | self.conn = ldap.ldapobject.ReconnectLDAPObject(uri, retry_max=3, trace_level=trace_level) 50 | self.conn.protocol_version = 3 51 | self.conn.timeout = timeout 52 | self.conn.sasl_gssapi_bind_s(authz_id=login) 53 | 54 | def whoami(self): 55 | raw = self.conn.whoami_s() 56 | dn = raw[4:] 57 | result = {"dn": dn} 58 | for part in dn.split(","): 59 | key, value = part.split("=") 60 | if key == "uid": 61 | result["username"] = value 62 | if key == "krbprincipalname": 63 | result["service"] = value.split("@")[0] 64 | return result 65 | 66 | def get_groups(self, attrs, page_size, page_number): 67 | return self.search( 68 | model=GroupModel, 69 | attrs=GroupModel.attrs_to_ldap(attrs), 70 | scope=ldap.SCOPE_SUBTREE, 71 | page_size=page_size, 72 | page_number=page_number, 73 | ) 74 | 75 | def get_group(self, groupname, attrs=None): 76 | dn = GroupModel.get_sub_dn_for(groupname) 77 | result = self.search( 78 | model=GroupModel, 79 | sub_dn=dn, 80 | attrs=GroupModel.attrs_to_ldap(attrs), 81 | scope=ldap.SCOPE_BASE, 82 | ) 83 | if not result.items: 84 | return None 85 | return result.items[0] 86 | 87 | def get_group_members(self, groupname, attrs, page_size, page_number): 88 | group_dn = GroupModel.get_sub_dn_for(groupname) 89 | filters = "(&" f"(memberOf={group_dn},{self.basedn})" f"{UserModel.filters}" ")" 90 | return self.search( 91 | model=UserModel, 92 | filters=filters, 93 | attrs=UserModel.attrs_to_ldap(attrs) or ["uid"], 94 | scope=ldap.SCOPE_SUBTREE, 95 | page_size=page_size, 96 | page_number=page_number, 97 | ) 98 | 99 | def get_group_sponsors(self, groupname, attrs=None): 100 | group_dn = GroupModel.get_sub_dn_for(groupname) 101 | filters = f"(&(objectClass=fasGroup)(cn={groupname}))" 102 | sponsors_result = self.search( 103 | model=SponsorModel, 104 | sub_dn=group_dn, 105 | attrs=["memberManager"], 106 | filters=filters, 107 | scope=ldap.SCOPE_SUBTREE, 108 | ) 109 | if not sponsors_result.items or "sponsors" not in sponsors_result.items[0]: 110 | return [] 111 | return self._sponsors_to_users(sponsors_result, attrs) 112 | 113 | def _list_sponsors_uid(self, sponsors_dn, attrs): 114 | for sponsor in sponsors_dn.items[0]["sponsors"]: 115 | group_match = GROUP_DN_RE.match(sponsor) 116 | if group_match: 117 | members = self.get_group_members( 118 | group_match.group(1), ["uid"], page_size=0, page_number=1 119 | ) 120 | for member in members.items: 121 | yield member["username"] 122 | user_match = USER_DN_RE.match(sponsor) 123 | if user_match: 124 | yield user_match.group(1) 125 | 126 | def _sponsors_to_users(self, sponsors_dn, attrs): 127 | sponsors = [] 128 | 129 | for username in self._list_sponsors_uid(sponsors_dn, attrs): 130 | uid = f"uid={username}" 131 | sponsors.append(uid) 132 | 133 | if not sponsors: 134 | return [] 135 | 136 | filters = ["(&(objectClass=fasUser)(|"] 137 | for uid in set(sponsors): 138 | filters.append(f"({uid})") 139 | filters.append("))") 140 | filters = "".join(filters) 141 | 142 | result = self.search( 143 | model=UserModel, 144 | filters=filters, 145 | attrs=UserModel.attrs_to_ldap(attrs) or ["uid"], 146 | ) 147 | return result.items 148 | 149 | def check_membership(self, groupname, username): 150 | group_dn = GroupModel.get_sub_dn_for(groupname) 151 | filters = ( 152 | "(&" 153 | f"(memberOf={group_dn},{self.basedn})" 154 | f"{UserModel.filters}" 155 | f"(uid={username})" 156 | ")" 157 | ) 158 | result = self.search( 159 | model=UserModel, 160 | filters=filters, 161 | attrs=["uid"], 162 | scope=ldap.SCOPE_SUBTREE, 163 | ) 164 | if not result.items: 165 | return False 166 | if len(result.items) == 1: 167 | return True 168 | raise ValueError(f"Unexpected result length: {len(result.items)}") 169 | 170 | def get_users(self, attrs, page_size, page_number): 171 | return self.search( 172 | model=UserModel, 173 | attrs=UserModel.attrs_to_ldap(attrs), 174 | scope=ldap.SCOPE_SUBTREE, 175 | page_size=page_size, 176 | page_number=page_number, 177 | ) 178 | 179 | def get_user(self, username, attrs=None): 180 | dn = UserModel.get_sub_dn_for(username) 181 | result = self.search( 182 | model=UserModel, 183 | sub_dn=dn, 184 | attrs=UserModel.attrs_to_ldap(attrs), 185 | scope=ldap.SCOPE_BASE, 186 | ) 187 | if not result.items: 188 | return None 189 | return result.items[0] 190 | 191 | def get_user_groups(self, username, attrs, page_size, page_number): 192 | user = self.get_user(username, ["groups"]) 193 | groups_filters = [ 194 | f"({dn.split(',')[0]})" 195 | for dn in user.get("groups", []) 196 | if dn.endswith(f"{GroupModel.sub_dn},{self.basedn}") 197 | ] 198 | if not groups_filters: 199 | return LDAPResult( 200 | items=[], 201 | page_size=page_size, 202 | page_number=page_number, 203 | total=0, 204 | ) 205 | filters = f"(&{GroupModel.filters}(|{''.join(groups_filters)}))" 206 | return self.search( 207 | model=GroupModel, 208 | attrs=GroupModel.attrs_to_ldap(attrs), 209 | filters=filters, 210 | page_number=page_number, 211 | page_size=page_size, 212 | ) 213 | 214 | def get_user_agreements(self, username, page_size, page_number): 215 | dn = UserModel.get_sub_dn_for(username) 216 | filters = f"(&(memberUser={dn},{self.basedn}){AgreementModel.filters})" 217 | return self.search( 218 | model=AgreementModel, 219 | filters=filters, 220 | page_number=page_number, 221 | page_size=page_size, 222 | ) 223 | 224 | def search_users( 225 | self, 226 | attrs, 227 | page_number, 228 | page_size, 229 | **filters, 230 | ): 231 | filter_string = ["(&", UserModel.filters, "(&"] 232 | attrs_map = UserModel.get_search_attrs_map() 233 | for term, filter in filters.items(): 234 | if not filter: 235 | continue 236 | 237 | substring_match = True 238 | if term.endswith("__exact"): 239 | term = term[:-7] 240 | substring_match = False 241 | if term in UserModel.always_exact_match: 242 | substring_match = False 243 | if term == "group": 244 | filter = [f"{GroupModel.get_sub_dn_for(name)},{self.basedn}" for name in filter] 245 | 246 | try: 247 | attribute = attrs_map[term] 248 | except KeyError: 249 | continue 250 | 251 | # the group filter is a list, handle them all as lists 252 | if not isinstance(filter, list): 253 | filter = [filter] 254 | for filter_item in filter: 255 | filter_string.append(_get_filter_string(attribute, filter_item, substring_match)) 256 | 257 | if filters.get("creation__before"): 258 | filter_value = ldap.filter.escape_filter_chars( 259 | filters["creation__before"].strftime("%Y%m%d%H%M%SZ"), 0 260 | ) 261 | filter_string.append(f"(fasCreationTime<={filter_value})") 262 | 263 | filter_string.append("))") 264 | filter_string = "".join(filter_string) 265 | 266 | return self.search( 267 | model=UserModel, 268 | filters=filter_string, 269 | attrs=UserModel.attrs_to_ldap(attrs), 270 | page_size=page_size, 271 | page_number=page_number, 272 | ) 273 | 274 | def search( 275 | self, 276 | model, 277 | sub_dn=None, 278 | base_dn=None, 279 | filters=None, 280 | attrs=None, 281 | scope=ldap.SCOPE_SUBTREE, 282 | page_size=0, 283 | page_number=1, 284 | ): 285 | """Perform an LDAP query with pagination support. 286 | 287 | LDAP's pagination system is not web-compatible, because the pagination cursor is 288 | connection-specific and webservers typically have multiple processes, and therefore multiple 289 | LDAP connections. 290 | As a result, to implement pagination we proceed as such: 291 | 292 | 1. query the primary keys for the whole result set (this is rather fast because only 293 | the primary keys are queried) 294 | 2. slice this list into pages 295 | 3. make a second query including only the primary keys that are in the requested page, 296 | but requesting all attributes 297 | 4. build a ``LDAPResult`` object that takes into account the total number of entries to 298 | provide pagination information 299 | 300 | Args: 301 | model (Model): The object model that is being queried 302 | sub_dn (str, optional): The DN of the subtree to query (no ``base_dn`` suffix). 303 | Defaults to the ``sub_dn`` provided by the model. 304 | filters (str): The LDAP filters to use (in LDAP syntax) 305 | attrs (list, optional): The list of attributes to request. Defaults to the 306 | model's attributes list. 307 | scope (int, optional): The LDAP scope to use. Defaults to ldap.SCOPE_SUBTREE. 308 | page_size (int, optional): The number of items per page. If this is zero, disable 309 | pagination and request all items. Defaults to 0. 310 | page_number (int, optional): The requested page number. Defaults to 1. 311 | 312 | Returns: 313 | LDAPResult: The query result, with pagination information if appropriate. 314 | """ 315 | base_dn = f"{sub_dn or model.sub_dn},{self.basedn}" 316 | filters = filters or model.filters 317 | total = None 318 | if page_size: 319 | # Get all primary keys regardless of paging 320 | pkeys = self._do_search( 321 | base_dn=base_dn, 322 | filters=filters, 323 | model=model, 324 | attrs=[model.primary_key], 325 | scope=scope, 326 | ) 327 | total = len(pkeys) 328 | # Find out which items we need for this page 329 | first = (page_number - 1) * page_size 330 | last = first + page_size 331 | pkeys_page = [item[model.primary_key][0].decode("utf-8") for item in pkeys[first:last]] 332 | if not pkeys_page: 333 | return LDAPResult( 334 | items=[], 335 | page_size=page_size, 336 | page_number=page_number, 337 | total=total, 338 | ) 339 | # Now adjust the filters to only get items on this page 340 | entries_filters = [f"({model.primary_key}={item})" for item in pkeys_page] 341 | filters = f"(&{filters}(|{''.join(entries_filters)}))" 342 | items = self._do_search( 343 | base_dn=base_dn, 344 | filters=filters, 345 | model=model, 346 | attrs=attrs, 347 | scope=scope, 348 | ) 349 | return LDAPResult( 350 | items=[model.convert_ldap_result(item) for item in items], 351 | page_size=page_size, 352 | page_number=page_number, 353 | total=total, 354 | ) 355 | 356 | def _do_search( 357 | self, 358 | base_dn, 359 | filters, 360 | model, 361 | attrs=None, 362 | scope=ldap.SCOPE_SUBTREE, 363 | # maximum=None, 364 | ): 365 | """Perform a single LDAP query 366 | 367 | Args: 368 | base_dn (str): The base DN for the query 369 | filters (str): The LDAP filters to use (in LDAP syntax) 370 | model (Model): The object model that is being queried 371 | attrs (list, optional): The list of attributes to request. Defaults to the 372 | model's attributes list. 373 | scope (int, optional): The LDAP scope to use. Defaults to ldap.SCOPE_SUBTREE. 374 | 375 | In the implementation, SimplePagedResultControl is used to buffer results and save 376 | memory, but it is not usable as a web-compatible paging system. 377 | 378 | Returns: 379 | list(dict): a list of dictionaries keyed by attributes. 380 | """ 381 | attrs = attrs or model.get_ldap_attrs() 382 | page_size = 1000 383 | # if maximum: 384 | # page_size = min(maximum, page_size) 385 | page_cookie = "" 386 | results = [] 387 | while True: 388 | page_control = SimplePagedResultsControl( 389 | criticality=False, size=page_size, cookie=page_cookie 390 | ) 391 | msgid = self.conn.search_ext( 392 | base_dn, 393 | scope, 394 | filters, 395 | attrlist=attrs, 396 | serverctrls=[page_control], 397 | ) 398 | _rtype, rdata, _rmsgid, serverctrls = self.conn.result3(msgid) 399 | results.extend(obj for dn, obj in rdata) 400 | for ctrl in serverctrls: 401 | if isinstance(ctrl, SimplePagedResultsControl): 402 | page_cookie = ctrl.cookie 403 | break 404 | if not page_cookie: 405 | break 406 | # if maximum and len(results) >= maximum: 407 | # break 408 | return results 409 | -------------------------------------------------------------------------------- /tests/unit/test_lib_ldap_client.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import types 3 | from unittest import mock 4 | 5 | import pytest 6 | from ldap.controls.pagedresults import SimplePagedResultsControl 7 | 8 | from fasjson.lib.ldap.client import LDAP, LDAPResult 9 | 10 | 11 | @pytest.fixture 12 | def mock_connection(mocker): 13 | conn_factory = mocker.patch("ldap.ldapobject.ReconnectLDAPObject") 14 | search_ext_mock = mock.Mock(return_value=1) 15 | return_value = types.SimpleNamespace( 16 | protocol_version=3, 17 | set_option=lambda a, b: a, 18 | search_ext=search_ext_mock, 19 | # sasl_interactive_bind_s=lambda s, n: "", 20 | sasl_gssapi_bind_s=lambda authz_id: "", 21 | ) 22 | conn_factory.return_value = return_value 23 | yield return_value 24 | 25 | 26 | def _single_page_result_factory(result): 27 | def _result(msgid, resp_ctrl_classes=""): 28 | return ( 29 | 101, 30 | [("", item) for item in result], 31 | 1, 32 | [SimplePagedResultsControl(True, size=len(result), cookie="")], 33 | ) 34 | 35 | return _result 36 | 37 | 38 | def test_whoami_user(mock_connection): 39 | r = "dn: uid=dummy,cn=users,cn=accounts,dc=example,dc=test" 40 | mock_connection.whoami_s = lambda: r 41 | 42 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 43 | 44 | expected = {"dn": r[4:], "username": "dummy"} 45 | assert expected == ldap.whoami() 46 | 47 | 48 | def test_whoami_service(mock_connection): 49 | r = ( 50 | "dn: krbprincipalname=test/fasjson.example.test@example.test," 51 | "cn=services,cn=accounts,dc=example,dc=test" 52 | ) 53 | mock_connection.whoami_s = lambda: r 54 | 55 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 56 | 57 | expected = {"dn": r[4:], "service": "test/fasjson.example.test"} 58 | assert expected == ldap.whoami() 59 | 60 | 61 | def test_get_groups(mock_connection): 62 | mocked = [ 63 | {"cn": [b"admins"]}, 64 | {"cn": [b"ipausers"]}, 65 | {"cn": [b"editors"]}, 66 | {"cn": [b"trust admins"]}, 67 | ] 68 | mock_connection.result3 = _single_page_result_factory(mocked) 69 | 70 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 71 | 72 | result = ldap.get_groups(["groupname"], page_number=1, page_size=0) 73 | expected = LDAPResult( 74 | items=[ 75 | {"groupname": "admins"}, 76 | {"groupname": "ipausers"}, 77 | {"groupname": "editors"}, 78 | {"groupname": "trust admins"}, 79 | ], 80 | total=4, 81 | page_size=0, 82 | page_number=1, 83 | ) 84 | assert result == expected 85 | 86 | 87 | def test_get_groups_with_attrs(mock_connection, mocker): 88 | mock_connection.result3 = _single_page_result_factory([]) 89 | mock_connection.search_ext = mocker.Mock(return_value=1) 90 | 91 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 92 | 93 | ldap.get_groups(["groupname", "url", "unknown"], page_number=1, page_size=0) 94 | mock_connection.search_ext.assert_called_once() 95 | assert mock_connection.search_ext.call_args[1]["attrlist"] == [ 96 | "cn", 97 | "fasurl", 98 | ] 99 | 100 | 101 | def test_get_group(mock_connection): 102 | mocked = [{"cn": [b"dummy-group"]}] 103 | mock_connection.result3 = _single_page_result_factory(mocked) 104 | 105 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 106 | 107 | assert ldap.get_group("dummy-group") == {"groupname": "dummy-group"} 108 | 109 | 110 | def test_get_group_not_found(mock_connection): 111 | mock_connection.result3 = _single_page_result_factory([]) 112 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 113 | assert ldap.get_group("dummy-group") is None 114 | 115 | 116 | def test_get_group_members(mock_connection): 117 | mocked = [{"uid": [b"admin"]}] 118 | mock_connection.result3 = _single_page_result_factory(mocked) 119 | 120 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 121 | 122 | result = ldap.get_group_members("admins", ["username"], page_number=1, page_size=0) 123 | expected = LDAPResult(items=[{"username": "admin"}], total=1, page_size=0, page_number=1) 124 | assert result == expected 125 | 126 | 127 | def test_get_group_sponsors(mock_connection): 128 | mocked = [{"memberManager": [b"uid=admin,cn=users,cn=accounts,dc=example,dc=test"]}] 129 | mocked_conversion = [{"username": "admin"}] 130 | mock_connection.result3 = _single_page_result_factory(mocked) 131 | 132 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 133 | with mock.patch.object(ldap, "_sponsors_to_users", side_effect=[mocked_conversion]): 134 | result = ldap.get_group_sponsors(groupname="admins") 135 | 136 | expected = [{"username": "admin"}] 137 | assert result == expected 138 | 139 | 140 | def test_get_group_sponsors_empty(mock_connection): 141 | mocked = [{"memberManager": [b"uid=admin,cn=users,cn=accounts,dc=example,dc=test"]}] 142 | mocked_conversion = LDAPResult() 143 | mock_connection.result3 = _single_page_result_factory(mocked) 144 | 145 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 146 | with mock.patch.object(ldap, "search", side_effect=[mocked_conversion]): 147 | result = ldap.get_group_sponsors(groupname="admins") 148 | 149 | expected = [] 150 | assert result == expected 151 | 152 | 153 | def test_get_group_sponsors_none(mock_connection): 154 | mock_connection.result3 = _single_page_result_factory([]) 155 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 156 | assert ldap.get_group_sponsors("dummy") == [] 157 | 158 | 159 | def test_sponsors_to_users(mock_connection): 160 | mocked = [{"uid": [b"admin"]}] 161 | mock_connection.result3 = _single_page_result_factory(mocked) 162 | 163 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 164 | 165 | sponsors_dn = LDAPResult( 166 | items=[{"sponsors": ["uid=admin,cn=users,cn=accounts,dc=example,dc=test"]}] 167 | ) 168 | 169 | result = ldap._sponsors_to_users(sponsors_dn, attrs=None) 170 | expected = [{"username": "admin"}] 171 | assert result == expected 172 | 173 | 174 | def test_sponsors_to_users_empty(mock_connection): 175 | mocked = [] 176 | mock_connection.result3 = _single_page_result_factory(mocked) 177 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 178 | 179 | sponsors_dn = LDAPResult(items=[{"sponsors": []}]) 180 | 181 | result = ldap._sponsors_to_users(sponsors_dn, attrs=None) 182 | expected = [] 183 | assert result == expected 184 | 185 | 186 | def test_list_sponsors_uid(mock_connection): 187 | mocked = [{"uid": [b"josephthornton"]}] 188 | mock_connection.result3 = _single_page_result_factory(mocked) 189 | 190 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 191 | sponsors_dn = LDAPResult( 192 | items=[{"sponsors": ["cn=translators,cn=groups,cn=accounts,dc=example,dc=test"]}] 193 | ) 194 | result = ldap._list_sponsors_uid(sponsors_dn, attrs=None) 195 | 196 | expected = ["josephthornton"] 197 | assert list(result) == expected 198 | 199 | 200 | def test_check_membership(mock_connection): 201 | mocked = [{"uid": [b"admin"]}] 202 | mock_connection.result3 = _single_page_result_factory(mocked) 203 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 204 | result = ldap.check_membership("admins", "admin") 205 | assert result is True 206 | 207 | 208 | def test_check_membership_not_member(mock_connection): 209 | mocked = [] 210 | mock_connection.result3 = _single_page_result_factory(mocked) 211 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 212 | result = ldap.check_membership("admins", "someoneelse") 213 | assert result is False 214 | 215 | 216 | def test_check_membership_duplicate(mock_connection): 217 | mocked = [{"uid": [b"admin"]}, {"uid": [b"admin"]}] 218 | mock_connection.result3 = _single_page_result_factory(mocked) 219 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 220 | with pytest.raises(ValueError): 221 | ldap.check_membership("admins", "admin") 222 | 223 | 224 | def test_get_users(mock_connection): 225 | def _get_mock_result(idx): 226 | return { 227 | "uid": [f"dummy-{idx}".encode("ascii")], 228 | "sn": [b""], 229 | "givenName": [b""], 230 | "mail": [f"dummy-{idx}@example.test".encode("ascii")], 231 | "fasCreationTime": [b"20200309103203Z"], 232 | "nsAccountLock": [b"false"], 233 | } 234 | 235 | mocked = [_get_mock_result(i) for i in range(1, 4)] 236 | mock_connection.result3 = _single_page_result_factory(mocked) 237 | 238 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 239 | 240 | result = ldap.get_users(attrs=None, page_number=1, page_size=0) 241 | creation_dt = datetime.datetime(2020, 3, 9, 10, 32, 3) 242 | 243 | def _get_expected(idx): 244 | return { 245 | "creation": creation_dt, 246 | "givenname": "", 247 | "locked": False, 248 | "username": f"dummy-{idx}", 249 | "emails": [f"dummy-{idx}@example.test"], 250 | "surname": "", 251 | } 252 | 253 | expected = LDAPResult( 254 | items=[_get_expected(i) for i in range(1, 4)], 255 | total=3, 256 | page_size=0, 257 | page_number=1, 258 | ) 259 | assert result == expected 260 | 261 | 262 | def test_get_user(mock_connection): 263 | mocked = [ 264 | { 265 | "uid": [b"admin"], 266 | "sn": [b"Administrator"], 267 | "givenName": [b""], 268 | "mail": [b"admin@example.test"], 269 | "fasIRCNick": [b"admin"], 270 | # "fasLocale": None, 271 | "fasTimeZone": [b"UTC"], 272 | # "fasGPGKeyId": None, 273 | "fasCreationTime": [b"20200309103203Z"], # %Y%m%d%H%M%SZ 274 | "nsAccountLock": [b"false"], 275 | "fasGitHubUsername": [b"admin"], 276 | "fasGitLabUsername": [b"admin"], 277 | "fasPronoun": [b"they/them/theirs"], 278 | "fasRHBZEmail": [b"admin@rhbz_example.test"], 279 | "fasWebsiteURL": [b"https://admin.example.com"], 280 | "fasRssURL": [b"https://admin.example.com/feed"], 281 | } 282 | ] 283 | mock_connection.result3 = _single_page_result_factory(mocked) 284 | 285 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 286 | 287 | expected = { 288 | "creation": datetime.datetime(2020, 3, 9, 10, 32, 3), 289 | "givenname": "", 290 | "ircnicks": ["admin"], 291 | "locked": False, 292 | "username": "admin", 293 | "emails": ["admin@example.test"], 294 | "surname": "Administrator", 295 | "timezone": "UTC", 296 | "github_username": "admin", 297 | "gitlab_username": "admin", 298 | "pronouns": ["they/them/theirs"], 299 | "rhbzemail": "admin@rhbz_example.test", 300 | "website": "https://admin.example.com", 301 | "rssurl": "https://admin.example.com/feed", 302 | "websites": ["https://admin.example.com"], 303 | "rssurls": ["https://admin.example.com/feed"], 304 | } 305 | assert expected == ldap.get_user("admin") 306 | 307 | 308 | def test_get_user_with_attrs(mock_connection, mocker): 309 | mock_connection.result3 = _single_page_result_factory([]) 310 | mock_connection.search_ext = mocker.Mock(return_value=1) 311 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 312 | 313 | ldap.get_user("admin", ["username", "surname"]) 314 | 315 | mock_connection.search_ext.assert_called_once() 316 | assert mock_connection.search_ext.call_args[1]["attrlist"] == [ 317 | "uid", 318 | "sn", 319 | ] 320 | 321 | 322 | def test_get_user_not_found(mock_connection): 323 | mock_connection.result3 = _single_page_result_factory([]) 324 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 325 | assert ldap.get_user("dummy") is None 326 | 327 | 328 | def test_get_user_groups(mock_connection): 329 | mocked_user = [ 330 | { 331 | "memberof": [ 332 | b"cn=ipausers,cn=groups,cn=accounts,dc=example,dc=test", 333 | b"ipaUniqueID=d5bf8308,cn=caacls,cn=ca,dc=example,dc=test", 334 | b"cn=testgroup,cn=groups,cn=accounts,dc=example,dc=test", 335 | b"cn=testgroup-parent,cn=groups,cn=accounts,dc=example,dc=test", 336 | ] 337 | } 338 | ] 339 | mocked_groups = [{"cn": [b"testgroup"]}, {"cn": [b"testgroup-parent"]}] 340 | 341 | def result_mock(*results): 342 | for result in results: 343 | yield _single_page_result_factory(result)(1) 344 | 345 | mock_connection.result3 = mock.Mock(side_effect=result_mock(mocked_user, mocked_groups)) 346 | 347 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 348 | 349 | result = ldap.get_user_groups(username="dummy", attrs=["groupname"], page_number=1, page_size=0) 350 | expected = LDAPResult( 351 | items=[ 352 | {"groupname": "testgroup"}, 353 | {"groupname": "testgroup-parent"}, 354 | ], 355 | total=2, 356 | page_size=0, 357 | page_number=1, 358 | ) 359 | assert result == expected 360 | mock_connection.search_ext.assert_called() 361 | attrs_list = mock_connection.search_ext.call_args_list[0][1]["attrlist"] 362 | assert "memberof" in attrs_list 363 | 364 | 365 | def test_get_user_groups_no_group(mock_connection): 366 | mock_connection.result3 = _single_page_result_factory([{}]) 367 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 368 | 369 | result = ldap.get_user_groups(username="dummy", attrs=["groupname"], page_number=1, page_size=0) 370 | 371 | expected = LDAPResult( 372 | items=[], 373 | total=0, 374 | page_size=0, 375 | page_number=1, 376 | ) 377 | assert result == expected 378 | 379 | 380 | def test_get_user_agreements(mock_connection): 381 | mocked = [ 382 | {"cn": [b"FPCA"]}, 383 | {"cn": [b"CentOS"]}, 384 | ] 385 | mock_connection.result3 = _single_page_result_factory(mocked) 386 | 387 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 388 | 389 | result = ldap.get_user_agreements(username="dummy", page_number=1, page_size=0) 390 | expected = LDAPResult( 391 | items=[{"name": "FPCA"}, {"name": "CentOS"}], 392 | total=2, 393 | page_size=0, 394 | page_number=1, 395 | ) 396 | assert result == expected 397 | 398 | 399 | def test_search_users(mock_connection): 400 | def _get_mock_result(idx): 401 | return { 402 | "uid": [f"dummy-{idx}".encode("ascii")], 403 | "sn": [b""], 404 | "givenName": [b""], 405 | "mail": [f"dummy-{idx}@example.test".encode("ascii")], 406 | "fasCreationTime": [b"20200309103203Z"], 407 | "nsAccountLock": [b"false"], 408 | } 409 | 410 | mocked = [_get_mock_result(i) for i in range(1, 4)] 411 | mock_connection.result3 = _single_page_result_factory(mocked) 412 | 413 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 414 | 415 | result = ldap.search_users( 416 | username="dummy", 417 | email="dummy", 418 | ircnick="dummy-1@example.test", 419 | attrs=None, 420 | page_number=1, 421 | page_size=0, 422 | ) 423 | creation_dt = datetime.datetime(2020, 3, 9, 10, 32, 3) 424 | 425 | def _get_expected(idx): 426 | return { 427 | "creation": creation_dt, 428 | "givenname": "", 429 | "locked": False, 430 | "username": f"dummy-{idx}", 431 | "emails": [f"dummy-{idx}@example.test"], 432 | "surname": "", 433 | } 434 | 435 | expected = LDAPResult( 436 | items=[_get_expected(i) for i in range(1, 4)], 437 | total=3, 438 | page_size=0, 439 | page_number=1, 440 | ) 441 | assert result == expected 442 | 443 | 444 | @pytest.mark.parametrize( 445 | "query,expected_filter", 446 | [ 447 | # empty value 448 | ({"username": ""}, ""), 449 | # substring match 450 | ({"username": "something"}, "(uid=*something*)"), 451 | ({"human_name": "something"}, "(displayName=*something*)"), 452 | # exact match 453 | ({"username__exact": "something"}, "(uid=something)"), 454 | ({"human_name__exact": "something"}, "(displayName=something)"), 455 | ( 456 | {"github_username__exact": "something"}, 457 | "(fasGitHubUsername=something)", 458 | ), 459 | # __before match 460 | ( 461 | {"creation__before": datetime.datetime(2042, 1, 1)}, 462 | "(fasCreationTime<=20420101000000Z)", 463 | ), 464 | # Always exact match 465 | ( 466 | {"rhbzemail": "something"}, 467 | "(fasRHBZEmail=something)", 468 | ), 469 | ( 470 | {"rhbzemail__exact": "something"}, 471 | "(fasRHBZEmail=something)", 472 | ), 473 | # Presence match 474 | ( 475 | {"rhbzemail": "*"}, 476 | "(fasRHBZEmail=*)", 477 | ), 478 | # Groups 479 | ( 480 | {"group": ["something"]}, 481 | "(memberof=cn=something,cn=groups,cn=accounts,dc=example,dc=test)", 482 | ), 483 | ( 484 | {"group": ["group1", "group2"]}, 485 | "(memberof=cn=group1,cn=groups,cn=accounts,dc=example,dc=test)" 486 | "(memberof=cn=group2,cn=groups,cn=accounts,dc=example,dc=test)", 487 | ), 488 | ], 489 | ) 490 | def test_search_users_filters(mock_connection, mocker, query, expected_filter): 491 | mock_connection.result3 = _single_page_result_factory([]) 492 | mock_connection.search_ext = mocker.Mock(return_value=1) 493 | 494 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 495 | ldap.search_users( 496 | attrs=None, 497 | page_number=1, 498 | page_size=0, 499 | **query, 500 | ) 501 | mock_connection.search_ext.assert_called_once() 502 | assert ( 503 | mock_connection.search_ext.call_args[0][2] 504 | == f"(&(&(objectClass=fasUser)(!(nsAccountLock=TRUE)))(&{expected_filter}))" 505 | ) 506 | 507 | 508 | def test_get_paged_search_filters(mock_connection): 509 | mocked = [] 510 | 511 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 512 | with mock.patch.object(ldap, "_do_search", side_effect=[mocked]) as do_search: 513 | result = ldap.search_users( 514 | attrs=None, 515 | page_number=2, 516 | page_size=3, 517 | username="something", 518 | ) 519 | 520 | called_filters = [call[1]["filters"] for call in do_search.call_args_list] 521 | assert called_filters == [ 522 | "(&(&(objectClass=fasUser)(!(nsAccountLock=TRUE)))(&(uid=*something*)))" 523 | ] 524 | expected = LDAPResult( 525 | items=[], 526 | total=0, 527 | page_size=3, 528 | page_number=2, 529 | ) 530 | assert result == expected 531 | 532 | 533 | def test_get_paged_groups(mock_connection): 534 | mocked = [{"cn": [f"group-{idx}".encode("ascii")]} for idx in range(1, 12)] 535 | 536 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 537 | with mock.patch.object(ldap, "_do_search", side_effect=[mocked, mocked[3:6]]) as do_search: 538 | result = ldap.get_groups(attrs=None, page_number=2, page_size=3) 539 | 540 | called_filters = [call[1]["filters"] for call in do_search.call_args_list] 541 | assert called_filters == [ 542 | "(objectClass=fasGroup)", 543 | "(&(objectClass=fasGroup)(|(cn=group-4)(cn=group-5)(cn=group-6)))", 544 | ] 545 | expected = LDAPResult( 546 | items=[ 547 | {"groupname": "group-4"}, 548 | {"groupname": "group-5"}, 549 | {"groupname": "group-6"}, 550 | ], 551 | total=11, 552 | page_size=3, 553 | page_number=2, 554 | ) 555 | assert result == expected 556 | 557 | 558 | def test_get_paged_search_no_results(mock_connection): 559 | mocked = [] 560 | 561 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 562 | with mock.patch.object(ldap, "_do_search", side_effect=[mocked]) as do_search: 563 | result = ldap.search_users( 564 | attrs=None, 565 | page_number=2, 566 | page_size=3, 567 | username="something", 568 | email="something@example.test", 569 | ircnick="something", 570 | givenname="some", 571 | surname="thing", 572 | human_name="something", 573 | ) 574 | 575 | called_filters = [call[1]["filters"] for call in do_search.call_args_list] 576 | assert called_filters == [ 577 | ( 578 | "(&(&(objectClass=fasUser)(!(nsAccountLock=TRUE)))(&(uid=*something*)" 579 | "(mail=something@example.test)(fasIRCNick=*something*)" 580 | "(givenName=*some*)(sn=*thing*)(displayName=*something*)))" 581 | ) 582 | ] 583 | expected = LDAPResult( 584 | items=[], 585 | total=0, 586 | page_size=3, 587 | page_number=2, 588 | ) 589 | assert result == expected 590 | 591 | 592 | def test_do_search_paged(mock_connection): 593 | dummy_server_control = object() 594 | pages = [ 595 | ( 596 | 101, 597 | [("", {"cn": [b"group-1"]})], 598 | 1, 599 | [ 600 | dummy_server_control, 601 | SimplePagedResultsControl(True, size=2, cookie="ignore"), 602 | ], 603 | ), 604 | ( 605 | 101, 606 | [("", {"cn": [b"group-2"]})], 607 | 1, 608 | [ 609 | dummy_server_control, 610 | SimplePagedResultsControl(True, size=2, cookie=""), 611 | ], 612 | ), 613 | ] 614 | mock_connection.result3 = mock.Mock(side_effect=pages) 615 | 616 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 617 | result = ldap.get_groups(None, 0, 1) 618 | 619 | expected = LDAPResult( 620 | items=[{"groupname": "group-1"}, {"groupname": "group-2"}], 621 | total=2, 622 | page_size=0, 623 | page_number=1, 624 | ) 625 | assert result == expected 626 | 627 | 628 | def test_do_search_other_server_control(mock_connection): 629 | dummy_server_control = object() 630 | ldap_return = [101, [], 1, [dummy_server_control]] 631 | mock_connection.result3 = mock.Mock(return_value=ldap_return) 632 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 633 | ldap.get_groups(None, 0, 1) 634 | # No loop 635 | assert mock_connection.result3.call_count == 1 636 | 637 | 638 | def test_do_search_no_server_control(mock_connection): 639 | ldap_return = [101, [], 1, []] 640 | mock_connection.result3 = mock.Mock(return_value=ldap_return) 641 | ldap = LDAP("ldap://dummy.com", basedn="dc=example,dc=test") 642 | ldap.get_groups(None, 0, 1) 643 | # No loop 644 | assert mock_connection.result3.call_count == 1 645 | --------------------------------------------------------------------------------