diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dafef46f1..5a7054dcb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,17 +16,15 @@ jobs: name: Python Unit Test with Postgres uses: uc-cdis/.github/.github/workflows/python_unit_test.yaml@master with: - python-version: '3.9' + python-version: '3.13' test-script: 'tests/ci_commands_script.sh' run-coveralls: true BuildImageAndPush: name: Build Image and Push needs: Security - with: - BUILD_PLATFORMS: "linux/amd64" # https://github.com/uc-cdis/.github/blob/master/.github/workflows/image_build_push.yaml - uses: uc-cdis/.github/.github/workflows/image_build_push.yaml@master + uses: uc-cdis/.github/.github/workflows/image_build_push_native.yaml@master secrets: ECR_AWS_ACCESS_KEY_ID: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }} ECR_AWS_SECRET_ACCESS_KEY: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index 62587f1d3..1ceb60aa8 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -2,10 +2,6 @@ name: Integration Tests on: pull_request -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - jobs: integration_tests: name: Integration tests @@ -13,15 +9,12 @@ jobs: with: SERVICE_TO_TEST: fence secrets: - CI_AWS_ACCESS_KEY_ID: ${{ secrets.CI_AWS_ACCESS_KEY_ID }} - CI_AWS_SECRET_ACCESS_KEY: ${{ secrets.CI_AWS_SECRET_ACCESS_KEY }} - JENKINS_API_TOKEN: ${{ secrets.JENKINS_API_TOKEN }} - QA_DASHBOARD_S3_PATH: ${{ secrets.QA_DASHBOARD_S3_PATH }} CI_TEST_ORCID_USERID: ${{ secrets.CI_TEST_ORCID_USERID }} CI_TEST_ORCID_PASSWORD: ${{ secrets.CI_TEST_ORCID_PASSWORD }} - CI_TEST_RAS_USERID: ${{ secrets.CI_TEST_RAS_USERID }} + CI_TEST_RAS_EMAIL: ${{ secrets.CI_TEST_RAS_EMAIL }} CI_TEST_RAS_PASSWORD: ${{ secrets.CI_TEST_RAS_PASSWORD }} - CI_TEST_RAS_2_USERID: ${{ secrets.CI_TEST_RAS_2_USERID }} + CI_TEST_RAS_2_EMAIL: ${{ secrets.CI_TEST_RAS_2_EMAIL }} CI_TEST_RAS_2_PASSWORD: ${{ secrets.CI_TEST_RAS_2_PASSWORD }} CI_SLACK_BOT_TOKEN: ${{ secrets.CI_SLACK_BOT_TOKEN }} CI_SLACK_CHANNEL_ID: ${{ secrets.CI_SLACK_CHANNEL_ID }} + EKS_CLUSTER_NAME: ${{ secrets.EKS_CLUSTER_NAME }} diff --git a/.gitignore b/.gitignore index 4a76c3a3e..3473a1f69 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ tests/resources/keys/*.pem .DS_Store .vscode .idea + +# snyk +.dccache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6220df12b..4ae7dcef4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,15 @@ repos: - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 25.1.0 hooks: - id: black - repo: git@github.com:Yelp/detect-secrets - rev: v1.4.0 + rev: v1.5.0 hooks: - id: detect-secrets args: ['--baseline', '.secrets.baseline'] exclude: poetry.lock + additional_dependencies: ['gibberish-detector'] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: diff --git a/.secrets.baseline b/.secrets.baseline index c166f9842..a506bd690 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,5 +1,5 @@ { - "version": "1.4.0", + "version": "1.5.0", "plugins_used": [ { "name": "ArtifactoryDetector" @@ -20,9 +20,15 @@ { "name": "CloudantDetector" }, + { + "name": "DiscordBotTokenDetector" + }, { "name": "GitHubTokenDetector" }, + { + "name": "GitLabTokenDetector" + }, { "name": "HexHighEntropyString", "limit": 3.0 @@ -33,6 +39,9 @@ { "name": "IbmCosHmacDetector" }, + { + "name": "IPPublicDetector" + }, { "name": "JwtTokenDetector" }, @@ -46,9 +55,15 @@ { "name": "NpmDetector" }, + { + "name": "OpenAIDetector" + }, { "name": "PrivateKeyDetector" }, + { + "name": "PypiTokenDetector" + }, { "name": "SendGridDetector" }, @@ -64,6 +79,9 @@ { "name": "StripeDetector" }, + { + "name": "TelegramBotTokenDetector" + }, { "name": "TwilioKeyDetector" } @@ -80,6 +98,10 @@ "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 }, + { + "path": "detect_secrets.filters.gibberish.should_exclude_secret", + "limit": 3.7 + }, { "path": "detect_secrets.filters.heuristic.is_indirect_reference" }, @@ -106,58 +128,16 @@ }, { "path": "detect_secrets.filters.heuristic.is_templated_secret" - }, - { - "path": "detect_secrets.filters.regex.should_exclude_file", - "pattern": [ - "poetry.lock" - ] } ], "results": { - ".github/workflows/buildpipeline.yaml": [ - { - "type": "Secret Keyword", - "filename": ".github/workflows/buildpipeline.yaml", - "hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0", - "is_verified": false, - "line_number": 17 - } - ], - "deployment/scripts/postgresql/postgresql_init.sql": [ - { - "type": "Secret Keyword", - "filename": "deployment/scripts/postgresql/postgresql_init.sql", - "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", - "is_verified": false, - "line_number": 7 - } - ], - "docs/azure_architecture.md": [ - { - "type": "Secret Keyword", - "filename": "docs/azure_architecture.md", - "hashed_secret": "e2f9484667edf047f647e0b1b9b2817db5fba705", - "is_verified": false, - "line_number": 51 - } - ], - "docs/fence_multifactor_authentication_guide.md": [ - { - "type": "Secret Keyword", - "filename": "docs/fence_multifactor_authentication_guide.md", - "hashed_secret": "0f674908b6342fcf2a9842d04699cb008d57d399", - "is_verified": false, - "line_number": 38 - } - ], "fence/blueprints/storage_creds/google.py": [ { "type": "Private Key", "filename": "fence/blueprints/storage_creds/google.py", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 140 + "line_number": 139 } ], "fence/blueprints/storage_creds/other.py": [ @@ -176,56 +156,31 @@ "line_number": 66 } ], - "fence/config-default.yaml": [ - { - "type": "Basic Auth Credentials", - "filename": "fence/config-default.yaml", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 32 - } - ], - "fence/local_settings.example.py": [ - { - "type": "Basic Auth Credentials", - "filename": "fence/local_settings.example.py", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 6 - }, - { - "type": "Secret Keyword", - "filename": "fence/local_settings.example.py", - "hashed_secret": "5d07e1b80e448a213b392049888111e1779a52db", - "is_verified": false, - "line_number": 63 - } - ], "fence/resources/google/utils.py": [ { "type": "Private Key", "filename": "fence/resources/google/utils.py", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 137 + "line_number": 138 } ], - "fence/utils.py": [ + "migrations/versions/a04a70296688_non_unique_client_name.py": [ { - "type": "Secret Keyword", - "filename": "fence/utils.py", - "hashed_secret": "8318df9ecda039deac9868adf1944a29a95c7114", + "type": "Hex High Entropy String", + "filename": "migrations/versions/a04a70296688_non_unique_client_name.py", + "hashed_secret": "bb2372fb50034d559d2920e7229fb5879cf1be72", "is_verified": false, - "line_number": 129 + "line_number": 16 } ], - "migrations/versions/a04a70296688_non_unique_client_name.py": [ + "migrations/versions/bff33a927a37_add_acr_and_amr_columns.py": [ { "type": "Hex High Entropy String", - "filename": "migrations/versions/a04a70296688_non_unique_client_name.py", - "hashed_secret": "bb2372fb50034d559d2920e7229fb5879cf1be72", + "filename": "migrations/versions/bff33a927a37_add_acr_and_amr_columns.py", + "hashed_secret": "34a4cf65eacb4d63a914791fd006fdfc2e2460f6", "is_verified": false, - "line_number": 14 + "line_number": 15 } ], "migrations/versions/e4c7b0ab68d3_create_tables.py": [ @@ -243,14 +198,14 @@ "filename": "migrations/versions/ea7e1b843f82_optional_client_redirect_uri.py", "hashed_secret": "bb2372fb50034d559d2920e7229fb5879cf1be72", "is_verified": false, - "line_number": 13 + "line_number": 14 }, { "type": "Hex High Entropy String", "filename": "migrations/versions/ea7e1b843f82_optional_client_redirect_uri.py", "hashed_secret": "adb1fcd33b07abf9b6a064745759accea5cb341f", "is_verified": false, - "line_number": 14 + "line_number": 15 } ], "tests/conftest.py": [ @@ -259,38 +214,17 @@ "filename": "tests/conftest.py", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 1556 + "line_number": 1628 }, { "type": "Base64 High Entropy String", "filename": "tests/conftest.py", "hashed_secret": "227dea087477346785aefd575f91dd13ab86c108", "is_verified": false, - "line_number": 1579 + "line_number": 1652 } ], "tests/credentials/google/test_credentials.py": [ - { - "type": "Secret Keyword", - "filename": "tests/credentials/google/test_credentials.py", - "hashed_secret": "a06bdb09c0106ab559bd6acab2f1935e19f7e939", - "is_verified": false, - "line_number": 381 - }, - { - "type": "Secret Keyword", - "filename": "tests/credentials/google/test_credentials.py", - "hashed_secret": "93aa43c580f5347782e17fba5091f944767b15f0", - "is_verified": false, - "line_number": 474 - }, - { - "type": "Secret Keyword", - "filename": "tests/credentials/google/test_credentials.py", - "hashed_secret": "768b7fe00de4fd233c0c72375d12f87ce9670144", - "is_verified": false, - "line_number": 476 - }, { "type": "Secret Keyword", "filename": "tests/credentials/google/test_credentials.py", @@ -299,22 +233,6 @@ "line_number": 580 } ], - "tests/data/test_indexed_file.py": [ - { - "type": "Secret Keyword", - "filename": "tests/data/test_indexed_file.py", - "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", - "is_verified": false, - "line_number": 449 - }, - { - "type": "Secret Keyword", - "filename": "tests/data/test_indexed_file.py", - "hashed_secret": "c258a8d1264cc59de81f8b1975ac06732b1cf182", - "is_verified": false, - "line_number": 470 - } - ], "tests/keys/2018-05-01T21:29:02Z/jwt_private_key.pem": [ { "type": "Private Key", @@ -324,47 +242,13 @@ "line_number": 1 } ], - "tests/login/test_fence_login.py": [ - { - "type": "Secret Keyword", - "filename": "tests/login/test_fence_login.py", - "hashed_secret": "d300421e208bfd0d432294de15169fd9b8975def", - "is_verified": false, - "line_number": 48 - } - ], "tests/login/test_idp_oauth2.py": [ { - "type": "Secret Keyword", + "type": "JSON Web Token", "filename": "tests/login/test_idp_oauth2.py", - "hashed_secret": "f3bbbd66a63d4bf1747940578ec3d0103530e21d", - "is_verified": false, - "line_number": 8 - } - ], - "tests/migrations/test_a04a70296688.py": [ - { - "type": "Hex High Entropy String", - "filename": "tests/migrations/test_a04a70296688.py", - "hashed_secret": "bb2372fb50034d559d2920e7229fb5879cf1be72", + "hashed_secret": "aa3c3dc9e4859fdaa8ce8d49d1ffe96be884874d", "is_verified": false, - "line_number": 27 - } - ], - "tests/migrations/test_ea7e1b843f82.py": [ - { - "type": "Hex High Entropy String", - "filename": "tests/migrations/test_ea7e1b843f82.py", - "hashed_secret": "adb1fcd33b07abf9b6a064745759accea5cb341f", - "is_verified": false, - "line_number": 26 - }, - { - "type": "Hex High Entropy String", - "filename": "tests/migrations/test_ea7e1b843f82.py", - "hashed_secret": "bb2372fb50034d559d2920e7229fb5879cf1be72", - "is_verified": false, - "line_number": 43 + "line_number": 192 } ], "tests/ras/test_ras.py": [ @@ -373,27 +257,27 @@ "filename": "tests/ras/test_ras.py", "hashed_secret": "d9db6fe5c14dc55edd34115cdf3958845ac30882", "is_verified": false, - "line_number": 120 + "line_number": 121 } ], - "tests/scripting/test_fence-create.py": [ + "tests/storageclient/storage_client_mock.py": [ { "type": "Secret Keyword", - "filename": "tests/scripting/test_fence-create.py", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "filename": "tests/storageclient/storage_client_mock.py", + "hashed_secret": "37bbea9557f9efd1eeadb25dda9ab6514f08fde9", "is_verified": false, - "line_number": 300 + "line_number": 158 } ], - "tests/test-fence-config.yaml": [ + "tests/storageclient/test_cleversafe_api_client.py": [ { - "type": "Basic Auth Credentials", - "filename": "tests/test-fence-config.yaml", - "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", + "type": "Secret Keyword", + "filename": "tests/storageclient/test_cleversafe_api_client.py", + "hashed_secret": "f683c485d521c2e45830146dd570111770baea29", "is_verified": false, - "line_number": 31 + "line_number": 130 } ] }, - "generated_at": "2025-01-03T16:46:44Z" + "generated_at": "2026-06-23T22:51:27Z" } diff --git a/Dockerfile b/Dockerfile index 98641c34a..035704373 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,10 @@ # docker run -v ~/.gen3/fence/fence-config.yaml:/var/www/fence/fence-config.yaml -v ./keys/:/fence/keys/ fence:latest # To check running container do: docker exec -it CONTAINER bash -ARG AZLINUX_BASE_VERSION=master -ARG COMMIT_HASH +ARG AZLINUX_BASE_VERSION=3.13-pythonnginx + # ------ Base stage ------ -FROM quay.io/cdis/python-nginx-al:${AZLINUX_BASE_VERSION} AS base +FROM quay.io/cdis/amazonlinux-base:${AZLINUX_BASE_VERSION} AS base # Comment this in, and comment out the line above, if quay is down # FROM 707767160287.dkr.ecr.us-east-1.amazonaws.com/gen3/python-nginx-al:${AZLINUX_BASE_VERSION} as base @@ -16,6 +16,9 @@ WORKDIR /${appname} RUN chown -R gen3:gen3 /${appname} +USER root +RUN chown -R gen3:gen3 /venv/ +USER gen3 # ------ Builder stage ------ FROM base AS builder @@ -34,6 +37,8 @@ COPY --chown=gen3:gen3 ./deployment/wsgi/wsgi.py /$appname/wsgi.py RUN poetry install --without dev --no-interaction # Setup version info +RUN git config --global --add safe.directory ${appname} && COMMIT=`git rev-parse HEAD` && echo "COMMIT=\"${COMMIT}\"" > $appname/version_data.py \ + && VERSION=`git describe --always --tags` && echo "VERSION=\"${VERSION}\"" >> $appname/version_data.py RUN echo "COMMIT=\"$COMMIT_HASH\"" > /fence/version_data.py @@ -41,8 +46,17 @@ RUN echo "COMMIT=\"$COMMIT_HASH\"" > /fence/version_data.py # ------ Final stage ------ FROM base -ENV PATH="/${appname}/.venv/bin:$PATH" +# Import global virtualenv from builder +COPY --from=builder /venv /venv +ENV PATH="/venv/bin:$PATH" +# FIXME: Remove this when it's in the base image +ENV PROMETHEUS_MULTIPROC_DIR="/var/tmp/prometheus_metrics" +RUN mkdir -p "${PROMETHEUS_MULTIPROC_DIR}" \ + && chown gen3:gen3 "${PROMETHEUS_MULTIPROC_DIR}" + +# Switching to root user to run dnf upgrades +USER root # Install ccrypt to decrypt dbgap telmetry files RUN echo "Upgrading dnf"; \ dnf upgrade -y; \ @@ -51,12 +65,21 @@ RUN echo "Upgrading dnf"; \ libxcrypt-compat-4.4.33 \ libpq-15.0 \ gcc \ + diffutils \ tar xz; \ echo "Installing RPM"; \ rpm -i https://ccrypt.sourceforge.net/download/1.11/ccrypt-1.11-1.src.rpm && \ cd /root/rpmbuild/SOURCES/ && \ tar -zxf ccrypt-1.11.tar.gz && cd ccrypt-1.11 && ./configure --disable-libcrypt && make install && make check; +RUN mkdir -p /var/www/fence && chown -R gen3:gen3 /var/www/fence +# This `sed` command was previously executed in the usersync CronJob as it is required for DBGaP sync: +# https://github.com/uc-cdis/gen3-helm/blob/e5f49978ac16364374252b8dfd9cfa28df4baf9a/helm/fence/templates/usersync-cron.yaml#L249C17-L249C192 +# +# The logic has been moved into the Dockerfile because it requires root privileges, +# while the usersync CronJob now runs as a non-root user. +RUN sed -i 's/KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,/KexAlgorithms ecdh-sha2-nistp256,/g' /etc/crypto-policies/back-ends/openssh.config +USER gen3 COPY --chown=gen3:gen3 --from=builder /$appname /$appname CMD ["/bin/bash", "-c", "/fence/dockerrun.bash"] \ No newline at end of file diff --git a/README.md b/README.md index 47d30c357..cad06e6ea 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ applications to use Gen3 services. Fence can be configured to support different Identity Providers (IdPs) for AuthN. At the moment, supported IDPs include: -- Google +- [Google](docs/additional_documentation/google_architecture.md) - [Shibboleth](docs/additional_documentation/fence_shibboleth.md) - NIH iTrust - InCommon @@ -34,7 +34,7 @@ At the moment, supported IDPs include: - CILogon - Cognito - Synapse -- Microsoft +- [Microsoft](docs/azure/azure_architecture.md) - ORCID - RAS diff --git a/bin/fence_create.py b/bin/fence_create.py index 2c2d6a718..fef324136 100755 --- a/bin/fence_create.py +++ b/bin/fence_create.py @@ -8,7 +8,7 @@ from cdislogging import get_logger from fence.jwt import keys -from fence.config import config +from fence.config import config, CONFIG_SEARCH_FOLDERS from fence.scripting.fence_create import ( JWTCreator, create_client_action, @@ -39,7 +39,6 @@ google_list_authz_groups, access_token_polling_job, ) -from fence.settings import CONFIG_SEARCH_FOLDERS from gen3authz.client.arborist.client import ArboristClient @@ -203,6 +202,25 @@ def parse_arguments(): help="destination where dbGaP whitelist files are saved", default=None, ) + dbgap_sync.add_argument( + "--prune-users", + dest="prune_users", + action="store_true", + default=True, + help=( + "delete Arborist users with no remaining non-generic policies " + "(default: enabled for backward compatibility)" + ), + ) + dbgap_sync.add_argument( + "--no-prune-users", + dest="prune_users", + action="store_false", + help=( + "preserve Arborist users with no remaining non-generic policies; " + "recommended when identities are managed dynamically" + ), + ) dbgap_download = subparsers.add_parser("dbgap-download-access-files") dbgap_download.add_argument( @@ -407,29 +425,10 @@ def main(): # get database information sys.path.append(args.path) - # replicate cfg loading done in flask app to maintain backwards compatibility - # TODO (DEPRECATE LOCAL_SETTINGS): REMOVE this when putting cfg in - # settings/local_settings is deprecated - import flask - - settings_cfg = flask.Config(".") - settings_cfg.from_object("fence.settings") - config.update(dict(settings_cfg)) - - # END - TODO (DEPRECATE LOCAL_SETTINGS): REMOVE - config.load(search_folders=CONFIG_SEARCH_FOLDERS) DB = os.environ.get("FENCE_DB") or config.get("DB") - # attempt to get from settings, this is backwards-compatibility for integration - # tests - if DB is None: - try: - from fence.settings import DB - except ImportError: - pass - BASE_URL = os.environ.get("BASE_URL") or config.get("BASE_URL") ROOT_DIR = os.environ.get("ROOT_DIR") or os.path.dirname( os.path.dirname(os.path.realpath(__file__)) @@ -512,6 +511,7 @@ def main(): sync_from_local_yaml_file=args.yaml, folder=args.folder, arborist=arborist, + prune_users=args.prune_users, ) elif args.action == "dbgap-download-access-files": download_dbgap_files( diff --git a/bin/old_migration_script.py b/bin/old_migration_script.py index 6e1bb181a..92fc4f8c3 100644 --- a/bin/old_migration_script.py +++ b/bin/old_migration_script.py @@ -7,7 +7,6 @@ Create a new Alembic revision instead. """ - from sqlalchemy import ( Integer, BigInteger, @@ -36,7 +35,7 @@ UserRefreshToken, ) -to_timestamp = ( +to_timestamp = text( "CREATE OR REPLACE FUNCTION pc_datetime_to_timestamp(datetoconvert timestamp) " "RETURNS BIGINT AS " "$BODY$ " @@ -64,8 +63,8 @@ def migrate(driver): session.execute(to_timestamp) with driver.session as session: session.execute( - "ALTER TABLE {} ALTER COLUMN expires TYPE BIGINT USING pc_datetime_to_timestamp(expires);".format( - UserRefreshToken.__tablename__ + text( + f"ALTER TABLE {UserRefreshToken.__tablename__} ALTER COLUMN expires TYPE BIGINT USING pc_datetime_to_timestamp(expires)" ) ) @@ -79,8 +78,8 @@ def migrate(driver): ) with driver.session as session: session.execute( - 'ALTER TABLE "{}" ALTER COLUMN username TYPE {};'.format( - User.__tablename__, str(User.username.type) + text( + f'ALTER TABLE "{User.__tablename__}" ALTER COLUMN username TYPE {str(User.username.type)}' ) ) @@ -90,8 +89,8 @@ def migrate(driver): if not ("ix_name" in [constraint.name for constraint in table.constraints]): with driver.session as session: session.execute( - "ALTER TABLE {} ADD constraint ix_name unique (name);".format( - Client.__tablename__ + text( + f"ALTER TABLE {Client.__tablename__} ADD constraint ix_name unique (name)" ) ) @@ -103,8 +102,8 @@ def migrate(driver): ) with driver.session as session: session.execute( - "ALTER TABLE {} ADD COLUMN _allowed_scopes VARCHAR;".format( - Client.__tablename__ + text( + f"ALTER TABLE {Client.__tablename__} ADD COLUMN _allowed_scopes VARCHAR" ) ) for client in session.query(Client): @@ -113,8 +112,8 @@ def migrate(driver): session.add(client) session.commit() session.execute( - "ALTER TABLE {} ALTER COLUMN _allowed_scopes SET NOT NULL;".format( - Client.__tablename__ + text( + f"ALTER TABLE {Client.__tablename__} ALTER COLUMN _allowed_scopes SET NOT NULL" ) ) @@ -223,7 +222,8 @@ def migrate(driver): with driver.session as session: session.execute( - """\ + text( + """\ CREATE OR REPLACE FUNCTION process_user_audit() RETURNS TRIGGER AS $user_audit$ BEGIN IF (TG_OP = 'DELETE') THEN @@ -242,21 +242,25 @@ def migrate(driver): RETURN NULL; END; $user_audit$ LANGUAGE plpgsql;""" + ) ) exist = session.scalar( "SELECT exists (SELECT * FROM pg_trigger WHERE tgname = 'user_audit')" ) session.execute( - ('DROP TRIGGER user_audit ON "User"; ' if exist else "") - + """\ + text( + ('DROP TRIGGER user_audit ON "User"; ' if exist else "") + + """\ CREATE TRIGGER user_audit AFTER INSERT OR UPDATE OR DELETE ON "User" FOR EACH ROW EXECUTE PROCEDURE process_user_audit();""" + ) ) session.execute( - """\ + text( + """\ CREATE OR REPLACE FUNCTION process_cert_audit() RETURNS TRIGGER AS $cert_audit$ BEGIN IF (TG_OP = 'DELETE') THEN @@ -281,17 +285,20 @@ def migrate(driver): RETURN NULL; END; $cert_audit$ LANGUAGE plpgsql;""" + ) ) exist = session.scalar( - "SELECT exists (SELECT * FROM pg_trigger WHERE tgname = 'cert_audit')" + text("SELECT exists (SELECT * FROM pg_trigger WHERE tgname = 'cert_audit')") ) session.execute( - ("DROP TRIGGER cert_audit ON certificate; " if exist else "") - + """\ + text( + ("DROP TRIGGER cert_audit ON certificate; " if exist else "") + + """\ CREATE TRIGGER cert_audit AFTER INSERT OR UPDATE OR DELETE ON certificate FOR EACH ROW EXECUTE PROCEDURE process_cert_audit();""" + ) ) # Google Access expiration @@ -339,8 +346,8 @@ def add_column_if_not_exist(table_name, column, driver, metadata, default=None): table = Table(table_name, metadata, autoload=True, autoload_with=driver.engine) if str(column_name) not in table.c: with driver.session as session: - command = 'ALTER TABLE "{}" ADD COLUMN {} {}'.format( - table_name, column_name, column_type + command = ( + f'ALTER TABLE "{table_name}" ADD COLUMN {column_name} {column_type}' ) if not column.nullable: command += " NOT NULL" @@ -360,7 +367,7 @@ def drop_column_if_exist(table_name, column_name, driver, metadata): if column_name in table.c: with driver.session as session: session.execute( - 'ALTER TABLE "{}" DROP COLUMN {};'.format(table_name, column_name) + text(f'ALTER TABLE "{table_name}" DROP COLUMN {column_name}') ) session.commit() @@ -376,13 +383,8 @@ def add_foreign_key_constraint_if_not_exist( if foreign_key_name not in foreign_keys: with driver.session as session: session.execute( - 'ALTER TABLE "{}" ADD CONSTRAINT {} ' - 'FOREIGN KEY({}) REFERENCES "{}" ({});'.format( - table_name, - foreign_key_name, - column_name, - fk_table_name, - fk_column_name, + text( + f'ALTER TABLE "{table_name}" ADD CONSTRAINT {foreign_key_name} FOREIGN KEY({column_name}) REFERENCES "{fk_table_name}" ({fk_column_name})' ) ) session.commit() @@ -439,15 +441,8 @@ def set_foreign_key_constraint_on_delete( if column_name in table.c: session.execute( - 'ALTER TABLE ONLY "{}" DROP CONSTRAINT IF EXISTS {}, ' - 'ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES "{}" ({}) ON DELETE {};'.format( - table_name, - foreign_key_name, - foreign_key_name, - column_name, - fk_table_name, - fk_column_name, - ondelete, + text( + f'ALTER TABLE ONLY "{table_name}" DROP CONSTRAINT IF EXISTS {foreign_key_name}, ADD CONSTRAINT {foreign_key_name} FOREIGN KEY ({column_name}) REFERENCES "{fk_table_name}" ({fk_column_name}) ON DELETE {ondelete}' ) ) @@ -461,8 +456,8 @@ def drop_foreign_key_constraint_if_exist(table_name, column_name, driver, metada if foreign_key_name in foreign_keys: with driver.session as session: session.execute( - 'ALTER TABLE "{}" DROP CONSTRAINT {};'.format( - table_name, foreign_key_name + text( + f'ALTER TABLE "{table_name}" DROP CONSTRAINT {foreign_key_name}' ) ) session.commit() @@ -478,8 +473,8 @@ def add_unique_constraint_if_not_exist(table_name, column_name, driver, metadata if index_name not in indexes: with driver.session as session: session.execute( - 'ALTER TABLE "{}" ADD CONSTRAINT {} UNIQUE ({});'.format( - table_name, index_name, column_name + text( + f'ALTER TABLE "{table_name}" ADD CONSTRAINT {index_name} UNIQUE ({column_name})' ) ) session.commit() @@ -502,8 +497,8 @@ def drop_unique_constraint_if_exist(table_name, column_name, driver, metadata): if constraint_name in constraints or unique_index: with driver.session as session: session.execute( - 'ALTER TABLE "{}" DROP CONSTRAINT {};'.format( - table_name, constraint_name + text( + f'ALTER TABLE "{table_name}" DROP CONSTRAINT {constraint_name}' ) ) session.commit() @@ -515,8 +510,8 @@ def drop_default_value(table_name, column_name, driver, metadata): if column_name in table.c: with driver.session as session: session.execute( - 'ALTER TABLE "{}" ALTER COLUMN "{}" DROP DEFAULT;'.format( - table_name, column_name + text( + f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" DROP DEFAULT' ) ) session.commit() @@ -528,8 +523,8 @@ def add_not_null_constraint(table_name, column_name, driver, metadata): if column_name in table.c: with driver.session as session: session.execute( - 'ALTER TABLE "{}" ALTER COLUMN "{}" SET NOT NULL;'.format( - table_name, column_name + text( + f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" SET NOT NULL' ) ) session.commit() @@ -637,7 +632,9 @@ def _update_for_authlib(driver, md): ) list(map(add_code_col, CODE_COLUMNS_TO_ADD)) with driver.session as session: - session.execute("ALTER TABLE client ALTER COLUMN client_secret DROP NOT NULL") + session.execute( + text("ALTER TABLE client ALTER COLUMN client_secret DROP NOT NULL") + ) session.commit() # these ones are "manual" @@ -649,25 +646,23 @@ def _update_for_authlib(driver, md): # delete expires_at column if "{}.expires_at".format(tablename) in auth_code_columns: with driver.session as session: - session.execute("ALTER TABLE {} DROP COLUMN expires_at;".format(tablename)) + session.execute(text(f"ALTER TABLE {tablename} DROP COLUMN expires_at")) session.commit() # add auth_time column if "{}.auth_time".format(tablename) not in auth_code_columns: with driver.session as session: - command = "ALTER TABLE {} ADD COLUMN auth_time Integer NOT NULL DEFAULT extract(epoch from now());".format( - tablename - ) - session.execute(command) + command = f"ALTER TABLE {tablename} ADD COLUMN auth_time Integer NOT NULL DEFAULT extract(epoch from now())" + session.execute(text(command)) session.commit() # make sure modifiers on auth_time column are correct with driver.session as session: session.execute( - "ALTER TABLE {} ALTER COLUMN auth_time SET NOT NULL;".format(tablename) + text(f"ALTER TABLE {tablename} ALTER COLUMN auth_time SET NOT NULL") ) session.commit() session.execute( - "ALTER TABLE {} ALTER COLUMN auth_time SET DEFAULT extract(epoch from now());".format( - tablename + text( + f"ALTER TABLE {tablename} ALTER COLUMN auth_time SET DEFAULT extract(epoch from now())" ) ) session.commit() diff --git a/clear_prometheus_multiproc b/clear_prometheus_multiproc deleted file mode 100755 index af1ba6d18..000000000 --- a/clear_prometheus_multiproc +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# This script is immediatelly executed by uwsgi during startup -# it prepares the prometheus_multiproc_dir folder to store the metrics from separate uwsgi workers (per PID) -set -ex - -rm -Rf $1 -mkdir -p $1 -chmod 755 $1 -if id -u nginx &>/dev/null; then - chown $(id -u nginx):$(id -g nginx) $1 -fi diff --git a/deployment/uwsgi/uwsgi.ini b/deployment/uwsgi/uwsgi.ini deleted file mode 100644 index 0ebedb7ce..000000000 --- a/deployment/uwsgi/uwsgi.ini +++ /dev/null @@ -1,37 +0,0 @@ -[uwsgi] -protocol = uwsgi -socket = /var/run/gen3/uwsgi.sock -buffer-size = 32768 -uid = nginx -gid = nginx -chown-socket = nginx:nginx -chmod-socket = 666 -master = true -harakiri-verbose = true -# No global HARAKIRI, using only user HARAKIRI, because export overwrites it -# Cannot overwrite global HARAKIRI with user's: https://git.io/fjYuD -# harakiri = 45 -http-timeout = 45 -socket-timeout = 45 -worker-reload-mercy = 45 -reload-mercy = 45 -mule-reload-mercy = 45 -disable-logging = true -wsgi-file=/fence/wsgi.py -plugins = python3 -vacuum = true -pythonpath = /var/www/fence/ -pythonpath = /fence/ -# poetry installs git dependencies at /usr/local/src -pythonpath = /usr/local/src/* - -# metrics setup -stats = 127.0.0.1:9191 -stats-http = true -env = prometheus_multiproc_dir=/var/tmp/uwsgi_flask_metrics -exec-asap = /fence/clear_prometheus_multiproc /var/tmp/uwsgi_flask_metrics - -# Initialize application in worker processes, not master. This prevents the -# workers from all trying to open the same database connections at startup. -lazy = true -lazy-apps = true diff --git a/deployment/wsgi/gunicorn.conf.py b/deployment/wsgi/gunicorn.conf.py index 70d2164b3..da39eb4a0 100644 --- a/deployment/wsgi/gunicorn.conf.py +++ b/deployment/wsgi/gunicorn.conf.py @@ -2,8 +2,9 @@ bind = "0.0.0.0:8000" workers = 1 preload_app = True -user = "root" -group = "root" +user = "gen3" +group = "gen3" timeout = 300 keepalive = 2 keepalive_timeout = 5 +limit_request_field_size = 131072 diff --git a/docs/additional_documentation/register.md b/docs/additional_documentation/register.md index 7154be46d..337721505 100644 --- a/docs/additional_documentation/register.md +++ b/docs/additional_documentation/register.md @@ -1,7 +1,47 @@ -# Description: -This register functionality enables a registration page for users to provide basic information before "being added to a preconfigured group". Registration means that a user provides their name, organization, and email, in order to gain some predefined permissions (i.e. to be automatically added to a preconfigured Arborist group). This functionality adds the registration form itself along with an admin endpoint to list registered users and their information. Register Users is an optional feature. The user information provided during registration IS NOT VALIDATED IN ANY WAY currently. The registration page is not customizable at the moment +# User Registration + +## Description + +The user registration functionality is an optional feature. It enables a registration form for users to provide basic information (such as name, organization, and email) **after** logging in with an identity provider, but **before** being granted access to any non-public pages or resources. + +Once registered, users are logged in and (if configured) automatically added to a preconfigured Arborist group. Note that using this group is effectively the same as granting access to the built-in "logged-in" Arborist group (== built-in "all_users_policies" user.yaml group), which applies to all logged in users. + +An admin endpoint `/admin/register` is available to list registered users and their information. + +**Notes:** +1. The user information provided during registration is NOT currently validated in any way. +2. The registration page and form are NOT currently customizable. + +## Configuration + +- Set `REGISTER_USERS_ON` to `true` in the Fence configuration. +- Optionally, set `REGISTERED_USERS_GROUP` to the name of an Arborist group users should be added to once registered. + +## Technical flow + +![user registration flow](../images/user_registration_flow.png) + +## Automatic registration + +Alternatively, user registration can be enabled without redirecting users to a form where they enter their information manually. If `enable_idp_users_registration` is enabled in the identity provider's `OPENID_CONNECT` configuration, the user's information is automatically parsed from the JWT token received from the identity provider and stored in the Fence database. + +``` +REGISTER_USERS_ON: true +OPENID_CONNECT: + : + [...] + enable_idp_users_registration: true +``` + +**Notes:** +1. The automatic registration gets the user's information from the following claim fields and is NOT currently customizable. + - firstname + - lastname + - org + - email + +## Testing steps for non-automatic registration -# How to enable: - Set up a useryaml that has a GROUP with a policy (for example: "registered_users" with "data_upload" policy) - Your test user should not be in this group - Set up and run your Arborist diff --git a/docs/additional_documentation/setup.md b/docs/additional_documentation/setup.md index 0997e7448..02dc617a9 100644 --- a/docs/additional_documentation/setup.md +++ b/docs/additional_documentation/setup.md @@ -56,7 +56,6 @@ create a second test database with a different name: ```bash # Create test database(s). # This one is for automated tests, which clear the database after running; -# `tests/test_settings.py` should have `fence_test_tmp` in the `DB` variable. psql -U test postgres -c 'create database fence_test_tmp' userdatamodel-init --db fence_test_tmp # This one is for manual testing/general local usage; Your config diff --git a/docs/additional_documentation/task_tokens.md b/docs/additional_documentation/task_tokens.md new file mode 100644 index 000000000..1736c750a --- /dev/null +++ b/docs/additional_documentation/task_tokens.md @@ -0,0 +1,106 @@ +# Task Tokens + +Fence supports **Task Tokens**, a new type of access token for interacting with services that explicitly support them (e.g., Funnel/TES). + +## Requesting a Task Token + +Users request a task token by calling the credentials endpoint with a `task_token` query parameter specifying the desired token type: + +``` +POST /credentials/api/access_token?task_token= +``` + +An `expires_in` query parameter may be included to request a specific token lifetime, in seconds: + +``` +POST /credentials/api/access_token?task_token=&expires_in= +``` + +If `expires_in` is omitted, the request defaults to the maximum lifetime the user is authorized for (see [User Authorization](#user-authorization) below). **This is expected to be the common case**. + +Authorization for this request is governed by Arborist resource policies. + +## Operator Setup + +To enable Task Tokens on a Gen3 commons, configure the following fields in the Fence config: + +```python +# Task token types that can be requested by users. +ALLOWED_TASK_TOKEN_TYPES: [] + +# The number of seconds after a task access token is issued until it expires. +# This max is applied even if the requesting user has access to request a +# longer-lived token in Arborist. This is configured per task token type, +# and falls back to MAX_ACCESS_TOKEN_TTL. +MAX_TASK_TOKEN_TTL: {} +# Example: +# TES: 345600 +``` + +- **`ALLOWED_TASK_TOKEN_TYPES`** — an allowlist of task token types the commons supports. A user can only request a token type that appears in this list, regardless of their Arborist permissions. +- **`MAX_TASK_TOKEN_TTL`** — a per-token-type ceiling on token lifetime, in seconds. If a token type isn't listed here, its max TTL falls back to `MAX_ACCESS_TOKEN_TTL`. This cap is enforced unconditionally — it overrides whatever lifetime a user's Arborist policy would otherwise allow. + +## User Authorization + +Task token access is controlled entirely through Arborist resource paths under `/services/fence/task-token/`, granted via `user.yaml`. + +### Basic access (the common case) + +To request a task token of type `FOO`, a user must have `create` access on the `fence` service for the resource: + +``` +/services/fence/task-token/FOO +``` + +A user with this access may request a `FOO` task token of **any** lifetime, up to and including the operator-configured `MAX_TASK_TOKEN_TTL`. If the user omits `expires_in` from their request, they're granted a token with the maximum lifetime they're entitled to — this is the expected default behavior, and we recommend operators grant unscoped access (rather than time-scoped access, below) unless there's a specific need to restrict a user to a shorter-lived token. + +### Time-scoped access (uncommon — for explicit, shorter-lived restrictions) + +Access can optionally be scoped to a fixed, shorter token lifetime by appending a duration (in seconds) to the resource path: + +``` +/services/fence/task-token/FOO/100 +``` + +A user with `create` access on this resource can request a `FOO` task token with `expires_in` that matches exactly **100 seconds**. Requesting a different value for `expires_in` will fail. + +Note: For time scoped requests, the `expires_in` query parameter must be included when requesting for a task-token and must match the value in the user mapping. + +This is intended for the uncommon case where a specific user, or token type, needs to be restricted to a token lifetime shorter than the operator-configured max. The exact value a user is restricted to should be communicated to them out-of-band (e.g. documentation, direct communication), since Fence does not expose a user's permitted value in its API responses. + +> If an Arborist policy in `user.yaml` grants a user an exact value greater than `MAX_TASK_TOKEN_TTL`, that user will never be able to receive a task token via that policy — the request fails outright rather than being capped or floored. `MAX_TASK_TOKEN_TTL` in the Fence config is the authoritative ceiling, and a `user.yaml` entry should never be relied upon as the sole enforcement of the maximum. + +### Example + +```yaml +# user.yaml +authz: + resources: + - name: services + subresources: + - name: fence + subresources: + - name: task-token + subresources: + - name: FOO + subresources: + - name: "100" + +users: + u1: + policies: + - funnel_foo_task_token_100s # grants create on /services/fence/task-token/FOO/100 +``` + +With this policy, `u1` can request a `FOO` task token only with a TTL == 100 seconds. If `MAX_TASK_TOKEN_TTL` is configured lower than 100, `u1` will not be able to request a task token via this policy at all — the request fails rather than being capped to the lower value. + +## Precedence Summary + +| Layer | Effect | +|---|---| +| `ALLOWED_TASK_TOKEN_TYPES` (operator config) | Gates which token types exist on the commons at all | +| Arborist `create` access on `/services/fence/task-token/{type}` | Gates whether a user can request that type; grants any lifetime up to the operator max (the common case) | +| Arborist `create` access on `/services/fence/task-token/{type}/{seconds}` | Restricts a specific user to an exact TTL (the uncommon, explicitly-configured case). If this exact value exceeds `MAX_TASK_TOKEN_TTL`, requests via this policy fail outright | +| `MAX_TASK_TOKEN_TTL[{type}]` (operator config, falls back to `MAX_ACCESS_TOKEN_TTL`) | Hard ceiling on TTL for unscoped requests — applies unconditionally. For exact-value (time-scoped) grants, exceeding this ceiling causes the request to fail rather than being capped | + +For unscoped (basic access) requests, the effective TTL is the **minimum** of the user-requested TTL (or the operator max, if `expires_in` was omitted) and the operator-configured `MAX_TASK_TOKEN_TTL`. For time-scoped (exact-value) requests, the granted TTL is exactly the Arborist-configured value — but only if that value does not exceed `MAX_TASK_TOKEN_TTL`; otherwise the request fails. diff --git a/docs/images/user_registration_flow.png b/docs/images/user_registration_flow.png new file mode 100644 index 000000000..5587d6faa Binary files /dev/null and b/docs/images/user_registration_flow.png differ diff --git a/fence/__init__.py b/fence/__init__.py index e1aec601d..4f1282526 100755 --- a/fence/__init__.py +++ b/fence/__init__.py @@ -1,3 +1,12 @@ +# Override the default_digest_method for Signer before flask and flask_wtf are loaded. +import hashlib +from itsdangerous import Signer + +# Explicitly set to sha256 as the default (sha1) will break in FIPS environments when flask_wtf attempts to process a user registration form. +# This is a known issue with itsdangerous defaults (see: https://github.com/pgadmin-org/pgadmin4/issues/7979 for a similar issue with pgadmin) +# According to: https://itsdangerous.palletsprojects.com/en/latest/concepts/#digest-method-security and https://stackoverflow.com/a/27669587, we can override the default here: +Signer.default_digest_method = hashlib.sha256 + from collections import OrderedDict import os from urllib.parse import urljoin @@ -18,8 +27,7 @@ logger = get_logger(__name__, log_level="debug") # Load the configuration *before* importing modules that rely on it -from fence.config import config -from fence.settings import CONFIG_SEARCH_FOLDERS +from fence.config import config, CONFIG_SEARCH_FOLDERS config.load( config_path=os.environ.get("FENCE_CONFIG_PATH"), @@ -29,7 +37,6 @@ from fence.auth import logout, build_redirect_url from fence.metrics import metrics from fence.blueprints.data.indexd import S3IndexedFileLocation -from fence.blueprints.login.utils import allowed_login_redirects, domain from fence.errors import UserError from fence.jwt import keys from fence.oidc.client import query_client @@ -48,9 +55,10 @@ from fence.resources.storage import StorageManager from fence.resources.user.user_session import UserSessionInterface from fence.error_handler import get_error_response -from fence.utils import get_SQLAlchemyDriver +from fence.utils import get_SQLAlchemyDriver, allowed_login_redirects, domain import fence.blueprints.admin import fence.blueprints.data +import fence.blueprints.data.content_blueprint as content_only import fence.blueprints.login import fence.blueprints.oauth2 import fence.blueprints.misc @@ -77,7 +85,6 @@ def warn_about_logger(): def app_init( app, - settings="fence.settings", root_dir=None, config_path=None, config_file_name=None, @@ -86,7 +93,6 @@ def app_init( app_config( app, - settings=settings, root_dir=root_dir, config_path=config_path, file_name=config_file_name, @@ -204,7 +210,7 @@ def public_keys(): @app.route("/metrics") def metrics_endpoint(): """ - /!\ There is no authz control on this endpoint! + WARNING: There is no authz control on this endpoint! In cloud-automation setups, access to this endpoint is blocked at the revproxy level. """ data, content_type = metrics.get_latest_metrics() @@ -333,7 +339,6 @@ def _check_buckets_aws_creds_and_region(app): def app_config( app, - settings="fence.settings", root_dir=None, config_path=None, file_name=None, @@ -344,16 +349,13 @@ def app_config( if root_dir is None: root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - logger.info("Loading settings...") - # not using app.config.from_object because we don't want all the extra flask cfg - # vars inside our singleton when we pass these through in the next step + # logger.info("Loading settings...") settings_cfg = flask.Config(app.config.root_path) - settings_cfg.from_object(settings) - # dump the settings into the config singleton before loading a configuration file + # # dump the settings into the config singleton before loading a configuration file config.update(dict(settings_cfg)) - # load the configuration file, this overwrites anything from settings/local_settings + # load the configuration file config.load( config_path=config_path, search_folders=CONFIG_SEARCH_FOLDERS, @@ -388,6 +390,9 @@ def _setup_data_endpoint_and_boto(app): buckets = config.get("S3_BUCKETS", {}) app.boto = BotoManager(creds, buckets, logger=logger) app.register_blueprint(fence.blueprints.data.blueprint, url_prefix="/data") + else: + # AWS not configured: only expose /data/content + app.register_blueprint(content_only.blueprint, url_prefix="/data") def _load_keys(app, root_dir): @@ -465,11 +470,16 @@ def _setup_oidc_clients(app): # https://docs.authlib.org/en/latest/client/frameworks.html app.fence_client.register(**settings) else: # generic OIDC implementation + if hasattr(app, "arborist"): + app_arborist = app.arborist + else: + app_arborist = None client = Oauth2ClientBase( settings=settings, logger=logger, HTTP_PROXY=config.get("HTTP_PROXY"), idp=settings.get("name") or idp.title(), + arborist=app_arborist, ) clean_idp = idp.lower().replace(" ", "") setattr(app, f"{clean_idp}_client", client) @@ -477,7 +487,13 @@ def _setup_oidc_clients(app): def _setup_arborist_client(app): if app.config.get("ARBORIST"): - app.arborist = ArboristClient(arborist_base_url=config["ARBORIST"]) + app.arborist = ArboristClient( + arborist_base_url=config["ARBORIST"], + timeout=app.config.get("ARBORIST_TIMEOUT", 30), + ) + else: + logger.info("Arborist not configured") + app.arborist = None def _setup_audit_service_client(app): diff --git a/fence/auth.py b/fence/auth.py index 3861951f8..42b0c4957 100644 --- a/fence/auth.py +++ b/fence/auth.py @@ -1,29 +1,56 @@ -import flask -from flask import current_app +import re +import urllib.request, urllib.parse, urllib.error from datetime import datetime from functools import wraps -import urllib.request, urllib.parse, urllib.error +from json import JSONDecodeError +import backoff +import flask +from flask import current_app from authutils.errors import JWTError, JWTExpiredError from authutils.token.validate import ( current_token, - require_auth_header, + require_auth_header as authutils_require_auth_header, set_current_token, validate_request, ) from cdislogging import get_logger +import requests + +# Note: Iniitalized earlier to avoid circular import errors. +GEN3_AUDIENCE = "gen3" + +from fence.authz.auth import check_arborist_auth +from fence.config import config from fence.errors import Unauthorized, InternalError from fence.jwt.validate import validate_jwt from fence.models import User, IdentityProvider, query_for_user from fence.user import get_current_user -from fence.utils import clear_cookies -from fence.config import config -from fence.authz.auth import check_arborist_auth +from fence.utils import ( + clear_cookies, + DEFAULT_BACKOFF_SETTINGS, + allowed_login_redirects, + domain, +) logger = get_logger(__name__) +def require_auth_header(*args, **kwargs): + """ + Injects the default token audience before calling authutils's `require_auth_header` + """ + if "audience" in kwargs: + if type(kwargs["audience"]) != list: + kwargs["audience"] = [kwargs["audience"]] + kwargs["audience"].append(GEN3_AUDIENCE) + else: + kwargs["audience"] = GEN3_AUDIENCE + + return authutils_require_auth_header(*args, **kwargs) + + def get_jwt(): """ Return the user's JWT from authorization header. Requires flask application context. @@ -62,61 +89,64 @@ def build_redirect_url(hostname, path): return redirect_base + path -def login_user( - username, provider, fence_idp=None, shib_idp=None, email=None, id_from_idp=None -): +def get_ip_information_string(): """ - Login a user with the given username and provider. Set values in Flask - session to indicate the user being logged in. In addition, commit the user - and associated idp information to the db. + Returns a string containing the client's IP address and any X-Forwarded headers. + + Returns: + str: A formatted string containing the client's IP address and X-Forwarded headers. + """ + x_forwarded_headers = [ + f"{header}: {value}" + for header, value in flask.request.headers + if "X-Forwarded" in header + ] + return f"flask.request.remote_addr={flask.request.remote_addr} x_forwarded_headers={x_forwarded_headers}" + + +def _identify_user_and_update_database( + user, + username, + provider, + email=None, + id_from_idp=None, + username_deny_regex=None, +) -> bool: + """ + Create a new user if one doesn't already exist in the database. Commit the user + and associated idp information to the database. Args: + user (User): user to be logged in, if it already exists, None otherwise username (str): specific username of user to be logged in provider (str): specfic idp of user to be logged in - fence_idp (str, optional): Downstreawm fence IdP - shib_idp (str, optional): Downstreawm shibboleth IdP email (str, optional): email of user (may or may not match username depending on the IdP) id_from_idp (str, optional): id from the IDP (which may be different than the username) - """ - def set_flask_session_values(user): - """ - Helper fuction to set user values in the session. + Return: + User: the created or updated user + """ + username_deny_regex = username_deny_regex or config["GLOBAL_USERNAME_DENY_REGEX"] + if username_deny_regex: + if re.search(pattern=username_deny_regex, string=username): + logger.info( + f"Blocked login of user with username {username} due to deny regex: {username_deny_regex}" + ) - Args: - user (User): User object - """ - flask.session["username"] = user.username - flask.session["user_id"] = str(user.id) - flask.session["provider"] = user.identity_provider.name - if fence_idp: - flask.session["fence_idp"] = fence_idp - if shib_idp: - flask.session["shib_idp"] = shib_idp - flask.g.user = user - flask.g.scopes = ["_all"] - flask.g.token = None + # intentionally empty message to prevent information leakage + raise Unauthorized(message="") - user = query_for_user(session=current_app.scoped_session(), username=username) if user: if user.active == False: # Abort login if user.active == False: raise Unauthorized( "User is known but not authorized/activated in the system" ) - _update_users_email(user, email) _update_users_id_from_idp(user, id_from_idp) _update_users_last_auth(user) - - # This expression is relevant to those users who already have user and - # idp info persisted to the database. We return early to avoid - # unnecessarily re-saving that user and idp info. - if user.identity_provider and user.identity_provider.name == provider: - set_flask_session_values(user) - return else: if not config["ALLOW_NEW_USER_ON_LOGIN"]: # do not create new active users automatically @@ -132,21 +162,121 @@ def set_flask_session_values(user): user.id_from_idp = id_from_idp # TODO: update iss_sub mapping table? - # setup idp connection for new user (or existing user w/o it setup) - idp = ( - current_app.scoped_session() - .query(IdentityProvider) - .filter(IdentityProvider.name == provider) - .first() + # This expression is relevant to those users who already have user and + # idp info persisted to the database. We avoid unnecessarily re-saving + # that user and idp info. + if not user.identity_provider or not user.identity_provider.name == provider: + # setup idp connection for new user (or existing user w/o it setup) + idp = ( + current_app.scoped_session() + .query(IdentityProvider) + .filter(IdentityProvider.name == provider) + .first() + ) + if not idp: + idp = IdentityProvider(name=provider) + + user.identity_provider = idp + current_app.scoped_session().add(user) + current_app.scoped_session().commit() + + # `login_in_progress_username` stored for use by the user registration code. + # not using `flask.session["username"]` because other code relies on it to know + # whether a user is logged in; in this case the user isn't logged in yet. + flask.session["login_in_progress_username"] = user.username + + flask.g.user = user + return user + + +def _is_user_registration_required_before_login(user, provider) -> bool: + auto_registration_enabled = ( + config["OPENID_CONNECT"] + .get(provider, {}) + .get("enable_idp_users_registration", False) + ) + # Registration is required if: + # - Registration is enabled in the config, AND + # - Automatic registration is NOT enabled, AND + # - The user's registration info is empty + return ( + config["REGISTER_USERS_ON"] + and not auto_registration_enabled + and user.additional_info.get("registration_info", {}) == {} ) - if not idp: - idp = IdentityProvider(name=provider) - user.identity_provider = idp - current_app.scoped_session().add(user) - current_app.scoped_session().commit() - set_flask_session_values(user) +def login_user_or_require_registration( + username, provider, upstream_idp=None, shib_idp=None, email=None, id_from_idp=None +) -> bool: + """ + Check if a user needs to go through the registration flow before being logged in. If not, + login the user with the given username and provider. Set values in Flask session to indicate + the user being logged in. + + Args: + username (str): specific username of user to be logged in + provider (str): specfic idp of user to be logged in + upstream_idp (str, optional): upstream fence IdP + shib_idp (str, optional): upstream shibboleth IdP + email (str, optional): email of user (may or may not match username depending + on the IdP) + id_from_idp (str, optional): id from the IDP (which may be different than + the username) + + Return: + bool: whether the user has been logged in (if registration is enabled and the user is not + registered, this would be False) + """ + + def log_ip(user): + ip_info = get_ip_information_string() + logger.info( + f"User logged in. user.id={user.id} user.username={user.username} {ip_info}" + ) + + def set_flask_session_values(user): + """ + Helper fuction to set user values in the session. + + Args: + user (User): User object + """ + flask.session["username"] = user.username + flask.session["user_id"] = str(user.id) + flask.session["provider"] = user.identity_provider.name + if upstream_idp: + flask.session["upstream_idp"] = upstream_idp + if shib_idp: + flask.session["shib_idp"] = shib_idp + flask.g.user = user + flask.g.scopes = ["_all"] + flask.g.token = None + + user = query_for_user(session=current_app.scoped_session(), username=username) + user = _identify_user_and_update_database( + user, username, provider, email, id_from_idp + ) + log_user_in = not _is_user_registration_required_before_login(user, provider) + if log_user_in: + set_flask_session_values(user) + log_ip(user) + return log_user_in + + +@backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS) +def get_openid_config_for_idp(open_id_connect): + """ + Return openid configuration for a given provider. + Args: + open_id_connect (dict): fence config for idp + Returns: + response: response of openid configuration + """ + well_known_url = open_id_connect["discovery_url"] + well_known_resp = requests.get(well_known_url) + well_known_resp.raise_for_status() + return well_known_resp def logout(next_url, force_era_global_logout=False): @@ -162,17 +292,66 @@ def logout(next_url, force_era_global_logout=False): # propogate logout to IDP provider_logout = None provider = flask.session.get("provider") + if force_era_global_logout or provider == IdentityProvider.itrust: safe_url = urllib.parse.quote_plus(next_url) provider_logout = config["ITRUST_GLOBAL_LOGOUT"] + safe_url elif provider == IdentityProvider.fence: base = config["OPENID_CONNECT"]["fence"]["api_base_url"] provider_logout = base + "/logout?" + urllib.parse.urlencode({"next": next_url}) + elif provider == "cognito": + idp_openid_connect = config["OPENID_CONNECT"]["cognito"] + well_known = None + try: + well_known_resp = get_openid_config_for_idp(idp_openid_connect) + well_known = well_known_resp.json() + except requests.exceptions.HTTPError as e: + logger.error( + f"Well-known endpoint returned an error status after multiple retries, Cognito Session not invalidated, Logging out of Gen3. Error: {e}" + ) + except requests.exceptions.ConnectionError as e: + logger.error( + f"Could not connect to well-known endpoint, Cognito Session not invalidated, Logging out of Gen3. Error: {e} " + ) + except JSONDecodeError as e: + logger.error( + f"Invalid JSON resonse from well-known, Cognito Session not invalidated, Logging out of Gen3. Error: {e}" + ) + except Exception as e: + logger.error( + f"Error occured trying to get well-known, Cognito Session not invalidated, Logging out from Gen3. Error: {e}" + ) + if well_known: + end_session_endpoint = well_known.get("end_session_endpoint") + # NOTE: discovery url for cognito is different than the cognito api domain url. Check the domain for the APIs like end_session_endpoint or authorization_endpoint found in the well-know openid config + if domain(end_session_endpoint) not in allowed_login_redirects(): + logger.error( + f"Logout url {end_session_endpoint} not in LOGIN_REDIRECT_WHITELIST config. Cognito Session not invalidated, Logging out from Gen3." + ) + else: + if end_session_endpoint: + provider_logout = ( + end_session_endpoint + + "?" + + urllib.parse.urlencode( + { + "client_id": idp_openid_connect["client_id"], + "logout_uri": next_url, # NOTE: This needs to be set up in the cognito console for an allowed sign-out url + } + ) + ) + else: + logger.error( + "end_session_endpoint not found in well-known config. Cognito Session not invalidated. Logging out from Gen3" + ) flask.session.clear() - redirect_response = flask.make_response( - flask.redirect(provider_logout or urllib.parse.unquote(next_url)) - ) + try: + redirect_response = flask.make_response( + flask.redirect(provider_logout or urllib.parse.unquote(next_url)) + ) + except Exception as e: + logger.error(f"Error logging out: {e}") clear_cookies(redirect_response) return redirect_response @@ -202,7 +381,11 @@ def decorator(f): @wraps(f) def wrapper(*args, **kwargs): if flask.session.get("username"): - login_user(flask.session["username"], flask.session["provider"]) + is_logged_in = login_user_or_require_registration( + flask.session["username"], flask.session["provider"] + ) + if not is_logged_in: + raise Unauthorized("Please register to login") return f(*args, **kwargs) eppn = None @@ -231,7 +414,11 @@ def wrapper(*args, **kwargs): username = eppn.split("!")[-1] flask.session["username"] = username flask.session["provider"] = IdentityProvider.itrust - login_user(username, flask.session["provider"]) + is_logged_in = login_user_or_require_registration( + username, flask.session["provider"] + ) + if not is_logged_in: + raise Unauthorized("Please register to login") return f(*args, **kwargs) else: raise Unauthorized("Please login") diff --git a/fence/authz/auth.py b/fence/authz/auth.py index 256b59f3d..f305f9b49 100644 --- a/fence/authz/auth.py +++ b/fence/authz/auth.py @@ -1,53 +1,106 @@ from functools import wraps +from cdislogging import get_logger +from fence import config import flask +from gen3authz.utils import is_path_prefix_of_path from fence.errors import Forbidden, Unauthorized from fence.jwt.utils import get_jwt_header -def check_arborist_auth(resource, method, constraints=None): - """ - Check with arborist to verify the authz for a request. +logger = get_logger(__name__) + - TODO (rudyardrichter, 2018-12-21): - update as necessary as changes happen to ABAC & arborist +def authorize(resource, method): + """ + Check with arborist to verify the authz for a request. Throws a ``Forbidden`` error if the user is not authorized to access the resource. Args: - resource (str): + resource (str or list[str]): Identifier for the thing being accessed. These look like filepaths. This ``resource`` must correspond to some resource entered previously in arborist. Currently the existing resources are going to be the program/projects set up by the user sync. - method (str): + method (str or list[str]): Identifier for the action the user is trying to do. Like ``resource``, this is something that has to exist in arborist already. - constraints (Optional[Dict[str, str]]): - Optional set of constraints to send to arborist for context on this request. - (These really aren't used at all yet.) + """ + if not hasattr(flask.current_app, "arborist"): + raise Forbidden( + "this fence instance is not configured with arborist;" + " this endpoint is unavailable" + ) + if "Authorization" not in flask.request.headers: + logger.debug("request missing Authorization header; treating as anonymous") + token = None + else: + token = get_jwt_header() + + if not flask.current_app.arborist.auth_request( + jwt=token, + service="fence", + methods=method, + resources=resource, + ): + raise Forbidden("user does not have privileges to access this endpoint") + + +def check_arborist_auth(resource, method): + """ + Same as `authorize`, but as a decorator. Return: Callable: decorator """ - constraints = constraints or {} def decorator(f): @wraps(f) def wrapper(*f_args, **f_kwargs): - if not hasattr(flask.current_app, "arborist"): - raise Forbidden( - "this fence instance is not configured with arborist;" - " this endpoint is unavailable" - ) - if not flask.current_app.arborist.auth_request( - jwt=get_jwt_header(), - service="fence", - methods=method, - resources=resource, - ): - raise Forbidden("user does not have privileges to access this endpoint") + authorize(resource, method) return f(*f_args, **f_kwargs) return wrapper return decorator + + +def can_user_get_task_token(task_token_type: str, expires_in: int) -> bool: + """ + Checks a requested expiration against the user's authz. + Example: a user with access to `/services/fence/task-token/FOO/100` can request a + task token of type "FOO" that expires exactly in 100 seconds. + + A user with access to `/services/fence/task-token/FOO` (no exact value) + can request a task token of any expiration. + + Args: + task_token_type (str): the type of task token being requested + expires_in (int): the requested expiration in seconds + + Returns: + bool: True if the user is authorized to request a task token of the given type and expiration, False otherwise + """ + + if not isinstance(expires_in, int) or isinstance(expires_in, bool): + return False + if expires_in <= 0: + return False + + max_task_token_ttl = config["MAX_TASK_TOKEN_TTL"].get( + task_token_type, config["MAX_ACCESS_TOKEN_TTL"] + ) + if expires_in > max_task_token_ttl: + return False + + # Check if the user has a policy providing access to create a task token + # of the requested type and expiration. + # Note: Arborist policies are hierarchical, so a policy granting access to `/services/fence/task-token/FOO` (no expiration) + # will also grant access to `/services/fence/task-token/FOO/100`. + resource_path = f"/services/fence/task-token/{task_token_type}/{expires_in}" + + try: + authorize(resource=resource_path, method="create") + return True + except Forbidden: + return False diff --git a/fence/blueprints/admin.py b/fence/blueprints/admin.py index b3e898460..ebfdebb04 100644 --- a/fence/blueprints/admin.py +++ b/fence/blueprints/admin.py @@ -7,11 +7,11 @@ import functools from flask import request, jsonify, Blueprint, current_app -from flask import current_app from cdislogging import get_logger from fence.auth import admin_login_required +from fence.resources.audit.utils import enable_request_logging from fence.resources import admin from fence.models import User @@ -22,30 +22,13 @@ blueprint = Blueprint("admin", __name__) -def debug_log(function): - """Output debug information to the logger for a function call.""" - argument_names = list(function.__code__.co_varnames) - - @functools.wraps(function) - def write_log(*args, **kwargs): - argument_values = ( - "{} = {}".format(arg, value) - for arg, value in list(zip(argument_names, args)) + list(kwargs.items()) - ) - msg = function.__name__ + "\n\t" + "\n\t".join(argument_values) - logger.debug(msg) - return function(*args, **kwargs) - - return write_log - - #### USERS #### @blueprint.route("/users/", methods=["GET"]) @blueprint.route("/user/", methods=["GET"]) @admin_login_required -@debug_log +@enable_request_logging def get_user(username): """ Get the information of a user from our userdatamodel database @@ -58,7 +41,7 @@ def get_user(username): @blueprint.route("/users", methods=["GET"]) @blueprint.route("/user", methods=["GET"]) @admin_login_required -@debug_log +@enable_request_logging def get_all_users(): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -76,7 +59,7 @@ def get_all_users(): @blueprint.route("/users", methods=["POST"]) @blueprint.route("/user", methods=["POST"]) @admin_login_required -@debug_log +@enable_request_logging def create_user(): """ Create a user on the userdatamodel database @@ -106,7 +89,7 @@ def create_user(): @blueprint.route("/users/", methods=["PUT"]) @blueprint.route("/user/", methods=["PUT"]) @admin_login_required -@debug_log +@enable_request_logging def update_user(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -131,7 +114,7 @@ def update_user(username): @blueprint.route("/users/", methods=["DELETE"]) @blueprint.route("/user/", methods=["DELETE"]) @admin_login_required -@debug_log +@enable_request_logging def delete_user(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -151,7 +134,7 @@ def delete_user(username): @blueprint.route("/users//soft", methods=["DELETE"]) @blueprint.route("/user//soft", methods=["DELETE"]) @admin_login_required -@debug_log +@enable_request_logging def soft_delete_user(username): """ Soft-remove the user by marking it as active=False. @@ -162,10 +145,26 @@ def soft_delete_user(username): return response +@blueprint.route("/users//reactivate", methods=["POST"]) +@blueprint.route("/user//reactivate", methods=["POST"]) +@admin_login_required +@enable_request_logging +def reactivate_user(username): + """ + Reverts the soft-remove done by soft_delete_user above, marking the user active=True. + + Returns json object + """ + response = jsonify( + admin.reactivate_soft_deleted_user(current_app.scoped_session(), username) + ) + return response + + @blueprint.route("/users//groups", methods=["GET"]) @blueprint.route("/user//groups", methods=["GET"]) @admin_login_required -@debug_log +@enable_request_logging def get_user_groups(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -183,7 +182,7 @@ def get_user_groups(username): @blueprint.route("/users//groups", methods=["PUT"]) @blueprint.route("/user//groups", methods=["PUT"]) @admin_login_required -@debug_log +@enable_request_logging def add_user_to_groups(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -204,7 +203,7 @@ def add_user_to_groups(username): @blueprint.route("/users//groups", methods=["DELETE"]) @blueprint.route("/user//groups", methods=["DELETE"]) @admin_login_required -@debug_log +@enable_request_logging def remove_user_from_groups(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -227,7 +226,7 @@ def remove_user_from_groups(username): @blueprint.route("/users//projects", methods=["DELETE"]) @blueprint.route("/user//projects", methods=["DELETE"]) @admin_login_required -@debug_log +@enable_request_logging def remove_user_from_projects(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -250,7 +249,7 @@ def remove_user_from_projects(username): @blueprint.route("/users//projects", methods=["PUT"]) @blueprint.route("/user//projects", methods=["PUT"]) @admin_login_required -@debug_log +@enable_request_logging def add_user_to_projects(username): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -276,7 +275,7 @@ def add_user_to_projects(username): @blueprint.route("/projects/", methods=["GET"]) @admin_login_required -@debug_log +@enable_request_logging def get_project(projectname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -293,7 +292,7 @@ def get_project(projectname): @blueprint.route("/projects", methods=["GET"]) @admin_login_required -@debug_log +@enable_request_logging def get_all_projects(): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -310,7 +309,7 @@ def get_all_projects(): @blueprint.route("/projects/", methods=["POST"]) @admin_login_required -@debug_log +@enable_request_logging def create_project(projectname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -333,7 +332,7 @@ def create_project(projectname): @blueprint.route("/projects/", methods=["DELETE"]) @admin_login_required -@debug_log +@enable_request_logging def delete_project(projectname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -350,7 +349,7 @@ def delete_project(projectname): @blueprint.route("/groups//projects", methods=["DELETE"]) @admin_login_required -@debug_log +@enable_request_logging def remove_projects_from_group(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -371,6 +370,7 @@ def remove_projects_from_group(groupname): @blueprint.route("/projects//groups", methods=["PUT"]) @admin_login_required +@enable_request_logging def add_project_to_groups(projectname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -391,6 +391,7 @@ def add_project_to_groups(projectname): @blueprint.route("/projects//bucket/", methods=["POST"]) @admin_login_required +@enable_request_logging def create_bucket_in_project(projectname, bucketname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -412,6 +413,7 @@ def create_bucket_in_project(projectname, bucketname): @blueprint.route("/projects//bucket/", methods=["DELETE"]) @admin_login_required +@enable_request_logging def delete_bucket_from_project(projectname, bucketname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -433,6 +435,7 @@ def delete_bucket_from_project(projectname, bucketname): @blueprint.route("/projects//bucket", methods=["GET"]) @admin_login_required +@enable_request_logging def list_buckets_from_project(projectname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -455,6 +458,7 @@ def list_buckets_from_project(projectname): @blueprint.route("/groups/", methods=["GET"]) @admin_login_required +@enable_request_logging def get_group_info(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -471,6 +475,7 @@ def get_group_info(groupname): @blueprint.route("/groups", methods=["GET"]) @admin_login_required +@enable_request_logging def get_all_groups(): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -487,6 +492,7 @@ def get_all_groups(): @blueprint.route("/groups//users", methods=["GET"]) @admin_login_required +@enable_request_logging def get_group_users(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -503,6 +509,7 @@ def get_group_users(groupname): @blueprint.route("/groups", methods=["POST"]) @admin_login_required +@enable_request_logging def create_group(): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -527,6 +534,7 @@ def create_group(): @blueprint.route("/groups/", methods=["PUT"]) @admin_login_required +@enable_request_logging def update_group(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -548,6 +556,7 @@ def update_group(groupname): @blueprint.route("/groups/", methods=["DELETE"]) @admin_login_required +@enable_request_logging def delete_group(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -565,6 +574,7 @@ def delete_group(groupname): @blueprint.route("/groups//projects", methods=["PUT"]) @admin_login_required +@enable_request_logging def add_projects_to_group(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -584,6 +594,7 @@ def add_projects_to_group(groupname): @blueprint.route("/groups//projects", methods=["GET"]) @admin_login_required +@enable_request_logging def get_group_projects(groupname): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -604,6 +615,7 @@ def get_group_projects(groupname): @blueprint.route("/cloud_providers/", methods=["GET"]) @blueprint.route("/cloud_provider/", methods=["GET"]) @admin_login_required +@enable_request_logging def get_cloud_provider(providername): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -620,6 +632,7 @@ def get_cloud_provider(providername): @blueprint.route("/cloud_providers/", methods=["POST"]) @blueprint.route("/cloud_provider/", methods=["POST"]) @admin_login_required +@enable_request_logging def create_cloud_provider(providername): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -646,6 +659,7 @@ def create_cloud_provider(providername): @blueprint.route("/cloud_providers/", methods=["DELETE"]) @blueprint.route("/cloud_provider/", methods=["DELETE"]) @admin_login_required +@enable_request_logging def delete_cloud_provider(providername): """ DEPRECATED: This endpoint is deprecated and will be removed in a future release. @@ -666,17 +680,13 @@ def delete_cloud_provider(providername): @blueprint.route("/register", methods=["GET"]) @admin_login_required +@enable_request_logging def get_registered_users(): """ - DEPRECATED: This endpoint is deprecated and will be removed in a future release. - - List registration info for every user for which there exists registration info. - Endpoint accessible to admins only. - Response json structure is provisional. """ - logger.warning( - f"Deprecated endpoint accessed: {request.path}. This endpoint is deprecated and will be removed in a future release." - ) registered_users = ( current_app.scoped_session() .query(User) diff --git a/fence/blueprints/data/blueprint.py b/fence/blueprints/data/blueprint.py index d8041b949..4f179f65e 100755 --- a/fence/blueprints/data/blueprint.py +++ b/fence/blueprints/data/blueprint.py @@ -1,18 +1,24 @@ import flask +import json +import hashlib from cdislogging import get_logger from cdispyutils.config import get_value +import requests from fence.auth import login_required, require_auth_header, current_token, get_jwt from fence.authz.auth import check_arborist_auth +from fence.blueprints.ga4gh import BulkObjectAccessRequest from fence.blueprints.data.indexd import ( BlankIndex, + BulkIndexedRecords, IndexedFile, get_signed_url_for_file, + bulk_get_signed_url_for_file, verify_data_upload_bucket_configuration, ) from fence.config import config -from fence.errors import Forbidden, InternalError, UserError, Unauthorized +from fence.errors import Forbidden, InternalError, NotSupported, UserError, Unauthorized from fence.utils import get_valid_expiration @@ -36,7 +42,6 @@ def delete_data_file(file_id): file_id (str): GUID of file to delete """ record = IndexedFile(file_id) - authz = record.index_document.get("authz") has_correct_authz = None if authz: @@ -51,6 +56,7 @@ def delete_data_file(file_id): # Don't fall back on uploader -- this prevents users from escalating from edit to # delete permissions by changing the uploader field to their own username # (b/c users only have edit access through arborist/authz) + if has_correct_authz: logger.info("Deleting record and files for {}".format(file_id)) message, status_code = record.delete_files(delete_all=True) @@ -91,7 +97,6 @@ def delete_data_file(file_id): 403, ) logger.info("deleting record and files for {}".format(file_id)) - message, status_code = record.delete_files(delete_all=True) if str(status_code)[0] != "2": return flask.jsonify({"message": message}), status_code @@ -348,6 +353,33 @@ def download_file(file_id): return flask.redirect(result["url"]) +@blueprint.route("/download/bulk", methods=["POST"]) +def download_bulk_files(): + """ + Get a presigned url to download a file given by file_id. + """ + # {"guids": ["1234", "4567"]} + params = flask.request.get_json() + if not params: + raise UserError("wrong Content-Type; expected application/json") + + if "guids" not in params: + raise UserError("missing required argument `guids`") + + guids = params["guids"] + requested_protocol = flask.request.args.get("protocol", None) + r_pays_project = flask.request.args.get("userProject", None) + no_force_sign_param = flask.request.args.get("no_force_sign", None) + results = bulk_get_signed_url_for_file( + guids, + requested_protocol=requested_protocol, + r_pays_project=r_pays_project, + no_force_sign_param=no_force_sign_param, + ) + + return flask.jsonify(results) + + @blueprint.route( "/buckets", methods=["GET"], @@ -369,3 +401,96 @@ def get_bucket_region_info(): buckets[bucket] = info_copy return flask.jsonify(result) + + +@blueprint.route("/content", methods=["POST"]) +def get_file_content(): + """ + Get content of files referenced by GUIDs provided. + + This effectively bypasses the need to resolve individual signed URLs IFF the underlying + URLs of the GUIDs support bulk content retrieval. + + For now, this only supports Gen3 AI Embeddings. + """ + data = flask.request.get_json() + + # construct headers to use in later bulk retrieval requests + # by pulling the authorization header from this request + # e.g. we are passing the user's token along to subsequent calls + # to trusted and allowlisted bulk URLs + auth_header = flask.request.headers.get("Authorization") + headers = {} + if auth_header: + headers["Authorization"] = auth_header + + if not data: + raise UserError("wrong Content-Type; expected application/json") + if "guids" not in data: + raise UserError("missing required argument `guids`") + + guids_to_get = data.get("guids", []) + + total_requested_count = len(guids_to_get) + if total_requested_count > config["MAX_BULK_CONTENT_GUIDS_COUNT"]: + return ( + flask.jsonify( + { + "error_code": 413, + "error_description": f"Request too large: {total_requested_count} objects requested, maximum is {config['MAX_BULK_CONTENT_GUIDS_COUNT']}", + } + ), + 413, + ) + + guids_content_response = { + "guids": {}, + "total_guids": len(guids_to_get), + } + + # note that indexd appears to not impose a limitation on bulk GUID resolution, so our + # limit above is the only gateway to control at this point + bulk_records = BulkIndexedRecords(guids=guids_to_get) + bulk_request_urls_and_payloads, bulk_id_to_guid = ( + bulk_records.get_bulk_requests_and_mapping() + ) + + for request_url, request_body in bulk_request_urls_and_payloads.items(): + if not any( + request_url.startswith(allowed_ai_url) + for allowed_ai_url in config["ALLOWED_GEN3_EMBEDDINGS_BULK_URL_PREFIXES"] + ): + raise UserError( + f"indexed record not supported from bulk request URL: {request_url}, cannot proceed" + ) + + logger.debug(f"posting to {request_url} with data:{request_body}") + response = requests.post(request_url, headers=headers, json=request_body) + + # if user is denied access, above call will fail - so reraise the HTTP error here + response.raise_for_status() + + try: + content_json = response.json() + except requests.JSONDecodeError as exc: + logger.error(exc, exc_info=True) + raise UserError( + f"invalid content from bulk request URL: {request_url}, cannot proceed" + ) + + for item in content_json.get("embeddings", []): + embedding_id = item.get("embedding_id") + if embedding_id not in bulk_id_to_guid: + raise UserError( + f"invalid response from bulk request URL: {request_url}, cannot proceed" + ) + + guid = bulk_id_to_guid.get(embedding_id) + if not guid: + raise UserError( + f"invalid data for bulk request URL: {request_url}, embedding_id: {embedding_id} not found in original bulk request" + ) + + guids_content_response["guids"][guid] = item + + return flask.jsonify(guids_content_response) diff --git a/fence/blueprints/data/bulk_helpers.py b/fence/blueprints/data/bulk_helpers.py new file mode 100644 index 000000000..58bea0250 --- /dev/null +++ b/fence/blueprints/data/bulk_helpers.py @@ -0,0 +1,258 @@ +from collections import defaultdict +from dataclasses import dataclass + +import flask +from cdislogging import get_logger + +from fence.auth import get_jwt +from fence.config import config +from fence.errors import Unauthorized, NotFound + +logger = get_logger(__name__) + + +@dataclass +class BulkSignedUrlResult: + """Result container for bulk signed URL generation. + + Attributes: + signed_urls: List of resolved signed URL objects for bulk requests. + users: List of users used to authorize each signed URL request. + failed_guids: List of dictionaries describing failed GUIDs and error codes. + """ + + signed_urls: list + users: list + failed_guids: list + + +def normalize_authz_key(file_authz): + """Normalize an authz entry into a canonical key. + + Args: + file_authz: A file-level authz value, either a string or list. + + Returns: + A normalized authz key: + - tuple(sorted(list)) for list values + - unchanged string values + - None for unexpected input formats + """ + if isinstance(file_authz, list): + return tuple(sorted(file_authz)) + if isinstance(file_authz, str): + return file_authz + + logger.error(f"Unexpected authz format: {file_authz}") + return None + + +def collect_authz_needing_check(guids, index_document, auth_roles): + """Collect authz keys from bulk files that need authorization verification. + + Args: + guids: Iterable of file GUIDs requested in the bulk operation. + index_document: Mapping of file GUID to index document metadata. + auth_roles: List of authz keys already authorized by the request. + + Returns: + A set of normalized authz keys that are present in the index document and + are not already satisfied by the existing auth_roles. + """ + authz_needing_check = set() + for file_id in guids: + document = index_document.get(file_id) + if document is None: + continue + + authz_key = normalize_authz_key(document.get("authz")) + if authz_key is None or authz_key in auth_roles: + continue + + authz_needing_check.add(authz_key) + + return authz_needing_check + + +def _auth_mapping_has_read_storage(auth_mapping, authz_key): + """Determine whether the Arborist auth mapping grants read-storage for an authz key. + + Args: + auth_mapping: Arborist authorization mapping for a user or token. + authz_key: Normalized authz key from indexd metadata. + + Returns: + True if the auth_mapping contains a fence read-storage permission for the key. + """ + if auth_mapping is None: + return False + + authz_list = list(authz_key) if isinstance(authz_key, tuple) else [authz_key] + for resource in authz_list: + methods_list = auth_mapping.get(resource) + if not methods_list: + continue + + if any( + m.get("service") == "fence" and "read-storage" in m.get("method", "") + for m in methods_list + ): + return True + + return False + + +def build_authz_to_authorized_username(authz_needing_check, users_from_passports): + """Map requested authz keys to an authorized username where applicable. + + If passport-based users are present, this helper checks each user for the + required read-storage permission. When no passport users exist, it falls back + to the current request bearer token. + + Args: + authz_needing_check: Set of authz keys requiring authorization verification. + users_from_passports: Mapping of passport usernames to passport data. + + Returns: + A dictionary mapping each authorized authz key to a username, or None when + authorization is satisfied via bearer token instead of a passport user. + """ + authz_to_authorized_username = {} + if not authz_needing_check: + return authz_to_authorized_username + + if users_from_passports: + for username in users_from_passports.keys(): + try: + auth_mapping = flask.current_app.arborist.auth_mapping(username) + except Exception as exc: + logger.error(f"Failed to get auth mapping for {username}: {exc}") + continue + + for authz_key in authz_needing_check: + if authz_key in authz_to_authorized_username: + continue + if _auth_mapping_has_read_storage(auth_mapping, authz_key): + authz_to_authorized_username[authz_key] = username + else: + try: + token = get_jwt() + except Unauthorized: + token = None + + try: + auth_mapping = flask.current_app.arborist.auth_mapping(jwt=token) + except Exception as exc: + logger.error(f"Failed to get auth mapping from Arborist: {exc}") + auth_mapping = {} + + for authz_key in authz_needing_check: + if _auth_mapping_has_read_storage(auth_mapping, authz_key): + authz_to_authorized_username[authz_key] = None + + return authz_to_authorized_username + + +def process_bulk_signed_urls( + bulk, + protocol, + expires_in, + force_signed_url, + r_pays_project, + users_from_passports, + acl_authorization_check, +): + """Process a bulk signed URL request and resolve per-file authorization. + + This helper holds the shared bulk request logic for indexd file + authorization and signed URL generation. It resolves authz metadata, + performs an Arborist check when needed, and aggregates successful signed URL + results and failed object IDs. + + Args: + bulk: BulkIndexedFiles instance containing guids, index_document, and auth_roles. + protocol: Requested storage protocol for signed URLs. + expires_in: Signed URL expiry interval. + force_signed_url: Whether to force generation of a signed URL. + r_pays_project: Google requestor pays project ID, if applicable. + users_from_passports: Mapping of usernames to passport metadata. + acl_authorization_check: Callable used for ACL-based authorization fallback. + + Returns: + BulkSignedUrlResult containing successful signed URLs, associated users, + and any failed GUIDs grouped by HTTP error code. + """ + users_from_passports = users_from_passports or {} + signed_urls = [] + users = [] + failed_guids_map = defaultdict(list) + + authz_needing_check = collect_authz_needing_check( + bulk.guids, bulk.index_document, bulk.auth_roles + ) + authz_to_authorized_username = build_authz_to_authorized_username( + authz_needing_check, users_from_passports + ) + + for file_id in bulk.guids: + if file_id not in bulk.index_document: + failed_guids_map[404].append(file_id) + continue + + file_authz = bulk.index_document.get(file_id).get("authz") + authorized_user = None + + if file_authz: + authz_key = normalize_authz_key(file_authz) + if authz_key is None: + failed_guids_map[500].append(file_id) + continue + + if authz_key in bulk.auth_roles: + if users_from_passports: + username = authz_to_authorized_username.get(authz_key) + authorized_user = users_from_passports.get(username) + else: + authorized_user = None + elif authz_key in authz_to_authorized_username: + username = authz_to_authorized_username.get(authz_key) + if users_from_passports: + authorized_user = users_from_passports.get(username) + else: + authorized_user = None + bulk.auth_roles.append(authz_key) + else: + error_code = 403 if users_from_passports else 401 + failed_guids_map[error_code].append(file_id) + continue + else: + if not acl_authorization_check(file_id): + failed_guids_map[401].append(file_id) + continue + + try: + signed_url = bulk._get_signed_urls( + protocol, + file_id, + expires_in, + force_signed_url, + r_pays_project, + authorized_user, + ) + signed_urls.append({"drs_object_id": file_id, "url": signed_url}) + users.append(authorized_user) + except Exception as exc: + if isinstance(exc, NotFound): + failed_guids_map[404].append(file_id) + else: + logger.error( + f"Unexpected error generating signed URL for {file_id}: {exc}" + ) + failed_guids_map[500].append(file_id) + + failed_guids = [ + {"error_code": error_code, "object_ids": object_ids} + for error_code, object_ids in failed_guids_map.items() + ] + + return BulkSignedUrlResult(signed_urls, users, failed_guids) diff --git a/fence/blueprints/data/content_blueprint.py b/fence/blueprints/data/content_blueprint.py new file mode 100644 index 000000000..db843bcc9 --- /dev/null +++ b/fence/blueprints/data/content_blueprint.py @@ -0,0 +1,5 @@ +import flask +from fence.blueprints.data.blueprint import get_file_content + +blueprint = flask.Blueprint("data_content_only", __name__) +blueprint.add_url_rule("/content", view_func=get_file_content, methods=["POST"]) diff --git a/fence/blueprints/data/indexd.py b/fence/blueprints/data/indexd.py index 85f2c021b..2c33f52ec 100755 --- a/fence/blueprints/data/indexd.py +++ b/fence/blueprints/data/indexd.py @@ -1,11 +1,13 @@ import re import time import json +import hashlib import boto3 from botocore.client import Config -from urllib.parse import urlparse, ParseResult, urlunparse -from datetime import datetime, timedelta - +from collections import defaultdict +from urllib.parse import urljoin, urlparse, ParseResult, urlunparse +from datetime import datetime, timedelta, UTC +from sqlalchemy import text from sqlalchemy.sql.functions import user from cached_property import cached_property import gen3cirrus @@ -25,6 +27,8 @@ from fence import auth from fence.auth import ( + GEN3_AUDIENCE, + get_ip_information_string, get_jwt, current_token, login_required, @@ -53,6 +57,7 @@ from fence.metrics import metrics from . import multipart_upload +from .bulk_helpers import process_bulk_signed_urls from ...models import AssumeRoleCacheAWS, query_for_user, query_for_user_by_id from ...models import AssumeRoleCacheGCP @@ -65,6 +70,7 @@ } SUPPORTED_PROTOCOLS = ["s3", "http", "ftp", "https", "gs", "az"] +PROTOCOLS_REQUIRING_GOOGLE_SYNC = {"gs"} SUPPORTED_ACTIONS = ["upload", "download"] ANONYMOUS_USER_ID = "-1" ANONYMOUS_USERNAME = "anonymous" @@ -99,11 +105,17 @@ def get_signed_url_for_file( users_from_passports = {} if ga4gh_passports: - # users_from_passports = {"username": Fence.User} + # Note: We don't need to check for None requested_protocol here, as using a ga4gh_passport requires specifying + # a protocol users_from_passports = sync_gen3_users_authz_from_ga4gh_passports( - ga4gh_passports, db_session=db_session + ga4gh_passports, + db_session=db_session, + skip_google_updates=requested_protocol + not in PROTOCOLS_REQUIRING_GOOGLE_SYNC, ) + # Collect audit headers (including X-Forwarded headers and User-Agent) to include in audit log + audit_headers = collect_audit_headers(flask.request.headers) # add the user details to `flask.g.audit_data` first, so they are # included in the audit log if `IndexedFile(file_id)` raises a 404 if users_from_passports: @@ -116,6 +128,7 @@ def get_signed_url_for_file( audit_data = { "username": username, "sub": user.id, + "additional_data": audit_headers or [], } logger.info( f"passport with multiple user ids is attempting data access. audit log: {audit_data}" @@ -125,6 +138,7 @@ def get_signed_url_for_file( flask.g.audit_data = { "username": username, "sub": user.id, + "additional_data": audit_headers or [], } else: auth_info = _get_auth_info_for_id_or_from_request( @@ -133,8 +147,8 @@ def get_signed_url_for_file( flask.g.audit_data = { "username": auth_info["username"], "sub": auth_info["user_id"], + "additional_data": audit_headers or [], } - indexed_file = IndexedFile(file_id) default_expires_in = config.get("MAX_PRESIGNED_URL_TTL", 3600) expires_in = get_valid_expiration_from_request( @@ -161,7 +175,6 @@ def get_signed_url_for_file( "username": authorized_user_from_passport.username, "sub": authorized_user_from_passport.id, } - _log_signed_url_data_info( indexed_file=indexed_file, user_sub=flask.g.audit_data.get("sub", ""), @@ -174,6 +187,111 @@ def get_signed_url_for_file( return {"url": signed_url} +@enable_audit_logging +def bulk_get_signed_url_for_file( + guids, + requested_protocol=None, + db_session=None, + ga4gh_passports=None, + no_force_sign_param=None, + r_pays_project=None, +): + """ + Generate signed access URLs for multiple indexed files. + Args: + guids (list[str]): GUIDs of indexed files to retrieve URLs for. + requested_protocol (str, optional): Desired access protocol + (for example, ``s3`` or ``gs``). If not provided, the default + protocol for each file is used. + db_session (Session, optional): Database session to use. If omitted, + the current Flask scoped session is used. + ga4gh_passports (list[str], optional): GA4GH Passport tokens used + for authentication and authorization. + no_force_sign_param (str, optional): If set to ``"true"``, allows + returning unsigned URLs when supported by the backend. + r_pays_project (str, optional): Requester-pays project identifier + used for supported storage providers. + + Returns: + dict: A dictionary containing: + - ``urls``: Mapping of file IDs to generated access URLs. + - ``failed_guids``: List of file IDs for which URL generation + failed. + """ + requested_protocol = requested_protocol + db_session = db_session or current_app.scoped_session() + + # default to signing the URL + force_signed_url = True + if no_force_sign_param and no_force_sign_param.lower() == "true": + force_signed_url = False + + # Collect audit headers (including X-Forwarded headers and User-Agent) to include in audit log + audit_headers = collect_audit_headers(flask.request.headers) + + users_from_passports = {} + if ga4gh_passports: + users_from_passports = sync_gen3_users_authz_from_ga4gh_passports( + ga4gh_passports, db_session=db_session + ) + if users_from_passports: + if len(users_from_passports) > 1: + logger.warning( + "audit service doesn't support multiple users for a " + "single request yet, so just log userinfo here" + ) + for username, user in users_from_passports.items(): + audit_data = { + "username": username, + "sub": user.id, + "additional_data": audit_headers or [], + } + logger.info( + f"passport with multiple user ids is attempting data access. audit log: {audit_data}" + ) + else: + username, user = next(iter(users_from_passports.items())) + flask.g.audit_data = { + "username": username, + "sub": user.id, + "additional_data": audit_headers or [], + } + else: + auth_info = _get_auth_info_for_id_or_from_request( + sub_type=int, db_session=db_session + ) + flask.g.audit_data = { + "username": auth_info["username"], + "sub": auth_info["user_id"], + "additional_data": audit_headers or [], + } + + indexed_files = BulkIndexedFiles(guids) + + default_expires_in = config.get("MAX_PRESIGNED_URL_TTL", 3600) + expires_in = get_valid_expiration_from_request( + max_limit=default_expires_in, + default=default_expires_in, + ) + + if ga4gh_passports and not config["GA4GH_PASSPORTS_TO_DRS_ENABLED"]: + raise NotSupported( + "Using GA4GH Passports as a means of authentication and authorization " + "is not supported by this instance of Gen3." + ) + + prepare_bulk_presigned_url_audit_log(requested_protocol, indexed_files) + signed_urls, users, failed_guids = indexed_files.get_signed_urls( + requested_protocol, + expires_in, + force_signed_url=force_signed_url, + r_pays_project=r_pays_project, + users_from_passports=users_from_passports, + ) + + return {"urls": signed_urls, "failed_guids": failed_guids} + + def get_bucket_from_urls(urls, protocol): """ Return the bucket name from the first of the provided URLs that starts with the given protocol (usually `gs`, `s3`, `az`...) @@ -210,6 +328,10 @@ def _log_signed_url_data_info( f"Signed URL Generated. action={action} size_in_kibibytes={size_in_kibibytes} " f"acl={acl} authz={authz} bucket={bucket} user_sub={user_sub} client_id={client_id}" ) + ip_info = get_ip_information_string() + logger.info( + f"User identified for signed URL generation. user_sub={user_sub} client_id={client_id} {ip_info}" + ) metrics.add_signed_url_event( action, @@ -249,6 +371,50 @@ def prepare_presigned_url_audit_log(protocol, indexed_file): flask.g.audit_data["protocol"] = protocol +def prepare_bulk_presigned_url_audit_log(protocol, indexed_files): + """ + Store audit data for bulk signed URL requests. + """ + + all_resource_paths = [] + per_file_info = [] + + for file_id, doc in indexed_files.index_document.items(): + resource_paths = doc.get("authz") or doc.get("acl") or [] + + # Determine protocol fallback like single-file version + file_protocol = protocol + if not file_protocol and doc.get("urls"): + file_protocol = IndexedFileLocation.from_url(doc["urls"][0]).protocol + + all_resource_paths.extend(resource_paths) + + per_file_info.append( + { + "file_id": file_id, + "resource_paths": resource_paths, + "protocol": file_protocol, + } + ) + + flask.g.audit_data["resource_paths"] = list(set(all_resource_paths)) + flask.g.audit_data["protocol"] = protocol + flask.g.audit_data["bulk"] = True + + # Optional but VERY useful for debugging / observability + flask.g.audit_data["bulk_files"] = per_file_info + + +def _check_acl_authorization(bulk, file_id): + temp_indexed_file = IndexedFile(file_id) + if ( + not temp_indexed_file.public_acl + and not temp_indexed_file.check_legacy_authorization("download") + ): + return False + return True + + class BlankIndex(object): """ A blank record in indexd, to use for the data upload flow. @@ -458,6 +624,260 @@ def generate_aws_presigned_url_for_part( ) +class BulkIndexedFiles(object): + """ + For bulk handling of files + + Args: + guids (list(str)): A list of GUIDs coresponding to files + """ + + def __init__(self, guids): + self.guids = guids + self.auth_roles = [] + + @cached_property + def indexd_server(self): + indexd_server = ( + flask.current_app.config.get("INDEXD") + or flask.current_app.config["BASE_URL"] + "/index" + ) + return indexd_server.rstrip("/") + + @cached_property + def index_document(self): + indexd_server = config.get("INDEXD") or config["BASE_URL"] + "/index" + url = indexd_server.rstrip("/") + "/bulk/documents" + + index_dict = {} + try: + res = requests.post(url, json=self.guids) + except Exception as e: + logger.error(f"Failed to reach indexd at {url}: {e}") + raise UnavailableError("Fail to reach id service to find data location") + + if res.status_code == 200: + try: + docs = res.json() + if not isinstance(docs, list): + raise ValueError("Response is not a list") + + # Build dict keyed by did - only returns docs that were found + index_dict = {doc["did"]: doc for doc in docs} + return index_dict + except (KeyError, ValueError, TypeError) as e: + logger.error(f"Invalid response from indexd: {e}") + raise InternalError("Internal error from indexd") + else: + raise UnavailableError(res.text) + + def get_signed_urls( + self, + protocol, + expires_in, + force_signed_url=True, + r_pays_project=None, + users_from_passports=None, + ): + """ + Generate signed URLs for a collection of indexed files. + + This method performs authorization checks in bulk to reduce the number + of Arborist authorization lookups required for large requests. Files are + grouped by their authz resources, authorization is evaluated once per + unique authz set, and the cached results are then reused while generating + signed URLs for individual files. + + Authorization can be performed using either: + - GA4GH Passport-derived users, or + - the JWT associated with the current request. + + Files that cannot be found, are unauthorized, or encounter URL generation + errors are collected and returned in a structured failure report. + + Args: + protocol (str): Access protocol to use when generating URLs. + expires_in (int): URL expiration time in seconds. + force_signed_url (bool, optional): Whether to force generation of a + signed URL even when the backend supports unsigned access. + Defaults to True. + r_pays_project (str, optional): Requester-pays project identifier + used for supported storage backends. + users_from_passports (dict[str, User], optional): Mapping of usernames + to user objects obtained from validated GA4GH passports. + + Returns: + tuple: + - signed_urls (list[dict]): Successfully generated URLs in the + format: + ``{"drs_object_id": , "url": }`` + - users (list[User | None]): Authorized user associated with each + generated URL, or ``None`` when JWT-based authorization is used. + - failed_guids (list[dict]): Failure information grouped by + error code in the format: + ``{"error_code": , "object_ids": []}`` + + Workflow: + 1. Collect unique authz requirements for all requested files. + 2. Perform bulk authorization checks. + 3. Generate signed URLs for authorized files. + 4. Aggregate failures by error code. + + Notes: + Files may fail with: + - 401: Authentication or authorization failure. + - 403: Passport-authenticated user lacks required permissions. + - 404: File does not exist. + - 500: Unexpected error while generating a signed URL. + """ + result = process_bulk_signed_urls( + self, + protocol, + expires_in, + force_signed_url, + r_pays_project, + users_from_passports, + acl_authorization_check=lambda file_id: _check_acl_authorization( + self, file_id + ), + ) + + return result.signed_urls, result.users, result.failed_guids + + def _get_signed_urls( + self, + protocol, + file_id, + expires_in, + force_signed_url, + r_pays_project, + authorized_user=None, + ): + """ + Generate a signed download URL for a specific file. + + The file's indexed locations are searched for a location matching the + requested protocol. If no protocol is specified, the first available + location is used. Requests for ``http`` are allowed to match locations + stored as ``https``. + + Args: + protocol (str, optional): Desired access protocol (for example, + ``s3``, ``gs``, ``http``, or ``https``). If not provided, the + first available file location is used. + file_id (str): Identifier of the indexed file. + expires_in (int): URL expiration time in seconds. + force_signed_url (bool): Whether to force generation of a signed URL + even when unsigned access is supported by the storage backend. + r_pays_project (str, optional): Requester-pays project identifier + used for supported storage providers. + authorized_user (User, optional): User object to use when generating + backend-specific access credentials. + + Returns: + str: A signed download URL for the requested file location. + + Raises: + NotFound: If the file has no locations or no location matching the + requested protocol. + """ + # we only allow for download with bulk operations + file_urls = self.index_document.get(file_id).get("urls", []) + file_locations = list(map(IndexedFileLocation.from_url, file_urls)) + if not protocol: + # no protocol specified, return first location as signed url + try: + return file_locations[0].get_signed_url( + "download", + expires_in, + force_signed_url=force_signed_url, + r_pays_project=r_pays_project, + authorized_user=authorized_user, + ) + except IndexError: + raise NotFound("Can't find any file locations.") + + for file_location in file_locations: + # allow file location to be https, even if they specific http + if (file_location.protocol == protocol) or ( + protocol == "http" and file_location.protocol == "https" + ): + return file_location.get_signed_url( + "download", + expires_in, + force_signed_url=force_signed_url, + r_pays_project=r_pays_project, + authorized_user=authorized_user, + ) + + raise NotFound( + "File {} does not have a location with specified " + "protocol {}.".format(file_id, protocol) + ) + + def get_authorized_with_username( + self, action, file_id, usernames_from_passports=None + ): + """ + Return a tuple of (boolean, str) which represents whether they're authorized + and their username. username is only returned if `usernames_from_passports` + is provided and one of the usernames from the passports is authorized. + + Args: + action (str): Authorization action being performed + usernames_from_passports (list[str], optional): List of user usernames parsed + from validated passports + + Returns: + tuple of (boolean, str): which represents whether they're authorized + and their username. username is only returned if `usernames_from_passports` + is provided and one of the usernames from the passports is authorized. + """ + if not self.index_document.get(file_id).get("authz"): + raise ValueError("index record missing `authz`") + + logger.debug( + f"authz check can user {action} on {self.index_document[file_id]['authz']} for fence? " + f"if passport provided, IDs parsed: {usernames_from_passports}" + ) + + # handle multiple GA4GH passports as a means of authn/z + if usernames_from_passports: + authorized = False + for username in usernames_from_passports: + authorized = flask.current_app.arborist.auth_request( + jwt=None, + user_id=username, + service="fence", + methods=action, + resources=self.index_document[file_id]["authz"], + ) + # if any passport provides access, user is authorized + if authorized: + # for google proxy groups and future use: we need to know which + # user_id actually gave access + return authorized, username + return authorized, None + else: + try: + token = get_jwt() + except Unauthorized: + # get_jwt raises an Unauthorized error when user is anonymous (no + # available token), so to allow anonymous users possible access to + # public data, we still make the request to Arborist + token = None + + return ( + flask.current_app.arborist.auth_request( + jwt=token, + service="fence", + methods=action, + resources=self.index_document[file_id]["authz"], + ), + None, + ) + + class IndexedFile(object): """ A file from the index service that will contain information about access and where @@ -761,6 +1181,7 @@ def delete_files(self, urls=None, delete_all=True): Return: Response (str: message, int: status code) """ + locations_to_delete = [] if not urls and delete_all: locations_to_delete = self.indexed_file_locations @@ -793,6 +1214,7 @@ def delete_files(self, urls=None, delete_all=True): @login_required({"data"}) def delete(self): + rev = self.index_document["rev"] path = "{}/index/{}".format(self.indexd_server, self.file_id) auth = (config["INDEXD_USERNAME"], config["INDEXD_PASSWORD"]) @@ -806,6 +1228,108 @@ def delete(self): return ("", 204) +class BulkIndexedRecords(object): + """ + Explicitly handles bulk interaction with Indexd, similar to IndexedFile class above. + """ + + def __init__(self, guids: list[str]): + self.guids = guids + + @cached_property + def bulk_indexed_records(self) -> dict: + """ + The bulk records referenced by self.guids. + + First time this is called, it actually makes the request to indexd + to get the records. + """ + indexd_server = config.get("INDEXD") or config["BASE_URL"] + "/index" + url = indexd_server + "/bulk/documents" + + try: + logger.debug(f"posting {url} with data:{self.guids}") + res = requests.post(url, json=self.guids) + except Exception as exc: + logger.error(f"failed to reach indexd at {url}: {exc}") + raise UnavailableError("Fail to reach id service to find data location") + + bulk_records = [] + if res.status_code == 200: + try: + bulk_records = res.json() + except requests.JSONDecodeError as exc: + logger.error("indexd response not valid JSON") + raise InternalError(f"internal error from indexd: {exc}") + elif res.status_code == 400: + message = "Bulk request failed. Could not find all provided IDs." + logger.error(message) + raise NotFound(message) + else: + raise UnavailableError(res.text) + + records = {} + for record in bulk_records: + guid = record.get("did") + if not guid: + raise InternalError(f"Could not retrieve `did` from record: {record}") + + records[guid] = record + + return records + + def get_bulk_requests_and_mapping(self): + """ + Returns a dict of URLs and bodies of data which support bulk retrieval. + The returned request urls and payloads must actually be resolved to get the + bulk content. + + At the moment, bulk content retrieval is only supported for Gen3 Embeddings. + The allow list of domains is part of the Fence config: ALLOWED_GEN3_EMBEDDINGS_BULK_URL_PREFIXES + """ + bulk_id_to_guid = {} + bulk_request_urls_and_payloads = {} + + logger.debug(f"bulk_indexed_records={self.bulk_indexed_records}") + for guid, record in self.bulk_indexed_records.items(): + file_locations = [ + IndexedFileLocation.from_url(url) for url in record.get("urls", []) + ] + + valid = False + for file_location in file_locations: + # handle Gen3 AI Embeddings + allowed_ai_url_root = "" + for allowed_ai_url in config[ + "ALLOWED_GEN3_EMBEDDINGS_BULK_URL_PREFIXES" + ]: + if file_location.url.startswith(allowed_ai_url): + match = re.search( + config["GEN3_EMBEDDINGS_API_REGEX"], + file_location.parsed_url.path, + ) + + if match: + collection_name = match.group("collection_name") + embedding_uuid = match.group("embedding_uuid") + + # start constructing the required bulk requests + bulk_embeddings_request_url = f"{allowed_ai_url.rstrip('/')}/vectorstore/collections/{collection_name}/embeddings/bulk" + bulk_request_urls_and_payloads.setdefault( + bulk_embeddings_request_url, [] + ).append(embedding_uuid) + bulk_id_to_guid[embedding_uuid] = guid + + valid = True + + # any new endpoints with valid bulk content retrieval support can be added here + + if not valid: + raise NotSupported("GUID URL(s) do not support bulk retrieval.") + + return bulk_request_urls_and_payloads, bulk_id_to_guid + + class IndexedFileLocation(object): """ Parent class for indexed file locations. @@ -945,7 +1469,8 @@ def assume_role(cls, bucket_cred, expires_in, aws_creds_config, boto=None): if hasattr(flask.current_app, "db"): # we don't have db in startup with flask.current_app.db.session as session: session.execute( - """\ + text( + """\ INSERT INTO assume_role_cache ( arn, expires_at, @@ -962,7 +1487,8 @@ def assume_role(cls, bucket_cred, expires_in, aws_creds_config, boto=None): expires_at = EXCLUDED.expires_at, aws_access_key_id = EXCLUDED.aws_access_key_id, aws_secret_access_key = EXCLUDED.aws_secret_access_key, - aws_session_token = EXCLUDED.aws_session_token;""", + aws_session_token = EXCLUDED.aws_session_token;""" + ), dict(arn=role_arn, expires_at=expires_at, **rv), ) return rv @@ -977,6 +1503,10 @@ def bucket_name(self): "S3_BUCKETS", InternalError("S3_BUCKETS not configured"), ) + explicit_match = self.parsed_url.netloc + if explicit_match in s3_buckets: + return explicit_match + for bucket in s3_buckets: if re.match("^" + bucket + "$", self.parsed_url.netloc): return bucket @@ -1000,7 +1530,7 @@ def get_credential_to_access_bucket( bucket_cred = s3_buckets.get(bucket_name) if bucket_cred is None: - logger.debug(f"Bucket '{bucket_name}' not found in S3_BUCKETS config") + logger.info(f"Bucket '{bucket_name}' not found in S3_BUCKETS config") raise InternalError("permission denied for bucket") cred_key = get_value( @@ -1251,7 +1781,6 @@ def get_signed_url( resource_path = self.get_resource_path() auth_info = _get_auth_info_for_id_or_from_request(user=authorized_user) - if not force_signed_url: url = "https://storage.cloud.google.com/" + resource_path elif _is_anonymous_user(auth_info): @@ -1367,6 +1896,7 @@ def _generate_google_storage_signed_url( # If our scheduled maintainence script removes the url-signing key # before the expiration of the url then the url will NOT work # (even though the url itself isn't expired) + if key_db_entry.expires < expiration_time: private_key = create_primary_service_account_key( user_id=user_id, username=username, proxy_group_id=proxy_group_id @@ -1386,7 +1916,8 @@ def _generate_google_storage_signed_url( # we don't need to populate gcp_key_db_entry anymore, it was for # expiration, but now we have a specific field for that. session.execute( - """\ + text( + """\ INSERT INTO gcp_assume_role_cache ( expires_at, gcp_proxy_group_id, @@ -1401,7 +1932,8 @@ def _generate_google_storage_signed_url( expires_at = EXCLUDED.expires_at, gcp_proxy_group_id = EXCLUDED.gcp_proxy_group_id, gcp_private_key = EXCLUDED.gcp_private_key, - gcp_key_db_entry = EXCLUDED.gcp_key_db_entry;""", + gcp_key_db_entry = EXCLUDED.gcp_key_db_entry;""" + ), db_entry, ) @@ -1464,7 +1996,7 @@ def _generate_azure_blob_storage_sas( :param str blob_name: Name of blob. :param int expires_in: - The SAS token will expire in a given number of seconds from datetime.utcnow() + The SAS token will expire in a given number of seconds from datetime.now(UTC) :param str azure_creds: The Azure Blob Storage Account connection string :param AccountSasPermissions permission: @@ -1488,7 +2020,7 @@ def _generate_azure_blob_storage_sas( account_key=blob_service_client.credential.account_key, resource_types=ResourceTypes(object=True), permission=permission, - expiry=datetime.utcnow() + timedelta(seconds=expires_in), + expiry=datetime.now(UTC) + timedelta(seconds=expires_in), ) sas_url = converted_url + "?" + sas_query @@ -1564,7 +2096,7 @@ def get_signed_url( :param str action: Get a signed url for an action like "upload" or "download". :param int expires_in: - The SAS token will expire in a given number of seconds from datetime.utcnow() + The SAS token will expire in a given number of seconds from datetime.now(UTC) :param bool force_signed_url: Enforce signing the URL for the Azure Blob Storage Account using a SAS token. The default is True. @@ -1594,9 +2126,11 @@ def get_signed_url( blob_name, expires_in, azure_creds, - permission=AccountSasPermissions(read=True) - if action == "download" - else AccountSasPermissions(read=True, write=True), + permission=( + AccountSasPermissions(read=True) + if action == "download" + else AccountSasPermissions(read=True, write=True) + ), ) return url @@ -1672,7 +2206,7 @@ def _get_auth_info_for_id_or_from_request( final_username = result.username final_user_id = sub_type(result.id) else: - token = validate_request(scope={"user"}, audience=config.get("BASE_URL")) + token = validate_request(scope={"user"}, audience=GEN3_AUDIENCE) set_current_token(token) final_user_id = current_token["sub"] final_user_id = sub_type(final_user_id) @@ -1685,7 +2219,7 @@ def _get_auth_info_for_id_or_from_request( client_id = "" try: if not token: - token = validate_request(scope=[], audience=config.get("BASE_URL")) + token = validate_request(scope=[], audience=GEN3_AUDIENCE) set_current_token(token) client_id = current_token.get("azp") or "" except Exception as exc: @@ -1732,5 +2266,42 @@ def verify_data_upload_bucket_configuration(bucket): s3_buckets = flask.current_app.config["ALLOWED_DATA_UPLOAD_BUCKETS"] if bucket not in s3_buckets: logger.error(f"Bucket '{bucket}' not in ALLOWED_DATA_UPLOAD_BUCKETS config") - logger.debug(f"Buckets configgured in ALLOWED_DATA_UPLOAD_BUCKETS {s3_buckets}") + logger.debug( + f"Buckets configgured in ALLOWED_DATA_UPLOAD_BUCKETS: {s3_buckets}" + ) raise Forbidden(f"Uploading to bucket '{bucket}' is not allowed") + + +def collect_x_forwarded_headers(headers): + """ + Return a list of X-* headers formatted as "header:value" strings. + + Args: + headers (Iterable[Tuple[str, str]]): Header name/value pairs. + + Returns: + List[str]: X-* headers in "header:value" format. + """ + x_forwarded_headers = [ + f"{header}:{value}" for header, value in headers if "X-" in header + ] + return x_forwarded_headers + + +def collect_audit_headers(headers): + """ + Collect request headers used for audit logging. + + Returns a list containing all X-* headers along with the User-Agent + header, formatted as "Header-Name:value" strings. + + Args: + headers: A mapping of HTTP request headers. + + Returns: + A list of formatted header strings for audit purposes. + """ + x_forwarded_headers = collect_x_forwarded_headers(headers) + user_agent = f"User-Agent:{headers.get('User-Agent')}" + audit_headers = x_forwarded_headers + [user_agent] + return audit_headers diff --git a/fence/blueprints/ga4gh.py b/fence/blueprints/ga4gh.py index 890da83f6..c680e01ec 100644 --- a/fence/blueprints/ga4gh.py +++ b/fence/blueprints/ga4gh.py @@ -1,14 +1,113 @@ import flask -from flask import request +from flask import current_app +from fence.auth import login_required from fence.errors import UserError +from collections import defaultdict +from typing import Optional + +from flask import Blueprint, request, jsonify +from pydantic import BaseModel, ValidationError +from cdislogging import get_logger + +from fence.errors import ( + NotSupported, + UserError, + Unauthorized, + Forbidden, + NotFound, + UnavailableError, +) from fence.config import config + from fence.blueprints.data.indexd import ( get_signed_url_for_file, + BulkIndexedFiles, + bulk_get_signed_url_for_file, ) -blueprint = flask.Blueprint("ga4gh", __name__) + +class BulkObjectAccessIds(BaseModel): + """Represents a mapping between a bulk object ID and the access IDs it can use. + + TODO: Remove this model when the GA4GH endpoints are converted to FastAPI. + See PD-151. + """ + + bulk_object_id: str + bulk_access_ids: list[str] + + +class BulkObjectAccessRequest(BaseModel): + """Represents the bulk GA4GH object-access request payload. + + TODO: Remove this model when the GA4GH endpoints are converted to FastAPI. + See PD-151. + """ + + passports: Optional[list[str]] = None + bulk_object_access_ids: list[BulkObjectAccessIds] + + def map_access_to_object_ids(self): + result = defaultdict(list) + for item in self.bulk_object_access_ids: + for access_id in item.bulk_access_ids: + result[access_id].append(item.bulk_object_id) + + return result + + +class ResolvedDrsObject(BaseModel): + """Represents a resolved DRS object with its signed access URL. + + TODO: Remove this model when the GA4GH endpoints are converted to FastAPI. + See PD-151. + """ + + drs_object_id: str + drs_access_id: str + url: str + headers: Optional[str] + + +class UnresolvedDrsObject(BaseModel): + """Represents the unresolved DRS objects returned for an access request. + + TODO: Remove this model when the GA4GH endpoints are converted to FastAPI. + See PD-151. + """ + + error_code: int + object_ids: list[str] + + +class BulkObjectSummary(BaseModel): + """Represents the summary counts for a bulk object access response. + + TODO: Remove this model when the GA4GH endpoints are converted to FastAPI. + See PD-151. + """ + + requested: int + resolved: int + unresolved: int + + +class BulkObjectAccessResponse(BaseModel): + """Represents the full bulk object access response payload. + + TODO: Remove this model when the GA4GH endpoints are converted to FastAPI. + See PD-151. + """ + + summary: BulkObjectSummary + unresolved_drs_objects: UnresolvedDrsObject + resolved_drs_object_access_urls: ResolvedDrsObject + + +blueprint = Blueprint("ga4gh", __name__) +logger = get_logger(__name__) @blueprint.route( @@ -25,12 +124,12 @@ def get_ga4gh_signed_url(object_id, access_id): raise UserError("Access ID/Protocol is required.") ga4gh_passports = None - if flask.request.method == "POST": - ga4gh_passports = flask.request.get_json(force=True, silent=True).get( + if request.method == "POST": + ga4gh_passports = request.get_json(force=True, silent=True).get( config["GA4GH_DRS_POSTED_PASSPORT_FIELD"] ) - if ga4gh_passports and flask.request.headers.get("Authorization"): + if ga4gh_passports and request.headers.get("Authorization"): raise UserError( "You cannot supply both GA4GH passports and a token " "in the Authorization header of a request." @@ -44,4 +143,126 @@ def get_ga4gh_signed_url(object_id, access_id): drs="True", ) - return flask.jsonify(result) + return jsonify(result) + + +@blueprint.route("/drs/v1/objects/access", methods=["POST"]) +def get_ga4gh_signed_urls(): + r_pays_project = flask.request.args.get("userProject", None) + no_force_sign_param = flask.request.args.get("no_force_sign", None) + try: + bulk_request = BulkObjectAccessRequest( + **request.get_json(force=True, silent=True) + ) + except ValidationError as e: + return jsonify(e.errors()), 400 + + if request.headers.get("Authorization") and bulk_request.passports: + raise UserError( + "You cannot supply both GA4GH passports and a token " + "in the Authorization header of a request." + ) + if bulk_request.passports and not config["GA4GH_PASSPORTS_TO_DRS_ENABLED"]: + raise NotSupported( + "Using GA4GH Passports as a means of authentication and authorization " + "is not supported by this instance of Gen3." + ) + + # Validate bulk request size against maxBulk config + total_requested_count = sum( + len(ids) for ids in bulk_request.map_access_to_object_ids().values() + ) + max_bulk_requests = config.get("MAX_BULK_DRS_REQUESTS") + if total_requested_count > max_bulk_requests: + return ( + jsonify( + { + "error_code": 413, + "error_description": f"Request too large: {total_requested_count} objects requested, maximum is {max_bulk_requests}", + } + ), + 413, + ) + + access_to_object_ids = bulk_request.map_access_to_object_ids() + resolved_urls = [] + unresolved_by_code = defaultdict(list) + + # Get URLs for each access protocol + for access_id, object_ids in access_to_object_ids.items(): + try: + result = bulk_get_signed_url_for_file( + guids=object_ids, + requested_protocol=access_id, + ga4gh_passports=bulk_request.passports, + r_pays_project=r_pays_project, + no_force_sign_param=no_force_sign_param, + ) + + # Process resolved URLs + urls = result.get("urls", []) + for url_response in urls: + if isinstance(url_response, dict) and "url" in url_response: + resolved_urls.append( + { + "drs_object_id": url_response.get("drs_object_id"), + "drs_access_id": access_id, + "url": url_response.get("url"), + "headers": url_response.get("headers", []), + } + ) + + # Process unresolved (failed) objects + failed_objects = result.get("failed_guids", []) + for failed in failed_objects: + error_code = failed.get("error_code", 500) + object_ids_list = failed.get("object_ids", []) + unresolved_by_code[error_code].extend(object_ids_list) + + except UserError as e: + # Authorization or validation errors - mark all as 403 + logger.debug(f"UserError fetching signed URLs for {access_id}: {e}") + unresolved_by_code[403].extend(object_ids) + except (Unauthorized, Forbidden) as e: + # Authentication/authorization errors + logger.debug(f"Auth error fetching signed URLs for {access_id}: {e}") + error_code = 401 if isinstance(e, Unauthorized) else 403 + unresolved_by_code[error_code].extend(object_ids) + except NotFound as e: + logger.debug(f"NotFound fetching signed URLs for {access_id}: {e}") + unresolved_by_code[404].extend(object_ids) + except UnavailableError as e: + logger.error(f"UnavailableError fetching signed URLs for {access_id}: {e}") + unresolved_by_code[500].extend(object_ids) + except Exception as e: + logger.error(f"Unexpected error fetching signed URLs for {access_id}: {e}") + unresolved_by_code[500].extend(object_ids) + + # Format unresolved objects response + unresolved_drs_objects = [ + {"error_code": error_code, "object_ids": object_ids_list} + for error_code, object_ids_list in unresolved_by_code.items() + ] + + # Calculate summary + total_resolved = len(resolved_urls) + total_unresolved = sum(len(ids) for ids in unresolved_by_code.values()) + + response = { + "summary": { + "requested": total_requested_count, + "resolved": total_resolved, + "unresolved": total_unresolved, + }, + "unresolved_drs_objects": unresolved_drs_objects, + "resolved_drs_object_access_urls": resolved_urls, + } + + # If nothing resolved, return the most common error code instead of 200 + if not resolved_urls and unresolved_by_code: + primary_error = max( + unresolved_by_code.keys(), key=lambda k: len(unresolved_by_code[k]) + ) + return jsonify(response), primary_error + + return jsonify(response), 200 diff --git a/fence/blueprints/link.py b/fence/blueprints/link.py index a9854f067..11c51ff85 100644 --- a/fence/blueprints/link.py +++ b/fence/blueprints/link.py @@ -15,7 +15,12 @@ from fence.models import UserGoogleAccount from fence.models import UserGoogleAccountToProxyGroup -from fence.auth import current_token, get_user_from_claims, validate_request +from fence.auth import ( + GEN3_AUDIENCE, + current_token, + get_user_from_claims, + validate_request, +) from fence.auth import require_auth_header from fence.config import config from fence.resources.google.utils import ( @@ -280,9 +285,7 @@ def get(self): # if we're mocking google auth, mock response to include the email # from the provided access token try: - token = validate_request( - scope={"user"}, audience=config.get("BASE_URL") - ) + token = validate_request(scope={"user"}, audience=GEN3_AUDIENCE) email = get_user_from_claims(token).username except Exception as exc: logger.info( diff --git a/fence/blueprints/login/__init__.py b/fence/blueprints/login/__init__.py index 7f7ff1d29..5f91d3dd9 100644 --- a/fence/blueprints/login/__init__.py +++ b/fence/blueprints/login/__init__.py @@ -6,7 +6,7 @@ blueprint resource). For generic OIDC implementations, the login and callback classes are created -dynamically by the `createLoginClass` and `createCallbackClass` functions. +dynamically by the `create_login_class` and `create_callback_class` functions. They are subclasses of the `DefaultOAuth2Login` and `DefaultOAuth2Callback` classes; only the provider name and settings differ. @@ -22,10 +22,11 @@ """ from authlib.common.urls import add_params_to_uri -import flask -import requests +from cachelib import SimpleCache from cdislogging import get_logger +from defusedxml import ElementTree +import flask from fence.blueprints.login.base import DefaultOAuth2Login, DefaultOAuth2Callback from fence.blueprints.login.cilogon import CilogonLogin, CilogonCallback @@ -42,11 +43,15 @@ from fence.resources.audit.utils import enable_audit_logging from fence.restful import RestfulApi from fence.config import config +from fence.utils import fetch_url_data logger = get_logger(__name__) -# Mapping from IDP ID to the name in the URL on the blueprint (see below). +UPSTREAM_IDP_CACHE = SimpleCache(default_timeout=86400) # cached for 24h + + +# Mapping from IdP ID to the name in the URL on the blueprint (see below). def get_idp_route_name(idp): special_routes = { "shibboleth": "shib", @@ -54,22 +59,24 @@ def get_idp_route_name(idp): return special_routes.get(idp, idp.lower()) -def absolute_login_url(provider_id, fence_idp=None, shib_idp=None): +def absolute_login_url(provider_id, upstream_idp=None, shib_idp=None): """ Args: provider_id (str): provider to log in with. - fence_idp (str, optional): if provider_id is "fence" - (multi-tenant Fence setup), fence_idp can be any of the + upstream_idp (str, optional): can be set to any of the providers + supported by the upstream provider (aka provider_id). + For example, if provider_id is "fence" + (multi-tenant Fence setup), upstream_idp can be any of the providers supported by the other Fence. If not specified, will default to NIH login. shib_idp (str, optional): if provider_id is "fence" and - fence_idp is "shibboleth", shib_idp can be any Shibboleth/ + upstream_idp is "shibboleth", shib_idp can be any Shibboleth/ InCommon provider. If not specified, will default to NIH login. Returns: str: login URL for this provider, including extra query - parameters if fence_idp and/or shib_idp are specified. + parameters if upstream_idp and/or shib_idp are specified. """ try: base_url = config["BASE_URL"].rstrip("/") @@ -78,8 +85,8 @@ def absolute_login_url(provider_id, fence_idp=None, shib_idp=None): raise InternalError("identity provider misconfigured: {}".format(str(e))) params = {} - if fence_idp: - params["idp"] = fence_idp + if upstream_idp: + params["idp"] = upstream_idp if shib_idp: params["shib_idp"] = shib_idp login_url = add_params_to_uri(login_url, params) @@ -87,22 +94,24 @@ def absolute_login_url(provider_id, fence_idp=None, shib_idp=None): return login_url -def provider_info(login_details): +def get_provider_info(login_details): """ Args: login_details (dict): - { name, desc, idp, fence_idp, shib_idps, secondary } + { name, desc, idp, upstream_idps, shib_idps, secondary } - "idp": a configured provider. - Multiple options can be configured with the same idp. - - if provider_id is "fence", "fence_idp" can be any of the - providers supported by the other Fence. If not specified, will - default to NIH login. - - if provider_id is "fence" and fence_idp is "shibboleth", a - list of "shib_idps" can be configured for InCommon login. If - not specified, will default to NIH login. + Multiple options can be configured with the same idp. + - "upstream_idps": list of upstream IdPs (IdPs supported by the + configured "idp") we want to enable. For example, if idp is "fence" (multi-tenant + Fence setup), upstream_idps can include any of the providers supported by the other + Fence. For multi-tenant Fence, if upstream_idps is not specified, will default to + NIH login. + - if "idp" is "fence" and upstream_idps includes "shibboleth", a + list of "shib_idps" can be configured for InCommon login. If + not specified, will default to NIH login. - Optional parameters: "desc" (description) and "secondary" - (boolean - can be used by the frontend to display secondary - buttons differently). + (boolean - can be used by the frontend to display secondary + buttons differently). Returns: dict: { name, desc, idp, urls, secondary } @@ -114,71 +123,150 @@ def provider_info(login_details): "idp": login_details["idp"], "name": login_details["name"], # "url" deprecated, replaced by "urls" - "url": absolute_login_url(login_details["idp"]), + "url": absolute_login_url(provider_id=login_details["idp"]), + "urls": [ + { + "name": login_details["name"], + "url": absolute_login_url(provider_id=login_details["idp"]), + } + ], "desc": login_details.get("desc", None), "secondary": login_details.get("secondary", False), } - # for Fence multi-tenant login - fence_idp = None - if login_details["idp"] == "fence": - fence_idp = login_details.get("fence_idp") - - # handle Shibboleth IDPs: InCommon login can either be configured - # directly in this Fence, or through multi-tenant Fence - if ( - login_details["idp"] == "shibboleth" or fence_idp == "shibboleth" - ) and "shib_idps" in login_details: - # get list of all available shib IDPs - if not hasattr(flask.current_app, "all_shib_idps"): - flask.current_app.all_shib_idps = get_all_shib_idps() - - requested_shib_idps = login_details["shib_idps"] - if requested_shib_idps == "*": - shib_idps = flask.current_app.all_shib_idps - elif isinstance(requested_shib_idps, list): - # get the display names for each requested shib IDP - shib_idps = [] - for requested_shib_idp in set(requested_shib_idps): - shib_idp = next( - ( - available_shib_idp - for available_shib_idp in flask.current_app.all_shib_idps - if available_shib_idp["idp"] == requested_shib_idp + hide_idps_list = config.get("HIDE_IDPS", []) + + # [Backwards compatibility for Fence multi-tenant login / Shibboleth legacy configuration] + # fall back to `fence_idp` if `upstream_idps` is not specified + requested_upstream_idps = login_details.get("upstream_idps") + if not requested_upstream_idps and "fence_idp" in login_details: + requested_upstream_idps = [login_details["fence_idp"]] + + # Get the IdP's discovery settings. + # [Backwards compatibility for Fence multi-tenant login / Shibboleth legacy configuration] + # Handle Shibboleth IdPs; InCommon login can either be configured directly in this Fence, + # or through multi-tenant Fence + shib_special_case = ( + login_details["idp"] == "shibboleth" + or requested_upstream_idps == ["shibboleth"] + ) and "shib_idps" in login_details + if shib_special_case: + """ + Set `discovery_details` manually because we need to enter the block below even though + `idp_discovery` is not configured: the legacy Shibboleth integration uses config + `shibboleth_discovery_url` instead of the generic `idp_discovery`. + """ + shib_url = ( + config["OPENID_CONNECT"] + .get(login_details["idp"], {}) + .get("shibboleth_discovery_url") + ) + if not shib_url: + raise InternalError( + f"Unable to get list of Shibboleth IdPs: 'OPENID_CONNECT.{login_details['idp']}.shibboleth_discovery_url' not configured" + ) + discovery_details = {"url": shib_url, "format": "shibboleth"} + requested_upstream_idps = login_details.get("shib_idps", []) + else: + discovery_details = ( + config["OPENID_CONNECT"] + .get(login_details["idp"], {}) + .get("idp_discovery", {}) + ) + + # provider WITHOUT IdP discovery settings + if not discovery_details: + assert ( + requested_upstream_idps != "*" + ), f'Login option "{login_details["name"]}" misconfigured: requested providers is set to "*", but "OPENID_CONNECT.{login_details["idp"]}.idp_discovery" is not configured' + if requested_upstream_idps: + info["urls"] = [ + { + "name": login_details["name"], + "url": absolute_login_url( + provider_id=login_details["idp"], upstream_idp=upstream_idp ), - None, - ) - if not shib_idp: - raise InternalError( - 'Requested shib_idp "{}" does not exist'.format( - requested_shib_idp - ) + } + for upstream_idp in requested_upstream_idps + ] + return info + + # provider WITH IdP discovery settings + assert discovery_details.get( + "url" + ), f"Unable to get list of {login_details['idp']} IdPs: 'OPENID_CONNECT.{login_details['idp']}.idp_discovery.url' not configured" + assert discovery_details.get( + "format" + ), f"Unable to get list of {login_details['idp']} IdPs: 'OPENID_CONNECT.{login_details['idp']}.idp_discovery.format' not configured" + cache_key = f"all_{login_details['idp']}_upstream_idps" + if not UPSTREAM_IDP_CACHE.has(cache_key): + UPSTREAM_IDP_CACHE.set( + cache_key, + get_all_upstream_idps( + login_details["idp"], + discovery_details["url"], + discovery_details["format"], + ), + ) + + if requested_upstream_idps == "*": + upstream_idps = UPSTREAM_IDP_CACHE.get(cache_key) + elif isinstance(requested_upstream_idps, list) and len(requested_upstream_idps): + upstream_idps = [] + for requested_upstream_idp in set(requested_upstream_idps): + # ensure the requested IdP exists in the list of discovered upstream IdPs + existing_upstream_idp = next( + ( + upstream_idp_match + for upstream_idp_match in UPSTREAM_IDP_CACHE.get(cache_key) + if upstream_idp_match["idp"] == requested_upstream_idp + ), + None, + ) + if not existing_upstream_idp: + raise InternalError( + 'IdP "{}": requested upstream_idp/shib_idp "{}" does not exist'.format( + login_details["name"], requested_upstream_idp ) - shib_idps.append(shib_idp) - else: - raise InternalError( - 'fence provider misconfigured: "shib_idps" must be a list or "*", got {}'.format( - requested_shib_idps ) + upstream_idps.append(existing_upstream_idp) + else: + if shib_special_case: + raise InternalError( + f'Login option "{login_details["name"]}" misconfigured: "shib_idps" must be a list or "*", got {requested_upstream_idp}' ) + raise InternalError( + f'Login option "{login_details["name"]}" misconfigured: because "OPENID_CONNECT.{login_details["idp"]}.idp_discovery" is configured, "LOGIN_OPTIONS.{login_details["name"]}.upstream_idps" must be a list or "*", got {requested_upstream_idps}' + ) + + if hide_idps_list and len(hide_idps_list) > 0: + hidden_idps_excluded = [ + x for x in upstream_idps if x.get("idp") not in hide_idps_list + ] + upstream_idps = hidden_idps_excluded + if shib_special_case: info["urls"] = [ { "name": shib_idp["name"], "url": absolute_login_url( - login_details["idp"], fence_idp, shib_idp["idp"] + provider_id=login_details["idp"], + upstream_idp="shibboleth", + shib_idp=shib_idp["idp"], ), } - for shib_idp in shib_idps + for shib_idp in upstream_idps ] - - # non-Shibboleth provider else: info["urls"] = [ { - "name": login_details["name"], - "url": absolute_login_url(login_details["idp"], fence_idp), + "name": upstream_idp["name"], + "url": absolute_login_url( + provider_id=login_details["idp"], + upstream_idp=upstream_idp["idp"], + ), } + for upstream_idp in upstream_idps ] return info @@ -216,29 +304,34 @@ def get_login_providers_info(): try: all_provider_info = [ - provider_info(login_details) for login_details in login_options + get_provider_info(login_details) for login_details in login_options ] except KeyError as e: raise InternalError("LOGIN_OPTIONS misconfigured: cannot find key {}".format(e)) - # if several login_options are defined for this default IDP, will + # if several login_options are defined for this default IdP, will # default to the first one: - default_provider_info = next( - (info for info in all_provider_info if info["idp"] == default_idp), None - ) - if not default_provider_info: + default_provider_matches = [ + info for info in all_provider_info if info["idp"] == default_idp + ] + if not default_provider_matches: raise InternalError( - "default provider misconfigured: DEFAULT_LOGIN_IDP is set to {}, which is not configured in LOGIN_OPTIONS".format( + "default provider misconfigured: DEFAULT_LOGIN_IDP is set to '{}', which is not configured in LOGIN_OPTIONS".format( default_idp ) ) + default_provider_info = default_provider_matches[0] + if len(default_provider_matches) > 1: + logger.info( + f"Default IdP is set to '{default_idp}', which matches more than one option in LOGIN_OPTIONS. Defaulting to the first one ('{default_provider_info['name']}')." + ) return default_provider_info, all_provider_info -def createLoginClass(idp_name): +def create_login_class(idp_name): """ - Creates and returns a new class `GenericLogin_`, which is a subclass + Creates and returns a new class `GenericLogin_`, which is a subclass of `DefaultOAuth2Login` (only the provider name and settings differ). See comment at the top of the file for details. """ @@ -256,9 +349,9 @@ def initiate(self): ) -def createCallbackClass(idp_name, settings): +def create_callback_class(idp_name, settings): """ - Creates and returns a new class `GenericCallback_`, which is a subclass + Creates and returns a new class `GenericCallback_`, which is a subclass of `DefaultOAuth2Callback` (only the provider name and settings differ). See comment at the top of the file for details. """ @@ -301,7 +394,7 @@ def default_login(): {"default_provider": default_provider_info, "providers": all_provider_info} ) - # Add identity provider login routes for IDPs enabled in the config. + # Add identity provider login routes for IdPs enabled in the config. configured_idps = config.get("OPENID_CONNECT", {}) for idp in set(configured_idps.keys()): @@ -319,7 +412,7 @@ def default_login(): elif idp == "ras": login_class = RASLogin callback_class = RASCallback - # note that the callback endpoint is "/ras/callback", not "/ras/login" like other IDPs + # note that the callback endpoint is "/ras/callback", not "/ras/login" like other IdPs custom_callback_endpoint = f"/{get_idp_route_name(idp)}/callback" elif idp == "synapse": login_class = SynapseLogin @@ -340,10 +433,10 @@ def default_login(): login_class = CilogonLogin callback_class = CilogonCallback else: # generic OIDC implementation - login_class = createLoginClass(idp.lower()) - callback_class = createCallbackClass(idp.lower(), configured_idps[idp]) + login_class = create_login_class(idp.lower()) + callback_class = create_callback_class(idp.lower(), configured_idps[idp]) - # create IDP routes + # create IdP routes blueprint_api.add_resource( login_class, f"/{get_idp_route_name(idp)}", @@ -360,37 +453,131 @@ def default_login(): return blueprint -def get_all_shib_idps(): +def get_all_upstream_idps(idp_name: str, discovery_url: str, format: str) -> list: """ - Get the list of all existing Shibboleth IDPs. + Fetch the data at the specified discovery URL and parses it into a list of all available IdPs. This function only returns the information we need to generate login URLs. Returns: list: list of {"idp": "", "name": ""} dictionaries + + NOTE May 2 2025: Compared the list of IdPs parsed by `get_all_shib_idps` from the + login.bionimbus.org Shibboleth Discofeed (at https://login.bionimbus.org/Shibboleth.sso/ + DiscoFeed) with the list of IdPs parsed by `get_all_upstream_idps` from the InCommon + discovery XML (at http://mdq.incommon.org/entities/idps/all). The list of providers is + exactly the same (except for the NIH IdP which was removed from the login.bionimbus.org + Discofeed and replaced by us manually). So we most likely could use the InCommon list and the generic `get_all_upstream_idps` function for Shibboleth instead of duplicating logic. """ - url = config["OPENID_CONNECT"].get("fence", {}).get("shibboleth_discovery_url") - if not url: + if format == "shibboleth": + return get_all_shib_idps(discovery_url) + elif format == "xml-mdq-v1.0": # InCommon Metadata Query Protocol version 1.0 + """ + According to https://spaces.at.internet2.edu/display/federation/metadata-saml, + "The SAML representation of InCommon metadata is defined in:" + - https://groups.oasis-open.org/higherlogic/ws/public/document?document_id=56785 + - https://wiki.oasis-open.org/security/SAML2MetadataAttr + - https://wiki.oasis-open.org/security/SAML2MetadataUI + As of June 2025, the last link says "Version 1.0" "Final standardization occurred on + 24 October 2019." + """ + all_idps = [] + xml_data = fetch_url_data(discovery_url, "text") + try: + tree = ElementTree.fromstring(xml_data) + except ElementTree.ParseError as e: + logger.error( + f"Unable to parse data received from '{discovery_url}'. Error: {e}. Received data:\n{xml_data}" + ) + raise e + for element in tree.iter(): + if ( + not element.tag.endswith("EntityDescriptor") + or "entityID" not in element.keys() + ): + continue + idp = element.get("entityID") + + # get the IdP's display name from IDPSSODescriptor.Extensions.UIInfo.DisplayName + display_names = [] + for idps_so_descriptor in element.findall( + "{urn:oasis:names:tc:SAML:2.0:metadata}IDPSSODescriptor" + ): + for extension in idps_so_descriptor.findall( + "{urn:oasis:names:tc:SAML:2.0:metadata}Extensions" + ): + for ui_info in extension.findall( + "{urn:oasis:names:tc:SAML:metadata:ui}UIInfo" + ): + for display_name in ui_info.findall( + "{urn:oasis:names:tc:SAML:metadata:ui}DisplayName" + ): + lang = "" + if ( + "{http://www.w3.org/XML/1998/namespace}lang" + in display_name.keys() + ): + lang = display_name.get( + "{http://www.w3.org/XML/1998/namespace}lang" + ) + display_names.append( + {"value": display_name.text, "lang": lang} + ) + + # if IDPSSODescriptor.Extensions.UIInfo.DisplayName is not provided, fall back to + # Organization.OrganizationDisplayName + if not display_names: + for org in element.findall( + "{urn:oasis:names:tc:SAML:2.0:metadata}Organization" + ): + for orgDisplayName in org.findall( + "{urn:oasis:names:tc:SAML:2.0:metadata}OrganizationDisplayName" + ): + lang = "" + if ( + "{http://www.w3.org/XML/1998/namespace}lang" + in orgDisplayName.keys() + ): + lang = orgDisplayName.get( + "{http://www.w3.org/XML/1998/namespace}lang" + ) + display_names.append( + {"value": orgDisplayName.text, "lang": lang} + ) + + all_idps.append( + { + "idp": idp, + "name": get_idp_english_name(display_names) or idp, + } + ) + return all_idps + else: raise InternalError( - "Unable to get list of Shibboleth IDPs: OPENID_CONNECT.fence.shibboleth_discovery_url not configured" + f"IdP 'OPENID_CONNECT.{idp_name}' misconfigured: idp_discovery.format '{format}' is not supported" ) - res = requests.get(url) - assert ( - res.status_code == 200 - ), "Unable to get list of Shibboleth IDPs from {}".format(url) + +def get_all_shib_idps(url: str): + """ + Get the list of all existing Shibboleth IdPs. + This function only returns the information we need to generate login URLs. + + Returns: + list: list of {"idp": "", "name": ""} dictionaries + """ all_shib_idps = [] - for shib_idp in res.json(): + for shib_idp in fetch_url_data(url, "json"): if "entityID" not in shib_idp: logger.warning( - f"get_all_shib_idps(): 'entityID' field not in IDP data: {shib_idp}. Skipping this IDP." + f"get_all_shib_idps(): 'entityID' field not in IdP data: {shib_idp}. Skipping this IdP." ) continue idp = shib_idp["entityID"] if len(shib_idp.get("DisplayNames", [])) > 0: - name = get_shib_idp_en_name(shib_idp["DisplayNames"]) + name = get_idp_english_name(shib_idp["DisplayNames"]) else: logger.warning( - f"get_all_shib_idps(): 'DisplayNames' field not in IDP data: {shib_idp}. Using IDP ID '{idp}' as IDP name." + f"get_all_shib_idps(): 'DisplayNames' field not in IdP data: {shib_idp}. Using IdP ID '{idp}' as IdP name." ) name = idp all_shib_idps.append( @@ -402,9 +589,9 @@ def get_all_shib_idps(): return all_shib_idps -def get_shib_idp_en_name(names): +def get_idp_english_name(names): """ - Returns a name in English for a Shibboleth IDP, or the first available + Returns a name in English for a Shibboleth IdP, or the first available name if no English name was provided. Args: @@ -422,8 +609,10 @@ def get_shib_idp_en_name(names): ] Returns: - str: Display name to use for this Shibboleth IDP + str: Display name to use for this Shibboleth IdP """ + if len(names) == 0: + return for name in names: if name.get("lang") == "en": return name["value"] diff --git a/fence/blueprints/login/base.py b/fence/blueprints/login/base.py index 08fcab61d..81d5bf354 100644 --- a/fence/blueprints/login/base.py +++ b/fence/blueprints/login/base.py @@ -1,14 +1,22 @@ +import time +import base64 +import json +from urllib.parse import urlparse, urlencode, parse_qsl +import jwt + +from flask import current_app import flask from cdislogging import get_logger from flask_restful import Resource -from urllib.parse import urlparse, urlencode, parse_qsl -from fence.auth import login_user +from fence.auth import login_user_or_require_registration, get_ip_information_string from fence.blueprints.login.redirect import validate_redirect +from fence.blueprints.register import add_user_registration_info_to_database from fence.config import config -from fence.errors import UserError +from fence.errors import UserError, Unauthorized from fence.metrics import metrics + logger = get_logger(__name__) @@ -20,7 +28,7 @@ def __init__(self, idp_name, client): Args: idp_name (str): name for the identity provider client (fence.resources.openid.idp_oauth2.Oauth2ClientBase): - Some instaniation of this base client class or a child class + Some instantiation of this base client class or a child class """ self.idp_name = idp_name self.client = client @@ -32,6 +40,16 @@ def get(self): if flask.redirect_url: flask.session["redirect"] = flask.redirect_url + # `post_registration_redirect`: After registering, the user is sent back to this endpoint + # to complete the login flow. + # Reconstruct and store the current URL - we can't just use `request.url` here because + # it's missing the `/user` prefix. This is caused by the revproxy stripping the URL + # prefix before forwarding requests to Fence. + current_url = config["BASE_URL"] + flask.request.path + if flask.request.query_string: + current_url += f"?{flask.request.query_string.decode('utf-8')}" + flask.session["post_registration_redirect"] = current_url + mock_login = ( config["OPENID_CONNECT"].get(self.idp_name.lower(), {}).get("mock", False) ) @@ -52,7 +70,8 @@ def get(self): username = flask.request.cookies.get( config.get("DEV_LOGIN_COOKIE_NAME"), mock_default_user ) - resp = _login(username, self.idp_name) + # log in the mocked user + resp, _ = _login_and_register(username, self.idp_name) prepare_login_log(self.idp_name) return resp @@ -92,8 +111,25 @@ def __init__( self.is_mfa_enabled = "multifactor_auth_claim_info" in config[ "OPENID_CONNECT" ].get(self.idp_name, {}) + + # Config option to explicitly persist refresh tokens + self.persist_refresh_token = False + + self.read_authz_groups_from_tokens = False + self.app = app + self.persist_refresh_token = ( + config["OPENID_CONNECT"].get(self.idp_name, {}).get("persist_refresh_token") + ) + + if "is_authz_groups_sync_enabled" in config["OPENID_CONNECT"].get( + self.idp_name, {} + ): + self.read_authz_groups_from_tokens = config["OPENID_CONNECT"][ + self.idp_name + ]["is_authz_groups_sync_enabled"] + def get(self): # Check if user granted access if flask.request.args.get("error"): @@ -119,7 +155,11 @@ def get(self): code = flask.request.args.get("code") result = self.client.get_auth_info(code) + + refresh_token = result.get("refresh_token") + username = result.get(self.username_field) + if not username: raise UserError( f"OAuth2 callback error: no '{self.username_field}' in {result}" @@ -128,23 +168,132 @@ def get(self): email = result.get(self.email_field) id_from_idp = result.get(self.id_from_idp_field) - resp = _login(username, self.idp_name, email=email, id_from_idp=id_from_idp) - self.post_login(user=flask.g.user, token_result=result, id_from_idp=id_from_idp) + resp, user_is_logged_in = _login_and_register( + username, + self.idp_name, + email=email, + id_from_idp=id_from_idp, + token_result=result, + ) + + if not user_is_logged_in: + return resp + + if not hasattr(flask.g, "user") or not flask.g.user: + raise UserError("Authentication failed: flask.g.user is missing.") + + expires = self.extract_exp(refresh_token) + + # if the access token is not a JWT, or does not carry exp, + # default to now + REFRESH_TOKEN_EXPIRES_IN + if expires is None: + expires = int(time.time()) + config["REFRESH_TOKEN_EXPIRES_IN"] + logger.info(f"Refresh token not in JWT, using default: {expires}") + + # Store refresh token in db + should_persist_token = ( + self.persist_refresh_token or self.read_authz_groups_from_tokens + ) + if should_persist_token: + # Ensure flask.g.user exists to avoid a potential AttributeError + if getattr(flask.g, "user", None): + self.client.store_refresh_token(flask.g.user, refresh_token, expires) + else: + logger.error( + "User information is missing from flask.g; cannot store refresh token." + ) + + self.post_login( + user=flask.g.user, + token_result=result, + id_from_idp=id_from_idp, + ) + return resp + def extract_exp(self, refresh_token): + """ + Extract the expiration time (`exp`) from a refresh token. + + This function attempts to retrieve the expiration time from the provided + refresh token using three methods: + + 1. Using PyJWT to decode the token (without signature verification). + 2. Manually base64 decoding the token's payload (if it's a JWT). + + **Disclaimer:** This function assumes that the refresh token is valid and + does not perform any JWT validation. For JWTs from an OpenID Connect (OIDC) + provider, validation should be done using the public keys provided by the + identity provider (from the JWKS endpoint) before using this function to + extract the expiration time. Without validation, the token's integrity and + authenticity cannot be guaranteed, which may expose your system to security + risks. Ensure validation is handled prior to calling this function, + especially in any public or production-facing contexts. + + Args: + refresh_token (str): The JWT refresh token from which to extract the expiration. + + Returns: + int or None: The expiration time (`exp`) in seconds since the epoch, + or None if extraction fails. + """ + + # Method 1: PyJWT + try: + # Skipping keys since we're not verifying the signature + decoded_refresh_token = jwt.decode( + refresh_token, + options={ + "verify_aud": False, + "verify_at_hash": False, + "verify_signature": False, + }, + algorithms=["RS256", "HS512"], + ) + exp = decoded_refresh_token.get("exp") + + if exp is not None: + return exp + except Exception as e: + logger.info(f"Refresh token expiry: Method (PyJWT) failed: {e}") + + # Method 2: Manual base64 decoding + try: + # Assuming the token is a JWT (header.payload.signature) + payload_encoded = refresh_token.split(".")[1] + # Add necessary padding for base64 decoding + payload_encoded += "=" * (4 - len(payload_encoded) % 4) + payload_decoded = base64.urlsafe_b64decode(payload_encoded) + payload_json = json.loads(payload_decoded) + exp = payload_json.get("exp") + + if exp is not None: + return exp + except Exception as e: + logger.info(f"Method 2 (Manual decoding) failed: {e}") + + # If all methods fail, return None + return None + def post_login(self, user=None, token_result=None, **kwargs): prepare_login_log(self.idp_name) + metrics.add_login_event( user_sub=flask.g.user.id, idp=self.idp_name, - fence_idp=flask.session.get("fence_idp"), + upstream_idp=flask.session.get("upstream_idp"), shib_idp=flask.session.get("shib_idp"), client_id=flask.session.get("client_id"), ) + if self.read_authz_groups_from_tokens: + self.client.update_user_authorization( + user=user, pkey_cache=None, db_session=None, idp_name=self.idp_name + ) + if token_result: username = token_result.get(self.username_field) - if self.is_mfa_enabled: + if self.app.arborist and self.is_mfa_enabled: if token_result.get("mfa"): logger.info(f"Adding mfa_policy for {username}") self.app.arborist.grant_user_policy( @@ -154,36 +303,114 @@ def post_login(self, user=None, token_result=None, **kwargs): return else: logger.info(f"Revoking mfa_policy for {username}") - self.app.arborist.revoke_user_policy( + assert self.app.arborist.revoke_user_policy( username=username, policy_id="mfa_policy", - ) + ), f"Could not revoke user {username} policy mfa_policy" def prepare_login_log(idp_name): + x_forwarded_headers = [ + f"{header}:{value}" for header, value in flask.request.headers if "X-" in header + ] flask.g.audit_data = { "username": flask.g.user.username, "sub": flask.g.user.id, "idp": idp_name, - "fence_idp": flask.session.get("fence_idp"), + "upstream_idp": flask.session.get("upstream_idp"), "shib_idp": flask.session.get("shib_idp"), "client_id": flask.session.get("client_id"), + "additional_data": x_forwarded_headers, + "ip": get_ip_information_string(), } -def _login(username, idp_name, email=None, id_from_idp=None): +def _login_and_register( + username, + idp_name, + email=None, + id_from_idp=None, + token_result=None, + upstream_idp=None, + shib_idp=None, +): """ - Login user with given username, then redirect if session has a saved - redirect. + Login user with given username, then automatically register if needed, + and finally redirect if session has a saved redirect. + + Return: + bool: whether the user has been logged in (if registration is enabled and the user is not + registered, this would be False) """ - login_user(username, idp_name, email=email, id_from_idp=id_from_idp) + user_is_logged_in = login_user_or_require_registration( + username, + idp_name, + upstream_idp=upstream_idp, + shib_idp=shib_idp, + email=email, + id_from_idp=id_from_idp, + ) + + auto_registration_enabled = ( + config["OPENID_CONNECT"] + .get(idp_name, {}) + .get("enable_idp_users_registration", False) + ) if config["REGISTER_USERS_ON"]: - if not flask.g.user.additional_info.get("registration_info"): - return flask.redirect( - config["BASE_URL"] + flask.url_for("register.register_user") - ) + user = flask.g.user + if not user.additional_info.get("registration_info"): + # If enabled, automatically register user from IdP + if auto_registration_enabled: + organization_claim_field = ( + config["OPENID_CONNECT"] + .get(idp_name, {}) + .get("organization_claim_field", "org") + ) + firstname_claim_field = ( + config["OPENID_CONNECT"] + .get(idp_name, {}) + .get("firstname_claim_field", "firstname") + ) + lastname_claim_field = ( + config["OPENID_CONNECT"] + .get(idp_name, {}) + .get("lastname_claim_field", "lastname") + ) + email_claim_field = ( + config["OPENID_CONNECT"] + .get(idp_name, {}) + .get("email_claim_field", "email") + ) + firstname = token_result.get(firstname_claim_field) + lastname = token_result.get(lastname_claim_field) + organization = token_result.get(organization_claim_field) + email = token_result.get(email_claim_field) + if email is None: + raise UserError("OAuth2 id token is missing email claim") + # Log warnings and set defaults if needed + if not firstname or not lastname: + logger.warning( + f"User {username} missing name fields. Proceeding with minimal info." + ) + firstname = firstname or "Unknown" + lastname = lastname or "Unknown" + if not organization: + logger.info( + f"User {username} missing organization. Defaulting to None." + ) + add_user_registration_info_to_database( + user, firstname, lastname, organization, email + ) + else: + return ( + flask.redirect( + config["BASE_URL"] + flask.url_for("register.register_user") + ), + user_is_logged_in, + ) if flask.session.get("redirect"): - return flask.redirect(flask.session.get("redirect")) - return flask.jsonify({"username": username}) + return flask.redirect(flask.session["redirect"]), user_is_logged_in + + return flask.jsonify({"username": username}), user_is_logged_in diff --git a/fence/blueprints/login/fence_login.py b/fence/blueprints/login/fence_login.py index 13b4de8c8..35da8a3df 100644 --- a/fence/blueprints/login/fence_login.py +++ b/fence/blueprints/login/fence_login.py @@ -1,8 +1,11 @@ from authlib.common.urls import add_params_to_uri import flask -from fence.auth import login_user -from fence.blueprints.login.base import DefaultOAuth2Login, DefaultOAuth2Callback +from fence.blueprints.login.base import ( + DefaultOAuth2Login, + DefaultOAuth2Callback, + _login_and_register, +) from fence.blueprints.login.redirect import validate_redirect from fence.config import config from fence.errors import Unauthorized @@ -31,7 +34,7 @@ def __init__(self): def get(self): """Handle ``GET /login/fence``.""" - # OAuth class can have mutliple clients + # OAuth class can have multiple clients client = flask.current_app.fence_client._clients[ flask.current_app.config["OPENID_CONNECT"]["fence"]["name"] ] @@ -50,7 +53,7 @@ def get(self): # add idp parameter to the authorization URL if "idp" in flask.request.args: idp = flask.request.args["idp"] - flask.session["fence_idp"] = idp + flask.session["upstream_idp"] = idp params = {"idp": idp} # if requesting to login through Shibboleth, also add shib_idp # parameter to the authorization URL @@ -61,6 +64,13 @@ def get(self): authorization_url = add_params_to_uri(authorization_url, params) flask.session["state"] = rv["state"] + + # see `post_registration_redirect` explanation in `DefaultOAuth2Login.get()` + current_url = config["BASE_URL"] + flask.request.path + if flask.request.query_string: + current_url += f"?{flask.request.query_string.decode('utf-8')}" + flask.session["post_registration_redirect"] = current_url + return flask.redirect(authorization_url) @@ -121,21 +131,18 @@ def get(self): ) username = id_token_claims["context"]["user"]["name"] email = id_token_claims["context"]["user"].get("email") - login_user( + + resp, user_is_logged_in = _login_and_register( username, IdentityProvider.fence, - fence_idp=flask.session.get("fence_idp"), + upstream_idp=flask.session.get("upstream_idp"), shib_idp=flask.session.get("shib_idp"), email=email, ) - self.post_login() - if config["REGISTER_USERS_ON"]: - if not flask.g.user.additional_info.get("registration_info"): - return flask.redirect( - config["BASE_URL"] + flask.url_for("register.register_user") - ) + if not user_is_logged_in: + return resp + + self.post_login() - if "redirect" in flask.session: - return flask.redirect(flask.session.get("redirect")) - return flask.jsonify({"username": username}) + return resp diff --git a/fence/blueprints/login/ras.py b/fence/blueprints/login/ras.py index 911cf86dd..9890a7602 100644 --- a/fence/blueprints/login/ras.py +++ b/fence/blueprints/login/ras.py @@ -4,7 +4,6 @@ # the whole fence_create module is imported to avoid issues with circular imports import fence.scripting.fence_create -from distutils.util import strtobool from urllib.parse import urlparse, parse_qs from cdislogging import get_logger @@ -15,7 +14,7 @@ from fence.config import config from fence.errors import InternalError from fence.models import IdentityProvider -from fence.utils import get_valid_expiration +from fence.utils import get_valid_expiration, strtobool import fence.resources.ga4gh.passports diff --git a/fence/blueprints/login/redirect.py b/fence/blueprints/login/redirect.py index 33bbe0d3b..590b53ffb 100644 --- a/fence/blueprints/login/redirect.py +++ b/fence/blueprints/login/redirect.py @@ -6,7 +6,7 @@ from cdislogging import get_logger import flask -from fence.blueprints.login.utils import allowed_login_redirects, domain +from fence.utils import allowed_login_redirects, domain from fence.errors import UserError diff --git a/fence/blueprints/login/shib.py b/fence/blueprints/login/shib.py index faa45e085..07bf10b38 100644 --- a/fence/blueprints/login/shib.py +++ b/fence/blueprints/login/shib.py @@ -1,8 +1,11 @@ from cdislogging import get_logger import flask -from fence.auth import login_user -from fence.blueprints.login.base import DefaultOAuth2Login, DefaultOAuth2Callback +from fence.blueprints.login.base import ( + DefaultOAuth2Login, + DefaultOAuth2Callback, + _login_and_register, +) from fence.blueprints.login.redirect import validate_redirect from fence.errors import InternalError, Unauthorized from fence.models import IdentityProvider @@ -33,6 +36,12 @@ def get(self): if redirect_url: flask.session["redirect"] = redirect_url + # see `post_registration_redirect` explanation in `DefaultOAuth2Login.get()` + current_url = config["BASE_URL"] + flask.request.path + if flask.request.query_string: + current_url += f"?{flask.request.query_string.decode('utf-8')}" + flask.session["post_registration_redirect"] = current_url + # figure out which IDP to target with shibboleth # check out shibboleth docs here for more info: # https://wiki.shibboleth.net/confluence/display/SP3/SSO @@ -40,7 +49,11 @@ def get(self): flask.session["entityID"] = entityID # TODO: use OPENID_CONNECT.shibboleth.redirect_url instead of hardcoded actual_redirect = config["BASE_URL"] + "/login/shib/login" - if not entityID or entityID == "urn:mace:incommon:nih.gov": + if ( + not entityID + or entityID == "urn:mace:incommon:nih.gov" + or entityID == "https://auth.nih.gov/IDP" + ): # default to SSO_URL from the config which should be NIH login return flask.redirect(config["SSO_URL"] + actual_redirect) return flask.redirect( @@ -70,20 +83,27 @@ def get(self): entityID = flask.session.get("entityID") # if eppn not available or logging in through NIH - if not username or not entityID or entityID == "urn:mace:incommon:nih.gov": + if ( + not username + or not entityID + or entityID == "urn:mace:incommon:nih.gov" + or entityID == "https://auth.nih.gov/IDP" + ): persistent_id = flask.request.headers.get(shib_header) username = persistent_id.split("!")[-1] if persistent_id else None if not username: # some inCommon providers are not returning eppn # or persistent_id. See PXP-4309 - # print("shib_header", shib_header) - # print("flask.request.headers", flask.request.headers) raise Unauthorized("Unable to retrieve username") idp = IdentityProvider.itrust if entityID: idp = entityID - login_user(username, idp) + + resp, user_is_logged_in = _login_and_register(username, idp) + if not user_is_logged_in: + return resp + self.post_login() if flask.session.get("redirect"): diff --git a/fence/blueprints/login/utils.py b/fence/blueprints/login/utils.py deleted file mode 100644 index 3dfca2eae..000000000 --- a/fence/blueprints/login/utils.py +++ /dev/null @@ -1,40 +0,0 @@ -from urllib.parse import urlparse - -import flask - -from fence.config import config -from fence.models import Client - - -def allowed_login_redirects(): - """ - Determine which redirects a login redirect endpoint (``/login/google``, etc) should - be allowed to redirect back to after login. By default this includes the base URL - from this flask application, and also includes the redirect URLs registered for any - OAuth clients. - - Return: - List[str]: allowed redirect URLs - """ - allowed = config.get("LOGIN_REDIRECT_WHITELIST", []) - allowed.append(config["BASE_URL"]) - with flask.current_app.db.session as session: - clients = session.query(Client).all() - for client in clients: - if isinstance(client.redirect_uris, list): - allowed.extend(client.redirect_uris) - elif isinstance(client.redirect_uris, str): - allowed.append(client.redirect_uris) - return {domain(url) for url in allowed} - - -def domain(url): - """ - Return just the domain for a URL, no schema or path etc. This is to consistently - compare different URLs from flask, the config, and from the user. - """ - if not url: - return "" - if url.startswith("/"): - return urlparse(config["BASE_URL"]).netloc - return urlparse(url).netloc diff --git a/fence/blueprints/misc.py b/fence/blueprints/misc.py index c4e846ae7..a838521ca 100644 --- a/fence/blueprints/misc.py +++ b/fence/blueprints/misc.py @@ -1,4 +1,5 @@ from cdiserrors import UnhealthyCheck +from sqlalchemy import text import flask from fence.version_data import VERSION, COMMIT @@ -12,7 +13,7 @@ def health_check(): """ with flask.current_app.db.session as session: try: - session.execute("SELECT 1") + session.execute(text("SELECT 1")) except Exception: raise UnhealthyCheck("Unhealthy") diff --git a/fence/blueprints/oauth2.py b/fence/blueprints/oauth2.py index 0d1428cf5..221315f78 100644 --- a/fence/blueprints/oauth2.py +++ b/fence/blueprints/oauth2.py @@ -14,16 +14,20 @@ stateless. """ +import json from authlib.common.urls import add_params_to_uri from authlib.oauth2.rfc6749 import AccessDeniedError, InvalidRequestError, OAuth2Error -import flask -import json - from authutils.errors import JWTExpiredError +from fence.jwt.validate import validate_jwt +import flask +import jwt +from fence.authz.auth import authorize as authz_authorize from fence.blueprints.login import get_idp_route_name, get_login_providers_info -from fence.errors import Unauthorized, UserError +from fence.errors import Unauthorized, UserError, BlacklistingInvalidTokenError +from fence.jwt import keys +from fence.jwt.blacklist import blacklist_encoded_token from fence.jwt.errors import JWTError from fence.jwt.token import SCOPE_DESCRIPTION from fence.models import Client @@ -32,9 +36,6 @@ from fence.utils import clear_cookies from fence.user import get_current_user from fence.config import config -from authlib.oauth2.rfc6749.errors import ( - InvalidScopeError, -) from fence.utils import validate_scopes from cdislogging import get_logger @@ -341,7 +342,7 @@ def get_token(*args, **kwargs): @blueprint.route("/revoke", methods=["POST"]) def revoke_token(): """ - Revoke a refresh token. + Revoke a refresh token or a task access token. If the operation is successful, return an empty response with a 204 status code. Otherwise, return error message in JSON with a 400 code. @@ -349,7 +350,70 @@ def revoke_token(): Return: Tuple[str, int]: JSON response and status code """ - return server.create_endpoint_response(RevocationEndpoint.ENDPOINT_NAME) + if "basic " in flask.request.headers.get("Authorization", "").lower(): + try: + # client using Basic auth: use the authlib RFC 7009 endpoint + return server.create_endpoint_response(RevocationEndpoint.ENDPOINT_NAME) + except BlacklistingInvalidTokenError as err: + # Attempting to revoke an invalid token fails and returns a 200 (per RFC 7009). + # However, other revocation failures should not return 200. RFC 7009 only says to + # return 200 for tokens that are already invalid and as such cannot be used, + # effectively the same result as revoking them. + logger.info( + "Token provided for revocation is not valid. " + "Per rfc7009, this should still return a 200. Error: " + f"{err}", + exc_info=True, + ) + return "", 200 + + # RFC 7009 is officially only for client usage, so the RevocationEndpoint above can + # only be used for client requests, but we also allow users to revoke tokens + if ( + flask.request.headers.get("Content-Type") != "application/x-www-form-urlencoded" + or "token" not in flask.request.form + ): + raise UserError( + "This endpoint expects Content-Type 'application/x-www-form-urlencoded' and a 'token' field" + ) + + token_to_revoke = flask.request.form["token"] + token_to_revoke_claims = validate_jwt( + encoded_token=token_to_revoke, + options={"verify_aud": False, "verify_exp": False}, + ) + + revoker_token_parts = flask.request.headers.get("Authorization", "").split(" ") + if len(revoker_token_parts) > 1: + revoker_claims = validate_jwt( + encoded_token=revoker_token_parts[1], + options={"verify_aud": False, "verify_exp": False}, + ) + revoker_sub = revoker_claims["sub"] + else: + logger.debug("request missing Bearer token; treating as anonymous") + revoker_sub = None + + # tokens can be revoked by their owner or by an admin + if revoker_sub != token_to_revoke_claims["sub"]: + authz_authorize("/services/fence/admin", "delete") + + try: + # user is authorized: revoke the token + blacklist_encoded_token(token_to_revoke) + return "", 200 + except BlacklistingInvalidTokenError as err: + # Attempting to revoke an invalid token fails and return a 200 (per RFC 7009). + # However, other revocation failures should not return 200. RFC 7009 only says to + # return 200 for tokens that are already invalid and as such cannot be used, effectively + # the same result as revoking them. + logger.info( + "Token provided for revocation is not valid. " + "Per rfc7009, this should still return a 200. Error: " + f"{err}", + exc_info=True, + ) + return "", 200 @blueprint.route("/errors", methods=["GET"]) diff --git a/fence/blueprints/privacy.py b/fence/blueprints/privacy.py index f8f2c6af5..7ab8baff6 100644 --- a/fence/blueprints/privacy.py +++ b/fence/blueprints/privacy.py @@ -45,7 +45,7 @@ def privacy_policy(): ) if not file_contents: raise NotFound("this endpoint is not configured") - cache.add("privacy-policy-md", file_contents) + cache.set("privacy-policy-md", file_contents) if "text/markdown" in str(flask.request.accept_mimetypes).lower(): return flask.Response(cache.get("privacy-policy-md"), mimetype="text/markdown") @@ -54,5 +54,5 @@ def privacy_policy(): html = Markdown().convert(cache.get("privacy-policy-md")) if not html: raise NotFound("this endpoint is not configured") - cache.add("privacy-policy-html", html) + cache.set("privacy-policy-html", html) return flask.Response(cache.get("privacy-policy-html"), mimetype="text/html") diff --git a/fence/blueprints/register.py b/fence/blueprints/register.py index b15cb1a6e..854b3022b 100644 --- a/fence/blueprints/register.py +++ b/fence/blueprints/register.py @@ -8,54 +8,32 @@ from flask import current_app from flask_wtf import FlaskForm from wtforms import StringField -from wtforms.validators import DataRequired, Email, StopValidation, ValidationError +from wtforms.validators import DataRequired, Email, ValidationError + +from cdislogging import get_logger from fence import config -from fence.auth import login_required -from fence.errors import UserError +from fence.errors import Unauthorized, UserError +from fence.models import query_for_user, User +logger = get_logger(__name__) blueprint = flask.Blueprint("register", __name__) -def xor_with_user_email(form, field): - """ - Custom validator for the RegistrationForm email field. - - Our current registration policy is that if user.email is present from the IdP, - then use that email; otherwise ask for email at registration. - - Therefore the HTML/jinja form will render an email field only if user.email is None, - and this validator will check that one and only one of user.email or field.data - is non-empty. - """ - if not flask.g.user.email and not field.data: - raise ValidationError("Email field is required") - if flask.g.user.email and field.data: - raise ValidationError( - "Received unexpected 'email' field; this user is already associated with the " - "email {}, so the form should not have had an email field".format( - flask.g.user.email - ) - ) - if flask.g.user.email and not field.data: - # If user.email is non-empty, the form should not render an email field, - # and empty field.data is expected--all good - field.errors[:] = [] - raise StopValidation() - - class RegistrationForm(FlaskForm): firstname = StringField(label="First Name", validators=[DataRequired()]) lastname = StringField(label="Last Name", validators=[DataRequired()]) organization = StringField(label="Organization", validators=[DataRequired()]) email = StringField( - label="Email", validators=[xor_with_user_email, Email(), DataRequired()] + label="Email", + validators=[Email(), DataRequired()], + # avoid validation errors when the email address has trailing spaces: + filters=[lambda s: s.strip() if s else s], ) @blueprint.route("/", methods=["GET", "POST"]) -@login_required() def register_user(): """ - If config["REGISTER_USERS_ON"] is True, then unregistered users are redirected @@ -80,47 +58,98 @@ def register_user(): """ form = RegistrationForm() + if hasattr(flask.g, "user") and flask.g.user: + user = flask.g.user + else: + username = flask.session.get("login_in_progress_username") + # can't use the @login_required() decorator here to enforce logging in, because at this + # point in the flow the user should have _started_ logging in, but may not be logged in + # yet + if not username: + raise Unauthorized("Please login") + user = query_for_user(session=current_app.scoped_session(), username=username) + if not user: + raise Exception( + "User should already exist in the database when accessing `/register`" + ) + if flask.request.method == "GET": return flask.render_template( "register_user.html", - user=flask.g.user, + user=user, form=form, ) + # Our current registration policy is that if user.email is present from the IdP, + # then use that email; otherwise ask for email at registration. + # Therefore the HTML/jinja form will render an email field only if user.email is None, + # and we check that one and only one of user.email or form.email is non-empty. + if user.email: + if flask.request.form.get("email"): + raise ValidationError( + "Received unexpected 'email' field; this user is already associated with the " + "email '{}', so the form should not have had an email field".format( + user.email + ) + ) + form.email.validators = [] # do not validate the email field + if not form.validate(): raise UserError("Form validation failed: {}".format(str(form.errors))) # Validation passed--don't check form data here. - firstname = flask.request.form["firstname"] - lastname = flask.request.form["lastname"] - org = flask.request.form["organization"] - email = flask.g.user.email or flask.request.form["email"] - - combined_info = {} - if flask.g.user.additional_info is not None: - combined_info.update(flask.g.user.additional_info) - registration_info = { - "registration_info": { - "firstname": firstname, - "lastname": lastname, - "org": org, - "email": email, - } - } - combined_info.update(registration_info) - flask.g.user.additional_info = combined_info - current_app.scoped_session().add(flask.g.user) - current_app.scoped_session().commit() + firstname = flask.request.form["firstname"].strip() + lastname = flask.request.form["lastname"].strip() + org = flask.request.form["organization"].strip() + email = user.email or flask.request.form["email"].strip() - with flask.current_app.arborist.context(): - # make sure the user exists in Arborist - flask.current_app.arborist.create_user(dict(name=flask.g.user.username)) - flask.current_app.arborist.add_user_to_group( - flask.g.user.username, - config["REGISTERED_USERS_GROUP"], - ) + registration_info = add_user_registration_info_to_database( + user, firstname, lastname, org, email + ) # Respect session redirect--important when redirected here from login flow - if flask.session.get("redirect"): - return flask.redirect(flask.session.get("redirect")) + post_registration_redirect = flask.session.get("post_registration_redirect") + if post_registration_redirect: + flask.session.pop("post_registration_redirect") + return flask.redirect(post_registration_redirect) + return flask.jsonify(registration_info) + + +def add_user_registration_info_to_database( + user: User, firstname: str, lastname: str, organization: str, email: str +): + user.additional_info = user.additional_info or {} + registration_info = { + "firstname": firstname, + "lastname": lastname, + "org": organization, + "email": email, + } + user.additional_info["registration_info"] = registration_info + current_app.scoped_session().add(user) + current_app.scoped_session().commit() + + if flask.current_app.arborist: + with flask.current_app.arborist.context(): + if config["REGISTERED_USERS_GROUP"]: + # make sure the user exists in Arborist + flask.current_app.arborist.create_user(dict(name=user.username)) + arborist_groups = set( + g["name"] + for g in flask.current_app.arborist.list_groups().get("groups", []) + ) + if config["REGISTERED_USERS_GROUP"] not in arborist_groups: + logger.debug( + f"Configured REGISTERED_USERS_GROUP '{config['REGISTERED_USERS_GROUP']}' does not exist yet. Creating it (it will have no policies)." + ) + flask.current_app.arborist.put_group( + config["REGISTERED_USERS_GROUP"] + ) + success = flask.current_app.arborist.add_user_to_group( + user.username, + config["REGISTERED_USERS_GROUP"], + ) + assert success is True, "Unable to add user to group" + + return registration_info diff --git a/fence/blueprints/storage_creds/__init__.py b/fence/blueprints/storage_creds/__init__.py index 057d1659a..f3d2d9ec7 100644 --- a/fence/blueprints/storage_creds/__init__.py +++ b/fence/blueprints/storage_creds/__init__.py @@ -1,5 +1,9 @@ +import json + +from cdislogging import get_logger import flask from flask import current_app +import jwt from fence.auth import require_auth_header from fence.blueprints.storage_creds.api import AccessKey, ApiKey, ApiKeyList @@ -7,12 +11,19 @@ from fence.blueprints.storage_creds.google import GoogleCredentials from fence.blueprints.storage_creds.other import OtherCredentialsList from fence.blueprints.storage_creds.other import OtherCredentials +from fence.errors import Unauthorized +from fence.jwt.blacklist import is_token_blacklisted +from fence.jwt.utils import get_jwt_header from fence.resources.storage import get_endpoints_descriptions from fence.restful import RestfulApi from fence.config import config + +logger = get_logger(__name__) + + ALL_RESOURCES = { - "/api": "access to CDIS APIs", + "/api": "access to Gen3 APIs", "/ceph": "access to Ceph storage", "/cleversafe": "access to cleversafe storage", "/aws-s3": "access to AWS S3 storage", @@ -62,11 +73,11 @@ def list_sources(): .. code-block:: JavaScript { - "/api": "access to CDIS APIs", + "/api": "access to Gen3 APIs", "/ceph": "access to Ceph storage", "/cleversafe": "access to cleversafe storage", - "/aws-s3", "access to AWS S3 storage" - "/google", "access to Google Cloud storage" + "/aws-s3": "access to AWS S3 storage", + "/google": "access to Google Cloud storage" } """ services = set( @@ -80,4 +91,42 @@ def list_sources(): get_endpoints_descriptions(services, current_app.scoped_session()) ) + @blueprint.route("/token/denylisted", methods=["POST"]) + def check_if_token_denylisted(): + """ + Check if a token is in the denylist/revoked. Return 403 if it is. + + If the token cannot be parsed, assume it is not denylisted and that the invalid + token will be rejected by downstream APIs. + + This endpoint is intended to be leveraged by an upstream gateway or proxy to block requests from denylisted tokens. + WARNING: It is NOT intended for this endpoint to be exposed externally to be called directly by users. + """ + # use the value of the request body `token` field if present, fall back to the request's + # Authorization header otherwise. + try: + body = json.loads(flask.request.data) + except json.JSONDecodeError: + logger.debug( + "request body is not valid JSON; falling back to Authorization header" + ) + body = {} + encoded_token = body.get("token") + try: + if not encoded_token: + encoded_token = get_jwt_header() + claims, is_denylisted = is_token_blacklisted(encoded_token) + except (jwt.exceptions.InvalidTokenError, Unauthorized) as e: + logger.info( + f"No provided token, or provided token is invalid: `{e}`. Token not denylisted." + ) + return "", 200 + + if is_denylisted: + logger.warning( + f'Blocking attempt to use a denylisted token. jti={claims.get("jti")}; azp={claims.get("azp")}; sub={claims.get("sub")}; username={claims.get("context", {}).get("user", {}).get("name")}' + ) + return "Token is denylisted", 403 + return "", 200 + return blueprint diff --git a/fence/blueprints/storage_creds/api.py b/fence/blueprints/storage_creds/api.py index c95738887..289e96907 100644 --- a/fence/blueprints/storage_creds/api.py +++ b/fence/blueprints/storage_creds/api.py @@ -2,9 +2,11 @@ import flask from flask_restful import Resource +import jwt -from fence.auth import require_auth_header -from fence.auth import current_token +from fence.auth import require_auth_header, current_token, get_user_from_claims +from fence.authz.auth import can_user_get_task_token +from fence.errors import Forbidden, UserError from fence.jwt.blacklist import blacklist_token from fence.models import UserRefreshToken from fence.config import config @@ -172,9 +174,45 @@ def post(self): api_key = None if not api_key: flask.abort(400, "Please provide an api_key in payload") + + # TODO Instead of using this endpoint for task tokens, implement oauth2 token exchange + # (https://datatracker.ietf.org/doc/html/rfc8693): exchange a Refresh Token or API Key for + # a longer-lived, downscoped access token. authlib doesn’t support token exchange yet + # (https://github.com/authlib/authlib/issues/821). + # => https://ctds-planx.atlassian.net/browse/PD-190 max_ttl = config.get("MAX_ACCESS_TOKEN_TTL", 3600) - expires_in = min(int(flask.request.args.get("expires_in", max_ttl)), max_ttl) + task_token_type = flask.request.args.get("task_token") or None + if ( + task_token_type + and task_token_type not in config["ALLOWED_TASK_TOKEN_TYPES"] + ): + raise UserError( + f"Invalid 'task_token' value '{task_token_type}'. Allowed task token types are: {config['ALLOWED_TASK_TOKEN_TYPES']}" + ) + try: + expires_in = int(flask.request.args.get("expires_in", max_ttl)) + except ValueError: + raise UserError( + f"Invalid 'expires_in' value '{flask.request.args['expires_in']}'" + ) + if not task_token_type: + expires_in = min(expires_in, max_ttl) + else: + # the classic max token TTL does not apply to task tokens, use the task token TTL + # instead + max_task_token_ttl = config["MAX_TASK_TOKEN_TTL"].get( + task_token_type, max_ttl + ) + expires_in = min(expires_in, max_task_token_ttl) + if not can_user_get_task_token(task_token_type, expires_in): + raise Forbidden( + f"You do not have access to obtain '{task_token_type}' tokens, or you do not have access to the token lifetime you requested" + ) + result = create_user_access_token( - flask.current_app.keypairs[0], api_key, expires_in + flask.current_app.keypairs[0], + api_key, + expires_in, + task_token_type=task_token_type, ) return flask.jsonify(dict(access_token=result)) diff --git a/fence/blueprints/storage_creds/google.py b/fence/blueprints/storage_creds/google.py index 49c4cadd4..51d910500 100644 --- a/fence/blueprints/storage_creds/google.py +++ b/fence/blueprints/storage_creds/google.py @@ -1,8 +1,7 @@ -from datetime import datetime +from datetime import datetime, UTC import flask import re import time -from distutils.util import strtobool from flask_restful import Resource from flask import current_app @@ -22,7 +21,7 @@ get_or_create_proxy_group_id, give_service_account_billing_access_if_necessary, ) -from fence.utils import get_valid_expiration_from_request +from fence.utils import get_valid_expiration_from_request, strtobool from cdislogging import get_logger @@ -81,7 +80,7 @@ def get(self): keys = g_cloud_manager.get_service_account_keys_info(service_account.email) # replace Google's expiration date by the one in our DB - reg = re.compile(".+\/keys\/(.+)") # get key_id from xx/keys/key_id + reg = re.compile(r".+\/keys\/(.+)") # get key_id from xx/keys/key_id for i, key in enumerate(keys): key_id = reg.findall(key["name"])[0] db_entry = ( @@ -94,7 +93,7 @@ def get(self): if db_entry: # convert timestamp to date - use the same format as Google API - exp_date = datetime.utcfromtimestamp(db_entry.expires).strftime( + exp_date = datetime.fromtimestamp(db_entry.expires, UTC).strftime( "%Y-%m-%dT%H:%M:%SZ" ) key["validBeforeTime"] = exp_date diff --git a/fence/config-default.yaml b/fence/config-default.yaml index f87e53282..d49c0615e 100755 --- a/fence/config-default.yaml +++ b/fence/config-default.yaml @@ -45,7 +45,7 @@ ENCRYPTION_KEY: '' # ////////////////////////////////////////////////////////////////////////////////////// # flask's debug setting # WARNING: DO NOT ENABLE IN PRODUCTION (for testing purposes only) -DEBUG: true +DEBUG: false # if true, will automatically login a user with username "test" # WARNING: DO NOT ENABLE IN PRODUCTION (for testing purposes only) MOCK_AUTH: false @@ -94,6 +94,7 @@ DB_MIGRATION_POSTGRES_LOCK_KEY: 100 # - WARNING: Be careful changing the *_ALLOWED_SCOPES as you can break basic # and optional functionality # ////////////////////////////////////////////////////////////////////////////////////// + OPENID_CONNECT: # any OIDC IDP that does not differ from the generic implementation can be # configured without code changes @@ -115,6 +116,51 @@ OPENID_CONNECT: multifactor_auth_claim_info: # optional, include if you're using arborist to enforce mfa on a per-file level claim: '' # claims field that indicates mfa, either the acr or acm claim. values: [ "" ] # possible values that indicate mfa was used. At least one value configured here is required to be in the token + # use `idp_discovery` to configure a URL at which the list of available upstream IdPs is + # provided. The list will be parsed and cached by fence. When `idp_discovery` is configured, + # `LOGIN_OPTIONS` for this IdP must include `upstream_idps`. + idp_discovery: + url: 'https://server.com/disco' + format: xml-mdq-v1.0 + # use `authorization_url_param_map` to set parameters when redirecting to the IdP. + # for the example config below, if the login URL includes `idp=google`, the redirect URL will + # include `upstream_idp_parameter=google`. + authorization_url_param_map: + idp: upstream_idp_parameter + # When true, it allows refresh tokens to be stored even if is_authz_groups_sync_enabled is set false. + # When false, the system will only store refresh tokens if is_authz_groups_sync_enabled is enabled + persist_refresh_token: false + # is_authz_groups_sync_enabled: A configuration flag that determines whether the application should + # verify and synchronize user group memberships between the identity provider (IdP) + # and the local authorization system (Arborist). When enabled, the refresh token is stored, the system retrieves + # the user's group information from their token issued by the IdP and compares it against + # the groups defined in the local system. Based on the comparison, the user is added to + # or removed from relevant groups in the local system to ensure their group memberships + # remain up-to-date. If this flag is disabled, no group synchronization occurs + is_authz_groups_sync_enabled: true + # Key used to retrieve group information from the token + group_claim_field: "groups" + # IdP group membership expiration (seconds). + group_membership_expiration_duration: 604800 + authz_groups_sync: + # This defines the prefix used to identify authorization groups. + group_prefix: "some_prefix" + # This flag indicates whether the audience (aud) claim in the JWT should be verified during token validation. + verify_aud: true + # This specifies the expected audience (aud) value for the JWT, ensuring that the token is intended for use with the 'fence' service. + audience: fence + # default refresh token expiration duration + default_refresh_token_exp: 3600 + # email claim field from idp + email_claim_field: 'email' + # firstname claim field from idp + firstname_claim_field: 'firstname' + # lastname claim field from idp + lastname_claim_field: 'lastname' + # organization claim field from idp + organization_claim_field: 'org' + # automatically register users using the info provided by the IdP in tokens + enable_idp_users_registration: false # These Google values must be obtained from Google's Cloud Console # Follow: https://developers.google.com/identity/protocols/OpenIDConnect # @@ -291,6 +337,12 @@ USER_ALLOWED_SCOPES: - "google_link" - "ga4gh_passport_v1" +# these are the default scopes that are included in a task access_token +TASK_TOKEN_DEFAULT_SCOPES: + - "fence" + - "openid" + - "user" + # these are the scopes that a browser session can create for a user (very # similar to USER_ALLOWED_SCOPES, as the session will actually create access_tokens # for an actively logged in user) @@ -305,9 +357,15 @@ SESSION_ALLOWED_SCOPES: - "google_link" - "ga4gh_passport_v1" +# this will error on authz callback from any of the above OIDC providers if the configured username field matches +# the search regex provided below +# example: +# .*@.*(126|163|aol|bk|foxmail|gmail|hotmail|icloud|inbox|live|mail|mailinator|outlook|proton|qq|rambler|sina|yeah|yahoo|yandex).* +GLOBAL_USERNAME_DENY_REGEX: "" + # ////////////////////////////////////////////////////////////////////////////////////// # LOGIN -# - Modify based on which OIDC provider(s) you configured above +# - Modify based on which OIDC provider(s) are configured in OPENID_CONNECT # - NOTE: You can have multiple IDPs for users to login with, but one has to be set # as the default # ////////////////////////////////////////////////////////////////////////////////////// @@ -315,14 +373,21 @@ SESSION_ALLOWED_SCOPES: # List of enabled login options (used by data-portal to display login buttons). # Each option must be configured with a "name" and an "idp". # - "idp" must be a configured provider in OPENID_CONNECT section. -# Multiple options can be configured with the same idp. -# - if provider_id is "fence", "fence_idp" can be any of the providers -# supported by the other Fence. If not specified, will default to NIH login. -# - if provider_id is "fence" and fence_idp is "shibboleth", a list of -# "shib_idps" can be configured for InCommon login. If not specified, will -# default to NIH login. +# Multiple options can be configured with the same idp. +# - "name": display name for this provider +# - "upstream_idps" can be '*' (all available IdPs) or a list of one or more IdPs supported by the +# upstream provider (aka "idp" above). If "*", the list of available IdPs is fetched by +# configuring `idp_discovery` in the `OPENID_CONNECT` section. +# For example, if idp is "fence" (multi-tenant Fence setup), upstream_idps can include any of the +# providers supported by the other Fence. For multi-tenant Fence, if upstream_idps is not +# specified, will default to NIH login. +# - "shib_idps": if "idp" is "fence" and upstream_idps is ["shibboleth"], a list of +# "shib_idps" can be configured for InCommon login. Can be '*' (all available IdPs) or a list of +# one or more entity IDs. If not specified, will default to NIH login. +# - "fence_idp": deprecated; same as a `upstream_idps` list of 1 item. +# ^ [Backwards compatibility for Fence multi-tenant login / Shibboleth legacy configuration] # - Optional parameters: "desc" (description) and "secondary" (boolean - can -# be used by the frontend to display secondary buttons differently). +# be used by the frontend to display secondary buttons differently). LOGIN_OPTIONS: [] # !!! remove the empty list to enable login options! # - name: 'Login from Google' # desc: 'description' @@ -341,18 +406,22 @@ LOGIN_OPTIONS: [] # !!! remove the empty list to enable login options! # idp: cognito # - name: 'Login from RAS' # idp: ras + # - name: 'Generic OIDC Login' + # idp: generic_oidc_idp + # upstream_idps: + # - urn:mace:incommon:nih.gov + # - urn:mace:incommon:uchicago.edu # - name: 'NIH Login' # idp: fence - # fence_idp: shibboleth + # upstream_idps: [shibboleth] # - name: 'ORCID Login through other Fence' # idp: fence - # fence_idp: orcid + # upstream_idps: [orcid] # - name: 'CILogon Login' # idp: cilogon # - name: 'InCommon Login' # idp: fence - # fence_idp: shibboleth - # # "shib_idps" can be '*' or a list of one or more entity IDs + # upstream_idps: [shibboleth] # shib_idps: # - urn:mace:incommon:nih.gov # - urn:mace:incommon:uchicago.edu @@ -453,9 +522,19 @@ MAX_PRESIGNED_URL_TTL: 3600 # The number of seconds after an API KEY is issued until it expires. MAX_API_KEY_TTL: 2592000 -# The number of seconds after an access token is issued until it expires. +# The number of seconds after an access token is issued from an API key until it expires. +# This is both the max and the default lifetime. MAX_ACCESS_TOKEN_TTL: 3600 +# The number of seconds after a task access token is issued until it expires. This max is +# applied even if the requesting user has access to request a longer-lived token in Arborist. +# This is configured per task token type, and falls back to `MAX_ACCESS_TOKEN_TTL`. +MAX_TASK_TOKEN_TTL: {} +# TES: 345600 + +# Task token types that can be requested by users. +ALLOWED_TASK_TOKEN_TYPES: [] + # TEMPORARY: The maximum number of projects allowed in token claims. # This config var should be removed after sheepdog and peregrine support # auth checks against Arborist, and no longer check the token. @@ -471,6 +550,15 @@ GEN3_PASSPORT_EXPIRES_IN: 43200 # We use the same field name for POSTs to /data/download for consistency GA4GH_DRS_POSTED_PASSPORT_FIELD: "passports" +# The number of seconds after a refresh token from an IdP will expire if there is not +# a specific exp configured for that IdP above in OPENID_CONNECT +DEFAULT_REFRESH_TOKEN_EXP: 3600 + +# Max number of GUIDs to request content retrieval for +# NOTE: This is limited by the MAX allowed for any underlying bulk content requests +# e.g. if the Gen3 Embeddings service is configured to allow max 1000, then this +# can't be more than that for embeddings content resolutions +MAX_BULK_CONTENT_GUIDS_COUNT: 500 ######################################################################################## # OPTIONAL CONFIGURATIONS # @@ -520,6 +608,10 @@ SHIBBOLETH_HEADER: 'persistent_id' SSO_URL: 'https://auth.nih.gov/affwebservices/public/saml2sso?SPID={{BASE_URL}}/shibboleth&RelayState=' ITRUST_GLOBAL_LOGOUT: 'https://auth.nih.gov/siteminderagent/smlogout.asp?mode=nih&AppReturnUrl=' +# Setting the HIDE_IDPS list will hide items from the InCommons drop down list +# eg, HIDE_IDPS = ["https://idp.uca.fr/idp/shibboleth", "urn:mace:incommon:osu.edu"] +HIDE_IDPS: [] + # ////////////////////////////////////////////////////////////////////////////////////// # dbGaP USER SYNCING SUPPORT # - Support syncing authorization information from dbGaP @@ -534,6 +626,7 @@ dbGaP: host: '' username: '' password: '' + private_key_filename: '' # use pubkey AuthN if set, otherwise use password AuthN port: 22 proxy: '' proxy_user: '' @@ -666,10 +759,14 @@ AWS_CREDENTIALS: {} # aws_access_key_id: '' # aws_secret_access_key: '' -# NOTE: the region is optonal for s3_buckets, however it should be specified to avoid a -# call to GetBucketLocation which you make lack the AWS ACLs for. -# public buckets do not need the region field. -# the cred values should be keys in section `AWS_CREDENTIALS`. +# S3_BUCKETS configuration: This is a bucket name to bucket credentials mapping. +# - the cred values should be keys in section `AWS_CREDENTIALS`. +# - the region is optional for s3_buckets, however it should be specified to avoid a +# call to GetBucketLocation which you make lack the AWS ACLs for. +# - public buckets do not need the region field. +# - regex support for bucket names: the keys are interpreted as `^$`. +# Example: `my-bucket-.*: {cred: ...}` matches "my-bucket-1", "my-bucket-12", etc +# - set `cred: '*'` for public buckets where you do not want to try signing at all S3_BUCKETS: {} # NOTE: Remove the {} and supply buckets if needed. Example in comments below # bucket1: @@ -722,7 +819,7 @@ HTTP_PROXY: port: 3128 # ////////////////////////////////////////////////////////////////////////////////////// -# MICROSERVICE PATHS +# OTHER MICROSERVICE PATHS AND CONFIG # - Support `/data` endpoints & authz functionality # ////////////////////////////////////////////////////////////////////////////////////// # url where indexd microservice is running (for signed urls primarily) @@ -736,6 +833,20 @@ INDEXD_USERNAME: 'fence' # can also be set via env var INDEXD_PASSWORD INDEXD_PASSWORD: '' +# allowlist for Gen3 Embeddings URL resolutions. +# use this to provide the prefix for allowed URLs for Gen3 Embeddings +# for example, if you want to be able to resolve Gen3 Embeddings from +# https://example.com/ai/vectorstore/collections/... +# you need to add "https://example.com/ai" to the allowlist here +# basically, everything BEFORE "/vectorstore/collections/..." +ALLOWED_GEN3_EMBEDDINGS_BULK_URL_PREFIXES: [] # IMPORTANT: REMEMBER TO REMOVE [] WHEN YOU ADD ITEMS +# - 'https://example.com/ai' + +# this is used to identify indexed record URLs which are pointing to embeddings, and thus support bulk content retrieval +# unless the Gen3 Embeddings API changes, you should NOT change this default +GEN3_EMBEDDINGS_API_REGEX: '/vectorstore/collections/(?P[^/]+)/embeddings/(?P[^/]+)' + + # ////////////////////////////////////////////////////////////////////////////////////// # AZURE STORAGE BLOB CONFIGURATION # - Support Azure Blob Data Access Methods @@ -751,8 +862,10 @@ AZ_BLOB_CREDENTIALS: AZ_BLOB_CONTAINER_URL: 'https://myfakeblob.blob.core.windows.net/my-fake-container/' # url where authz microservice is running -ARBORIST: null +ARBORIST: 'http://arborist-service' +# time in seconds to wait for arborist to respond to requests from fence +ARBORIST_TIMEOUT: 30 # url where the audit-service is running AUDIT_SERVICE: 'http://audit-service' ENABLE_AUDIT_LOGS: @@ -983,6 +1096,10 @@ SERVICE_ACCOUNT_LIMIT: 6 # whether or not to accept GA4GH Passports as a means of AuthN/Z to the DRS data access endpoint GA4GH_PASSPORTS_TO_DRS_ENABLED: false +# Maximum number of objects allowed in a single bulk DRS request (POST /drs/v1/objects/access) +# Protects against overly large requests that could impact performance +MAX_BULK_DRS_REQUESTS: 100 + # RAS refresh_tokens expire in 15 days RAS_REFRESH_EXPIRATION: 1296000 # List of JWT issuers from which Fence will accept GA4GH visas diff --git a/fence/config.py b/fence/config.py index 36348b0b1..bdf9b97ad 100644 --- a/fence/config.py +++ b/fence/config.py @@ -3,6 +3,7 @@ import urllib.parse import gen3cirrus +from os.path import expanduser from gen3config import Config from cdislogging import get_logger @@ -13,6 +14,9 @@ os.path.dirname(os.path.abspath(__file__)), "config-default.yaml" ) +# Folders to look in for the *config.yaml for fence +CONFIG_SEARCH_FOLDERS = ["/var/www/fence", "{}/.gen3/fence".format(expanduser("~"))] + class FenceConfig(Config): def post_process(self): @@ -35,13 +39,16 @@ def post_process(self): "GOOGLE_SERVICE_ACCOUNT_KEY_FOR_URL_SIGNING_EXPIRES_IN", "GOOGLE_USER_SERVICE_ACCOUNT_ACCESS_EXPIRES_IN", "GOOGLE_ACCOUNT_ACCESS_EXPIRES_IN", - "ACCESS_TOKEN_EXPIRES_IN", "dbGaP", "CIRRUS_CFG", "WHITE_LISTED_GOOGLE_PARENT_ORGS", "CLIENT_CREDENTIALS_ON_DOWNLOAD_ENABLED", "DATA_UPLOAD_BUCKET", "DEFAULT_BACKOFF_SETTINGS_MAX_TRIES", + "ARBORIST_TIMEOUT", + "HIDE_IDPS", + "GEN3_EMBEDDINGS_API_REGEX", + "MAX_BULK_CONTENT_GUIDS_COUNT", ] for default in defaults: self.force_default_if_none(default, default_cfg=default_config) @@ -140,12 +147,28 @@ def post_process(self): ) for idp_id, idp in self._configs.get("OPENID_CONNECT", {}).items(): + if not isinstance(idp, dict): + raise TypeError( + "Expected 'OPENID_CONNECT' configuration to be a dictionary." + ) mfa_info = idp.get("multifactor_auth_claim_info") if mfa_info and mfa_info["claim"] not in ["amr", "acr"]: logger.warning( f"IdP '{idp_id}' is using multifactor_auth_claim_info '{mfa_info['claim']}', which is neither AMR or ACR. Unable to determine if a user used MFA. Fence will continue and assume they have not used MFA." ) + groups_sync_enabled = idp.get("is_authz_groups_sync_enabled", False) + # when is_authz_groups_sync_enabled, then you must provide authz_groups_sync, with group prefix + if groups_sync_enabled: + if not idp.get("authz_groups_sync"): + error = f"Error: is_authz_groups_sync_enabled is enabled, required values not configured, for idp: {idp_id}" + logger.error(error) + raise Exception(error) + if not self._configs.get("ARBORIST"): + error = f"Error: is_authz_groups_sync_enabled is enabled for idp '{idp_id}' but ARBORIST url is not configured" + logger.error(error) + raise Exception(error) + self._validate_parent_child_studies(self._configs["dbGaP"]) @staticmethod diff --git a/fence/error_handler.py b/fence/error_handler.py index 5b3a0cfdb..e59e86ae3 100644 --- a/fence/error_handler.py +++ b/fence/error_handler.py @@ -8,37 +8,44 @@ from fence.errors import APIError from fence.config import config +import traceback logger = get_logger(__name__) -def get_error_response(error): +def get_error_response(error: Exception): + """ + Generates a response for the given error with detailed logs and appropriate status codes. + + Args: + error (Exception): The error that occurred. + + Returns: + Tuple (str, int): Rendered error HTML and HTTP status code. + """ details, status_code = get_error_details_and_status(error) support_email = config.get("SUPPORT_EMAIL_FOR_ERRORS") app_name = config.get("APP_NAME", "Gen3 Data Commons") - message = details.get("message") - error_id = _get_error_identifier() logger.error( - "{} HTTP error occured. ID: {}\nDetails: {}".format( - status_code, error_id, str(details) + "{} HTTP error occurred. ID: {}\nDetails: {}\nTraceback: {}".format( + status_code, error_id, details, traceback.format_exc() ) ) - # don't include internal details in the public error message - # to do this, only include error messages for known http status codes - # that are less that 500 + # Prepare user-facing message + message = details.get("message") valid_http_status_codes = [ int(code) for code in list(http_responses.keys()) if int(code) < 500 ] + try: status_code = int(status_code) if status_code not in valid_http_status_codes: message = None except (ValueError, TypeError): - # this handles case where status_code is NOT a valid integer (e.g. HTTP status code) message = None status_code = 500 @@ -59,6 +66,15 @@ def get_error_response(error): def get_error_details_and_status(error): + """ + Extracts details and HTTP status code from the given error. + + Args: + error (Exception): The error to process. + + Returns: + Tuple (dict, int): Error details as a dictionary and HTTP status code. + """ message = error.message if hasattr(error, "message") else str(error) if isinstance(error, APIError): if hasattr(error, "json") and error.json: @@ -70,11 +86,11 @@ def get_error_details_and_status(error): error_response = {"message": error.description}, error.status_code elif isinstance(error, HTTPException): error_response = ( - {"message": getattr(error, "description")}, + {"message": getattr(error, "description", str(error))}, error.get_response().status_code, ) else: - logger.exception("Catch exception") + logger.exception("Unexpected exception occurred") error_code = 500 if hasattr(error, "code"): error_code = error.code @@ -86,4 +102,10 @@ def get_error_details_and_status(error): def _get_error_identifier(): + """ + Generates a unique identifier for tracking the error. + + Returns: + UUID: A unique identifier for the error. + """ return uuid.uuid4() diff --git a/fence/errors.py b/fence/errors.py index 6751aa673..46509ec71 100644 --- a/fence/errors.py +++ b/fence/errors.py @@ -19,6 +19,10 @@ def __init__(self, message): self.code = 400 +class BlacklistingInvalidTokenError(APIError): + pass + + class InternalError(APIError): def __init__(self, message): super(InternalError, self).__init__(message) diff --git a/fence/job/visa_update_cronjob.py b/fence/job/access_token_updater.py similarity index 76% rename from fence/job/visa_update_cronjob.py rename to fence/job/access_token_updater.py index cac8d9182..8c1c15b1c 100644 --- a/fence/job/visa_update_cronjob.py +++ b/fence/job/access_token_updater.py @@ -3,16 +3,18 @@ import time from cdislogging import get_logger +from flask import current_app from fence.config import config from fence.models import User from fence.resources.openid.ras_oauth2 import RASOauth2Client as RASClient +from fence.resources.openid.idp_oauth2 import Oauth2ClientBase as OIDCClient logger = get_logger(__name__, log_level="debug") -class Visa_Token_Update(object): +class TokenAndAuthUpdater(object): def __init__( self, chunk_size=None, @@ -20,6 +22,7 @@ def __init__( thread_pool_size=None, buffer_size=None, logger=logger, + arborist=None, ): """ args: @@ -44,17 +47,36 @@ def __init__( self.visa_types = config.get("USERSYNC", {}).get("visa_types", {}) - # Initialize visa clients: + # Dict on self which contains all clients that need update + self.oidc_clients_requiring_token_refresh = {} + + # keep this as a special case, because RAS will not set group information configuration. oidc = config.get("OPENID_CONNECT", {}) + if "ras" not in oidc: self.logger.error("RAS client not configured") - self.ras_client = None else: - self.ras_client = RASClient( + ras_client = RASClient( oidc["ras"], HTTP_PROXY=config.get("HTTP_PROXY"), logger=logger, ) + self.oidc_clients_requiring_token_refresh["ras"] = ras_client + + self.arborist = arborist + + # Initialise a client for each OIDC client in oidc, which does have is_authz_groups_sync_enabled set to true and add them + # to oidc_clients_requiring_token_refresh + for oidc_name, settings in oidc.items(): + if settings.get("is_authz_groups_sync_enabled", False): + oidc_client = OIDCClient( + settings=settings, + HTTP_PROXY=config.get("HTTP_PROXY"), + logger=logger, + idp=oidc_name, + arborist=arborist, + ) + self.oidc_clients_requiring_token_refresh[oidc_name] = oidc_client async def update_tokens(self, db_session): """ @@ -68,7 +90,7 @@ async def update_tokens(self, db_session): """ start_time = time.time() - self.logger.info("Initializing Visa Update Cronjob . . .") + self.logger.info("Initializing Visa Update and Token refreshing Cronjob . . .") self.logger.info("Total concurrency size: {}".format(self.concurrency)) self.logger.info("Total thread pool size: {}".format(self.thread_pool_size)) self.logger.info("Total buffer size: {}".format(self.buffer_size)) @@ -139,13 +161,12 @@ async def worker(self, name, queue, updater_queue): queue.task_done() async def updater(self, name, updater_queue, db_session): - """ - Update visas in the updater_queue. - Note that only visas which pass validation will be saved. - """ while True: - user = await updater_queue.get() try: + user = await updater_queue.get() + if user is None: # Use None to signal termination + break + client = self._pick_client(user) if client: self.logger.info( @@ -160,30 +181,35 @@ async def updater(self, name, updater_queue, db_session): pkey_cache=self.pkey_cache, db_session=db_session, ) + else: self.logger.debug( f"Updater {name} NOT updating authorization for " f"user {user.username} because no client was found for IdP: {user.identity_provider}" ) + + updater_queue.task_done() + except Exception as exc: self.logger.error( f"Updater {name} could not update authorization " - f"for {user.username}. Error: {exc}. Continuing." + f"for {user.username if user else 'unknown user'}. Error: {exc}. Continuing." ) - pass - - updater_queue.task_done() + # Ensure task is marked done if exception occurs + updater_queue.task_done() def _pick_client(self, user): """ - Pick oidc client according to the identity provider + Select OIDC client based on identity provider. """ - client = None - if ( - user.identity_provider - and getattr(user.identity_provider, "name") == self.ras_client.idp - ): - client = self.ras_client + + client = self.oidc_clients_requiring_token_refresh.get( + getattr(user.identity_provider, "name"), None + ) + if client: + self.logger.info(f"Picked client: {client.idp} for user {user.username}") + else: + self.logger.info(f"No client found for user {user.username}") return client def _pick_client_from_visa(self, visa): diff --git a/fence/jwt/blacklist.py b/fence/jwt/blacklist.py index 60e1f1276..87b83d4b4 100644 --- a/fence/jwt/blacklist.py +++ b/fence/jwt/blacklist.py @@ -13,13 +13,30 @@ import flask import jwt +from collections import OrderedDict from sqlalchemy import BigInteger, Column, String -from fence.errors import BlacklistingError +from fence.errors import BlacklistingError, BlacklistingInvalidTokenError from fence.jwt import keys -from fence.jwt.errors import JWTError from fence.models import Base, UserRefreshToken +BLACKLISTED_CACHE_SIZE = 10000 +_blacklisted_cache = OrderedDict() + + +def _cache_add(jti): + """ + Add a JWT id to the in-memory cache of blacklisted key ids adhering to the cache size limit and FIFO policy. + + Args: + jti (str): JWT id to add to cache + """ + _blacklisted_cache[jti] = True + _blacklisted_cache.move_to_end(jti) + # Limit cache size to 1000 entries. + if len(_blacklisted_cache) > BLACKLISTED_CACHE_SIZE: + _blacklisted_cache.popitem(last=False) + class BlacklistedToken(Base): """ @@ -58,6 +75,8 @@ def blacklist_token(jti, exp): (session.query(UserRefreshToken).filter_by(jti=jti, expires=exp).delete()) session.commit() + _cache_add(jti) + def blacklist_encoded_token(encoded_token, public_key=None): """ @@ -65,7 +84,7 @@ def blacklist_encoded_token(encoded_token, public_key=None): using its JWT id ``jti`` and expiration ``exp``. This just wraps ``blacklist_token`` by decoding the token first. The token - _must_ be a refresh token; only refresh tokens may be blacklisted. + _must_ be a refresh token or task access token; only refresh tokens or task access tokens may be blacklisted. Args: encoded_token (str): the token @@ -75,12 +94,14 @@ def blacklist_encoded_token(encoded_token, public_key=None): None Raises: - - BlacklistingError: + - BlacklistingInvalidTokenError: - ``jti`` is not UUID4 - ``exp`` not provided - token is missing a claim (``aud``, ``exp``, or ``jti``) - token decoding fails - token is missing + - BlacklistingError: + - invalid token type for blacklisting Side Effects: - Add entry with ``jti`` to ``BlacklistedToken`` table @@ -95,20 +116,24 @@ def blacklist_encoded_token(encoded_token, public_key=None): options={"verify_aud": False}, ) except jwt.InvalidTokenError as e: - raise BlacklistingError("failed to decode token: {}".format(e)) + raise BlacklistingInvalidTokenError("failed to decode token: {}".format(e)) try: jti = claims["jti"] exp = claims["exp"] pur = claims["pur"] except KeyError as e: - raise BlacklistingError("token missing claim: {}".format(e)) + raise BlacklistingInvalidTokenError("token missing claim: {}".format(e)) # Do checks. # Check that JWT id is UUID4 (this raises a ValueError otherwise). uuid.UUID(jti, version=4) - # Must be refresh token or API key in order to revoke. - if pur != "refresh" and pur != "api_key": - raise BlacklistingError("can only blacklist refresh tokens and API keys") + # Must be task access token, refresh token or API key in order to revoke. + if pur not in ["access", "refresh", "api_key"] or ( + pur == "access" and "task_token_type" not in claims.get("context", {}) + ): + raise BlacklistingError( + "can only blacklist task access tokens, refresh tokens and API keys" + ) blacklist_token(jti, exp) @@ -123,8 +148,14 @@ def is_blacklisted(jti): Return: bool: whether JWT with the given id is blacklisted """ + if jti in _blacklisted_cache: + return True with flask.current_app.db.session as session: - return bool(session.query(BlacklistedToken).filter_by(jti=jti).first()) + exists = bool(session.query(BlacklistedToken).filter_by(jti=jti).first()) + + if exists: + _cache_add(jti) + return exists def is_token_blacklisted(encoded_token, public_key=None): @@ -136,16 +167,14 @@ def is_token_blacklisted(encoded_token, public_key=None): public key (Optional[str]): key to decode JWT with Return: + token: decoded JWT claims bool: whether JWT is blacklisted """ public_key = public_key or keys.default_public_key() - try: - token = jwt.decode( - encoded_token, - public_key, - algorithms=["RS256"], - options={"verify_aud": False}, - ) - except jwt.exceptions.InvalidTokenError as e: - raise JWTError("could not decode token to check blacklisting: {}".format(e)) - return is_blacklisted(token["jti"]) + token = jwt.decode( + encoded_token, + public_key, + algorithms=["RS256"], + options={"verify_aud": False, "verify_exp": False}, + ) + return token, is_blacklisted(token["jti"]) diff --git a/fence/jwt/token.py b/fence/jwt/token.py index a748db72f..b1840e30f 100644 --- a/fence/jwt/token.py +++ b/fence/jwt/token.py @@ -1,4 +1,3 @@ -import json import time import uuid from enum import Enum @@ -8,9 +7,9 @@ from authlib.oidc.core.util import create_half_hash from cdislogging import get_logger -import flask import jwt +from fence.auth import GEN3_AUDIENCE from fence.jwt import keys from fence.jwt.errors import JWTSizeError from fence.config import config @@ -178,7 +177,7 @@ def generate_signed_session_token(kid, private_key, expires_in, context=None): claims = { "pur": "session", - "aud": ["fence", issuer], + "aud": ["fence", GEN3_AUDIENCE], "sub": str(context.get("user_id", "")), "iss": issuer, "iat": iat, @@ -296,7 +295,7 @@ def generate_signed_refresh_token( "pur": "refresh", "sub": sub, "iss": iss, - "aud": [iss], + "aud": [GEN3_AUDIENCE], "iat": iat, "exp": exp, "jti": jti, @@ -342,7 +341,7 @@ def generate_api_key(kid, private_key, user_id, expires_in, scopes, client_id): "pur": "api_key", "sub": sub, "iss": iss, - "aud": [iss], + "aud": [GEN3_AUDIENCE], "iat": iat, "exp": exp, "jti": jti, @@ -362,11 +361,13 @@ def generate_signed_access_token( private_key, expires_in, scopes, + audience=None, user=None, iss=None, forced_exp_time=None, client_id=None, linked_google_email=None, + task_token_type=None, ): """ Generate a JWT access token and output a UTF-8 @@ -396,10 +397,20 @@ def generate_signed_access_token( " running outside of flask application" ) + aud = [GEN3_AUDIENCE] + if audience: + if type(audience) == list: + aud = audience + else: + aud = [audience] + + if client_id: + aud.append(client_id) + claims = { "pur": "access", "iss": iss, - "aud": [iss], + "aud": aud, "iat": iat, "exp": exp, "jti": jti, @@ -408,13 +419,6 @@ def generate_signed_access_token( "azp": client_id or "", } - if client_id: - claims["aud"].append(client_id) - - # Keep scopes in aud claim in access tokens for backwards comp.... - if scopes: - claims["aud"] += scopes - sub = None if user: sub = str(user.id) @@ -430,6 +434,8 @@ def generate_signed_access_token( claims["context"]["user"]["google"][ "linked_google_account" ] = linked_google_email + if task_token_type: + claims["context"]["task_token_type"] = task_token_type logger.info( "issuing JWT access token with id [{}] to user sub [{}] and client id [{}]".format( @@ -526,8 +532,8 @@ def generate_id_token( aud = audiences.copy() if audiences else [] if client_id and client_id not in aud: aud.append(client_id) - if issuer not in aud: - aud.append(issuer) + if GEN3_AUDIENCE not in aud: + aud.append(GEN3_AUDIENCE) claims["aud"] = aud if user.tags: diff --git a/fence/jwt/utils.py b/fence/jwt/utils.py index be6c60f4b..d0ed6d283 100644 --- a/fence/jwt/utils.py +++ b/fence/jwt/utils.py @@ -1,7 +1,7 @@ -import flask - from cdislogging import get_logger +import flask +from fence.config import config from fence.errors import Unauthorized @@ -19,7 +19,7 @@ def get_jwt_header(): except KeyError: raise Unauthorized("missing authorization header") if not header.lower().startswith("bearer"): - raise Unauthorized("unexpected Authorization header format (expected `Bearer`") + raise Unauthorized("unexpected Authorization header format (expected `Bearer`)") try: jwt = header.split(" ")[1] except IndexError: diff --git a/fence/jwt/validate.py b/fence/jwt/validate.py index fa47979b4..d756668fd 100644 --- a/fence/jwt/validate.py +++ b/fence/jwt/validate.py @@ -3,6 +3,7 @@ import authutils.token.validate import jwt +from fence.auth import GEN3_AUDIENCE from fence.config import config from fence.errors import Unauthorized from fence.jwt.blacklist import is_blacklisted @@ -93,7 +94,11 @@ def validate_jwt( # Can't set arg default to config[x] in fn def, so doing it this way. if aud is None: - aud = config["BASE_URL"] + aud = GEN3_AUDIENCE + + # TODO: Remove this at the next release (> 13.2.0). Temporarily add the base url in the audience + # for backwards compatibility (to accept JWTs generated before the aud was enforced) + aud = [aud, config["BASE_URL"]] iss = config["BASE_URL"] if issuers is None: @@ -126,71 +131,24 @@ def validate_jwt( **kwargs ) except authutils.errors.JWTError as e: - - ##### begin refresh token and API key patch block ##### - # TODO: In the next release, remove this if/elif block and take the else block - # back out of the else. - # Old refresh tokens and API keys are not compatible with new validation, so to smooth - # the transition, allow old style refresh tokens/API keys with this patch; - # remove patch in next tag. Refresh tokens and API keys have default TTL of 30 days. - from authutils.errors import JWTAudienceError - + msg = "Invalid token : {}".format(str(e)) unverified_claims = jwt.decode( encoded_token, algorithms=["RS256"], options={"verify_signature": False} ) - if unverified_claims.get("pur") == "refresh" and isinstance( - e, JWTAudienceError - ): - # Check everything else is fine minus the audience - try: - claims = authutils.token.validate.validate_jwt( - encoded_token=encoded_token, - aud="openid", - scope=None, - purpose="refresh", - issuers=issuers, - public_key=public_key, - attempt_refresh=attempt_refresh, - **kwargs - ) - except Error as e: - raise JWTError("Invalid refresh token: {}".format(e)) - elif unverified_claims.get("pur") == "api_key" and isinstance( - e, JWTAudienceError - ): - # Check everything else is fine minus the audience - try: - claims = authutils.token.validate.validate_jwt( - encoded_token=encoded_token, - aud="fence", - scope=None, - purpose="api_key", - issuers=issuers, - public_key=public_key, - attempt_refresh=attempt_refresh, - **kwargs - ) - except Error as e: - raise JWTError("Invalid API key: {}".format(e)) - else: - ##### end refresh token, API key patch block ##### - msg = "Invalid token : {}".format(str(e)) - unverified_claims = jwt.decode( - encoded_token, algorithms=["RS256"], options={"verify_signature": False} - ) - if not unverified_claims.get("scope") or "" in unverified_claims["scope"]: - msg += "; was OIDC client configured with scopes?" - raise JWTError(msg) + if not unverified_claims.get("scope") or "" in unverified_claims["scope"]: + msg += "; was OIDC client configured with scopes?" + raise JWTError(msg) if purpose: validate_purpose(claims, purpose) if require_purpose and "pur" not in claims: raise JWTError("token {} missing purpose (`pur`) claim".format(claims["jti"])) - # For refresh tokens and API keys specifically, check that they are not - # blacklisted. - if require_purpose and (claims["pur"] == "refresh" or claims["pur"] == "api_key"): - if is_blacklisted(claims["jti"]): - raise JWTError("token is blacklisted") + # For access/refresh tokens and API keys specifically, check that they are not + # denylisted. For access tokens, only check that if it's a task token. + if require_purpose and claims["pur"] in ["refresh", "api_key", "access"]: + if claims["pur"] != "access" or "task_token_type" in claims.get("context", {}): + if is_blacklisted(claims["jti"]): + raise JWTError("token is denylisted") return claims diff --git a/fence/local_settings.example.py b/fence/local_settings.example.py deleted file mode 100644 index a491963de..000000000 --- a/fence/local_settings.example.py +++ /dev/null @@ -1,232 +0,0 @@ -import os -import json -from boto.s3.connection import OrdinaryCallingFormat - - -DB = "postgresql://test:test@localhost:5432/fence" - -MOCK_AUTH = False -MOCK_STORAGE = False - -SERVER_NAME = "http://localhost/user" -BASE_URL = SERVER_NAME -APPLICATION_ROOT = "/user" - -ROOT_DIR = "/fence" - -# If using multi-tenant setup, configure this to the base URL for the provider -# fence (i.e. ``BASE_URL`` in the provider fence config). -# OIDC_ISSUER = 'http://localhost:8080/user - -EMAIL_SERVER = "localhost" - -SEND_FROM = "phillis.tt@gmail.com" - -SEND_TO = "phillis.tt@gmail.com" - -HMAC_ENCRYPTION_KEY = "" - -DEFAULT_LOGIN_URL = BASE_URL + "/login/google" - -OPENID_CONNECT = { - "google": { - "client_id": "", - "client_secret": "", - "redirect_url": "", - "name": "Google", - }, - "fence": { - "client_id": "", - "client_secret": "", - "api_base_url": "FENCE_ROOT_ENDPOINT", - "authorize_url": "FENCE_ROOT_ENDPOINT/oauth2/authorize", - "access_token_url": "FENCE_ROOT_ENDPOINT/oauth2/token", - "refresh_token_url": "FENCE_ROOT_ENDPOINT/oauth2/token", - "client_kwargs": { - "scope": "openid user", - "redirect_uri": "BASE_URL/login/fence/login", - }, - "name": "NIH", - }, -} - -STORAGE_CREDENTIALS = { - "cleversafe-server-a": { - "backend": "cleversafe", - "aws_access_key_id": "", - "aws_secret_access_key": "", - "host": "somemanager.osdc.io", - "public_host": "someobjstore.datacommons.io", - "port": 443, - "is_secure": True, - "username": "someone", - "password": "somepass", - "calling_format": OrdinaryCallingFormat(), - "is_mocked": True, - }, - "google-cloud-server": { - "backend": "google", - "google_project_id": "some-project-id-239870as9f23flkja8010", - }, -} - -# Configuration necessary for cirrus (Cloud Management Library) -# https://github.com/uc-cdis/cirrus -# will eventually be passed as params but cirrus looks for env vars right now -CIRRUS_CFG = { - "GOOGLE_API_KEY": "", - "GOOGLE_PROJECT_ID": "", - "GOOGLE_APPLICATION_CREDENTIALS": "", - "GOOGLE_STORAGE_CREDS": "", - "GOOGLE_ADMIN_EMAIL": "", - "GOOGLE_IDENTITY_DOMAIN": "", - "GOOGLE_CLOUD_IDENTITY_ADMIN_EMAIL": "", -} - -""" -If the api is behind firewall that need to set http proxy: - HTTP_PROXY = {'host': 'cloud-proxy', 'port': 3128} -""" -HTTP_PROXY = None -STORAGES = ["/cleversafe"] - -SHIBBOLETH_HEADER = "persistent_id" - -# assumes shibboleth is deployed under {BASE_URL}/shibboleth -SSO_URL = "https://auth.nih.gov/affwebservices/public/saml2sso?SPID={}/shibboleth&RelayState=".format( - BASE_URL -) - -ITRUST_GLOBAL_LOGOUT = ( - "https://auth.nih.gov/siteminderagent/smlogout.asp?mode=nih&AppReturnUrl=" -) - -SESSION_COOKIE_SECURE = False -ENABLE_CSRF_PROTECTION = True - -INDEXD = "/index" - -INDEXD_AUTH = ("gdcapi", "") - -ARBORIST = "/rbac" - -AWS_CREDENTIALS = { - "CRED1": {"aws_access_key_id": "", "aws_secret_access_key": ""}, - "CRED2": {"aws_access_key_id": "", "aws_secret_access_key": ""}, -} - -ASSUMED_ROLES = {"arn:aws:iam::role1": "CRED1"} - -DATA_UPLOAD_BUCKET = "bucket1" - -S3_BUCKETS = { - "bucket1": {"cred": "CRED1"}, - "bucket2": {"cred": "CRED2"}, - "bucket3": {"cred": "CRED1", "role-arn": "arn:aws:iam::role1"}, -} - -GS_BUCKETS = { - "bucket1": {"cred": "CRED1"}, - "bucket2": {"cred": "CRED2"}, -} - -#: Confiure which identity providers this fence instance can use for login. -#: -#: See ``fence/blueprints/login/__init__.py`` for which identity providers can -#: be loaded. -#: -#: NOTE: Don't enable shibboleth if the deployment is not protected by -#: shibboleth module, the shib module takes care of preventing header spoofing. -ENABLED_IDENTITY_PROVIDERS = { - # ID for which of the providers to default to. - "default": "google", - # Information for identity providers. The name will be what show - # up in portal login page - "providers": { - "fence": {"name": "NIH Login"}, - "google": {"name": "Google OAuth"}, - "shibboleth": {"name": "NIH Login"}, - }, -} - -APP_NAME = "" - -GOOGLE_GROUP_PREFIX = "" - -#: ``MAX_PRESIGNED_URL_TTL: int`` -#: The number of seconds after a pre-signed url is issued until it expires. -MAX_PRESIGNED_URL_TTL = 3600 - -#: ``MAX_API_KEY_TTL: int`` -#: The number of seconds after an API KEY is issued until it expires. -MAX_API_KEY_TTL = 2592000 - -#: ``MAX_ACCESS_TOKEN_TTL: int`` -#: The number of seconds after an access token is issued until it expires. -MAX_ACCESS_TOKEN_TTL = 3600 -dir_path = "/secrets" -fence_creds = os.path.join(dir_path, "fence_credentials.json") - -GOOGLE_MANAGED_SERVICE_ACCOUNT_DOMAINS = { - "dataflow-service-producer-prod.iam.gserviceaccount.com", - "cloudbuild.gserviceaccount.com", - "cloud-ml.google.com.iam.gserviceaccount.com", - "container-engine-robot.iam.gserviceaccount.com", - "dataflow-service-producer-prod.iam.gserviceaccount.com", - "sourcerepo-service-accounts.iam.gserviceaccount.com", - "dataproc-accounts.iam.gserviceaccount.com", - "gae-api-prod.google.com.iam.gserviceaccount.com", - "genomics-api.google.com.iam.gserviceaccount.com", - "containerregistry.iam.gserviceaccount.com", - "container-analysis.iam.gserviceaccount.com", - "cloudservices.gserviceaccount.com", - "stackdriver-service.iam.gserviceaccount.com", - "appspot.gserviceaccount.com", - "partnercontent.gserviceaccount.com", - "trifacta-gcloud-prod.iam.gserviceaccount.com", - "gcf-admin-robot.iam.gserviceaccount.com", - "compute-system.iam.gserviceaccount.com", - "gcp-sa-websecurityscanner.iam.gserviceaccount.com", - "storage-transfer-service.iam.gserviceaccount.com", -} - -REMOVE_SERVICE_ACCOUNT_EMAIL_NOTIFICATION = { - "enable": False, - "domain": "smtp domain", - "subject": "User service account removal notification", - "from": "do-not-reply@planx-pla.net", - "admin": [], - "contact number": "123456789", - "content": """ - The service accounts were removed from access control data because some \ -users or service accounts of GCP project {} are not authorized to access \ -the data sets associated to the service accounts, or do not \ -adhere to the security policies. - """, -} - -SUPPORT_EMAIL_FOR_ERRORS = None -dbGaP = {} -if os.path.exists(fence_creds): - with open(fence_creds, "r") as f: - data = json.load(f) - AWS_CREDENTIALS = data["AWS_CREDENTIALS"] - S3_BUCKETS = data["S3_BUCKETS"] - DEFAULT_LOGIN_URL = data["DEFAULT_LOGIN_URL"] - OPENID_CONNECT.update(data["OPENID_CONNECT"]) - OIDC_ISSUER = data["OIDC_ISSUER"] - ENABLED_IDENTITY_PROVIDERS = data["ENABLED_IDENTITY_PROVIDERS"] - APP_NAME = data["APP_NAME"] - HTTP_PROXY = data["HTTP_PROXY"] - dbGaP = data["dbGaP"] - GOOGLE_GROUP_PREFIX = data.get("GOOGLE_GROUP_PREFIX") - WHITE_LISTED_SERVICE_ACCOUNT_EMAILS = data.get( - "WHITE_LISTED_SERVICE_ACCOUNT_EMAILS" - ) - WHITE_LISTED_GOOGLE_PARENT_ORGS = data.get("WHITE_LISTED_GOOGLE_PARENT_ORGS") - GOOGLE_MANAGED_SERVICE_ACCOUNT_DOMAINS.update( - data.get("GOOGLE_MANAGED_SERVICE_ACCOUNT_DOMAINS", []) - ) - GUN_MAIL = data.get("GUN_MAIL") - -CLIENT_CREDENTIALS_ON_DOWNLOAD_ENABLED: False diff --git a/fence/metrics.py b/fence/metrics.py index acdb200a9..8dca8b387 100644 --- a/fence/metrics.py +++ b/fence/metrics.py @@ -12,7 +12,6 @@ - Add unit tests to the `tests/test_metrics` file. """ - import os import pathlib @@ -40,9 +39,7 @@ class Metrics: metrics (dict): Dictionary to store Prometheus metrics """ - def __init__(self, prometheus_dir="/var/tmp/uwsgi_flask_metrics"): - pathlib.Path(prometheus_dir).mkdir(parents=True, exist_ok=True) - os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_dir + def __init__(self): self._registry = CollectorRegistry() multiprocess.MultiProcessCollector(self._registry) @@ -121,7 +118,7 @@ def _set_gauge(self, name, labels, value): logger.debug(f"Setting gauge '{name}' with labels: {labels}") self._metrics[name].labels(*labels.values()).set(value) - def add_login_event(self, user_sub, idp, fence_idp, shib_idp, client_id): + def add_login_event(self, user_sub, idp, upstream_idp, shib_idp, client_id): """ Record a login event """ @@ -133,7 +130,7 @@ def add_login_event(self, user_sub, idp, fence_idp, shib_idp, client_id): "user_sub": user_sub, "idp": idp, "client_id": client_id, - "fence_idp": fence_idp, + "upstream_idp": upstream_idp, "shib_idp": shib_idp, }, ) @@ -144,7 +141,7 @@ def add_login_event(self, user_sub, idp, fence_idp, shib_idp, client_id): "idp": "all", "client_id": client_id, # when counting all IDPs, we don't care about the fence and shib IDP values - "fence_idp": None, + "upstream_idp": None, "shib_idp": None, }, ) diff --git a/fence/models.py b/fence/models.py index 779d27519..09a08cbc3 100644 --- a/fence/models.py +++ b/fence/models.py @@ -152,7 +152,7 @@ def get_client_expires_at(expires_in, grant_types): # for backwards compatibility, 0 means no expiration if expires_in != 0: - # do not use `datetime.utcnow()` or the timestamp will be wrong, + # do not use `datetime.now(UTC)` or the timestamp will be wrong, # `timestamp()` already converts to UTC expires_at = (datetime.now() + timedelta(days=expires_in)).timestamp() diff --git a/fence/oidc/endpoints.py b/fence/oidc/endpoints.py index 254d9ef69..fd7ab7665 100644 --- a/fence/oidc/endpoints.py +++ b/fence/oidc/endpoints.py @@ -5,7 +5,7 @@ from cdislogging import get_logger -from fence.errors import BlacklistingError +from fence.errors import BlacklistingError, BlacklistingInvalidTokenError import fence.jwt.blacklist import jwt @@ -30,15 +30,7 @@ def revoke_token(self, token, request): """ Revoke a token. """ - try: - fence.jwt.blacklist.blacklist_encoded_token(token.encoded_string) - except BlacklistingError as err: - logger.info( - "Token provided for revocation is not valid. " - "Per rfc7009, this should still return a 200. Error: " - f"{err}", - exc_info=True, - ) + fence.jwt.blacklist.blacklist_encoded_token(token.encoded_string) def validate_authenticate_client(self): """ @@ -114,9 +106,12 @@ def create_revocation_response(self): class JWTToken(object): def __init__(self, token): self.encoded_string = token - self.client_id = jwt.decode( - token, algorithms=["RS256"], options={"verify_signature": False} - ).get("azp") + try: + self.client_id = jwt.decode( + token, algorithms=["RS256"], options={"verify_signature": False} + ).get("azp") + except jwt.InvalidTokenError as e: + raise BlacklistingInvalidTokenError("failed to decode token: {}".format(e)) def check_client(self, client): """ diff --git a/fence/oidc/grants/oidc_code_grant.py b/fence/oidc/grants/oidc_code_grant.py index 0514e68a5..c6382269a 100644 --- a/fence/oidc/grants/oidc_code_grant.py +++ b/fence/oidc/grants/oidc_code_grant.py @@ -85,10 +85,10 @@ def save_authorization_code(self, code, request): code = AuthorizationCode( code=code, client_id=client.client_id, - redirect_uri=request.redirect_uri, - scope=request.scope, + redirect_uri=request.payload.redirect_uri, + scope=request.payload.scope, user_id=request.user.id, - nonce=request.data.get("nonce"), + nonce=request.payload.data.get("nonce"), refresh_token_expires_in=refresh_token_expires_in, ) @@ -191,7 +191,7 @@ def validate_request_prompt(self, end_user, redirect_uri): """ Override method in authlib to fix behavior with login prompt. """ - prompt = self.request.data.get("prompt") + prompt = self.request.payload.data.get("prompt") if not prompt: if not end_user: self.prompt = "login" @@ -242,7 +242,7 @@ def validate_token_request(self): f'The client is not authorized to use "grant_type={self.GRANT_TYPE}"' ) - code = self.request.data.get("code") + code = self.request.payload.data.get("code") if code is None: raise InvalidRequestError('Missing "code" in request.') @@ -251,14 +251,14 @@ def validate_token_request(self): # code was issued to "client_id" in the request authorization_code = self.query_authorization_code(code, client) if not authorization_code: - raise InvalidGrantError('Invalid "code" in request.') + raise InvalidGrantError("Invalid 'code' in request.") # validate redirect_uri parameter logger.debug("Validate token redirect_uri of %r", client) - redirect_uri = self.request.redirect_uri + redirect_uri = self.request.payload.redirect_uri original_redirect_uri = authorization_code.get_redirect_uri() if original_redirect_uri and redirect_uri != original_redirect_uri: - raise InvalidGrantError('Invalid "redirect_uri" in request.') + raise InvalidGrantError("Invalid 'redirect_uri' in request.") # save for create_token_response self.request.client = client diff --git a/fence/oidc/grants/refresh_token_grant.py b/fence/oidc/grants/refresh_token_grant.py index b607bd9af..3602d3862 100644 --- a/fence/oidc/grants/refresh_token_grant.py +++ b/fence/oidc/grants/refresh_token_grant.py @@ -129,7 +129,7 @@ def create_token_response(self): raise InvalidRequestError('There is no "user" for this token.') client = self.request.client - scope = self.request.scope + scope = self.request.payload.scope if not scope: # scope = credential["scope"] @@ -156,7 +156,7 @@ def create_token_response(self): # TODO: this could be handled differently, we could track last authN # and still allow refreshing refresh tokens if self.GRANT_TYPE == "refresh_token": - token["refresh_token"] = self.request.data.get("refresh_token", "") + token["refresh_token"] = self.request.payload.data.get("refresh_token", "") self.request.user = user self.server.save_token(token, self.request) @@ -170,7 +170,7 @@ def _validate_request_token(self, client): Why? Becuase our "token" is not a class with `check_client` method. So we just need to treat it like a dictionary. """ - refresh_token = self.request.data.get("refresh_token") + refresh_token = self.request.payload.data.get("refresh_token") if refresh_token is None: raise InvalidRequestError( 'Missing "refresh_token" in request.', @@ -188,7 +188,7 @@ def _validate_token_scope(self, token): Why? Becuase our "token" is not a class with `get_scope` method. So we just need to treat it like a dictionary. """ - scope = self.request.scope + scope = self.request.payload.scope if not scope: return diff --git a/fence/oidc/oidc_server.py b/fence/oidc/oidc_server.py index 7e185873f..d2fdf57b1 100644 --- a/fence/oidc/oidc_server.py +++ b/fence/oidc/oidc_server.py @@ -1,4 +1,5 @@ import flask +from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Payload from fence.oidc.errors import InvalidClientError from fence.oidc.jwt_generator import generate_token @@ -36,7 +37,6 @@ def authenticate(self, request, methods, endpoint): request, methods, endpoint ) - logger.info("oidc_server.py clientAuthentication authenticate complete") # don't allow confidential clients to not use auth if client.is_confidential: m = list(methods) @@ -112,6 +112,7 @@ class FenceOAuth2Request(OAuth2Request): def __init__(self, request: Request): super().__init__(request.method, request.url, None, request.headers) self._request = request + self.payload = FlaskOAuth2Payload(request) @property def args(self): diff --git a/fence/resources/admin/admin_users.py b/fence/resources/admin/admin_users.py index 69aae8cb5..22b004601 100644 --- a/fence/resources/admin/admin_users.py +++ b/fence/resources/admin/admin_users.py @@ -29,6 +29,7 @@ "add_user_to_projects", "delete_user", "soft_delete_user", + "reactivate_soft_deleted_user", "add_user_to_groups", "connect_user_to_group", "remove_user_from_groups", @@ -369,6 +370,21 @@ def soft_delete_user(current_session, username): return us.get_user_info(current_session, usr.username) +def reactivate_soft_deleted_user(current_session, username): + """ + Reverts the soft-remove done by soft_delete_user above + by marking user as active=True. + """ + usr = us.get_user(current_session, username) + if (usr.active): + raise UserError(("Error: user is already active")) + + logger.debug(f"Reactivate soft-deleted user '{username}'") + usr.active = True + current_session.commit() + return us.get_user_info(current_session, usr.username) + + def delete_user(current_session, username): """ Remove a user from both the userdatamodel diff --git a/fence/resources/audit/client.py b/fence/resources/audit/client.py index 9b90fa0d9..bc8774de0 100644 --- a/fence/resources/audit/client.py +++ b/fence/resources/audit/client.py @@ -3,6 +3,7 @@ import json import requests import traceback +from cachelib import SimpleCache from fence.config import config from fence.errors import InternalError @@ -10,6 +11,9 @@ from fence.utils import DEFAULT_BACKOFF_SETTINGS +AUDIT_SCHEMA_CACHE = SimpleCache(default_timeout=1800) # cached for 30m + + class AuditServiceClient: def __init__(self, service_url, logger): self.service_url = service_url.rstrip("/") @@ -32,6 +36,7 @@ def __init__(self, service_url, logger): self.logger.warning( "Audit logs are enabled but audit-service is unreachable. Continuing anyway..." ) + self._set_schema_models_cache() else: self.logger.warning("NOT enabling audit logs") return @@ -70,6 +75,71 @@ def _ping(self): self.logger.debug(f"Checking audit-service availability at {status_url}") requests.get(status_url) + @backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS) + def _get_audit_schema(self): + """ + Hit the audit-service _schema endpoint. + """ + status_url = f"{self.service_url}/_schema" + self.logger.info(f"Getting audit-service schema version at {status_url}") + return requests.get(status_url) + + def _set_schema_models_cache(self): + """ + Set schema versions/models in schema model cache if expired. + """ + cache_key = "audit_schema" + if not AUDIT_SCHEMA_CACHE.has(cache_key): + resp = self._get_audit_schema() + if resp.status_code == 200: + schema = resp.json() + self.logger.info(f"Setting audit schema cache: {schema}") + AUDIT_SCHEMA_CACHE.set(cache_key, schema) + elif resp.status_code == 404: + schema = { + "login": { + "version": 1.0, + "model": { + "request_url": "str", + "status_code": "int", + "timestamp": "int", + "username": "str", + "sub": "int", + "idp": "str", + "fence_idp": "str?", + "shib_idp": "str?", + "client_id": "str?", + }, + }, + "presigned_url": { + "version": 1.0, + "model": { + "request_url": "str", + "status_code": "int", + "timestamp": "int", + "username": "str", + "sub": "int", + "guid": "str", + "resource_paths": "list", + "action": "str", + "protocol": "str", + }, + }, + } + self.logger.info( + f"/_schema endpoint {resp.status_code} – assuming version 1 for all audit log models: {schema}" + ) + AUDIT_SCHEMA_CACHE.set(cache_key, schema) + else: + try: + err = resp.json() + except Exception: + err = resp.text + self.logger.error( + f"Unexpected response from audit schema endpoint. Status code: {resp.status_code} - Details:\n{err}" + ) + raise InternalError("Unable to get audit schema") + def _validate_config(self): """ Validate the audit configuration, making sure required fields @@ -150,6 +220,7 @@ def create_presigned_url_log( action, resource_paths=None, protocol=None, + additional_data=[], ): """ Create a presigned URL audit log, or do nothing if auditing is @@ -160,6 +231,8 @@ def create_presigned_url_log( if not is_audit_enabled("presigned_url"): return + self._set_schema_models_cache() + data = { "request_url": request_url, "status_code": status_code, @@ -170,6 +243,25 @@ def create_presigned_url_log( "action": action, "protocol": protocol, } + + if ("presigned_url" in AUDIT_SCHEMA_CACHE.get("audit_schema").keys()) and ( + "version" + in AUDIT_SCHEMA_CACHE.get("audit_schema").get("presigned_url").keys() + ): + + if ( + AUDIT_SCHEMA_CACHE.get("audit_schema") + .get("presigned_url") + .get("version") + >= 1.1 + ): + data["additional_data"] = additional_data + + else: + self.logger.log( + "Could not retrieve presigned_url version from audit schema" + ) + self._create_audit_log("presigned_url", data) def create_login_log( @@ -179,9 +271,11 @@ def create_login_log( username, sub, idp, - fence_idp=None, + upstream_idp=None, shib_idp=None, client_id=None, + additional_data=[], + ip=None, ): """ Create a login audit log, or do nothing if auditing is disabled. @@ -191,8 +285,10 @@ def create_login_log( if not is_audit_enabled("login"): return + self._set_schema_models_cache() + # special case for idp=fence when falling back on - # fence_idp=shibboleth and shib_idp=NIH + # upstream_idp=shibboleth and shib_idp=NIH if shib_idp == "None": shib_idp = None @@ -202,8 +298,26 @@ def create_login_log( "username": username, "sub": sub, "idp": idp, - "fence_idp": fence_idp, + # NOTE: audit-service still registers `upstream_idp` as `fence_idp` + "fence_idp": upstream_idp, "shib_idp": shib_idp, "client_id": client_id, } + + if ("login" in AUDIT_SCHEMA_CACHE.get("audit_schema").keys()) and ( + "version" in AUDIT_SCHEMA_CACHE.get("audit_schema").get("login").keys() + ): + + if ( + AUDIT_SCHEMA_CACHE.get("audit_schema").get("login").get("version") + >= 2.1 + ): + data["additional_data"] = additional_data + + if AUDIT_SCHEMA_CACHE.get("audit_schema").get("login").get("version") >= 2: + data["ip"] = ip + else: + self.logger.log("Could not retrieve login version from audit schema") + pass + self._create_audit_log("login", data) diff --git a/fence/resources/audit/utils.py b/fence/resources/audit/utils.py index 9331da793..cc01e2341 100644 --- a/fence/resources/audit/utils.py +++ b/fence/resources/audit/utils.py @@ -1,11 +1,15 @@ import flask from functools import wraps import traceback +import time from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from cdislogging import get_logger from fence.config import config +from flask.wrappers import Request +from fence.jwt.validate import validate_jwt +from fence.auth import get_user_from_claims logger = get_logger(__name__) @@ -20,20 +24,23 @@ def is_audit_enabled(category=None): def _clean_authorization_request_url(request_url): """ - Remove sensitive data from a login request URL. + Remove sensitive data from request URLs. """ parsed_url = urlparse(request_url) query_params = dict(parse_qsl(parsed_url.query, keep_blank_values=True)) + # specifically look for code and state parameters (commonly used in OAuth redirects) + # and replace their values with "redacted": for param in ["code", "state"]: if param in query_params: query_params[param] = "redacted" - url_parts = list(parsed_url) # cast to list to override query params + # rebuild the URL with the sanitized query params: + url_parts = list(parsed_url) url_parts[4] = urlencode(query=query_params) request_url = urlunparse(url_parts) return request_url -def create_audit_log_for_request(response): +def create_audit_log_for_request(response, duration): """ Right before returning the response to the user (see `after_this_request` in `enable_audit_logging` decorator), record an audit log. The data we @@ -65,6 +72,12 @@ def create_audit_log_for_request(response): else: guid = endpoint[len("/ga4gh/drs/v1/objects/") :] guid = guid.split("/access/")[0] + + audit_data.get("additional_data", []).append(f"duration:{duration}") + audit_data.get("additional_data", []).append( + f"bytes:{response.content_length}" + ) + audit_data.get("additional_data", []).append(f"http_method:{method}") flask.current_app.audit_service_client.create_presigned_url_log( status_code=response.status_code, request_url=request_url, @@ -80,6 +93,25 @@ def create_audit_log_for_request(response): request_url=request_url, **audit_data, ) + elif method == "POST" and endpoint.startswith("/ga4gh/drs/v1/objects/access"): + request_url = _clean_authorization_request_url(request_url) + audit_data.get("additional_data", []).append(f"duration:{duration}") + audit_data.get("additional_data", []).append(f"http_method:{method}") + + if audit_data.get("bulk_files"): + audit_data["additional_data"].append( + "bulk_guids:" + + ",".join(entry["file_id"] for entry in audit_data["bulk_files"]) + ) + + flask.current_app.audit_service_client.create_presigned_url_log( + status_code=response.status_code, + request_url=request_url, + guid="bulk", + action="download", + **audit_data, + ) + except Exception: # TODO monitor this somehow traceback.print_exc() @@ -88,25 +120,72 @@ def create_audit_log_for_request(response): return response +def create_log_for_request(request: Request): + """ + Right before processing the request (see `enable_request_logging` decorator), + record a log entry. + """ + claims = validate_jwt() + username = get_user_from_claims(claims).username if "sub" in claims else "None" + client_id = claims.get("azp", "None") or "None" + method = request.method + endpoint = request.path + request_url = endpoint + if request.query_string: + # could use `request.url` but we don't want the root URL + request_url += f"?{request.query_string.decode('utf-8')}" + request_url = _clean_authorization_request_url(request_url) + logger.info( + f"Incoming request: user=%s, client=%s, method=%s, endpoint=%s, request_url=%s", + username, + client_id, + method, + endpoint, + request_url, + ) + + +def enable_request_logging(f): + """ + This decorator should be added to any API endpoint for which we want to + write a log entry to the local fence logs. + """ + + @wraps(f) + def wrapper(*args, **kwargs): + create_log_for_request(flask.request) + return f(*args, **kwargs) + + return wrapper + + def enable_audit_logging(f): """ - This decorator should be added to any API endpoint for which we - record audit logs. It should not be added to non-audited endpoints, - so that performance is not impacted. + This decorator should be added to any API endpoint for which we want to + push audit logs into the **audit-service**. The audit-service logs serve a very + specific use case where we need some of the audit log entries available via audit-service. + For most of the cases, local logs will suffice, and therefore one should consider using + the enable_request_logging decorator instead. + + This decorator should also not be added to non-audited endpoints, so that performance is not impacted. + The `create_audit_log_for_request_decorator` decorator is only added if auditing is enabled, so that performance is not impacted when auditing is disabled. Possible improvement: pass a "category" argument to `is_audit_enabled`. - /!\ This decorator is not enough to enable audit logging for an endpoint. + NOTE: This decorator is not enough to enable audit logging for an endpoint. Logic must be added to `create_audit_log_for_request()` and the audit service might need to be updated to handle new types of data. """ @wraps(f) def wrapper(*args, **kwargs): + start_time = time.time() + def create_audit_log_for_request_decorator(response): - return create_audit_log_for_request(response) + duration = time.time() - start_time + return create_audit_log_for_request(response, duration=duration) if is_audit_enabled(): # we can't add the `after_this_request` and diff --git a/fence/resources/ga4gh/passports.py b/fence/resources/ga4gh/passports.py index 9d06a4f3d..0dcf13965 100644 --- a/fence/resources/ga4gh/passports.py +++ b/fence/resources/ga4gh/passports.py @@ -1,3 +1,4 @@ +from sqlalchemy import text import flask import os import collections @@ -325,7 +326,7 @@ def get_or_create_gen3_user_from_iss_sub(issuer, subject_id, db_session=None): logger.debug( f"get_or_create_gen3_user_from_iss_sub: issuer: {issuer} & subject_id: {subject_id}" ) - iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get((issuer, subject_id)) + iss_sub_pair_to_user = db_session.get(IssSubPairToUser, (issuer, subject_id)) if not iss_sub_pair_to_user: username = subject_id + issuer[len("https://") :] gen3_user = query_for_user(session=db_session, username=username) @@ -495,7 +496,8 @@ def put_gen3_usernames_for_passport_into_cache( PASSPORT_CACHE[passport_hash] = user_ids_from_passports, expires_at db_session.execute( - """\ + text( + """\ INSERT INTO ga4gh_passport_cache ( passport_hash, expires_at, @@ -506,7 +508,8 @@ def put_gen3_usernames_for_passport_into_cache( :user_ids ) ON CONFLICT (passport_hash) DO UPDATE SET expires_at = EXCLUDED.expires_at, - user_ids = EXCLUDED.user_ids;""", + user_ids = EXCLUDED.user_ids;""" + ), dict( passport_hash=passport_hash, expires_at=expires_at, diff --git a/fence/resources/google/utils.py b/fence/resources/google/utils.py index 3a4745cae..3f475eaa3 100644 --- a/fence/resources/google/utils.py +++ b/fence/resources/google/utils.py @@ -86,6 +86,7 @@ def get_or_create_primary_service_account_key( sa_private_key = create_primary_service_account_key( user_id, username, proxy_group_id, expires ) + user_service_account_key = _get_primary_service_account_key( user_id, username, proxy_group_id ) diff --git a/fence/resources/google/validity.py b/fence/resources/google/validity.py index 505b83b2e..b49281ea6 100644 --- a/fence/resources/google/validity.py +++ b/fence/resources/google/validity.py @@ -1,7 +1,11 @@ """ Objects with validity checking for Google service account registration. """ -from collections import Mapping +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping + from fence.errors import NotFound from fence.resources.google.utils import ( diff --git a/fence/resources/openid/cilogon_oauth2.py b/fence/resources/openid/cilogon_oauth2.py index 163663420..cc1b8674d 100644 --- a/fence/resources/openid/cilogon_oauth2.py +++ b/fence/resources/openid/cilogon_oauth2.py @@ -39,7 +39,9 @@ def get_auth_info(self, code): jwks_endpoint = self.get_value_from_discovery_doc( "jwks_uri", "https://cilogon.org/oauth2/certs" ) - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) if claims.get("sub"): return {"sub": claims["sub"]} diff --git a/fence/resources/openid/cognito_oauth2.py b/fence/resources/openid/cognito_oauth2.py index 73038c87f..3da80fb9d 100644 --- a/fence/resources/openid/cognito_oauth2.py +++ b/fence/resources/openid/cognito_oauth2.py @@ -45,7 +45,9 @@ def get_auth_info(self, code): try: token_endpoint = self.get_value_from_discovery_doc("token_endpoint", "") jwks_endpoint = self.get_value_from_discovery_doc("jwks_uri", "") - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) self.logger.info(f"Received id token from Cognito: {claims}") diff --git a/fence/resources/openid/google_oauth2.py b/fence/resources/openid/google_oauth2.py index b396fe9ca..60c2d69c2 100644 --- a/fence/resources/openid/google_oauth2.py +++ b/fence/resources/openid/google_oauth2.py @@ -47,7 +47,9 @@ def get_auth_info(self, code): jwks_endpoint = self.get_value_from_discovery_doc( "jwks_uri", "https://www.googleapis.com/oauth2/v3/certs" ) - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) if claims.get("email") and claims.get("email_verified"): return {"email": claims["email"], "sub": claims.get("sub")} diff --git a/fence/resources/openid/idp_oauth2.py b/fence/resources/openid/idp_oauth2.py index 463db77cf..3721b7868 100644 --- a/fence/resources/openid/idp_oauth2.py +++ b/fence/resources/openid/idp_oauth2.py @@ -1,13 +1,23 @@ +from authlib.common.urls import add_params_to_uri from authlib.integrations.requests_client import OAuth2Session from cached_property import cached_property +import flask from flask import current_app -from jose import jwt + +from jose.exceptions import JWTError import requests import time - +import datetime +import backoff +import jwt +from fence.utils import DEFAULT_BACKOFF_SETTINGS from fence.errors import AuthError from fence.models import UpstreamRefreshToken +from fence.jwt.validate import validate_jwt +from authutils.token.keys import get_public_key_for_token +from fence.config import config + class Oauth2ClientBase(object): """ @@ -15,7 +25,14 @@ class Oauth2ClientBase(object): """ def __init__( - self, settings, logger, idp, scope=None, discovery_url=None, HTTP_PROXY=None + self, + settings, + logger, + idp, + scope=None, + discovery_url=None, + HTTP_PROXY=None, + arborist=None, ): self.logger = logger self.settings = settings @@ -25,14 +42,17 @@ def __init__( scope=scope or settings.get("scope") or "openid", redirect_uri=settings["redirect_url"], ) + self.discovery_url = ( discovery_url or settings.get("discovery_url") or getattr(self, "DISCOVERY_URL", None) or "" ) - self.idp = idp # display name for use in logs and error messages + # display name for use in logs and error messages + self.idp = idp self.HTTP_PROXY = HTTP_PROXY + self.authz_groups_from_idp = [] if not self.discovery_url and not settings.get("discovery"): self.logger.warning( @@ -40,6 +60,12 @@ def __init__( f"Some calls for this client may fail if they rely on the OIDC Discovery page. Use 'discovery' to configure clients without a discovery page." ) + self.read_authz_groups_from_tokens = self.settings.get( + "is_authz_groups_sync_enabled", False + ) + + self.arborist = arborist + @cached_property def discovery_doc(self): return requests.get(self.discovery_url) @@ -53,6 +79,7 @@ def get_proxies(self): return None def get_token(self, token_endpoint, code): + return self.session.fetch_token( url=token_endpoint, code=code, proxies=self.get_proxies() ) @@ -63,6 +90,7 @@ def get_jwt_keys(self, jwks_uri): Return None if there is an error while retrieving keys from the api """ resp = requests.get(url=jwks_uri, proxies=self.get_proxies()) + if resp.status_code != requests.codes.ok: self.logger.error( "{} ERROR: Can not retrieve jwt keys from IdP's API {}".format( @@ -76,15 +104,48 @@ def get_jwt_claims_identity(self, token_endpoint, jwks_endpoint, code): """ Get jwt identity claims """ + token = self.get_token(token_endpoint, code) + + refresh_token = token.get("refresh_token", None) + keys = self.get_jwt_keys(jwks_endpoint) - return jwt.decode( - token["id_token"], - keys, - options={"verify_aud": False, "verify_at_hash": False}, - algorithms=["RS256"], - ) + # Extract issuer from the id token without signature verification + try: + decoded_id_token = jwt.decode( + token["id_token"], + options={"verify_signature": False}, + algorithms=["RS256"], + key=keys, + ) + issuer = decoded_id_token.get("iss") + except JWTError as e: + raise JWTError(f"Invalid token: {e}") + + # validate audience and hash. also ensure that the algorithm is correctly derived from the token. + # hash verification has not been implemented yet + verify_aud = self.settings.get("verify_aud", False) + audience = self.settings.get("audience", self.settings.get("client_id")) + + decoded_access_token = None + + if self.read_authz_groups_from_tokens: + try: + decoded_access_token = validate_jwt( + encoded_token=token["access_token"], + aud=audience, + scope=None, + issuers=[issuer], + purpose=None, + require_purpose=False, + options={"verify_aud": verify_aud, "verify_hash": False}, + attempt_refresh=True, + ) + except JWTError as e: + raise JWTError(f"Invalid token: {e}") + + return decoded_id_token, refresh_token, decoded_access_token def get_value_from_discovery_doc(self, key, default_value): """ @@ -154,6 +215,19 @@ def get_auth_url(self): uri, _ = self.session.create_authorization_url( authorization_endpoint, prompt="login" ) + + if "idp" in flask.request.args: + flask.session["upstream_idp"] = flask.request.args["idp"] + + # add query parameters to the url as configured in `authorization_url_param_map` + params = {} + for in_param, out_param in self.settings.get( + "authorization_url_param_map", {} + ).items(): + if in_param in flask.request.args: + params[out_param] = flask.request.args[in_param] + uri = add_params_to_uri(uri, params) + return uri def get_auth_info(self, code): @@ -163,10 +237,39 @@ def get_auth_info(self, code): user OR "error" field with details of the error. """ user_id_field = self.settings.get("user_id_field", "sub") + try: token_endpoint = self.get_value_from_discovery_doc("token_endpoint", "") jwks_endpoint = self.get_value_from_discovery_doc("jwks_uri", "") - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) + + groups = None + group_prefix = None + + organization_claim_field = self.settings.get( + "organization_claim_field", "org" + ) + firstname_claim_field = self.settings.get( + "firstname_claim_field", "firstname" + ) + lastname_claim_field = self.settings.get("lastname_claim_field", "lastname") + email_claim_field = self.settings.get("email_claim_field", "email") + + if self.read_authz_groups_from_tokens: + try: + group_claim_field = self.settings.get("group_claim_field", "groups") + # Get groups from access token + groups = access_token.get(group_claim_field) + group_prefix = self.settings.get("authz_groups_sync", {}).get( + "group_prefix", "" + ) + except KeyError as e: + self.logger.error( + f"Error: is_authz_groups_sync_enabled is enabled, however groups not found in claims: {e}" + ) + raise Exception(e) if claims.get(user_id_field): if user_id_field == "email" and not claims.get("email_verified"): @@ -174,6 +277,15 @@ def get_auth_info(self, code): return { user_id_field: claims[user_id_field], "mfa": self.has_mfa_claim(claims), + "refresh_token": refresh_token, + "iat": claims.get("iat"), + "exp": claims.get("exp"), + "groups": groups, + "group_prefix": group_prefix, + "org": claims.get(organization_claim_field), + "firstname": claims.get(firstname_claim_field), + "lastname": claims.get(lastname_claim_field), + "email": claims.get(email_claim_field), } else: self.logger.exception( @@ -183,7 +295,7 @@ def get_auth_info(self, code): except Exception as e: self.logger.exception(f"Can't get user info from {self.idp}: {e}") - return {"error": f"Can't get user info from {self.idp}"} + return {"error": f"Can't get user info from {self.idp}: {e}"} def get_access_token(self, user, token_endpoint, db_session=None): """ @@ -192,11 +304,12 @@ def get_access_token(self, user, token_endpoint, db_session=None): refresh_token = None expires = None - # get refresh_token and expiration from db + # Get the refresh_token and expiration from the database for row in sorted(user.upstream_refresh_tokens, key=lambda row: row.expires): refresh_token = row.refresh_token expires = row.expires + # Check if the token is expired if time.time() > expires: # reset to check for next token refresh_token = None @@ -209,21 +322,42 @@ def get_access_token(self, user, token_endpoint, db_session=None): if not refresh_token: raise AuthError("User doesn't have a valid, non-expired refresh token") - token_response = self.session.refresh_token( - url=token_endpoint, - proxies=self.get_proxies(), - refresh_token=refresh_token, - ) - refresh_token = token_response["refresh_token"] + verify_aud = self.settings.get("verify_aud", False) + audience = self.settings.get("audience", self.settings.get("client_id")) - self.store_refresh_token( - user, - refresh_token=refresh_token, - expires=expires, - db_session=db_session, - ) + refresh_kwargs = { + "url": token_endpoint, + "proxies": self.get_proxies(), + "refresh_token": refresh_token, + } + + if verify_aud: + refresh_kwargs["audience"] = audience + + try: + token_response = self.session.refresh_token(**refresh_kwargs) + + refresh_token = token_response["refresh_token"] + # Fetching the expires at from token_response. + # Defaulting to config settings. + default_refresh_token_exp = self.settings.get( + "default_refresh_token_exp", config["DEFAULT_REFRESH_TOKEN_EXP"] + ) + expires_at = token_response.get( + "expires_at", time.time() + default_refresh_token_exp + ) - return token_response + self.store_refresh_token( + user, + refresh_token=refresh_token, + expires=expires_at, + db_session=db_session, + ) + + return token_response + except Exception as e: + self.logger.exception(f"Error refreshing token for user {user.id}: {e}") + raise AuthError("Failed to refresh access token.") def has_mfa_claim(self, decoded_id_token): """ @@ -266,13 +400,169 @@ def store_refresh_token(self, user, refresh_token, expires, db_session=None): """ Store refresh token in db. """ - db_session = db_session or current_app.scoped_session() + db_session = db_session or flask.current_app.scoped_session() user.upstream_refresh_tokens = [] upstream_refresh_token = UpstreamRefreshToken( user=user, refresh_token=refresh_token, expires=expires, ) - current_db_session = db_session.object_session(upstream_refresh_token) - current_db_session.add(upstream_refresh_token) + db_session.add(upstream_refresh_token) db_session.commit() + self.logger.info( + f"Refresh token has been persisted for user: {user} , with expiration of {expires}" + ) + + def get_groups_from_token(self, decoded_access_token, group_prefix=""): + """ + Retrieve and format groups from the decoded token based on a configurable field name. + + Args: + decoded_access_token (dict): The decoded token containing claims. + group_prefix (str): The prefix to strip from group names. + + Returns: + list: A list of formatted group names. + + Variables: + group_claim_field (str): The field name in the token that contains the group information. + authz_groups_from_idp (list): The list of groups retrieved from the token, potentially empty. + """ + # Retrieve the configured field name for groups, defaulting to 'groups' + group_claim_field = self.settings.get("group_claim_field", "groups") + authz_groups_from_idp = decoded_access_token.get(group_claim_field, []) + + if authz_groups_from_idp: + authz_groups_from_idp = [ + group.removeprefix(group_prefix).lstrip("/") + for group in authz_groups_from_idp + ] + return authz_groups_from_idp + + @backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS) + def update_user_authorization(self, user, pkey_cache, db_session=None, **kwargs): + """ + Update the user's authorization by refreshing their access token and synchronizing + their group memberships with Arborist. + + This method refreshes the user's access token using an identity provider (IdP), + retrieves and decodes the token, and optionally synchronizes the user's group + memberships between the IdP and Arborist if the `groups` configuration is enabled. + + Args: + user (User): The user object, which contains details like username and identity provider. + pkey_cache (dict): A cache of public keys used for verifying JWT signatures. + db_session (SQLAlchemy Session, optional): A database session object. If not provided, + it defaults to the scoped session of the current application context. + **kwargs: Additional keyword arguments. + + Raises: + Exception: If there is an issue with retrieving the access token, decoding the token, + or synchronizing the user's groups. + + Workflow: + 1. Retrieves the token endpoint and JWKS URI from the identity provider's discovery document. + 2. Uses the user's refresh token to get a new access token and persists it in the database. + 3. Decodes the ID token using the JWKS (JSON Web Key Set) retrieved from the IdP. + 4. If group synchronization is enabled: + a. Retrieves the list of groups from Arborist. + b. Retrieves the user's groups from the IdP. + c. Adds the user to groups in Arborist that match the groups from the IdP. + d. Removes the user from groups in Arborist that they are no longer part of in the IdP. + + Logging: + - Logs the group membership synchronization activities (adding/removing users from groups). + - Logs any issues encountered while refreshing the token or during group synchronization. + + Warnings: + - If groups are not received from the IdP but group synchronization is enabled, logs a warning. + + """ + db_session = db_session or flask.current_app.scoped_session() + + expires_at = None + + try: + token_endpoint = self.get_value_from_discovery_doc("token_endpoint", "") + + # this get_access_token also persists the refresh token in the db + token = self.get_access_token(user, token_endpoint, db_session) + + verify_aud = self.settings.get("verify_aud", False) + audience = self.settings.get("audience", self.settings.get("client_id")) + + key = get_public_key_for_token( + token["id_token"], attempt_refresh=True, pkey_cache={} + ) + + decoded_access_token = jwt.decode( + token["access_token"], + key=key, + options={"verify_aud": verify_aud, "verify_at_hash": False}, + algorithms=["RS256"], + audience=audience, + ) + self.logger.info("Token decoded and validated successfully.") + + except Exception as e: + err_msg = "Could not refresh token" + self.logger.exception("{}: {}".format(err_msg, e)) + raise + + if self.read_authz_groups_from_tokens: + group_prefix = self.settings.get("authz_groups_sync", {}).get( + "group_prefix", "" + ) + + # grab all groups defined in arborist + arborist_groups = self.arborist.list_groups().get("groups") + + # groups defined in idp + authz_groups_from_idp = self.get_groups_from_token( + decoded_access_token, group_prefix + ) + + # if group name is in the list from arborist: + if authz_groups_from_idp: + authz_groups_from_idp = [ + group.removeprefix(group_prefix).lstrip("/") + for group in authz_groups_from_idp + ] + + idp_group_names = set(authz_groups_from_idp) + + # Expiration for group membership. Default 7 days + group_membership_duration = self.settings.get( + "group_membership_expiration_duration", 3600 * 24 * 7 + ) + + # Get the refresh token expiration from the token response + refresh_token_expires_at = datetime.datetime.fromtimestamp( + token.get("expires_at", time.time()), tz=datetime.timezone.utc + ) + + # Calculate the configured group membership expiration + configured_expires_at = datetime.datetime.now( + tz=datetime.timezone.utc + ) + datetime.timedelta(seconds=group_membership_duration) + + # Ensure group membership does not exceed refresh token expiration + group_membership_expires_at = min( + refresh_token_expires_at, configured_expires_at + ) + + # Add user to all matching groups from IDP + for arborist_group in arborist_groups: + if arborist_group["name"] in idp_group_names: + self.logger.info( + f"Adding {user.username} to group: {arborist_group['name']}, sub: {user.id} exp: {group_membership_expires_at}" + ) + self.arborist.add_user_to_group( + username=user.username, + group_name=arborist_group["name"], + expires_at=group_membership_expires_at, + ) + else: + self.logger.warning( + f"is_authz_groups_sync_enabled feature is enabled, but did not receive groups from idp {self.idp} for user: {user.username}" + ) diff --git a/fence/resources/openid/microsoft_oauth2.py b/fence/resources/openid/microsoft_oauth2.py index 916a4a2b1..d4fa6b634 100755 --- a/fence/resources/openid/microsoft_oauth2.py +++ b/fence/resources/openid/microsoft_oauth2.py @@ -48,7 +48,9 @@ def get_auth_info(self, code): "jwks_uri", "https://login.microsoftonline.com/organizations/discovery/v2.0/keys", ) - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) if claims.get("email"): return {"email": claims["email"], "sub": claims.get("sub")} diff --git a/fence/resources/openid/okta_oauth2.py b/fence/resources/openid/okta_oauth2.py index 572031623..bb590915b 100644 --- a/fence/resources/openid/okta_oauth2.py +++ b/fence/resources/openid/okta_oauth2.py @@ -37,7 +37,9 @@ def get_auth_info(self, code): "jwks_uri", "", ) - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) if claims.get("email"): return {"email": claims["email"], "sub": claims.get("sub")} diff --git a/fence/resources/openid/orcid_oauth2.py b/fence/resources/openid/orcid_oauth2.py index ee8711f33..3e0056984 100644 --- a/fence/resources/openid/orcid_oauth2.py +++ b/fence/resources/openid/orcid_oauth2.py @@ -41,7 +41,9 @@ def get_auth_info(self, code): jwks_endpoint = self.get_value_from_discovery_doc( "jwks_uri", "https://orcid.org/oauth/jwks" ) - claims = self.get_jwt_claims_identity(token_endpoint, jwks_endpoint, code) + claims, refresh_token, access_token = self.get_jwt_claims_identity( + token_endpoint, jwks_endpoint, code + ) if claims.get("sub"): return {"orcid": claims["sub"], "sub": claims["sub"]} diff --git a/fence/resources/openid/ras_oauth2.py b/fence/resources/openid/ras_oauth2.py index 846b2d448..fae232fd5 100644 --- a/fence/resources/openid/ras_oauth2.py +++ b/fence/resources/openid/ras_oauth2.py @@ -217,9 +217,7 @@ def map_iss_sub_pair_to_user( exception noted above """ db_session = db_session or current_app.scoped_session() - iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get( - (issuer, subject_id) - ) + iss_sub_pair_to_user = db_session.get(IssSubPairToUser, (issuer, subject_id)) user = query_for_user(db_session, username) if iss_sub_pair_to_user: if not user: diff --git a/fence/resources/storage/cdis_jwt.py b/fence/resources/storage/cdis_jwt.py index e53eced85..3ea819412 100644 --- a/fence/resources/storage/cdis_jwt.py +++ b/fence/resources/storage/cdis_jwt.py @@ -1,7 +1,9 @@ import flask +import time from fence.auth import get_user_from_claims -from fence.errors import Unauthorized +from fence.config import config +from fence.errors import Unauthorized, UserError from fence.jwt import token from fence.jwt.errors import JWTError from fence.jwt.validate import validate_jwt @@ -42,33 +44,39 @@ def create_session_token(keypair, expires_in, context=None): ).token -def create_user_access_token(keypair, api_key, expires_in): +def create_user_access_token(keypair, api_key, expires_in, task_token_type): """ create access token given a user's api key Args: keypair: RSA keypair for signing jwt api_key: user created jwt token, the azp should match with user.id expires_in: expiration time in seconds + task_token_type: type of task token to create, if any, otherwise None Return: access token """ + try: claims = validate_jwt(api_key, scope={"fence"}, purpose="api_key") - # scopes = claims["scope"] - - ##### begin api key patch block ##### - # TODO: In the next release, remove this block and uncomment line above. - # Old API keys are not compatible with new validation - # This is to help transition - try: - scopes = claims["scope"] - except KeyError as e: - scopes = claims["aud"] - ##### end api key patch block ##### + scopes = ( + config["TASK_TOKEN_DEFAULT_SCOPES"] if task_token_type else claims["scope"] + ) user = get_user_from_claims(claims) except Exception as e: raise Unauthorized(str(e)) + + if claims.get("exp", 0) < time.time() + expires_in: + raise UserError( + "Cannot issue an access token that would expire after the provided API key does. Please obtain a new API key and try again" + ) + return token.generate_signed_access_token( - keypair.kid, keypair.private_key, expires_in, scopes, user=user + keypair.kid, + keypair.private_key, + expires_in, + scopes, + audience=task_token_type, + user=user, + task_token_type=task_token_type, ).token diff --git a/fence/resources/storage/storageclient/cleversafe.py b/fence/resources/storage/storageclient/cleversafe.py index 2517b13f9..690c0ad2b 100644 --- a/fence/resources/storage/storageclient/cleversafe.py +++ b/fence/resources/storage/storageclient/cleversafe.py @@ -2,10 +2,10 @@ Connection manager for the Cleversafe storage system Since it is compatible with S3, we will be using boto. """ -from boto import connect_s3 -from boto.s3 import connection -from boto.exception import S3ResponseError -from boto.s3.acl import Grant + +import boto3 +from botocore.client import Config +from botocore.exceptions import ClientError import requests from urllib.parse import urlencode import json @@ -42,12 +42,6 @@ def __init__(self, config): } self._permissions_value = ["disabled", "readOnly", "readWrite", "owner"] self._auth = requests.auth.HTTPBasicAuth(self._username, self._password) - self._conn = connect_s3( - aws_access_key_id=self._access_key, - aws_secret_access_key=self._secret_key, - host=self._public_host, - calling_format=connection.OrdinaryCallingFormat(), - ) self._bucket_name_id_table = {} self._update_bucket_name_id_table() self._user_name_id_table = {} @@ -153,9 +147,9 @@ def _populate_user(self, data): for vault_permission in vault_roles: vault_response = self._get_bucket_by_id(vault_permission["vault"]) vault = json.loads(vault_response.text) - new_user.permissions[ - vault["responseData"]["vaults"][0]["name"] - ] = vault_permission["permission"] + new_user.permissions[vault["responseData"]["vaults"][0]["name"]] = ( + vault_permission["permission"] + ) return new_user except KeyError as key_e: msg = "Failed to parse the user data. Check user fields inside the accounts section" @@ -412,18 +406,23 @@ def create_bucket(self, bucket_name, access_key=None, secret_key=None): access_key = self._access_key if not secret_key: secret_key = self._secret_key - creds = {"host": self._public_host} + host = self._public_host + creds = {"endpoint_url": f"https://{host}"} creds["aws_access_key_id"] = access_key creds["aws_secret_access_key"] = secret_key - conn = connect_s3(calling_format=connection.OrdinaryCallingFormat(), **creds) + s3_client = boto3.client( + "s3", + config=Config(s3={"addressing_style": "path"}) # Enforces path style, + ** creds, + ) try: - bucket = conn.create_bucket(bucket_name) + bucket = s3_client.create_bucket(Bucket=bucket_name) self._update_bucket_name_id_table() return bucket - except S3ResponseError as exce: - msg = "Create bucket failed with error code: {0}" - self.logger.error(msg.format(exce.error_code)) - raise RequestError(str(exce), exce.error_code) + except ClientError as e: + error_code = e.response["Error"]["Code"] + self.logger.error(f"Create bucket failed with error code: {error_code}") + raise RequestError(str(e), error_code) def edit_bucket_template(self, default_template_id, **kwargs): """ diff --git a/fence/resources/user/__init__.py b/fence/resources/user/__init__.py index 152e75057..7ed402b55 100644 --- a/fence/resources/user/__init__.py +++ b/fence/resources/user/__init__.py @@ -67,6 +67,10 @@ def get_user(current_session, username): def get_current_user_info(): + if not hasattr(flask.g, "user") or not flask.g.user: + # edge case where the session has a user but the user doesn't exist in the DB + # (for example, the user was deleted from the DB while logged in) + raise Unauthorized("User not logged in") with flask.current_app.db.session as session: return get_user_info(session, session.merge(flask.g.user).username) @@ -100,8 +104,11 @@ def get_user_info(current_session, username): "message": "", } - if "fence_idp" in flask.session: - info["fence_idp"] = flask.session["fence_idp"] + if "upstream_idp" in flask.session: + info["upstream_idp"] = flask.session["upstream_idp"] + # [Backwards compatibility for Fence multi-tenant login / Shibboleth legacy configuration] + # also set `fence_idp` + info["fence_idp"] = flask.session["upstream_idp"] if "shib_idp" in flask.session: info["shib_idp"] = flask.session["shib_idp"] @@ -113,18 +120,21 @@ def get_user_info(current_session, username): primary_service_account_email = getattr(primary_service_account, "email", None) info["primary_google_service_account"] = primary_service_account_email - if hasattr(flask.current_app, "arborist"): - try: - auth_mapping = flask.current_app.arborist.auth_mapping(user.username) - resources = list(auth_mapping.keys()) - except ArboristError as exc: - logger.error( - f"request to arborist for user's resources failed; going to list empty. Error: {exc}" - ) - resources = [] - auth_mapping = {} - info["resources"] = resources - info["authz"] = auth_mapping + try: + auth_mapping = ( + flask.current_app.arborist.auth_mapping(user.username) + if flask.current_app.arborist + else {} + ) + resources = list(auth_mapping.keys()) + except ArboristError as exc: + logger.error( + f"request to arborist for user's resources failed; going to list empty. Error: {exc}" + ) + resources = [] + auth_mapping = {} + info["resources"] = resources + info["authz"] = auth_mapping if user.tags is not None and len(user.tags) > 0: info["tags"] = {tag.key: tag.value for tag in user.tags} diff --git a/fence/resources/user/user_session.py b/fence/resources/user/user_session.py index fc061a74c..8fa9d96e1 100644 --- a/fence/resources/user/user_session.py +++ b/fence/resources/user/user_session.py @@ -141,7 +141,7 @@ def __getitem__(self, key): return self.session_token["context"][key] def __setitem__(self, key, value): - # If token doesn't exists, create the first session token when + # If token doesn't exist, create the first session token when # data in the session is attempting to be set if not self._encoded_token: self.create_initial_token() @@ -287,6 +287,11 @@ def _get_valid_access_token(app, session, request): except Unauthorized: return None + if not user: + # edge case where the session has a user but the user doesn't exist in the DB + # (for example, the user was deleted from the DB while logged in) + return None + # check that the current user is the one from the session and access_token user_sess_id = _get_auth_info_from_session(session) token_user_id = _get_auth_info_from_access_token(valid_access_token) diff --git a/fence/scripting/fence_create.py b/fence/scripting/fence_create.py index a4b15aff8..430a9b230 100644 --- a/fence/scripting/fence_create.py +++ b/fence/scripting/fence_create.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, UTC import os import os.path import requests @@ -38,7 +38,7 @@ generate_signed_refresh_token, issued_and_expiration_times, ) -from fence.job.visa_update_cronjob import Visa_Token_Update +from fence.job.access_token_updater import TokenAndAuthUpdater from fence.models import ( Client, GoogleServiceAccount, @@ -245,7 +245,7 @@ def format_uris(uris): warning_days = float(warning_days) if warning_days else 7 warning_days_in_secs = warning_days * 24 * 60 * 60 # days to seconds warning_expiry = ( - datetime.utcnow() + timedelta(seconds=warning_days_in_secs) + datetime.now(UTC) + timedelta(seconds=warning_days_in_secs) ).timestamp() expiring_clients = ( current_session.query(Client) @@ -365,11 +365,6 @@ def _remove_client_service_accounts(db_session, client): def get_default_init_syncer_inputs(authz_provider): DB = os.environ.get("FENCE_DB") or config.get("DB") - if DB is None: - try: - from fence.settings import DB - except ImportError: - pass arborist = ArboristClient( arborist_base_url=config["ARBORIST"], @@ -472,6 +467,7 @@ def download_dbgap_files( sync_from_local_yaml_file=None, arborist=None, folder=None, + prune_users=True, ): syncer = init_syncer( dbGaP, @@ -513,7 +509,7 @@ def sync_users( ) if not syncer: exit(1) - syncer.sync() + syncer.sync(prune_users=prune_users) def create_sample_data(DB, yaml_file_path): @@ -558,9 +554,11 @@ def create_project(s, project_data): for storage_access in sa_list: provider = storage_access["name"] buckets = storage_access.get("buckets", []) + sa = ( s.query(StorageAccess) - .join(StorageAccess.provider, StorageAccess.project) + .join(StorageAccess.provider) + .join(StorageAccess.project) .filter(Project.name == project.name) .filter(CloudProvider.name == provider) .first() @@ -1814,12 +1812,19 @@ def access_token_polling_job( thread_pool_size (int): number of Docker container CPU used for jwt verifcation buffer_size (int): max size of queue """ + # Instantiating a new client here because the existing + # client uses authz_provider + arborist = ArboristClient( + arborist_base_url=config["ARBORIST"], + logger=get_logger("user_syncer.arborist_client"), + ) driver = get_SQLAlchemyDriver(db) - job = Visa_Token_Update( + job = TokenAndAuthUpdater( chunk_size=int(chunk_size) if chunk_size else None, concurrency=int(concurrency) if concurrency else None, thread_pool_size=int(thread_pool_size) if thread_pool_size else None, buffer_size=int(buffer_size) if buffer_size else None, + arborist=arborist, ) with driver.session as db_session: loop = asyncio.get_event_loop() diff --git a/fence/settings.py b/fence/settings.py deleted file mode 100644 index 0254cde04..000000000 --- a/fence/settings.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Fence Configuration Support Settings - -NOTE TO DEVELOPERS: This is NOT intended to be a place for configurable variables. - Please put new cfg into the config-default.yaml - -This exists primarily as a legacy support for old configuration, but has been -repurposed to hold some info used when searching for a provided configuration variable. -""" -from os.path import expanduser - -# Folders to look in for the *config.yaml for fence -CONFIG_SEARCH_FOLDERS = ["/var/www/fence", "{}/.gen3/fence".format(expanduser("~"))] - -# PLEASE USE NEW config-default.yaml FOR CONFIGURATION VARIABLES, NOT THIS FILE! - -# WARNING: USE OF local_settings.py IS DEPRECATED. -# WILL BE REMOVED IN FUTURE RELEASE. -# TODO (DEPRECATE LOCAL_SETTINGS): remove this entire block -# -# Please convert to using new configuration yaml file in one of the -# CONFIG_SEARCH_FOLDERS. -# -# ``local_settings"" is not installed under the fence module in produdction. -# Instead, it should be located at ``/var/www/local_settings.py``. If it is -# located elsewhere, use that location in ``imp.load_source`` instead of -# ``/var/www/local_settings.py``, just below. -def use_deprecated_settings(): - ENCRYPTION_KEY = HMAC_ENCRYPTION_KEY - - -try: - # Import everything from ``local_settings``, if it exists. - from local_settings import * - - use_deprecated_settings() -except ImportError: - # If it doesn't, look in ``/var/www/fence``. - try: - import imp - - imp.load_source("local_settings", "/var/www/fence/local_settings.py") - use_deprecated_settings() - except IOError: - pass diff --git a/fence/sync/sync_users.py b/fence/sync/sync_users.py index 8296fc868..12d0f9493 100644 --- a/fence/sync/sync_users.py +++ b/fence/sync/sync_users.py @@ -1,5 +1,8 @@ +import paramiko.ssh_exception import backoff import glob + +import httpx import jwt import os import re @@ -20,7 +23,7 @@ import paramiko from cdislogging import get_logger from email_validator import validate_email, EmailNotValidError -from gen3authz.client.arborist.errors import ArboristError +from gen3authz.client.arborist.errors import ArboristError, ArboristTimeoutError from gen3users.validation import validate_user_yaml from paramiko.proxy import ProxyCommand from sqlalchemy.exc import IntegrityError @@ -402,13 +405,30 @@ def _get_from_sftp_with_proxy(self, server, path): with paramiko.SSHClient() as client: client.set_log_channel(self.logger.name) - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + # Patch paramiko to use sha256 instead of md5 for enhanced security and fips compliance + paramiko.PKey.get_fingerprint = lambda self: hashlib.sha256( + self.asbytes() + ).digest() + + # Load known host keys + known_hosts_path = os.path.expanduser("~/.ssh/known_hosts") + if os.path.exists(known_hosts_path): + client.load_host_keys(known_hosts_path) + else: + self.logger.error( + "No known_hosts file found — rejecting unknown hosts - make sure the SFTP host key is present in known_hosts before attempting connection." + ) + + client.set_missing_host_key_policy(paramiko.RejectPolicy()) parameters = { "hostname": str(server.get("host", "")), "username": str(server.get("username", "")), - "password": str(server.get("password", "")), "port": int(server.get("port", 22)), } + if server.get("private_key_filename"): + parameters["key_filename"] = str(server.get("private_key_filename")) + else: + parameters["password"] = str(server.get("password", "")) if proxy: parameters["sock"] = proxy @@ -418,9 +438,13 @@ def _get_from_sftp_with_proxy(self, server, path): parameters.get("port", "unknown"), ) ) - self._connect_with_ssh(ssh_client=client, parameters=parameters) - with client.open_sftp() as sftp: - download_dir(sftp, "./", path) + try: + self._connect_with_ssh(ssh_client=client, parameters=parameters) + + with client.open_sftp() as sftp: + download_dir(sftp, "./", path) + except paramiko.ssh_exception.SSHException as e: + self.logger.error(f"SSH connection failed, error: {e}") if proxy: proxy.close() @@ -1269,6 +1293,7 @@ def _init_projects(self, user_project, sess): """ initialize projects """ + if self.project_mapping: for projects in list(self.project_mapping.values()): for p in projects: @@ -1509,6 +1534,15 @@ def _process_user_projects( dbgap_project += "." + consent_code + self._add_children_for_dbgap_project( + dbgap_project, + privileges, + username, + sess, + user_projects_to_modify, + dbgap_config, + ) + self._process_dbgap_project( dbgap_project, privileges, @@ -1520,12 +1554,19 @@ def _process_user_projects( for user in user_projects_to_modify.keys(): user_projects[user] = user_projects_to_modify[user] - def sync(self): + def sync(self, prune_users=True): + """Run a full user synchronization. + + Args: + prune_users (bool): whether users with no remaining non-generic + policies may be deleted from Arborist during user.yaml + reconciliation. Defaults to ``True`` for backward compatibility. + """ if self.session: - self._sync(self.session) + self._sync(self.session, prune_users=prune_users) else: with self.driver.session as s: - self._sync(s) + self._sync(s, prune_users=prune_users) def download(self): for dbgap_server in self.dbGaP: @@ -1556,12 +1597,11 @@ def _download(self, dbgap_config): self.logger.error(e) raise - def _sync(self, sess): + def _sync(self, sess, prune_users=True): """ Collect files from dbgap server(s), sync csv and yaml files to storage backend and fence DB """ - # get all dbgap files user_projects = {} user_info = {} @@ -1652,7 +1692,7 @@ def _sync(self, sess): " arborist client--did you run sync with --arborist arg?" ) self.logger.info("Synchronizing arborist...") - success = self._update_arborist(sess, user_yaml) + success = self._update_arborist(user_yaml) if success: self.logger.info("Finished synchronizing arborist") else: @@ -1664,7 +1704,12 @@ def _sync(self, sess): # update the Arborist DB (user access) if self.arborist_client: self.logger.info("Synchronizing arborist with authorization info...") - success = self._update_authz_in_arborist(sess, user_projects, user_yaml) + success = self._update_authz_in_arborist( + sess, + user_projects, + user_yaml, + prune_users=prune_users, + ) if success: self.logger.info( "Finished synchronizing authorization info to arborist" @@ -1743,7 +1788,7 @@ def _grant_all_consents_to_c999_users( {phsid_with_consent: {"read-storage", "read"}} ) - def _update_arborist(self, session, user_yaml): + def _update_arborist(self, user_yaml): """ Create roles, resources, policies, groups in arborist from the information in ``user_yaml``. @@ -1780,7 +1825,7 @@ def _update_arborist(self, session, user_yaml): self.logger.debug("dbgap resource paths: {}".format(dbgap_resource_paths)) combined_resources = utils.combine_provided_and_dbgap_resources( - resources, dbgap_resource_paths + resources, dbgap_resource_paths, self.logger ) for resource in combined_resources: @@ -1791,7 +1836,7 @@ def _update_arborist(self, session, user_yaml): self.arborist_client.update_resource("/", resource, merge=True) except ArboristError as e: self.logger.error(e) - # keep going; maybe just some conflicts from things existing already + raise # update roles roles = user_yaml.authz.get("roles", []) @@ -1810,7 +1855,7 @@ def _update_arborist(self, session, user_yaml): self._created_roles.add(role["id"]) except ArboristError as e: self.logger.error(e) - # keep going; maybe just some conflicts from things existing already + raise # update policies policies = user_yaml.authz.get("policies", []) @@ -1818,14 +1863,14 @@ def _update_arborist(self, session, user_yaml): policy_id = policy.pop("id") try: self.logger.debug( - "Trying to upsert policy with id {}".format(policy_id) + "Trying to upsert policy with id {}: {}".format(policy_id, policy) ) response = self.arborist_client.update_policy( policy_id, policy, create_if_not_exist=True ) except ArboristError as e: self.logger.error(e) - # keep going; maybe just some conflicts from things existing already + raise else: if response: self.logger.debug("Upserted policy with id {}".format(policy_id)) @@ -1883,35 +1928,122 @@ def _update_arborist(self, session, user_yaml): return True - def _revoke_all_policies_preserve_mfa(self, username, idp=None): - """ - If MFA is enabled for the user's idp, check if they have the /multifactor_auth resource and restore the - mfa_policy after revoking all policies. + def _grant_arborist_policies( + self, + username, + incoming_policies, + user_yaml, + expires=None, + remove_users_with_no_policies=True, + ): """ + Find the difference between the existing policies for a user and the incoming policies, + and decide whether to add, remove, or keep policies. - is_mfa_enabled = "multifactor_auth_claim_info" in config["OPENID_CONNECT"].get( - idp, {} - ) - - if not is_mfa_enabled: - # TODO This should be a diff, not a revocation of all policies. - self.arborist_client.revoke_all_policies_for_user(username) - return + Args: + username (str): the username of the user + incoming_policies (set): set of policies to be applied to the user + user_yaml (UserYAML): UserYAML object containing authz information + expires (int): time at which authz info in Arborist should expire + remove_users_with_no_policies (bool): whether to delete users with no access from + the Arborist database + """ + user_existing_policies = set() + to_add = set() + to_remove = set() + is_revoke_all = False - policies = [] try: - user_data_from_arborist = self.arborist_client.get_user(username) - policies = user_data_from_arborist["policies"] - except Exception as e: + user_existing_policies = set( + policy["policy"] + for policy in self.arborist_client.get_user(username)["policies"] + ) + self.logger.info( + f"Fetched user {username} existing policies: {user_existing_policies}" + ) + except ArboristError as e: self.logger.error( - f"Could not retrieve user's policies, revoking all policies anyway. {e}" + f"Could not get user {username} policies from Arborist: {e}. Revoking all policies..." + ) + # if getting existing policies fails, revoke all policies and re-apply + is_revoke_all = True + + if user_yaml: + anonymous_policies = set( + user_yaml.authz.get("anonymous_policies", []) + + user_yaml.authz.get("all_users_policies", []) ) - finally: - # TODO This should be a diff, not a revocation of all policies. - self.arborist_client.revoke_all_policies_for_user(username) + user_existing_policies = user_existing_policies - anonymous_policies + + if is_revoke_all is False and len(incoming_policies) > 0: + to_add = incoming_policies - user_existing_policies + to_remove = user_existing_policies - incoming_policies + else: + # if incoming_policies is empty, we revoke all policies + is_revoke_all = True + + if not is_revoke_all: + success = not to_remove + try: + if to_remove: + for policy in to_remove: + self.logger.info( + f"Revoking policy {policy} for user {username}." + ) + success = self.arborist_client.revoke_user_policy( + username, policy + ) + except ArboristError as e: + self.logger.error( + f"Could not revoke user {username} policy {policy}: {e}" + ) + if not success: + # `revoke_user_policy` returns None in case of error + self.logger.error( + f"Could not revoke user {username} policy. Revoking all instead." + ) + is_revoke_all = True + + if is_revoke_all: + if ( + remove_users_with_no_policies + and not incoming_policies + and not user_existing_policies + ): + # user without any access (other than anonymous and logged-in groups). + # cleanup: remove from the arborist DB so we do not check their access again every + # time this code runs. + self.logger.info( + f"Deleting user {username} from Arborist (since they have no policies)." + ) + self.arborist_client.delete_user(username) + return + success = False + try: + # Note: If a user only has group policies, we call `revoke_all_policies_for_user` + # for nothing. Could be fixed by adding a flag to the arborist "get user" endpoint + # to get the list of policies _excluding_ group policies, or by manually checking + # which policies are group policies (not worth it atm). + self.logger.info(f"Revoking all policies for user {username}.") + success = self.arborist_client.revoke_all_policies_for_user(username) + except ArboristError as e: + self.logger.error( + f"Could not revoke all policies for user {username}. Error: {e}" + ) + if not success: + # `revoke_all_policies_for_user` returns None in case of error + raise Exception(f"Could not revoke all policies for user {username}") + to_add = incoming_policies # if we revoke all, we need to add all incoming policies + + if ( + "mfa_policy" not in incoming_policies + and "mfa_policy" in user_existing_policies + ): + to_add.add("mfa_policy") - if "mfa_policy" in policies: - self.arborist_client.grant_user_policy(username, "mfa_policy") + if to_add: + self.logger.info(f"Bulk granting user {username} policies {to_add}.") + self._grant_bulk_user_policies(username, to_add, expires) def _update_authz_in_arborist( self, @@ -1920,6 +2052,7 @@ def _update_authz_in_arborist( user_yaml=None, single_user_sync=False, expires=None, + prune_users=True, ): """ Assign users policies in arborist from the information in @@ -1958,8 +2091,10 @@ def _update_authz_in_arborist( # get list of users from arborist to make sure users that are completely removed # from authorization sources get policies revoked + arborist_user_projects = {} if not single_user_sync: + try: arborist_users = self.arborist_client.get_users().json["users"] @@ -1982,9 +2117,6 @@ def _update_authz_in_arborist( # update the project info with users from arborist self.sync_two_phsids_dict(arborist_user_projects, user_projects) - policy_id_list = [] - policies = [] - # prefer in-memory if available from user_yaml, if not, get from database if user_yaml and user_yaml.project_to_resource: project_to_authz_mapping = user_yaml.project_to_resource @@ -2019,8 +2151,6 @@ def _update_authz_in_arborist( idp = user.identity_provider.name if user.identity_provider else None self.arborist_client.create_user_if_not_exist(username) - if not single_user_sync: - self._revoke_all_policies_preserve_mfa(username, idp) # as of 2/11/2022, for single_user_sync, as RAS visa parsing has # previously mapped each project to the same set of privileges @@ -2030,11 +2160,12 @@ def _update_authz_in_arborist( unique_policies = self._determine_unique_policies( user_project_info, project_to_authz_mapping ) - for roles in unique_policies.keys(): for role in roles: self._create_arborist_role(role) + incoming_policies = set() # set of policies for current user. + if single_user_sync: for ordered_roles, ordered_resources in unique_policies.items(): policy_hash = self._hash_policy_contents( @@ -2053,6 +2184,7 @@ def _update_authz_in_arborist( username, policy_hash, expires=expires ) else: + policy_ids_to_grant = set() for roles, resources in unique_policies.items(): for role in roles: for resource in resources: @@ -2062,15 +2194,18 @@ def _update_authz_in_arborist( # format project '/x/y/z' -> 'x.y.z' # so the policy id will be something like 'x.y.z-create' policy_id = _format_policy_id(resource, role) + incoming_policies.add(policy_id) if policy_id not in self._created_policies: try: + policy = { + "description": "policy created by fence sync", + "role_ids": [role], + "resource_paths": [resource], + } + self.logger.info(f"Updating policy: {policy}") self.arborist_client.update_policy( policy_id, - { - "description": "policy created by fence sync", - "role_ids": [role], - "resource_paths": [resource], - }, + policy, create_if_not_exist=True, ) except ArboristError as e: @@ -2080,18 +2215,28 @@ def _update_authz_in_arborist( ) ) self._created_policies.add(policy_id) - - self._grant_arborist_policy( - username, policy_id, expires=expires - ) + policy_ids_to_grant.add(policy_id) + self._grant_arborist_policies( + username, + policy_ids_to_grant, + user_yaml=None, + expires=expires, + remove_users_with_no_policies=False, + ) if user_yaml: - for policy in user_yaml.policies.get(username, []): - self.arborist_client.grant_user_policy( - username, - policy, - expires_at=expires, - ) + user_yaml_policies = set(user_yaml.policies.get(username, [])) + incoming_policies = ( + incoming_policies | user_yaml_policies + ) # add policies from whitelist and useryaml + + self._grant_arborist_policies( + username, + incoming_policies, + user_yaml, + expires=expires, + remove_users_with_no_policies=prune_users, + ) if user_yaml: for client_name, client_details in user_yaml.clients.items(): @@ -2244,11 +2389,11 @@ def _create_arborist_resources(self, resources): TODO for the sake of simplicity, it would be nice if only one network request was made no matter the input. """ - for request_body in utils.combine_provided_and_dbgap_resources({}, resources): + for request_body in utils.combine_provided_and_dbgap_resources( + {}, resources, self.logger + ): try: - response_json = self.arborist_client.update_resource( - "/", request_body, merge=True - ) + self.arborist_client.update_resource("/", request_body, merge=True) except ArboristError as e: self.logger.error( "could not create Arborist resources using request body `{}`. error: {}".format( @@ -2279,12 +2424,14 @@ def _create_arborist_policy( bool: True if policy creation was successful. False otherwise """ try: + policy = { + "id": policy_id, + "role_ids": roles, + "resource_paths": resources, + } + self.logger.info(f"Creating policy: {policy}") response_json = self.arborist_client.create_policy( - { - "id": policy_id, - "role_ids": roles, - "resource_paths": resources, - }, + policy, skip_if_exists=skip_if_exists, ) except ArboristError as e: @@ -2312,7 +2459,7 @@ def _hash_policy_contents(self, ordered_roles, ordered_resources): """ def escape(s): - return s.replace(",", "\,") + return s.replace(",", "\\,") canonical_roles = ",".join(escape(r) for r in ordered_roles) canonical_resources = ",".join(escape(r) for r in ordered_resources) @@ -2335,11 +2482,18 @@ def _grant_arborist_policy(self, username, policy_id, expires=None): bool: True if granting of policy was successful, False otherwise """ try: - response_json = self.arborist_client.grant_user_policy( + resp = self.arborist_client.grant_user_policy( username, policy_id, expires_at=expires, ) + if not resp: + self.logger.error( + "could not grant policy `{}` to user `{}`".format( + policy_id, username + ) + ) + return False except ArboristError as e: self.logger.error( "could not grant policy `{}` to user `{}`: {}".format( @@ -2353,6 +2507,41 @@ def _grant_arborist_policy(self, username, policy_id, expires=None): ) return True + def _grant_bulk_user_policies(self, username, policy_ids, expires=None): + """ + Wrapper around gen3authz's grant_user_policies with additional logging + + Args: + username (str): username of user in Arborist who policy should be + granted to + policy_ids (set[str]): Arborist policy ids + + Return: + bool: True if granting of policies was successful, False otherwise + """ + try: + resp = self.arborist_client.grant_bulk_user_policy( + username, policy_ids, expires + ) + if not resp: + self.logger.error( + "could not grant bulk policies to user `{}`".format(username) + ) + return False + except ArboristError as e: + self.logger.error( + "could not grant bulk policies to user `{}`: {}".format(username, e) + ) + return False + except ArboristTimeoutError as e: + self.logger.error( + f"Timeout waiting for response to grant bulk policies to user `{username}`: {e}" + "This user will be skipped and usersync will continue." + "As long as the timeout is not a pool/connection timeout, then " + ) + return False + return True + def _determine_arborist_resource(self, dbgap_study, dbgap_config): """ Determine the arborist resource path and add it to diff --git a/fence/sync/utils.py b/fence/sync/utils.py index 46100d3df..2f6c20444 100644 --- a/fence/sync/utils.py +++ b/fence/sync/utils.py @@ -1,7 +1,10 @@ from functools import reduce +import json -def combine_provided_and_dbgap_resources(useryaml_resources, arborist_paths): +def combine_provided_and_dbgap_resources( + useryaml_resources, arborist_paths, logger=None +): """ Combine provided user.yaml resources loaded into python list of dictionaries and a list of string that are arborist resource paths for dbgap. @@ -151,4 +154,7 @@ def insert_segment(current, segment): reduce(insert_segment, segments, start) return root - return reduce(nest_resource, arborist_paths, list(useryaml_resources)) + res = reduce(nest_resource, arborist_paths, list(useryaml_resources)) + if logger: + logger.info(f"Combined resources: {json.dumps(res, indent=2)}") + return res diff --git a/fence/templates/register_user.html b/fence/templates/register_user.html index e92bb7953..40af41a86 100644 --- a/fence/templates/register_user.html +++ b/fence/templates/register_user.html @@ -3,19 +3,18 @@ - Register + Registration {% block header %} -

{% block title %}Register{% endblock %}

+

{% block title %}Registration{% endblock %}

{% endblock %} {% block content %}
- -

Register in order to get access to download data

+

To get access to download data, please register:

{{ form.csrf_token }}
  • {{ form.firstname.label }} {{ form.firstname }}
  • @@ -29,6 +28,9 @@

    Register in order to get access to download data

    {% endif %}
+

By registering and submitting this form, you hereby acknowledge and consent to be bound by our data use agreement (DUA).

+

Note that depending on the identity provider you used to login, you may be asked to login again after registering.

+
{% endblock %} diff --git a/fence/user.py b/fence/user.py index d02dd26e4..8875f80f8 100644 --- a/fence/user.py +++ b/fence/user.py @@ -13,4 +13,9 @@ def get_current_user(flask_session=None): username = "test" if not username: raise Unauthorized("User not logged in") - return query_for_user(session=current_app.scoped_session(), username=username) + user = query_for_user(session=current_app.scoped_session(), username=username) + if not user: + # edge case where the session has a user but the user doesn't exist in the DB + # (for example, the user was deleted from the DB while logged in) + raise Unauthorized("User not logged in") + return user diff --git a/fence/utils.py b/fence/utils.py index 463fb6f75..06c1352b0 100644 --- a/fence/utils.py +++ b/fence/utils.py @@ -7,7 +7,7 @@ import re import string import requests -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse from urllib.parse import parse_qs, urlsplit, urlunsplit import sys @@ -34,6 +34,27 @@ def json_res(data): return flask.Response(json.dumps(data), mimetype="application/json") +def fetch_url_data(url: str, format: str, expected_status_code: int = 200) -> str: + """ + Perform a GET request and return the raw response data. + Using this function instead of making the request directly in the caller function allows us + to mock the returned data in unit tests. + + Args: + url (str): URL to GET + + Returns: + str: raw response data + """ + res = requests.get(url) + assert res.status_code == expected_status_code, f"Unable to fetch data from '{url}'" + if format == "text": + return res.text + elif format == "json": + return res.json() + raise Exception(f"Unknown 'fetch_url_data' format '{format}'") + + def generate_client_credentials(confidential): """ Generate a new client ID. If the client is confidential, also generate a new client secret. @@ -453,3 +474,53 @@ def validate_scopes(request_scopes, client): raise InvalidScopeError("Failed to Authorize due to unsupported scope") return True + + +def strtobool(val: str) -> bool: + """Convert a string representation of truth to true (1) or false (0). + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return True + elif val in ("n", "no", "f", "false", "off", "0"): + return False + else: + raise ValueError(f"invalid truth value {val!r}") + + +def allowed_login_redirects(): + """ + Determine which redirects a login redirect endpoint (``/login/google``, etc) should + be allowed to redirect back to after login. By default this includes the base URL + from this flask application, and also includes the redirect URLs registered for any + OAuth clients. + + Return: + List[str]: allowed redirect URLs + """ + allowed = config.get("LOGIN_REDIRECT_WHITELIST", []) + allowed.append(config["BASE_URL"]) + with flask.current_app.db.session as session: + clients = session.query(Client).all() + for client in clients: + if isinstance(client.redirect_uris, list): + allowed.extend(client.redirect_uris) + elif isinstance(client.redirect_uris, str): + allowed.append(client.redirect_uris) + return {domain(url) for url in allowed} + + +def domain(url): + """ + Return just the domain for a URL, no schema or path etc. This is to consistently + compare different URLs from flask, the config, and from the user. + """ + if not url: + return "" + if url.startswith("/"): + return urlparse(config["BASE_URL"]).netloc + return urlparse(url).netloc diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 000000000..466c9e364 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,5 @@ +# README + +:warning: When adding any new migration logic, **please make sure** to also include +a note in your PR and/or release notes about the new migration, since Fence can +be configured to have migrations executed manually by a sysadmin instead of automatically. diff --git a/migrations/env.py b/migrations/env.py index fe5dcd7e3..9b5853421 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -7,17 +7,15 @@ - lock the DB during migrations to ensure only 1 migration runs at a time. """ - from alembic import context import logging from logging.config import fileConfig import os -from sqlalchemy import engine_from_config, pool +from sqlalchemy import engine_from_config, pool, text from userdatamodel import Base -from fence.config import config as fence_config -from fence.settings import CONFIG_SEARCH_FOLDERS +from fence.config import config as fence_config, CONFIG_SEARCH_FOLDERS config = context.config @@ -85,7 +83,9 @@ def run_migrations_online(): # Solution based on https://github.com/sqlalchemy/alembic/issues/633 # TODO lock the DB for all processes during migrations connection.execute( - f"SELECT pg_advisory_xact_lock({fence_config['DB_MIGRATION_POSTGRES_LOCK_KEY']});" + text( + f"SELECT pg_advisory_xact_lock({fence_config['DB_MIGRATION_POSTGRES_LOCK_KEY']})" + ) ) else: logger.info( diff --git a/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py b/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py index 05a3c7200..383b9363c 100644 --- a/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py +++ b/migrations/versions/9b3a5a7145d7_authlib_update_1_2_1.py @@ -5,6 +5,7 @@ Create Date: 2023-09-01 10:27:16.686456 """ + from alembic import op import logging import sqlalchemy as sa @@ -151,10 +152,10 @@ def copy_client_to_temp_table_and_clear_data(op, temp_table_name: str): session = Session(bind=conn) # Drop temp table if it already exists # copy client table with all table metadata then copy all row data - session.execute("DROP TABLE IF EXISTS " + temp_table_name + ";") - session.execute("CREATE TABLE " + temp_table_name + " (LIKE client INCLUDING ALL);") - session.execute("INSERT INTO " + temp_table_name + " SELECT * FROM client;") - session.execute("Truncate client;") + session.execute(text(f"DROP TABLE IF EXISTS {temp_table_name}")) + session.execute(text(f"CREATE TABLE {temp_table_name} (LIKE client INCLUDING ALL)")) + session.execute(text(f"INSERT INTO {temp_table_name} SELECT * FROM client")) + session.execute(text("Truncate client")) session.commit() @@ -222,7 +223,7 @@ def set_old_column_values(): session = Session(bind=conn) clientDatas = [] - rs = session.execute("SELECT * FROM migration_client") + rs = session.execute(text("SELECT * FROM migration_client")) for client in rs: data = {} data["client_id"] = client.client_id @@ -327,4 +328,6 @@ def remove_foreign_key_constraint_if_exists(op): type_="foreignkey", ) else: - logger.debug("Foreign key client_id does not exist. This is expected for newer versions of the service.") + logger.debug( + "Foreign key client_id does not exist. This is expected for newer versions of the service." + ) diff --git a/migrations/versions/a04a70296688_non_unique_client_name.py b/migrations/versions/a04a70296688_non_unique_client_name.py index f9ddd2783..6dccd707f 100644 --- a/migrations/versions/a04a70296688_non_unique_client_name.py +++ b/migrations/versions/a04a70296688_non_unique_client_name.py @@ -5,7 +5,9 @@ Create Date: 2022-12-23 09:36:28.425744 """ + from alembic import op +from sqlalchemy import text import logging @@ -22,7 +24,9 @@ def upgrade(): # get all "unique" constraints on "client" table connection = op.get_bind() results = connection.execute( - "SELECT conname FROM pg_constraint WHERE conrelid = 'client'::regclass and contype = 'u'" + text( + "SELECT conname FROM pg_constraint WHERE conrelid = 'client'::regclass and contype = 'u'" + ) ) # filter out the constraints that are not for the "name" column @@ -41,12 +45,14 @@ def downgrade(): # remove duplicate rows (rows with the same `name`): # for each client `name`, only keep the row with the latest expiration op.execute( - """DELETE FROM client WHERE client_id IN ( + text( + """DELETE FROM client WHERE client_id IN ( SELECT client_id FROM ( SELECT client_id, ROW_NUMBER() OVER(PARTITION BY name ORDER BY expires_at DESC) AS row_num FROM client ) dup where dup.row_num > 1 )""" + ) ) # the `name` must be unique diff --git a/migrations/versions/bff33a927a37_add_acr_and_amr_columns.py b/migrations/versions/bff33a927a37_add_acr_and_amr_columns.py new file mode 100644 index 000000000..4d1b21fb1 --- /dev/null +++ b/migrations/versions/bff33a927a37_add_acr_and_amr_columns.py @@ -0,0 +1,27 @@ +"""add acr and amr columns + +Revision ID: bff33a927a37 +Revises: 3a5712474808 +Create Date: 2025-10-21 15:44:42.028779 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "bff33a927a37" +down_revision = "3a5712474808" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("authorization_code", sa.Column("acr", sa.Text(), nullable=True)) + op.add_column("authorization_code", sa.Column("amr", sa.Text(), nullable=True)) + + +def downgrade(): + op.drop_column("authorization_code", "acr") + op.drop_column("authorization_code", "amr") diff --git a/migrations/versions/ea7e1b843f82_optional_client_redirect_uri.py b/migrations/versions/ea7e1b843f82_optional_client_redirect_uri.py index a9c4a9573..3d508380b 100644 --- a/migrations/versions/ea7e1b843f82_optional_client_redirect_uri.py +++ b/migrations/versions/ea7e1b843f82_optional_client_redirect_uri.py @@ -5,6 +5,7 @@ Create Date: 2022-07-27 16:49:52.793557 """ + from alembic import op import sqlalchemy as sa @@ -22,5 +23,5 @@ def upgrade(): def downgrade(): # replace null values with an empty string - op.execute("UPDATE client SET redirect_uri='' WHERE redirect_uri IS NULL") + op.execute(sa.text("UPDATE client SET redirect_uri='' WHERE redirect_uri IS NULL")) op.alter_column("client", "redirect_uri", nullable=False) diff --git a/openapis/swagger.yaml b/openapis/swagger.yaml index 6ab2e33eb..3a6b78100 100644 --- a/openapis/swagger.yaml +++ b/openapis/swagger.yaml @@ -279,8 +279,8 @@ paths: post: tags: - oauth2 - summary: Revoke a refresh token per RFC 7009 - description: Revoke a refresh (not access) token granted to a user. + summary: Revoke a token per RFC 7009 + description: Revoke a refresh token granted to a user through an OIDC client, or a task token. operationId: revoke responses: '200': @@ -435,6 +435,28 @@ paths: description: successful deletion '404': description: Couldn't find user + '/admin/user/{username}/reactivate': + post: + tags: + - 'admin/user' + summary: Reactivate a soft-deleted user + description: Admin method to reactivate a soft-deleted user, setting the user back to active + security: + - OAuth2: + - admin + operationId: reactivateUser + parameters: + - name: username + required: true + in: path + description: Username of user to reactivate + schema: + type: string + responses: + 200: + description: Successful operation + 400: + description: User is already active /admin/user: post: tags: @@ -498,6 +520,43 @@ paths: internal server error; could not delete stored files, or not able to delete indexd record content-type: application/json + '/data/content': + post: + tags: + - data + summary: Bulk content retrieval of GUIDs + description: | + Allow users to get the content for GUIDs (e.g. skip signed URLs). Only works for GUIDs + with underlying bulk content retrieval APIs that also support this paradigm. + + IMPORTANT: The underlying content is variable in structure. The example here shows minimal information. + The final return is a mapping from GUID to content, but the structure of the content depends + on the type of support bulk retrieval. + + Current examples of supported APIs: + - Gen3 Embeddings Bulk Retrieval API for indexed embeddings + security: + - OAuth2: + - user + operationId: bulkContentRetrieval + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RequestBulkContentRetrieval' + responses: + '200': + description: successful operation + content: + '*/*': + schema: + $ref: '#/components/schemas/ResponseBulkContentRetrieval' + '400': + description: 'GUID provided does not support bulk content retrieval' + '413': + description: 'Too many GUIDs requested' + '503': + description: 'Indexd service or bulk content retrieval URL not available' '/data/download/{file_id}': get: tags: @@ -575,6 +634,41 @@ paths: description: 'Invalid input: UUID not found or invalid location' '404': description: 'No location found for this file' + "/download/bulk": + post: + tags: + - data + summary: Create signed URLs for bulk data download given a list of file_ids + description: Allow users to get signed URLs for a list of data files. + security: + - OAuth2: + - user + operationId: bulkDownloadSignedURL + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BulkDownloadRequest' + parameters: + - name: passports + required: false + in: query + description: >- + if set, then the request body will include a list of GA4GH Passports + - name: bulk_object_access_ids + required: false + in: query + description: >- + if set, then the request body will include a list of tuples of (object_id, access_id) for GA4GH DRS Access API + responses: + '200': + description: successful operation + content: + '*/*': + schema: + $ref: '#/components/schemas/BulkDownloadResponse' + '400': + description: 'Invalid input: UUID not found or invalid location' '/ga4gh/drs/v1/objects/{object_id}/access/{access_id}': get: summary: GA4GH DRS Access API to get a URL for fetching bytes. @@ -615,6 +709,85 @@ paths: description: An access_id from the access_methods list of a DrsObject tags: - data + post: + summary: GA4GH DRS Access API to get a URL for fetching bytes. + description: >- + Returns a URL that can be used to fetch the bytes of a DrsObject. + This method only needs to be called when using an AccessMethod that contains an access_id + (e.g., for servers that use signed URLs for fetching object bytes). + operationId: PostAccessURL + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RequestUrlPassport' + responses: + '200': + description: The access URL was found successfully. + content: + '*/*': + schema: + $ref: '#/components/schemas/AccessURL' + '400': + description: The request is malformed. + '401': + description: The request is unauthorized. + '404': + description: The requested access URL wasn't found + '403': + description: The requester is not authorized to perform this action. + '500': + description: An unexpected error occurred. + parameters: + - name: object_id + schema: + type: string + in: path + required: true + description: An id of a DrsObject + - name: access_id + schema: + type: string + in: path + required: true + description: An access_id from the access_methods list of a DrsObject + tags: + - data + "/drs/v1/objects/access": + post: + tags: + - data + summary: Create signed URLs for bulk data download given a list of file_ids + description: Allow users to get signed URLs for a list of data files. + security: + - OAuth2: + - user + operationId: bulkDownloadSignedURL + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BulkDownloadRequest' + parameters: + - name: passports + required: false + in: query + description: >- + if set, then the request body will include a list of GA4GH Passports + - name: bulk_object_access_ids + required: false + in: query + description: >- + if set, then the request body will include a list of tuples of (object_id, access_id) for GA4GH DRS Access API + responses: + '200': + description: successful operation + content: + '*/*': + schema: + $ref: '#/components/schemas/BulkDownloadResponse' + '400': + description: 'Invalid input: UUID not found or invalid location' '/data/upload': post: tags: @@ -867,6 +1040,27 @@ paths: responses: '200': description: successful operation + /credentials/token/denylisted: + post: + tags: + - credentials + summary: Check if a token is denylisted/revoked + description: Check if a token is denylisted/revoked. Return 403 if it is. + requestBody: + content: + application/json: + schema: + type: object + properties: + token: + type: string + responses: + '200': + description: token is not denylisted + '401': + description: user is not authenticated + '403': + description: token is denylisted /credentials/google: get: tags: @@ -1044,6 +1238,25 @@ paths: description: >- Get a new temporary access token for the current user by providing a valid API key. + parameters: + - name: expires_in + required: false + in: query + description: >- + Optional integer specifying the access token lifetime (in seconds). Cannot + be greater than the configured MAX_ACCESS_TOKEN_TTL, unless you are + requesting a task token, in which case this cannot be greater than the configured + MAX_TASK_TOKEN_TTL. If omitted, defaults to MAX_ACCESS_TOKEN_TTL. + schema: + type: integer + - name: task_token + required: false + in: query + description: >- + Optionally request a specific task token type. If omitted, defaults to + generating a regular access token. + schema: + type: string security: - OAuth2: - user @@ -1127,6 +1340,25 @@ paths: description: >- Get a new temporary access token for the current user by providing a valid API key. + parameters: + - name: expires_in + required: false + in: query + description: >- + Optional integer specifying the access token lifetime (in seconds). Cannot + be greater than the configured MAX_ACCESS_TOKEN_TTL, unless you are + requesting a task token, in which case this cannot be greater than the configured + MAX_TASK_TOKEN_TTL. If omitted, defaults to MAX_ACCESS_TOKEN_TTL. + schema: + type: integer + - name: task_token + required: false + in: query + description: >- + Optionally request a specific task token type. If omitted, defaults to + generating a regular access token. + schema: + type: string security: - OAuth2: - user @@ -1803,6 +2035,16 @@ components: tags: type: object description: User's tags + RequestUrlPassport: + type: object + required: true + properties: + passports: + type: array + items: + type: string + required: true + description: The list of GA4GH passports containing the authorizations for a given user to access the requested file. NOTE - the key "passports" is configurable in fence via the GA4GH_DRS_POSTED_PASSPORT_FIELD field. The default of "passports" has been provided. RequestUploadBlank: type: object required: @@ -2662,3 +2904,49 @@ components: type: string userinfo_endpoint: type: string + RequestBulkContentRetrieval: + type: object + properties: + guids: + type: array + items: + type: string + description: GUIDs to get content for + example: + guids: + - dg.1234/fb0b8022-bb2f-49d8-8875-0573c15a40c0 + - dg.1234/9f35227d-ea60-48f2-b013-01a75459c71b + - dg.1234/3b841eb7-6b8f-47cc-ab54-5346591bc4c0 + ResponseBulkContentRetrieval: + type: object + properties: + guids: + type: object + items: + type: object + description: GUIDs to content mapping + total_guids: + type: integer + example: + guids: + dg.1234/fb0b8022-bb2f-49d8-8875-0573c15a40c0: + vector: [ + 1.0, + 2.0, + 3.0 + ] + embedding_id: "1a6e593e-b260-4733-99aa-b1b18f17e1dd" + dg.1234/9f35227d-ea60-48f2-b013-01a75459c71b: + vector: [ + 4.0, + 5.0, + 6.0 + ] + embedding_id: "58001f69-271c-4215-8027-dc545b13d158" + dg.1234/3b841eb7-6b8f-47cc-ab54-5346591bc4c0: + vector: [ + 7.0, + 8.0, + 9.0 + ] + embedding_id: "1e5dd555-ced2-4113-b5f5-03d90776f998" diff --git a/poetry.lock b/poetry.lock index 755358d1a..5f4439289 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "addict" @@ -6,6 +6,7 @@ version = "2.4.0" description = "Addict is a dictionary whose items can be set using both attribute and item syntax." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc"}, {file = "addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494"}, @@ -13,97 +14,91 @@ files = [ [[package]] name = "alembic" -version = "1.14.1" +version = "1.18.5" description = "A database migration tool for SQLAlchemy." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "alembic-1.14.1-py3-none-any.whl", hash = "sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5"}, - {file = "alembic-1.14.1.tar.gz", hash = "sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213"}, + {file = "alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc"}, + {file = "alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e"}, ] [package.dependencies] Mako = "*" -SQLAlchemy = ">=1.3.0" -typing-extensions = ">=4" +SQLAlchemy = ">=1.4.23" +typing-extensions = ">=4.12" [package.extras] -tz = ["backports.zoneinfo", "tzdata"] +tz = ["tzdata"] [[package]] name = "aniso8601" -version = "10.0.0" +version = "10.0.1" description = "A library for parsing ISO 8601 strings." optional = false python-versions = "*" +groups = ["main"] files = [ - {file = "aniso8601-10.0.0-py2.py3-none-any.whl", hash = "sha256:3c943422efaa0229ebd2b0d7d223effb5e7c89e24d2267ebe76c61a2d8e290cb"}, - {file = "aniso8601-10.0.0.tar.gz", hash = "sha256:ff1d0fc2346688c62c0151547136ac30e322896ed8af316ef7602c47da9426cf"}, + {file = "aniso8601-10.0.1-py2.py3-none-any.whl", hash = "sha256:eb19717fd4e0db6de1aab06f12450ab92144246b257423fe020af5748c0cb89e"}, + {file = "aniso8601-10.0.1.tar.gz", hash = "sha256:25488f8663dd1528ae1f54f94ac1ea51ae25b4d531539b8bc707fed184d16845"}, ] [package.extras] dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] [[package]] -name = "anyio" -version = "4.8.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, - {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, ] -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] -trio = ["trio (>=0.26.1)"] - [[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] [[package]] -name = "attrs" -version = "25.1.0" -description = "Classes Without Boilerplate" +name = "anyio" +version = "4.14.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, - {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, + {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, + {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, ] +[package.dependencies] +idna = ">=2.8" + [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +trio = ["trio (>=0.32.0)"] [[package]] name = "authlib" -version = "1.3.1" +version = "1.6.12" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "Authlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:d35800b973099bbadc49b42b256ecb80041ad56b7fe1216a362c7943c088f377"}, - {file = "authlib-1.3.1.tar.gz", hash = "sha256:7ae843f03c06c5c0debd63c9db91f9fda64fa62a42a77419fa15fbb7e7a58917"}, + {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"}, + {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"}, ] [package.dependencies] @@ -111,117 +106,256 @@ cryptography = "*" [[package]] name = "authutils" -version = "6.2.6" +version = "8.0.0" description = "Gen3 auth utility functions" optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4.0,>=3.13" +groups = ["main"] files = [ - {file = "authutils-6.2.6-py3-none-any.whl", hash = "sha256:5c0201249aad384f706f9ac981af04eaadcea7e94df415691551e1ae00112106"}, - {file = "authutils-6.2.6.tar.gz", hash = "sha256:3043a2269d8976f73869bf7c40b125d4be8608f870ef76ea27e02727f5ba8500"}, + {file = "authutils-8.0.0-py3-none-any.whl", hash = "sha256:4781c24dac4e1349288a660fedc6fe78be396ebc53139ed20e138844b7bc4892"}, + {file = "authutils-8.0.0.tar.gz", hash = "sha256:a4491d9d7d7d240f7ebc15037c45c60c2da092c30068002003337101e31a04b8"}, ] [package.dependencies] -authlib = ">=1.1.0" +authlib = ">=1.6.6" cached-property = ">=1.4,<2.0" -cdiserrors = "<2.0.0" -cryptography = ">=41.0.6" +cdiserrors = "*" +cdislogging = "*" +cryptography = ">=48.0.1" httpx = ">=0.23.0,<1.0.0" pyjwt = {version = ">=2.4.0,<3.0", extras = ["crypto"]} +starlette = "*" +werkzeug = ">=3.1.5" xmltodict = ">=0.9,<1.0" [package.extras] -fastapi = ["fastapi (>=0.65.2,<0.66.0)"] -flask = ["Flask (<=2.3.3)"] - -[[package]] -name = "aws-xray-sdk" -version = "0.95" -description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers to record and emit information from within their applications to the AWS X-Ray service." -optional = false -python-versions = "*" -files = [ - {file = "aws-xray-sdk-0.95.tar.gz", hash = "sha256:9e7ba8dd08fd2939376c21423376206bff01d0deaea7d7721c6b35921fed1943"}, - {file = "aws_xray_sdk-0.95-py2.py3-none-any.whl", hash = "sha256:72791618feb22eaff2e628462b0d58f398ce8c1bacfa989b7679817ab1fad60c"}, -] - -[package.dependencies] -jsonpickle = "*" -requests = "*" -wrapt = "*" +datamodelutils = ["datamodelutils"] +fastapi = ["fastapi"] +flask = ["Flask"] [[package]] name = "azure-core" -version = "1.32.0" +version = "1.41.0" description = "Microsoft Azure Core Library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "azure_core-1.32.0-py3-none-any.whl", hash = "sha256:eac191a0efb23bfa83fddf321b27b122b4ec847befa3091fa736a5c32c50d7b4"}, - {file = "azure_core-1.32.0.tar.gz", hash = "sha256:22b3c35d6b2dae14990f6c1be2912bf23ffe50b220e708a28ab1bb92b1c730e5"}, + {file = "azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d"}, + {file = "azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a"}, ] [package.dependencies] requests = ">=2.21.0" -six = ">=1.11.0" typing-extensions = ">=4.6.0" [package.extras] aio = ["aiohttp (>=3.0)"] +tracing = ["opentelemetry-api (>=1.26,<2.0)"] [[package]] name = "azure-storage-blob" -version = "12.24.1" +version = "12.30.0" description = "Microsoft Azure Blob Storage Client Library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "azure_storage_blob-12.24.1-py3-none-any.whl", hash = "sha256:77fb823fdbac7f3c11f7d86a5892e2f85e161e8440a7489babe2195bf248f09e"}, - {file = "azure_storage_blob-12.24.1.tar.gz", hash = "sha256:052b2a1ea41725ba12e2f4f17be85a54df1129e13ea0321f5a2fcc851cbf47d4"}, + {file = "azure_storage_blob-12.30.0-py3-none-any.whl", hash = "sha256:d415ac50b67a8da6b3ae7e9f1014b1b55cd7aafa0b8d4ca9b380568dc7360423"}, + {file = "azure_storage_blob-12.30.0.tar.gz", hash = "sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be"}, ] [package.dependencies] -azure-core = ">=1.30.0" +azure-core = ">=1.37.0" cryptography = ">=2.1.4" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" [package.extras] -aio = ["azure-core[aio] (>=1.30.0)"] +aio = ["azure-core[aio] (>=1.37.0)"] [[package]] name = "backoff" -version = "1.11.1" +version = "2.2.1" description = "Function decoration for backoff and retry" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.7,<4.0" +groups = ["main"] files = [ - {file = "backoff-1.11.1-py2.py3-none-any.whl", hash = "sha256:61928f8fa48d52e4faa81875eecf308eccfb1016b018bb6bd21e05b5d90a96c5"}, - {file = "backoff-1.11.1.tar.gz", hash = "sha256:ccb962a2378418c667b3c979b504fdeb7d9e0d29c0579e3b13b86467177728cb"}, + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + +[[package]] +name = "backports-zstd" +version = "1.6.0" +description = "Backport of compression.zstd" +optional = false +python-versions = "<3.14,>=3.10" +groups = ["dev"] +markers = "python_version == \"3.13\"" +files = [ + {file = "backports_zstd-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:73000459db113a658c4fb0510100ef0e79137b5828bf957b7709aacae4eb1b87"}, + {file = "backports_zstd-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6e78d5e28f812b39f92397806ecddd4a6f3bf35531a8c039a1f187abc931af8"}, + {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:32f04d54ec1fdf3aa648b24a10b1c9234ed2046cc4af7a8850cbc236c05d42f3"}, + {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83415af3c64550a56cc20b4cce59bbaa81f21d28466d7adf98feff011ecbc66d"}, + {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3c17e6a267d13de9cbf14bf2ebfa87e03d26692456fc67d2dbed9da4f479b18"}, + {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:75578c71644b031118ce938855a53530708db7f4af6e83e2f8840d5a1de990f8"}, + {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4ae7ed5a6d813450cc2d818284ea3db9721edcef50a56aae42ea06feec38c6e"}, + {file = "backports_zstd-1.6.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e9a8370c8ed873083d5de956d6b2e60adbad31e52d7a11111c96ef01d1910ae"}, + {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c2d1ccfe088e8279d605011a3575619a74526c261be357695b3258c0f636115a"}, + {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e73a550dbeb84e8fa50f8385f7735e9a4735b465851ef617d02f80ab10e44e7e"}, + {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84f92e5a60a78c72ccda79d0417d311a1f6da18f446423ed411726d545bf7b56"}, + {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0eb4281f402b94d397b7482f6d9efd04c28274e4ed6eb57eb1f87bdd091a6a87"}, + {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d6b9b06323e3ba947c0003b2d70e02f33c90c36bc6262a92eb8201afc4a1aa08"}, + {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8872a0e9f1af975966b5be6af7eebd3dc4046f15e470b719316516dc3d137cd6"}, + {file = "backports_zstd-1.6.0-cp310-cp310-win32.whl", hash = "sha256:c14fa5dc39a804f1b92d63506f450eca5c59647a18d197d1a564b89dac1be1ce"}, + {file = "backports_zstd-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:8219d6fceae6b39535c4ac323dba0923d10f781d59962ff3504e693fdcafa92c"}, + {file = "backports_zstd-1.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7bc9a0b66097f03820a54316d2fdd0beb38859cf98f10d63e94c55450ed8920"}, + {file = "backports_zstd-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4fc41b2df5529cad5ceb230319e82728096d4b353ce8d4df68a2ec37e291bb8"}, + {file = "backports_zstd-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:83391ef5935cc0f329b1abca414ae20ffe40d335fc21a4b5e664f08a74317d5f"}, + {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7d3f64c503af7b60115b97c16feaf75bd191ef2c978d5c0c7725a6682bef63c5"}, + {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0308990ffc998df3c7ed35276bde049728b5c3956203cae40d80893576a41459"}, + {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c298785e2fadeab82342040f2d9ce764ce500e6da6a6d99a2de514e63580b5a"}, + {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae106fe16e36efc60ab098d02478d30aa0e31e1420eb4ecf0116459253bc6361"}, + {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7293fefe15f0e5852bdb4ad1e0e26f3cbd4d3e61c19f751ecc4ff34bc1eb237d"}, + {file = "backports_zstd-1.6.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ece8e7288db5b827ef8c64b2f78519f1a173a8991a625978fce02eccd7654fe9"}, + {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28eef3881164f3c23ce58ed59e4684103bdd279583eb2d299858c9e9b72fde9a"}, + {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:481a1e9bd8f419fdc625307aa20234687f99368c75df511ef589693c5fea4c6f"}, + {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3b6713371f8987a1178df93cb36f29eef191f224021e2d656b2f11ce60d26816"}, + {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b0ddbcd2866b8ff1a2836e4b0e4d44788f5b992d83fac75a38cda8f9a2bee079"}, + {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2914abea516704bdafb2090acd3f15b5f9debecfabd15b8dd8285b2ad3b92209"}, + {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd085eafa2aac6f883afd28210a3231f717f25409a1e44a39bb7b04c8c5b5646"}, + {file = "backports_zstd-1.6.0-cp311-cp311-win32.whl", hash = "sha256:b81b4cf3d6e0ad7ac92bef248f49fafc954262c5fb0f7e19d6aac497e5a856b2"}, + {file = "backports_zstd-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:10b61850c4112952e05aa6e6cce8c9a5936fbeadb321e154216705cc76a14afa"}, + {file = "backports_zstd-1.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:068ef3d8c18815a2e3a752f766313e19910e7c50939b956923748d9c04ebcb1b"}, + {file = "backports_zstd-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0466b14723f3b7697669c00ee66fe16e30e25636b286b0a923fa86fa3d8a753c"}, + {file = "backports_zstd-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d146926e997d2d3de8212bdcbf4985344a2622ca3bec458d8908000a84fd883"}, + {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:460fd6b3f338c659507ae36cfd6b58ac9942a2ff233c5cf574416dfec0451a84"}, + {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c2b1f4a640c51130caa92cef5bf72bd3c3dbbcfbf814c37403aa0601b1811b0"}, + {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:beb43e9885202c8d4f3762319ed4d5e98e197622afbff8439fbbdd81d08938b9"}, + {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fbb746522ebfc11155f1cd688e2c48ef3d74125e38b63eabdaab068a055c3e88"}, + {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a99710fbb225d459d66def4dc2bb2cd4a9a0bdc8b799fc0621cfdd863be9c93"}, + {file = "backports_zstd-1.6.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f69365ee2b836939137de024a302395a1cb8654fb6dc5ffef6381105259c8f87"}, + {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66cf8038893c7708ec345ffb3ac63c775d10f430f323ac2f0334fdb6a397c57c"}, + {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e514c71ca72f3b56bd8fbda1a6a5b7d1100a2764b42a3c74a38841f25f9b00ab"}, + {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7741e44f7938ec94f9a52678c8d19b7bc548522ffdc39c9e4481af8db545fa9a"}, + {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97e8a9674652496c7612b528085dd5a296c052a2edc466ca1bfb7b0b27820413"}, + {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:23a793f2fed4dbf0517319759a2cded0b0dd8e8d3797fe30badd5693e320c175"}, + {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b951113113ed4b8d173418a4f155c14b739dace626b3fa3f82be1831958d39e4"}, + {file = "backports_zstd-1.6.0-cp312-cp312-win32.whl", hash = "sha256:6430b34a2ae6fcc604672f4f913102563473d9a015bdca1ce8c95041cc1f2677"}, + {file = "backports_zstd-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:08793876172551a930ce4d65c712cd516184d1a97070d4a1193e05bf0cf7040d"}, + {file = "backports_zstd-1.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:03b7c59c71f7a597e2bcb3f8368371e9a660a1bdf1c37afc1f1ad1496a013c19"}, + {file = "backports_zstd-1.6.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:2ace939e4d620e119423606f2d3d7115f8707733bf57f279ad9a9383f875986f"}, + {file = "backports_zstd-1.6.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:4c68a9ed2df0cca51d774c521e68a34d2e3d9ebfc687ef8096adfd4f345b551d"}, + {file = "backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:30576f49b82328ec8af16c11100efe52ca88526f71bbe100ef6b4e707dc13bf2"}, + {file = "backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b4bddfcfb6679215d6f4dc5f79a1f9301af339480d70527a14b57a1f2e6b6cbf"}, + {file = "backports_zstd-1.6.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:65048ed08c5124f05ff9f355ab9703014bb2dbe7f8d9948ce193685b1775f442"}, + {file = "backports_zstd-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5918fc6b31437208721276964323933cd86077b8d5b469c59c1b3fd2c8220a05"}, + {file = "backports_zstd-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b6c8b02ab0ccb2431bb7bc238be91d158b308915e7b07937388e540466fe7e7"}, + {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:711e6b98f8924e8b4a61ff97ab6321f33de024e1ed6a32f5123763aeda8459be"}, + {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ba9ac10fc393e5123a08802e0e895a107cb4a66b9973d2844dbd8a343111e59"}, + {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f723219335387d7546412d8141e0303590600949b4184a1391a0c6a3c756058"}, + {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64b94d7a836568926a3309ff510c7f8261b881b341fd4992cabf4f0998878f8a"}, + {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e39258a09b1c7ca70b5e94a5c5ccfe4700b4250b8077cfeab31d0f79565d4c9b"}, + {file = "backports_zstd-1.6.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:15b1aae0f64cd742df4bba1d989d0a09a6ec619202543fdba684640454541fd3"}, + {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25b5ddc789480072551af571a746e9500356b2aff0499861cf2ca07ea7431e68"}, + {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a13cfa3410a75e4cb87abdb669aaf79da861cb79299159054ff8f77b9671bc40"}, + {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2ddab55a5f54dec8acfad68ef70f1c704fd21919990ddc238afbd6f496e61c6a"}, + {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fa305a84087e10d7a85e8a8a3dcba8cdbda4868f2180173b264b7b488fd37c55"}, + {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:df27b57d214a3124fbe4e933ef5a903d4567f154260d9aece8c797a987f2a205"}, + {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28fecd73459d74910ae1987ab84b7bef690d3dd860948430dd5555108b006daf"}, + {file = "backports_zstd-1.6.0-cp313-cp313-win32.whl", hash = "sha256:3e689af303df287142770abe3a48bbefd24dab4a09da5807d0e1fa8c75bab026"}, + {file = "backports_zstd-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b067b1ef9c8e41fb0882c828aa37829938b5c0dab067eca72b23fc24c563b9da"}, + {file = "backports_zstd-1.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:a838296f5b84c920172fb579cac894d255c1fc25457c7234613ddcfa385e49b7"}, + {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6c73ae37dbf9207727ac095dedef864c05d836eaec962a47b3b64eaadaf1c6b6"}, + {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:839faf90a7eb525a401978dc925df8c44bd12526e8ba1529b9f8a7106e729637"}, + {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f8f5c1c7c69a4b00889e52d9304a918a5b49010f9645768eb5fd0ad404f790ba"}, + {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80bceebc9b58e959bede9b26cafe15b5b9526f3533a6dd06330c5da73cb9329"}, + {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79284c1dd702f4f24ed1a36e51555c907dd237b6c0d829595978f4089a2aeea9"}, + {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1e20b3ecd0a711be82e964aca28554eabbc31ee69a20e5e7b8fd42268af46212"}, + {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:aeef8563b82ed4af328f98e5041c1b4800d86f68f857ffd1577d4d47dc9aa6cd"}, + {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb75e33131946fabd6319061df3b8b1d588fe0963183280e9b5f49f7772fc09"}, + {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ef132cfb638e9a86bd5dc07fb4e1cb895bc55bce6bb5e759366e8b160d0747e2"}, + {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab70eace272d6f122b121c057e436709b50a28abf30d97aab28433c08f4a4095"}, + {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17efb3d11137de5166dd51eedab9c36ad633402acba386eee8d715213ea47e49"}, + {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:994167ff6551b9c1ce226e0aab16295b98c94507b5701aa60d2c32b7d50796b1"}, + {file = "backports_zstd-1.6.0.tar.gz", hash = "sha256:80a7859ffe70bf239d7a2ce15293bdeb5b4280ff7dc326ffab312b0e254dbb24"}, ] [[package]] name = "bcrypt" -version = "3.2.2" +version = "5.0.0" description = "Modern password hashing for your software and your servers" optional = false -python-versions = ">=3.6" -files = [ - {file = "bcrypt-3.2.2-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e"}, - {file = "bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26"}, - {file = "bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb"}, - {file = "bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a"}, - {file = "bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521"}, - {file = "bcrypt-3.2.2-cp36-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40"}, - {file = "bcrypt-3.2.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa"}, - {file = "bcrypt-3.2.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa"}, - {file = "bcrypt-3.2.2-cp36-abi3-win32.whl", hash = "sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e"}, - {file = "bcrypt-3.2.2-cp36-abi3-win_amd64.whl", hash = "sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129"}, - {file = "bcrypt-3.2.2.tar.gz", hash = "sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb"}, +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, + {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, + {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, + {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, + {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, + {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, + {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, ] -[package.dependencies] -cffi = ">=1.1" - [package.extras] tests = ["pytest (>=3.2.1,!=3.3.0)"] typecheck = ["mypy"] @@ -232,62 +366,95 @@ version = "1.9.0" description = "Fast, simple object-to-object and broadcast signaling" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, ] -[[package]] -name = "boto" -version = "2.49.0" -description = "Amazon Web Services Library" -optional = false -python-versions = "*" -files = [ - {file = "boto-2.49.0-py2.py3-none-any.whl", hash = "sha256:147758d41ae7240dc989f0039f27da8ca0d53734be0eb869ef16e3adcfa462e8"}, - {file = "boto-2.49.0.tar.gz", hash = "sha256:ea0d3b40a2d852767be77ca343b58a9e3a4b00d9db440efb8da74b4e58025e5a"}, -] - [[package]] name = "boto3" -version = "1.36.25" +version = "1.43.44" description = "The AWS SDK for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "boto3-1.36.25-py3-none-any.whl", hash = "sha256:41fb90a516995946563ec91b9d891e2516c58617e9556d5e86dfa62da3fdebe6"}, - {file = "boto3-1.36.25.tar.gz", hash = "sha256:a057c19adffb48737c192bdb10f9d85e0d9dcecd21327f51520c15db9022a835"}, + {file = "boto3-1.43.44-py3-none-any.whl", hash = "sha256:621113f7850caec7c8405a07baa26075f700f78844a7f2fdf4d1f879bd3cc3c1"}, + {file = "boto3-1.43.44.tar.gz", hash = "sha256:035d73afe3e29bf271a5b27b30476ff8eefa5ae36f6702eada8e412d5c1420aa"}, ] [package.dependencies] -botocore = ">=1.36.25,<1.37.0" +botocore = ">=1.43.44,<1.44.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.11.0,<0.12.0" +s3transfer = ">=0.19.0,<0.20.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.36.25" +version = "1.43.44" description = "Low-level, data-driven core of boto 3." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "botocore-1.36.25-py3-none-any.whl", hash = "sha256:04c8ff03531e8d92baa8c98d1850bdf01668a805467f4222b65e5325f94aa8af"}, - {file = "botocore-1.36.25.tar.gz", hash = "sha256:3b0a857d2621c336fb82a36cb6da4b6e062d346451ac46d110b074e5e5fd7cfc"}, + {file = "botocore-1.43.44-py3-none-any.whl", hash = "sha256:6afb846fd93815133a53baf1578f1d6016ddbeda247623360379758ca2e3f338"}, + {file = "botocore-1.43.44.tar.gz", hash = "sha256:cc2a95a53efdcf0ce34a51bca28058e72fcc3b10dd625eb1ad900c5ca3eb8bef"}, ] [package.dependencies] jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" -urllib3 = [ - {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, - {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, +urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" + +[package.extras] +crt = ["awscrt (==0.32.2)"] + +[[package]] +name = "build" +version = "1.5.1" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "build-1.5.1-py3-none-any.whl", hash = "sha256:f1a58fe2e5af5b0238a07b9e70207492c79ddebbdb1ad954fc86d62a56be3e0d"}, + {file = "build-1.5.1.tar.gz", hash = "sha256:94e17f1db803ab22f46049376c44c8437c52090f0dfdf1adc43df56542d644fb"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +packaging = ">=24.0" +pyproject_hooks = "*" + +[package.extras] +keyring = ["keyring"] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.36.1)"] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"}, + {file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"}, ] +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + [package.extras] -crt = ["awscrt (==0.23.8)"] +dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] [[package]] name = "cached-property" @@ -295,6 +462,7 @@ version = "1.5.2" description = "A decorator for caching properties in classes." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, @@ -302,78 +470,78 @@ files = [ [[package]] name = "cachelib" -version = "0.2.0" +version = "0.14.0" description = "A collection of cache libraries in the same API interface." optional = false -python-versions = ">=3.6" -files = [ - {file = "cachelib-0.2.0-py3-none-any.whl", hash = "sha256:ee28ffec3d5470314184030a475725b4e5b1b90775c55d34530e16bf7e3e1e11"}, - {file = "cachelib-0.2.0.tar.gz", hash = "sha256:dcb5fafe6b6b544aaa8d0cacb12d70bbf9bbf72c041f17fcad1618db7bedeada"}, -] - -[[package]] -name = "cachetools" -version = "5.5.2" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, - {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, + {file = "cachelib-0.14.0-py3-none-any.whl", hash = "sha256:4671000b032baa8fac47ad19850f4f522785cee764b4e04c5cfe8955a18d67de"}, + {file = "cachelib-0.14.0.tar.gz", hash = "sha256:73fedcadd0ba818fb2bb9f3c7cd5fcc2a71e86286f1842f55f28d500faee17f1"}, ] [[package]] name = "cdiserrors" -version = "1.0.0" +version = "2.1.0" description = "Gen3 shared exceptions and utilities." optional = false -python-versions = ">=3.6,<4.0" +python-versions = "<4,>=3.13" +groups = ["main"] files = [ - {file = "cdiserrors-1.0.0-py3-none-any.whl", hash = "sha256:2e188645832e8c98468267af3e54bc5d3a298078b9869899256251e54dc1599d"}, - {file = "cdiserrors-1.0.0.tar.gz", hash = "sha256:d6e56b0a9f6cc0104f4344f837b735fefb2caf8d0c4ef50569881dc140ebfc4a"}, + {file = "cdiserrors-2.1.0-py3-none-any.whl", hash = "sha256:702a15e3831f85b4d8b1cd4f66f8581cbe43faf6bb22d1726e657e76cf6d238b"}, + {file = "cdiserrors-2.1.0.tar.gz", hash = "sha256:853f807111f188d26031d553bfd2b70fe7d23826304cba324aebc40e6442fd2d"}, ] [package.dependencies] -cdislogging = ">=1.0.0,<2.0.0" +cdislogging = "*" +werkzeug = "*" [package.extras] -flask = ["Flask (>=1.1.2,<2.0.0)"] +flask = ["Flask"] [[package]] name = "cdislogging" -version = "1.1.1" -description = "Standardized logging tool and format for cdis applications" +version = "1.2.0" +description = "Standardized logging tool and format for CTDS applications" optional = false -python-versions = "*" +python-versions = "<4,>=3.13" +groups = ["main"] files = [ - {file = "cdislogging-1.1.1.tar.gz", hash = "sha256:77e11648244cda3a8094b8ae6081435a2303f259612846c49ef8825c7be141e3"}, + {file = "cdislogging-1.2.0-py3-none-any.whl", hash = "sha256:3596addc972c3e65c738231381244089f84466e9c6e2662e096ad53154018f54"}, + {file = "cdislogging-1.2.0.tar.gz", hash = "sha256:ca7511b685bbf0b4727fef85ca4eab78271b4618139a621b09c2846aed6d19d1"}, ] [[package]] name = "cdispyutils" -version = "2.3.0" +version = "2.5.1" description = "This package includes several utility Python tools for the Gen3 stack." optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4,>=3.13" +groups = ["main"] files = [ - {file = "cdispyutils-2.3.0-py3-none-any.whl", hash = "sha256:7f8a60074726da818bae18b1e89aa76db1301cfec142596df88039c1f6d82aa8"}, - {file = "cdispyutils-2.3.0.tar.gz", hash = "sha256:6e581ea1efb1019c12398474a995aeed0b926cf8377fe17502b92cf0602c5826"}, + {file = "cdispyutils-2.5.1-py3-none-any.whl", hash = "sha256:ec7f3478b6c5cda66fa9de4a0942681f12ab99c92d3620c5704e474e9a25cfde"}, + {file = "cdispyutils-2.5.1.tar.gz", hash = "sha256:870a6fe228a5597822f54b2209456c7608300d629516bdfd979ea8152d9b8dcd"}, ] [package.dependencies] cdiserrors = "*" -cryptography = ">=43.0.1,<44.0.0" +cdislogging = "*" Flask = "*" prometheus-client = "*" PyJWT = "*" requests = "*" +[package.extras] +profiling = ["Werkzeug", "matplotlib", "numpy"] +uwsgi = ["uwsgi"] + [[package]] name = "cdisutilstest" version = "2.0.0" description = "Collection of test data and tools" optional = false python-versions = "*" +groups = ["dev"] files = [] develop = false @@ -385,204 +553,259 @@ resolved_reference = "74a607736ca4af5ec35f17830ab9b78b5db15837" [[package]] name = "certifi" -version = "2025.1.31" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +groups = ["main", "dev"] files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] name = "cffi" -version = "1.17.1" +version = "2.1.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.8" -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, +] +markers = {main = "platform_python_implementation != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\""} + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.4.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, -] +groups = ["main", "dev"] +files = [ + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, +] + +[[package]] +name = "cleo" +version = "2.1.0" +description = "Cleo allows you to create beautiful and testable command-line interfaces." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["dev"] +files = [ + {file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"}, + {file = "cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523"}, +] + +[package.dependencies] +crashtest = ">=0.4.1,<0.5.0" +rapidfuzz = ">=3.0.0,<4.0.0" [[package]] name = "click" -version = "8.1.8" +version = "8.4.2" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"}, + {file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"}, ] [package.dependencies] @@ -594,6 +817,7 @@ version = "1.3.11" description = "Codacy coverage reporter for Python" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "codacy-coverage-1.3.11.tar.gz", hash = "sha256:b94651934745c638a980ad8d67494077e60f71e19e29aad1c275b66e0a070cbc"}, {file = "codacy_coverage-1.3.11-py2.py3-none-any.whl", hash = "sha256:d8a1ce56b0dd156d6b1de14fa6217d32ec86097902f08a17ff2f95ba27264474"}, @@ -612,215 +836,380 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\""} [[package]] name = "coverage" -version = "5.5" +version = "7.15.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -files = [ - {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, - {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, - {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, - {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, - {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, - {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, - {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, - {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, - {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, - {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, - {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, - {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, - {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, - {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, - {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, - {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, - {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, - {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, - {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, - {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, - {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, - {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, - {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, - {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, -] - -[package.extras] -toml = ["toml"] +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90"}, + {file = "coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0"}, + {file = "coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540"}, + {file = "coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4"}, + {file = "coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6"}, + {file = "coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da"}, + {file = "coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77"}, + {file = "coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6"}, + {file = "coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37"}, + {file = "coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b"}, + {file = "coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629"}, + {file = "coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe"}, + {file = "coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f"}, + {file = "coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663"}, + {file = "coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68"}, + {file = "coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b"}, + {file = "coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080"}, + {file = "coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5"}, + {file = "coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19"}, + {file = "coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "coveralls" -version = "2.2.0" +version = "4.1.0" description = "Show coverage stats online via coveralls.io" optional = false -python-versions = ">= 3.5" +python-versions = "<4.0,>=3.10" +groups = ["dev"] files = [ - {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"}, - {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"}, + {file = "coveralls-4.1.0-py3-none-any.whl", hash = "sha256:bfacfda2d443c24fc90d67035027cec15015fff2dbd036427e8bf8f4953dda2e"}, + {file = "coveralls-4.1.0.tar.gz", hash = "sha256:dab364025ba80cbb95ce56c6fc62cd9172d7fd637060ea235dde99d9b46a4494"}, ] [package.dependencies] -coverage = ">=4.1,<6.0" -docopt = ">=0.6.1" -requests = ">=1.0.0" +coverage = {version = ">=5.0,<6.0.dev0 || >6.1,<6.1.1 || >6.1.1,<8.0", extras = ["toml"]} +requests = ">=1.0.0,<3.0.0" +typer = ">=0.12.0,<1.0.0" [package.extras] -yaml = ["PyYAML (>=3.10)"] +yaml = ["pyyaml (>=3.10,<7.0)"] + +[[package]] +name = "crashtest" +version = "0.4.1" +description = "Manage Python errors with ease" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["dev"] +files = [ + {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, + {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, +] [[package]] name = "cryptography" -version = "43.0.3" +version = "49.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, - {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, - {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, - {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, - {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, - {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, - {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main", "dev"] +files = [ + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] -test-randomorder = ["pytest-randomly"] [[package]] name = "decorator" -version = "5.1.1" +version = "5.3.1" description = "Decorators for Humans" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, + {file = "decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c"}, + {file = "decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82"}, ] [[package]] -name = "dnspython" -version = "2.7.0" -description = "DNS toolkit" +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" optional = false -python-versions = ">=3.9" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] files = [ - {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, - {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=43)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=1.0.0)"] -idna = ["idna (>=3.7)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - [[package]] -name = "docker" -version = "7.1.0" -description = "A Python library for the Docker Engine API." +name = "deptry" +version = "0.25.1" +description = "A command line utility to check for unused, missing and transitive dependencies in a Python project." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, - {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, + {file = "deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8"}, + {file = "deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d"}, + {file = "deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091"}, + {file = "deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f"}, + {file = "deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3"}, + {file = "deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7"}, + {file = "deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99"}, + {file = "deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:30d64d4df1c08bc69de56cb0b4ec1f4cd9fa2e42582347d5b1eb25fd0e401745"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:87bcd90f99a98bb059c7580bc315c3f87d97fe2db725530030bc974176834735"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80f31eb5c520651b102568dd91f738222b250a3e44c9e95d4941322109b8d40a"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df88952a2bab7517ef23cb304b979199b28449e5d9db2e9ba9bc27a286ac852b"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e6f7b8fa72932e51e86799b10dcd29381b2132dc799c790dca3b28ab08dffb28"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e3fa3321078e11cd1ac3f10ce3ff0547731c53f9253b87c757a8749c76fe8fa9"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:03c032c32492fde434736954fbcaff09c02bf207b0f793b77e9040300e34b344"}, + {file = "deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d"}, ] [package.dependencies] -pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} -requests = ">=2.26.0" -urllib3 = ">=1.26.0" - -[package.extras] -dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] -docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] -ssh = ["paramiko (>=2.4.3)"] -websockets = ["websocket-client (>=1.3.0)"] +click = ">=8.0.0,<9" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +packaging = ">=23.2" +requirements-parser = ">=0.11.0,<1" +tomli = {version = ">=2.0.1", markers = "python_full_version < \"3.15.0\""} [[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" +name = "distlib" +version = "0.4.3" +description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, ] +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + +[[package]] +name = "dulwich" +version = "1.2.10" +description = "Python Git Library" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "dulwich-1.2.10-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:07f81ec4773c89d5989d173a077ed54b400719b4990b2f46a7b5604f16da46d1"}, + {file = "dulwich-1.2.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:afb850b4f6ff6f1c7178bfbefbf079a0d0d03014bb385929a0934732ac7dd0c0"}, + {file = "dulwich-1.2.10-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:86c81a30340ca15aa1d275f42abc1f297e0af2a2d6cc213b5e9e40f52c61ff4f"}, + {file = "dulwich-1.2.10-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:51d97aaa382b6e9ee0adffcd0028c430ff133be4d6bbff53ee1aeedcb3fcff07"}, + {file = "dulwich-1.2.10-cp310-cp310-win32.whl", hash = "sha256:93299fd523892d39f69e08bf71984267798d76cb914163c48c998181891a24a2"}, + {file = "dulwich-1.2.10-cp310-cp310-win_amd64.whl", hash = "sha256:f91eb2908d900288da261f05fb436a384fc94b30980869a2db4db7b52b30047a"}, + {file = "dulwich-1.2.10-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f6d989605955bfbb678425a2e8710100ef35960340bfb55faaca902006c69163"}, + {file = "dulwich-1.2.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af4a750597d1a6e77bca74b9246bf84b62a9bfa7e48783af4ed0e9f04f0dbfbd"}, + {file = "dulwich-1.2.10-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6c715289e8c5667978ff28a1847736633766ca1c7082144ba0ee95e459be3f97"}, + {file = "dulwich-1.2.10-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f21c7f2dc4de8353b59b82bc0245838f3f94ff9332b71f6394642e3bb012c71b"}, + {file = "dulwich-1.2.10-cp311-cp311-win32.whl", hash = "sha256:ca1fbe5f4b2ee48154b24c68eeb689b848394a857f7cb3dea3de0b86c464454b"}, + {file = "dulwich-1.2.10-cp311-cp311-win_amd64.whl", hash = "sha256:9874964ac78713c640c42c9dc27a74dd2d4169d99821a57301d37e5673409d27"}, + {file = "dulwich-1.2.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4bf4671061aa98e7f4d420fe5238b2ca2cb2a00a280c8f549fa92d34569971a8"}, + {file = "dulwich-1.2.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43315f1e95f7d5c1a6f2695f5534263b80678ccbfd9756101f07759a4e65850e"}, + {file = "dulwich-1.2.10-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:699edc94fddd0a2346e41e405fd7c537072d75cb9ddb39bd47f2e8fbd56d1e4e"}, + {file = "dulwich-1.2.10-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dc7c46cfeb40022d2fca9d23c3ca1aba602ee99bfb6554278f05f2aa890a0e"}, + {file = "dulwich-1.2.10-cp312-cp312-win32.whl", hash = "sha256:9c01a6b29cedb054a16fa04adf834a6531a39450888fc7ce2b3bf0fb6cba70c4"}, + {file = "dulwich-1.2.10-cp312-cp312-win_amd64.whl", hash = "sha256:96981c6704def4fd0f91fb9be9f613cf99ba327e2a655b8ef987f5f5245420f3"}, + {file = "dulwich-1.2.10-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:bd859654ed2e8c61c03066b739fc758b70edfcfce41344fae07f5f7f41b998b4"}, + {file = "dulwich-1.2.10-cp313-cp313-android_24_x86_64.whl", hash = "sha256:adf9a780add1b6da7e6df1b609afca66733380188a3a257ea340bc080ef4637c"}, + {file = "dulwich-1.2.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f337fd7dffe5e6cbff5e9a4694339b7575ed948babcc7675321efac075cb0a0e"}, + {file = "dulwich-1.2.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ded6744fcd2fb0e1f66c811492aefcdbee169010057f2a2e034a463eb0118c80"}, + {file = "dulwich-1.2.10-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a16ea1a6d96db53a0089f7f6c4e6b942eff143e4de26b786aa894bf978ac148d"}, + {file = "dulwich-1.2.10-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:dfe2683285a7363a0d4b16f7f6fc9ee8a57a7b1cb1c390695475b76d8ce1b0f5"}, + {file = "dulwich-1.2.10-cp313-cp313-win32.whl", hash = "sha256:0840968ec6636a677b48bc6e38bf4ce2326e8f0d60cd9c9753cceeff5e23e92c"}, + {file = "dulwich-1.2.10-cp313-cp313-win_amd64.whl", hash = "sha256:a2e884a93452e8e1fb4a4886d14b328bae65c7a2fa88466c0b38faa63204f34d"}, + {file = "dulwich-1.2.10-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:fd86069664383fc456eeb291d67dc541c7f6ab163c59da1f8e18c72283f120c5"}, + {file = "dulwich-1.2.10-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e445229de65ad6c3a8e2d9934fb807453c09b94f1469d32804e19fdfdcf6fd94"}, + {file = "dulwich-1.2.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:09c36bed6f2695cf34e606c403a00c6511f87f31ed20cac68f0bacf6df21cc4b"}, + {file = "dulwich-1.2.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea42aa414a53d71ae0e294003a10b17ad454ff73d345a82e47da93e977fd2ff1"}, + {file = "dulwich-1.2.10-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:5b60380225742d7292703e573ce5a86be6f69fb9088ef2965c43830479df9f90"}, + {file = "dulwich-1.2.10-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c321650fc9fa0b3e614607f0a229523491153d0ab00502366ac93b022d3c7f6c"}, + {file = "dulwich-1.2.10-cp314-cp314-win32.whl", hash = "sha256:834df23dca29e5adc5310b1b73eb573ad805fd0f39987e86afda2d81bfe24ab1"}, + {file = "dulwich-1.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:abd8ffeec9c151cea62aafd25260ac7141a7ea56ce363dfc72cddf63396e8d02"}, + {file = "dulwich-1.2.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d5fd557539c982aabe15f7a14cff3d8786cdb62578d6f90f7871379fa3453260"}, + {file = "dulwich-1.2.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66cb00f1298e4e0dd254ff6a68554577020b4b810b4a31083409baea62f8b1df"}, + {file = "dulwich-1.2.10-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eca947467a28992088a0461c408791b405153294887accb92f64d60f9cc04758"}, + {file = "dulwich-1.2.10-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7712da64d7cad219c77aa4216d46a1b14876ed078b2a88621462d2834f4f85b3"}, + {file = "dulwich-1.2.10-cp314-cp314t-win32.whl", hash = "sha256:52cfbd70f178b5ad770b9a1aee8a757aeda0e7282ae72cd546a98e6b9b0816cb"}, + {file = "dulwich-1.2.10-cp314-cp314t-win_amd64.whl", hash = "sha256:38478aa5d1821fd3d41e1ec7c65fb9a55d302d041635079db11118b79109054f"}, + {file = "dulwich-1.2.10-py3-none-any.whl", hash = "sha256:b9f18afbe8e6f6a380b199df3d2da14312b4eac4ee34ee77c6f61c56bd2693ed"}, + {file = "dulwich-1.2.10.tar.gz", hash = "sha256:106546960f592216315e56d32a92e72e67f1e86671335722f833f5ecd5b73314"}, +] + +[package.dependencies] +urllib3 = ">=2.2.2" + +[package.extras] +aiohttp = ["aiohttp"] +colordiff = ["rich"] +dev = ["codespell (==2.4.2)", "dissolve (>=0.1.1)", "mypy (==2.1.0)", "ruff (==0.15.20)"] +fastimport = ["fastimport"] +fuzzing = ["atheris"] +https = ["urllib3 (>=2.2.2)"] +hypothesis = ["hypothesis (>=6)"] +merge = ["merge3"] +paramiko = ["paramiko"] +patiencediff = ["patiencediff"] +pgp = ["gpg"] +range-diff = ["munkres"] + [[package]] name = "ecdsa" -version = "0.19.0" +version = "0.19.2" description = "ECDSA cryptographic signature library (pure python)" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.6" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] files = [ - {file = "ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a"}, - {file = "ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8"}, + {file = "ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399"}, + {file = "ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930"}, ] [package.dependencies] @@ -832,51 +1221,82 @@ gmpy2 = ["gmpy2"] [[package]] name = "email-validator" -version = "1.3.1" +version = "2.3.0" description = "A robust email address syntax and deliverability validation library." optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "email_validator-1.3.1-py2.py3-none-any.whl", hash = "sha256:49a72f5fa6ed26be1c964f0567d931d10bf3fdeeacdf97bc26ef1cd2a44e0bda"}, - {file = "email_validator-1.3.1.tar.gz", hash = "sha256:d178c5c6fa6c6824e9b04f199cf23e79ac15756786573c190d2ad13089411ad2"}, + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, ] [package.dependencies] -dnspython = ">=1.15.0" +dnspython = ">=2.0.0" idna = ">=2.0.0" [[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" +name = "fastjsonschema" +version = "2.21.2" +description = "Fastest Python implementation of JSON schema" optional = false -python-versions = ">=3.7" +python-versions = "*" +groups = ["dev"] files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, + {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, ] [package.extras] -test = ["pytest (>=6)"] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] + +[[package]] +name = "filelock" +version = "3.29.7" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51"}, + {file = "filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d"}, +] + +[[package]] +name = "findpython" +version = "0.8.0" +description = "A utility to find python versions on your system" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "findpython-0.8.0-py3-none-any.whl", hash = "sha256:4a61ee1618a8b55014f7d41f59345d322be93f6ce62395bdccccc651b3f7e28a"}, + {file = "findpython-0.8.0.tar.gz", hash = "sha256:53b32264874dfa5990bd09d717819386d8db3149d89fe20f88fe1078de286bae"}, +] + +[package.dependencies] +packaging = ">=20" +platformdirs = ">=4.3.6" [[package]] name = "flask" -version = "3.1.0" +version = "3.1.3" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136"}, - {file = "flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac"}, + {file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"}, + {file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"}, ] [package.dependencies] -blinker = ">=1.9" +blinker = ">=1.9.0" click = ">=8.1.3" -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} -itsdangerous = ">=2.2" -Jinja2 = ">=3.1.2" -Werkzeug = ">=3.1" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" [package.extras] async = ["asgiref (>=3.2)"] @@ -884,17 +1304,19 @@ dotenv = ["python-dotenv"] [[package]] name = "flask-cors" -version = "5.0.0" -description = "A Flask extension adding a decorator for CORS support" +version = "6.0.5" +description = "A Flask extension simplifying CORS support" optional = false -python-versions = "*" +python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "Flask_Cors-5.0.0-py2.py3-none-any.whl", hash = "sha256:b9e307d082a9261c100d8fb0ba909eec6a228ed1b60a8315fd85f783d61910bc"}, - {file = "flask_cors-5.0.0.tar.gz", hash = "sha256:5aadb4b950c4e93745034594d9f3ea6591f734bb3662e16e255ffbf5e89c88ef"}, + {file = "flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8"}, + {file = "flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601"}, ] [package.dependencies] -Flask = ">=0.9" +flask = ">=0.9" +Werkzeug = ">=0.7" [[package]] name = "flask-restful" @@ -902,6 +1324,7 @@ version = "0.3.10" description = "Simple framework for creating REST APIs" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"}, {file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"}, @@ -918,13 +1341,14 @@ docs = ["sphinx"] [[package]] name = "flask-wtf" -version = "1.2.2" +version = "1.3.0" description = "Form rendering, validation, and CSRF protection for Flask with WTForms." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "flask_wtf-1.2.2-py3-none-any.whl", hash = "sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70"}, - {file = "flask_wtf-1.2.2.tar.gz", hash = "sha256:79d2ee1e436cf570bccb7d916533fa18757a2f18c290accffab1b9a0b684666b"}, + {file = "flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160"}, + {file = "flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc"}, ] [package.dependencies] @@ -935,63 +1359,59 @@ wtforms = "*" [package.extras] email = ["email-validator"] -[[package]] -name = "future" -version = "0.18.3" -description = "Clean single-source support for Python 3 and 2" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"}, -] - [[package]] name = "gen3authz" -version = "1.5.1" +version = "3.1.1" description = "Gen3 authz client" optional = false -python-versions = ">=3.6,<4.0" +python-versions = "<4.0,>=3.13" +groups = ["main"] files = [ - {file = "gen3authz-1.5.1-py3-none-any.whl", hash = "sha256:249ab21471e1ae3c283a30f3e32819db218aaa63d8d7151ac7b6b83496ac3518"}, - {file = "gen3authz-1.5.1.tar.gz", hash = "sha256:897f804a2f9d29181a6074fa14335fc9f18e757dd2e5dcec2a68bb9a008bfcb8"}, + {file = "gen3authz-3.1.1-py3-none-any.whl", hash = "sha256:5223cdf841900ca9ca3c445a8cf4d12a90f73b0cb0a4450491dca42f3c020340"}, + {file = "gen3authz-3.1.1.tar.gz", hash = "sha256:6bd36e1a6cb5b5141a9ea388ee33398fa7fd777b10f91386c5e28d946a18b505"}, ] [package.dependencies] -backoff = ">=1.6,<2.0" -cdiserrors = "<2.0.0" +backoff = ">=2.2.1,<3.0.0" +cdiserrors = "<3.0.0" httpx = ">=0.20.0,<1.0.0" six = ">=1.16.0,<2.0.0" +sniffio = ">=1.3.1" [[package]] name = "gen3cirrus" -version = "3.1.0" +version = "4.0.1" description = "" optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4.0,>=3.13" +groups = ["main"] files = [ - {file = "gen3cirrus-3.1.0-py3-none-any.whl", hash = "sha256:42c89d1579d7d89c87c5c355815197e1dbf8045a2030310a6b2f6ca089a74fde"}, - {file = "gen3cirrus-3.1.0.tar.gz", hash = "sha256:81e5a0a4b5dc2d820ad3351bd5326151424806bc8f64e022100320f01027a310"}, + {file = "gen3cirrus-4.0.1-py3-none-any.whl", hash = "sha256:db8aaf9b0134d1285e9d9d3efcdd29abc244b3bbc5b9410b4ad5211cd92d0b6a"}, + {file = "gen3cirrus-4.0.1.tar.gz", hash = "sha256:afca76ac435cb765bac6f8e902e873204602494accf27860ae45805d3adca9ec"}, ] [package.dependencies] backoff = "*" boto3 = "*" +botocore = "*" cdislogging = "*" google-api-python-client = "*" google-auth = "*" -google-auth-httplib2 = "*" google-cloud-storage = "*" +httplib2 = "*" oauth2client = "*" +requests = "*" [[package]] name = "gen3config" -version = "2.0.0" +version = "2.1.0" description = "Gen3 Configuration Library" optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4,>=3.13" +groups = ["main"] files = [ - {file = "gen3config-2.0.0-py3-none-any.whl", hash = "sha256:0d56a64f82e7c4f9a417e7be5645b745e6450431b77a1a4d38f52bea19e56a49"}, - {file = "gen3config-2.0.0.tar.gz", hash = "sha256:4e4480a5d2b0d8b4263150ad1b354b3dc37406427e010a8acb161e32861c3acb"}, + {file = "gen3config-2.1.0-py3-none-any.whl", hash = "sha256:7cb088127eb9598ec954c10e083f50d19f6d7360780f4bbf3344443ce83a27e0"}, + {file = "gen3config-2.1.0.tar.gz", hash = "sha256:ef31d679b841ca30cd3c285f720b1701024bb27911d327023d9fb6aa559bdef3"}, ] [package.dependencies] @@ -1007,6 +1427,7 @@ version = "1.1.1" description = "Utils for Gen3 Commons user management" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ {file = "gen3users-1.1.1-py3-none-any.whl", hash = "sha256:5a38ba90c8cef5f7c4ed6ae2f1f1d733524d48b1b2c60e66db8537e36194faab"}, {file = "gen3users-1.1.1.tar.gz", hash = "sha256:6636ff127ce145f9104fc72358dd17de54b19be19ae45b89e13876c0adcf4ba0"}, @@ -1020,335 +1441,360 @@ requests = "*" [[package]] name = "google-api-core" -version = "2.24.1" +version = "2.31.0" description = "Google API client core library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google_api_core-2.24.1-py3-none-any.whl", hash = "sha256:bc78d608f5a5bf853b80bd70a795f703294de656c096c0968320830a4bc280f1"}, - {file = "google_api_core-2.24.1.tar.gz", hash = "sha256:f8b36f5456ab0dd99a1b693a40a31d1e7757beea380ad1b38faaf8941eae9d8a"}, + {file = "google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab"}, + {file = "google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2"}, ] [package.dependencies] -google-auth = ">=2.14.1,<3.0.dev0" -googleapis-common-protos = ">=1.56.2,<2.0.dev0" -proto-plus = [ - {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, - {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, -] -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" -requests = ">=2.18.0,<3.0.0.dev0" +google-auth = ">=2.14.1,<3.0.0" +googleapis-common-protos = ">=1.63.2,<2.0.0" +proto-plus = {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""} +protobuf = ">=5.29.6,<8.0.0" +requests = ">=2.33.0,<3.0.0" [package.extras] -async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] -grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +async-rest = ["aiohttp (>=3.13.4)", "google-auth[aiohttp] (>=2.14.1,<3.0.0)"] +grpc = ["grpcio (>=1.41.0,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.41.0,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] [[package]] name = "google-api-python-client" -version = "2.161.0" +version = "2.198.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_api_python_client-2.161.0-py2.py3-none-any.whl", hash = "sha256:9476a5a4f200bae368140453df40f9cda36be53fa7d0e9a9aac4cdb859a26448"}, - {file = "google_api_python_client-2.161.0.tar.gz", hash = "sha256:324c0cce73e9ea0a0d2afd5937e01b7c2d6a4d7e2579cdb6c384f9699d6c9f37"}, + {file = "google_api_python_client-2.198.0-py3-none-any.whl", hash = "sha256:fabac935474e817da5e662ff61bf7139439d6f92b32d332a7318a2d45931e03e"}, + {file = "google_api_python_client-2.198.0.tar.gz", hash = "sha256:dfe3e16fb241af6e9c460a33f65085b3450e05cea09364f6b5d8997fb7e43e2a"}, ] [package.dependencies] -google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" google-auth-httplib2 = ">=0.2.0,<1.0.0" -httplib2 = ">=0.19.0,<1.dev0" +httplib2 = ">=0.19.0,<1.0.0" uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.38.0" +version = "2.55.2" description = "Google Authentication Library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a"}, - {file = "google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4"}, + {file = "google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b"}, + {file = "google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae"}, ] [package.dependencies] -cachetools = ">=2.0.0,<6.0" +cryptography = ">=38.0.3" pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] -enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +aiohttp = ["aiohttp (>=3.8.0,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] +cryptography = ["cryptography (>=38.0.3)"] +enterprise-cert = ["cryptography (>=38.0.3)"] +grpc = ["grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3)"] reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +requests = ["requests (>=2.20.0,<3.0.0)"] +rsa = ["rsa (>=3.1.4,<5)"] +testing = ["aiohttp (>=3.8.0,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "packaging", "pyjwt (>=2.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +urllib3 = ["packaging", "urllib3"] [[package]] name = "google-auth-httplib2" -version = "0.2.0" +version = "0.4.0" description = "Google Authentication Library: httplib2 transport" optional = false -python-versions = "*" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, - {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, + {file = "google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91"}, + {file = "google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35"}, ] [package.dependencies] -google-auth = "*" -httplib2 = ">=0.19.0" +google-auth = ">=1.32.0,<3.0.0" +httplib2 = ">=0.19.0,<1.0.0" [[package]] name = "google-cloud-core" -version = "2.4.2" +version = "2.6.0" description = "Google Cloud API client core library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google_cloud_core-2.4.2-py2.py3-none-any.whl", hash = "sha256:7459c3e83de7cb8b9ecfec9babc910efb4314030c56dd798eaad12c426f7d180"}, - {file = "google_cloud_core-2.4.2.tar.gz", hash = "sha256:a4fcb0e2fcfd4bfe963837fad6d10943754fd79c1a50097d68540b6eb3d67f35"}, + {file = "google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e"}, + {file = "google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83"}, ] [package.dependencies] -google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" -google-auth = ">=1.25.0,<3.0dev" +google-api-core = ">=2.11.0,<3.0.0" +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" [package.extras] -grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] +grpc = ["grpcio (>=1.47.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.47.0,<2.0.0)"] [[package]] name = "google-cloud-storage" -version = "3.0.0" +version = "3.12.1" description = "Google Cloud Storage API client library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google_cloud_storage-3.0.0-py2.py3-none-any.whl", hash = "sha256:f85fd059650d2dbb0ac158a9a6b304b66143b35ed2419afec2905ca522eb2c6a"}, - {file = "google_cloud_storage-3.0.0.tar.gz", hash = "sha256:2accb3e828e584888beff1165e5f3ac61aa9088965eb0165794a82d8c7f95297"}, + {file = "google_cloud_storage-3.12.1-py3-none-any.whl", hash = "sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb"}, + {file = "google_cloud_storage-3.12.1.tar.gz", hash = "sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b"}, ] [package.dependencies] -google-api-core = ">=2.15.0,<3.0.0dev" -google-auth = ">=2.26.1,<3.0dev" -google-cloud-core = ">=2.3.0,<3.0dev" -google-crc32c = ">=1.0,<2.0dev" -google-resumable-media = ">=2.7.2" -requests = ">=2.18.0,<3.0.0dev" +google-api-core = ">=2.27.0,<3.0.0" +google-auth = ">=2.26.1,<3.0.0" +google-cloud-core = ">=2.4.2,<3.0.0" +google-crc32c = ">=1.6.0,<2.0.0" +google-resumable-media = ">=2.7.2,<3.0.0" +requests = ">=2.22.0,<3.0.0" [package.extras] -protobuf = ["protobuf (<6.0.0dev)"] -tracing = ["opentelemetry-api (>=1.1.0)"] +grpc = ["google-api-core[grpc] (>=2.27.0,<3.0.0)", "grpc-google-iam-v1 (>=0.14.0,<1.0.0)", "grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "proto-plus (>=1.22.3,<2.0.0) ; python_version < \"3.13\"", "proto-plus (>=1.25.0,<2.0.0) ; python_version >= \"3.13\"", "protobuf (>=4.25.8,<8.0.0)"] +protobuf = ["protobuf (>=3.20.2,<7.0.0)"] +testing = ["PyYAML", "black", "brotli", "coverage", "flake8", "google-cloud-iam", "google-cloud-kms", "google-cloud-pubsub", "google-cloud-testutils", "google-cloud-testutils", "mock", "numpy", "opentelemetry-sdk", "psutil", "py-cpuinfo", "pyopenssl", "pytest", "pytest-asyncio", "pytest-benchmark", "pytest-cov", "pytest-rerunfailures", "pytest-xdist"] +tracing = ["opentelemetry-api (>=1.1.0,<2.0.0)"] [[package]] name = "google-crc32c" -version = "1.6.0" +version = "1.8.0" description = "A python wrapper of the C library 'Google CRC32C'" optional = false python-versions = ">=3.9" -files = [ - {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa"}, - {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc"}, - {file = "google_crc32c-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42"}, - {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4"}, - {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8"}, - {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d"}, - {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f"}, - {file = "google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, - {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205"}, - {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57"}, - {file = "google_crc32c-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c"}, - {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc"}, - {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d"}, - {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24"}, - {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d"}, - {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, +groups = ["main"] +files = [ + {file = "google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff"}, + {file = "google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288"}, + {file = "google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d"}, + {file = "google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092"}, + {file = "google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733"}, + {file = "google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8"}, + {file = "google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7"}, + {file = "google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15"}, + {file = "google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a"}, + {file = "google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2"}, + {file = "google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113"}, + {file = "google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb"}, + {file = "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411"}, + {file = "google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454"}, + {file = "google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962"}, + {file = "google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b"}, + {file = "google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27"}, + {file = "google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa"}, + {file = "google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8"}, + {file = "google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f"}, + {file = "google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697"}, + {file = "google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651"}, + {file = "google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2"}, + {file = "google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21"}, + {file = "google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2"}, + {file = "google_crc32c-1.8.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:ba6aba18daf4d36ad4412feede6221414692f44d17e5428bdd81ad3fc1eee5dc"}, + {file = "google_crc32c-1.8.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:87b0072c4ecc9505cfa16ee734b00cd7721d20a0f595be4d40d3d21b41f65ae2"}, + {file = "google_crc32c-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d488e98b18809f5e322978d4506373599c0c13e6c5ad13e53bb44758e18d215"}, + {file = "google_crc32c-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01f126a5cfddc378290de52095e2c7052be2ba7656a9f0caf4bcd1bfb1833f8a"}, + {file = "google_crc32c-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:61f58b28e0b21fcb249a8247ad0db2e64114e201e2e9b4200af020f3b6242c9f"}, + {file = "google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93"}, + {file = "google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c"}, + {file = "google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79"}, ] -[package.extras] -testing = ["pytest"] - [[package]] name = "google-resumable-media" -version = "2.7.2" +version = "2.10.0" description = "Utilities for Google Media Downloads and Resumable Uploads" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa"}, - {file = "google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0"}, + {file = "google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c"}, + {file = "google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee"}, ] [package.dependencies] -google-crc32c = ">=1.0,<2.0dev" +google-crc32c = ">=1.0.0,<2.0.0" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] -requests = ["requests (>=2.18.0,<3.0.0dev)"] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "google-auth (>=1.22.0,<2.0.0)"] +requests = ["requests (>=2.18.0,<3.0.0)"] [[package]] name = "googleapis-common-protos" -version = "1.68.0" +version = "1.75.0" description = "Common protobufs used in Google APIs" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "googleapis_common_protos-1.68.0-py2.py3-none-any.whl", hash = "sha256:aaf179b2f81df26dfadac95def3b16a95064c76a5f45f07e4c68a21bb371c4ac"}, - {file = "googleapis_common_protos-1.68.0.tar.gz", hash = "sha256:95d38161f4f9af0d9423eed8fb7b64ffd2568c3464eb542ff02c5bfa1953ab3c"}, + {file = "googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed"}, + {file = "googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd"}, ] [package.dependencies] -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +protobuf = ">=4.25.8,<8.0.0" [package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] +grpc = ["grpcio (>=1.44.0,<2.0.0)"] [[package]] name = "greenlet" -version = "3.1.1" +version = "3.5.3" description = "Lightweight in-process concurrent programming" optional = false -python-versions = ">=3.7" -files = [ - {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, - {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, - {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, - {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, - {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, - {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, - {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, - {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, - {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, - {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, - {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, - {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, - {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, - {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, - {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, - {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, - {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +files = [ + {file = "greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c"}, + {file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04"}, + {file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce"}, + {file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8"}, + {file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec"}, + {file = "greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71"}, + {file = "greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8"}, + {file = "greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702"}, + {file = "greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db"}, + {file = "greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4"}, + {file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc"}, + {file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6"}, + {file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb"}, + {file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7"}, + {file = "greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c"}, + {file = "greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8"}, + {file = "greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8"}, + {file = "greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7"}, + {file = "greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44"}, + {file = "greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2"}, + {file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b"}, + {file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab"}, + {file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23"}, + {file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861"}, + {file = "greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c"}, + {file = "greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149"}, + {file = "greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea"}, + {file = "greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c"}, + {file = "greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d"}, + {file = "greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550"}, + {file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5"}, + {file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3"}, + {file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1"}, + {file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a"}, + {file = "greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda"}, + {file = "greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb"}, + {file = "greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b"}, + {file = "greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b"}, + {file = "greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4"}, + {file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"}, + {file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"}, + {file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d"}, + {file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814"}, + {file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c"}, + {file = "greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260"}, + {file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a"}, + {file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154"}, + {file = "greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e"}, + {file = "greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605"}, + {file = "greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be"}, + {file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310"}, + {file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8"}, + {file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d"}, + {file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f"}, + {file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0"}, + {file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21"}, + {file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da"}, + {file = "greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3"}, + {file = "greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc"}, + {file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47"}, + {file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81"}, + {file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357"}, + {file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d"}, + {file = "greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128"}, + {file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34"}, + {file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b"}, + {file = "greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930"}, + {file = "greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227"}, + {file = "greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c"}, + {file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f"}, + {file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2"}, + {file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91"}, + {file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608"}, + {file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d"}, + {file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb"}, + {file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16"}, + {file = "greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf"}, + {file = "greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31"}, + {file = "greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1"}, ] [package.extras] docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil"] +test = ["objgraph", "psutil", "setuptools"] [[package]] name = "gunicorn" -version = "23.0.0" +version = "26.0.0" description = "WSGI HTTP Server for UNIX" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, - {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, + {file = "gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc"}, + {file = "gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf"}, ] [package.dependencies] packaging = "*" [package.extras] -eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] -gevent = ["gevent (>=1.4.0)"] +fast = ["gunicorn_h1c (>=0.6.5)"] +gevent = ["gevent (>=24.10.1)"] +http2 = ["h2 (>=4.1.0)"] setproctitle = ["setproctitle"] -testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] -tornado = ["tornado (>=0.2)"] +testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"] +tornado = ["tornado (>=6.5.0)"] [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, - {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] @@ -1358,17 +1804,18 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httplib2" -version = "0.22.0" +version = "0.32.0" description = "A comprehensive HTTP client library." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, - {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, + {file = "httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816"}, + {file = "httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025"}, ] [package.dependencies] -pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +pyparsing = ">=3.1,<4" [[package]] name = "httpx" @@ -1376,6 +1823,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1388,7 +1836,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -1396,50 +1844,53 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.10" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] -name = "importlib-metadata" -version = "8.6.1" -description = "Read metadata from Python packages" +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, - {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] +[[package]] +name = "installer" +version = "1.0.1" +description = "A library for installing Python wheels." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "installer-1.0.1-py3-none-any.whl", hash = "sha256:011d045df8b954ced7dde3a7e42ae4418da40ecda7990f2d11d5ed7c146fd98b"}, + {file = "installer-1.0.1.tar.gz", hash = "sha256:052c7fc3721d54c696e2dea019be67539d7b144e924f559f54beb3121831c364"}, +] [[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" +name = "invoke" +version = "3.0.3" +description = "Pythonic task execution" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"}, + {file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"}, ] [[package]] @@ -1448,6 +1899,7 @@ version = "0.7.2" description = "An ISO 8601 date/time/duration parser and formatter" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, @@ -1459,225 +1911,469 @@ version = "2.2.0" description = "Safely pass data to untrusted environments and back." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, ] [[package]] -name = "jinja2" -version = "3.1.5" -description = "A very fast and expressive template engine." +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, - {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, ] [package.dependencies] -MarkupSafe = ">=2.0" +more-itertools = "*" [package.extras] -i18n = ["Babel (>=2.7)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [[package]] -name = "jmespath" -version = "1.0.1" -description = "JSON Matching Expressions" +name = "jaraco-context" +version = "6.1.2" +description = "Useful decorators and context managers" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, + {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, + {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, ] +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] -name = "jsondiff" -version = "1.1.1" -description = "Diff JSON and JSON-like structures in Python" +name = "jaraco-functools" +version = "4.5.0" +description = "Functools like those found in stdlib" optional = false -python-versions = "*" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "jsondiff-1.1.1.tar.gz", hash = "sha256:2d0437782de9418efa34e694aa59f43d7adb1899bd9a793f063867ddba8f7893"}, + {file = "jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4"}, + {file = "jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03"}, ] +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] -name = "jsonpickle" -version = "4.0.2" -description = "jsonpickle encodes/decodes any Python object to/from JSON" +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" +groups = ["dev"] +markers = "sys_platform == \"linux\"" files = [ - {file = "jsonpickle-4.0.2-py3-none-any.whl", hash = "sha256:cd3c90d32a68dcaa7f0e4b918bda7d4bb61f3c03b182d82dae2caf9ded0ab6b3"}, - {file = "jsonpickle-4.0.2.tar.gz", hash = "sha256:3e650b9853adcdab9d9d62a88412b6d36e9a59ba423b01cacf0cd4ee80733aca"}, + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, ] [package.extras] -cov = ["pytest-cov"] -dev = ["black", "pyupgrade"] -docs = ["furo", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -packaging = ["build", "setuptools (>=61.2)", "setuptools-scm[toml] (>=6.0)", "twine"] -testing = ["PyYAML", "atheris (>=2.3.0,<2.4.0)", "bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=6.0,!=8.1.*)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy", "scipy (>=1.9.3)", "simplejson", "sqlalchemy", "ujson"] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] [[package]] -name = "mako" -version = "1.3.9" -description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" +groups = ["main", "dev"] files = [ - {file = "Mako-1.3.9-py3-none-any.whl", hash = "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1"}, - {file = "mako-1.3.9.tar.gz", hash = "sha256:b5d65ff3462870feec922dbccf38f6efb44e5714d7b593a656be86663d8600ac"}, + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] -MarkupSafe = ">=0.9.2" +MarkupSafe = ">=2.0" [package.extras] -babel = ["Babel"] -lingua = ["lingua"] -testing = ["pytest"] +i18n = ["Babel (>=2.7)"] [[package]] -name = "markdown" -version = "3.7" -description = "Python implementation of John Gruber's Markdown." +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + +[[package]] +name = "keyring" +version = "25.7.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, + {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, +] + +[package.dependencies] +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] + +[[package]] +name = "mako" +version = "1.3.12" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, - {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, + {file = "mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"}, + {file = "mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a"}, ] [package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markdown" +version = "3.10.2" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, +] [package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] testing = ["coverage", "pyyaml"] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] + [[package]] name = "markupsafe" -version = "2.1.5" +version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "mock" -version = "2.0.0" +version = "5.2.0" description = "Rolling backport of unittest.mock for all Pythons" optional = false -python-versions = "*" +python-versions = ">=3.6" +groups = ["main"] files = [ - {file = "mock-2.0.0-py2.py3-none-any.whl", hash = "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1"}, - {file = "mock-2.0.0.tar.gz", hash = "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba"}, + {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, + {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, ] -[package.dependencies] -pbr = ">=0.11" -six = ">=1.9" - [package.extras] -docs = ["Pygments (<2)", "jinja2 (<2.7)", "sphinx", "sphinx (<1.3)"] -test = ["unittest2 (>=1.1.0)"] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "more-itertools" +version = "11.1.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, + {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, +] [[package]] name = "moto" -version = "1.3.7" -description = "A library that allows your python tests to easily mock out the boto library" +version = "5.2.2" +description = "A library that allows you to easily mock out tests based on AWS infrastructure" optional = false -python-versions = "*" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "moto-1.3.7-py2.py3-none-any.whl", hash = "sha256:4df37936ff8d6a4b8229aab347a7b412cd2ca4823ff47bd1362ddfbc6c5e4ecf"}, - {file = "moto-1.3.7.tar.gz", hash = "sha256:129de2e04cb250d9f8b2c722ec152ed1b5426ef179b4ebb03e9ec36e6eb3fcc5"}, + {file = "moto-5.2.2-py3-none-any.whl", hash = "sha256:3817f1e39721ca833579b921e53e3b68547ace6a34d848c9486fbb5905808de9"}, + {file = "moto-5.2.2.tar.gz", hash = "sha256:aac8023a429e125e91c91f8f4730a67b54f518cda587352f7e67252fe3168f75"}, ] [package.dependencies] -aws-xray-sdk = ">=0.93,<0.96" -boto = ">=2.36.0" -boto3 = ">=1.6.16" -botocore = ">=1.12.13" -cryptography = ">=2.3.0" -docker = ">=2.5.1" -Jinja2 = ">=2.7.3" -jsondiff = "1.1.1" -mock = "*" -pyaml = "*" -python-dateutil = ">=2.1,<3.0.0" -python-jose = "<3.0.0" -pytz = "*" +boto3 = ">=1.9.201" +botocore = ">=1.20.88,<1.35.45 || >1.35.45,<1.35.46 || >1.35.46" +cryptography = ">=35.0.0" requests = ">=2.5" -responses = ">=0.9.0" -six = ">1.9" -werkzeug = "*" +responses = ">=0.15.0,<0.25.5 || >0.25.5" +werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" xmltodict = "*" [package.extras] -server = ["flask"] +all = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "jsonpath_ng", "jsonschema", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.3)", "pyparsing (>=3.0.7)", "setuptools"] +apigateway = ["PyYAML (>=5.1)", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)"] +apigatewayv2 = ["PyYAML (>=5.1)", "openapi-spec-validator (>=0.5.0)"] +appsync = ["graphql-core"] +awslambda = ["docker (>=3.0.0)"] +batch = ["docker (>=3.0.0)"] +cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.3)", "pyparsing (>=3.0.7)", "setuptools"] +cognitoidp = ["joserfc (>=0.9.0)"] +dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.6.3)"] +dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.6.3)"] +events = ["jsonpath_ng"] +glue = ["pyparsing (>=3.0.7)"] +proxy = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=2.5.1)", "graphql-core", "joserfc (>=0.9.0)", "jsonpath_ng", "multipart", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.3)", "pyparsing (>=3.0.7)", "setuptools"] +quicksight = ["jsonschema"] +resourcegroupstaggingapi = ["PyYAML (>=5.1)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "graphql-core", "joserfc (>=0.9.0)", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.3)", "pyparsing (>=3.0.7)"] +s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.6.3)"] +s3crc32c = ["PyYAML (>=5.1)", "crc32c", "py-partiql-parser (==0.6.3)"] +server = ["PyYAML (>=5.1)", "antlr4-python3-runtime", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "joserfc (>=0.9.0)", "jsonpath_ng", "openapi-spec-validator (>=0.5.0)", "py-partiql-parser (==0.6.3)", "pyparsing (>=3.0.7)", "setuptools"] +ssm = ["PyYAML (>=5.1)"] +stepfunctions = ["antlr4-python3-runtime", "jsonpath_ng"] +xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] + +[[package]] +name = "msgpack" +version = "1.2.1" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c"}, + {file = "msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce"}, + {file = "msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74"}, + {file = "msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb"}, + {file = "msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22"}, + {file = "msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f"}, + {file = "msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d"}, + {file = "msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8"}, + {file = "msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66"}, + {file = "msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35"}, + {file = "msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64"}, + {file = "msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac"}, + {file = "msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24"}, + {file = "msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07"}, + {file = "msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064"}, + {file = "msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6"}, + {file = "msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707"}, + {file = "msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9"}, + {file = "msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a"}, + {file = "msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d"}, + {file = "msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7"}, + {file = "msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb"}, + {file = "msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b"}, + {file = "msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7"}, + {file = "msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273"}, + {file = "msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c"}, + {file = "msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1"}, + {file = "msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2"}, + {file = "msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107"}, + {file = "msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647"}, +] [[package]] name = "oauth2client" @@ -1685,6 +2381,7 @@ version = "4.1.3" description = "OAuth 2.0 client library" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac"}, {file = "oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6"}, @@ -1699,191 +2396,273 @@ six = ">=1.6.1" [[package]] name = "packaging" -version = "24.2" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] name = "paramiko" -version = "3.5.1" +version = "5.0.0" description = "SSH2 protocol library" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61"}, - {file = "paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822"}, + {file = "paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c"}, + {file = "paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79"}, ] [package.dependencies] bcrypt = ">=3.2" cryptography = ">=3.3" +invoke = ">=2.0" pynacl = ">=1.5" +[[package]] +name = "pbs-installer" +version = "2026.6.10" +description = "Installer for Python Build Standalone" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pbs_installer-2026.6.10-py3-none-any.whl", hash = "sha256:c8d7faa06d529ec8db53975da810376fff44e850e5b7b440a29f3b88edd62bd6"}, + {file = "pbs_installer-2026.6.10.tar.gz", hash = "sha256:85f6665692aeb4297e1295761c9f4a6c7f047e0bb6947847ab3abf6de9650a10"}, +] + +[package.dependencies] +"backports.zstd" = {version = ">=1.0.0", optional = true, markers = "python_version < \"3.14\" and extra == \"install\""} +httpx = {version = ">=0.27.0,<1", optional = true, markers = "extra == \"download\""} + [package.extras] -all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] -gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] -invoke = ["invoke (>=2.0)"] +all = ["pbs-installer[download,install]"] +download = ["httpx (>=0.27.0,<1)"] +install = ["backports.zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] -name = "pbr" -version = "6.1.1" -description = "Python Build Reasonableness" +name = "pkginfo" +version = "1.12.1.2" +description = "Query metadata from sdists / bdists / installed packages." optional = false -python-versions = ">=2.6" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"}, - {file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"}, + {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, + {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, ] -[package.dependencies] -setuptools = "*" +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + +[[package]] +name = "platformdirs" +version = "4.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, +] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "poetry" +version = "2.4.1" +description = "Python dependency management and packaging made easy." +optional = false +python-versions = "<4.0,>=3.10" +groups = ["dev"] +files = [ + {file = "poetry-2.4.1-py3-none-any.whl", hash = "sha256:a91f13279a3c9add0d12c5ca5c7cb173622930a5c8272fee68c751cb5c72f951"}, + {file = "poetry-2.4.1.tar.gz", hash = "sha256:189399b80347ecf908244b2564a7b1d92b648fa1fe2a204888f94a472fec0cac"}, +] + +[package.dependencies] +build = ">=1.2.1,<2.0.0" +cachecontrol = {version = ">=0.14.0,<0.15.0", extras = ["filecache"]} +cleo = ">=2.1.0,<3.0.0" +dulwich = ">=0.25.0,<2" +fastjsonschema = ">=2.18.0,<3.0.0" +findpython = ">=0.6.2,<0.9.0" +installer = ">=0.7.0,<2.0.0" +keyring = ">=25.1.0,<26.0.0" +packaging = ">=24.2" +pbs-installer = {version = ">=2025.6.10", extras = ["download", "install"]} +pkginfo = ">=1.12,<2.0" +platformdirs = ">=3.0.0,<5" +poetry-core = "2.4.0" +pyproject-hooks = ">=1.0.0,<2.0.0" +requests = ">=2.26,<3.0" +requests-toolbelt = ">=1.0.0,<2.0.0" +shellingham = ">=1.5,<2.0" +tomlkit = ">=0.11.4,<1.0.0" +trove-classifiers = ">=2022.5.19" +virtualenv = ">=20.26.6" +xattr = {version = ">=1.0.0,<2.0.0", markers = "sys_platform == \"darwin\""} + +[[package]] +name = "poetry-core" +version = "2.4.0" +description = "Poetry PEP 517 Build Backend" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["dev"] +files = [ + {file = "poetry_core-2.4.0-py3-none-any.whl", hash = "sha256:4305848477da00272bebd3f615bbec87f64bd117cdb858ab660b626a06a9d96c"}, + {file = "poetry_core-2.4.0.tar.gz", hash = "sha256:4e8c7496cf797998ffc493f2e23eba4b038c894c08eadacdcdf688945de6b43a"}, +] [[package]] name = "prometheus-client" -version = "0.21.1" +version = "0.25.0" description = "Python client for the Prometheus monitoring system." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "prometheus_client-0.21.1-py3-none-any.whl", hash = "sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301"}, - {file = "prometheus_client-0.21.1.tar.gz", hash = "sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb"}, + {file = "prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1"}, + {file = "prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28"}, ] [package.extras] +aiohttp = ["aiohttp"] +django = ["django"] twisted = ["twisted"] [[package]] name = "proto-plus" -version = "1.26.0" +version = "1.28.1" description = "Beautiful, Pythonic protocol buffers" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "proto_plus-1.26.0-py3-none-any.whl", hash = "sha256:bf2dfaa3da281fc3187d12d224c707cb57214fb2c22ba854eb0c105a3fb2d4d7"}, - {file = "proto_plus-1.26.0.tar.gz", hash = "sha256:6e93d5f5ca267b54300880fff156b6a3386b3fa3f43b1da62e680fc0c586ef22"}, + {file = "proto_plus-1.28.1-py3-none-any.whl", hash = "sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed"}, + {file = "proto_plus-1.28.1.tar.gz", hash = "sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168"}, ] [package.dependencies] -protobuf = ">=3.19.0,<6.0.0dev" +protobuf = ">=4.25.8,<8.0.0" [package.extras] testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "5.29.3" +version = "7.35.1" description = "" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888"}, - {file = "protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a"}, - {file = "protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e"}, - {file = "protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84"}, - {file = "protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f"}, - {file = "protobuf-5.29.3-cp38-cp38-win32.whl", hash = "sha256:84a57163a0ccef3f96e4b6a20516cedcf5bb3a95a657131c5c3ac62200d23252"}, - {file = "protobuf-5.29.3-cp38-cp38-win_amd64.whl", hash = "sha256:b89c115d877892a512f79a8114564fb435943b59067615894c3b13cd3e1fa107"}, - {file = "protobuf-5.29.3-cp39-cp39-win32.whl", hash = "sha256:0eb32bfa5219fc8d4111803e9a690658aa2e6366384fd0851064b963b6d1f2a7"}, - {file = "protobuf-5.29.3-cp39-cp39-win_amd64.whl", hash = "sha256:6ce8cc3389a20693bfde6c6562e03474c40851b44975c9b2bf6df7d8c4f864da"}, - {file = "protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f"}, - {file = "protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620"}, + {file = "protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6"}, + {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799"}, + {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4"}, + {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4"}, + {file = "protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30"}, + {file = "protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87"}, + {file = "protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9"}, + {file = "protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a"}, ] [[package]] name = "psycopg2-binary" -version = "2.9.10" +version = "2.9.12" description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false -python-versions = ">=3.8" -files = [ - {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"}, - {file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"}, - {file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"}, - {file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"}, - {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, - {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10"}, + {file = "psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c"}, ] [[package]] @@ -1892,114 +2671,232 @@ version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] files = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] -[[package]] -name = "pyaml" -version = "25.1.0" -description = "PyYAML-based module to produce a bit more pretty and readable YAML-serialized data" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyaml-25.1.0-py3-none-any.whl", hash = "sha256:f7b40629d2dae88035657c860f539db3525ddd0120a11e0bcb44d47d5968b3bc"}, - {file = "pyaml-25.1.0.tar.gz", hash = "sha256:33a93ac49218f57e020b81e280d2706cea554ac5a76445ac79add760d019c709"}, -] - -[package.dependencies] -PyYAML = "*" - -[package.extras] -anchors = ["unidecode"] - [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.4" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, + {file = "pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b"}, + {file = "pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81"}, ] [[package]] name = "pyasn1-modules" -version = "0.4.1" +version = "0.4.2" description = "A collection of ASN.1-based protocols modules" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, - {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, ] [package.dependencies] -pyasn1 = ">=0.4.6,<0.7.0" +pyasn1 = ">=0.6.1,<0.7.0" [[package]] name = "pycparser" -version = "2.22" +version = "3.0" description = "C parser in Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] +markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", dev = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\""} + +[[package]] +name = "pydantic" +version = "2.13.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pycryptodome" -version = "3.21.0" -description = "Cryptographic library for Python" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -files = [ - {file = "pycryptodome-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dad9bf36eda068e89059d1f07408e397856be9511d7113ea4b586642a429a4fd"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a1752eca64c60852f38bb29e2c86fca30d7672c024128ef5d70cc15868fa10f4"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ba4cc304eac4d4d458f508d4955a88ba25026890e8abff9b60404f76a62c55e"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cb087b8612c8a1a14cf37dd754685be9a8d9869bed2ffaaceb04850a8aeef7e"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:26412b21df30b2861424a6c6d5b1d8ca8107612a4cfa4d0183e71c5d200fb34a"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:cc2269ab4bce40b027b49663d61d816903a4bd90ad88cb99ed561aadb3888dd3"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0fa0a05a6a697ccbf2a12cec3d6d2650b50881899b845fac6e87416f8cb7e87d"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6cce52e196a5f1d6797ff7946cdff2038d3b5f0aba4a43cb6bf46b575fd1b5bb"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:a915597ffccabe902e7090e199a7bf7a381c5506a747d5e9d27ba55197a2c568"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e74c522d630766b03a836c15bff77cb657c5fdf098abf8b1ada2aebc7d0819"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:a3804675283f4764a02db05f5191eb8fec2bb6ca34d466167fc78a5f05bbe6b3"}, - {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4"}, - {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b"}, - {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e"}, - {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8"}, - {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1"}, - {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a"}, - {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2"}, - {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93"}, - {file = "pycryptodome-3.21.0-cp36-abi3-win32.whl", hash = "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764"}, - {file = "pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53"}, - {file = "pycryptodome-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:2cb635b67011bc147c257e61ce864879ffe6d03342dc74b6045059dfbdedafca"}, - {file = "pycryptodome-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:4c26a2f0dc15f81ea3afa3b0c87b87e501f235d332b7f27e2225ecb80c0b1cdd"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:590ef0898a4b0a15485b05210b4a1c9de8806d3ad3d47f74ab1dc07c67a6827f"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35e442630bc4bc2e1878482d6f59ea22e280d7121d7adeaedba58c23ab6386b"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff99f952db3db2fbe98a0b355175f93ec334ba3d01bbde25ad3a5a33abc02b58"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8acd7d34af70ee63f9a849f957558e49a98f8f1634f86a59d2be62bb8e93f71c"}, - {file = "pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.dependencies] @@ -2007,92 +2904,117 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] name = "pynacl" -version = "1.5.0" +version = "1.6.2" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = false -python-versions = ">=3.6" -files = [ - {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, - {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, -] - -[package.dependencies] -cffi = ">=1.4.1" +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} [package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] [[package]] name = "pyparsing" -version = "3.2.1" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" +version = "3.3.2" +description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1"}, - {file = "pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a"}, + {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, + {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, ] [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + [[package]] name = "pytest" -version = "6.2.5" +version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=19.2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -toml = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" -version = "2.12.1" +version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, - {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] -coverage = ">=5.2.1" -pytest = ">=4.6" -toml = "*" +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-flask" @@ -2100,6 +3022,7 @@ version = "1.3.0" description = "A set of py.test fixtures to test Flask applications." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e"}, {file = "pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253"}, @@ -2119,6 +3042,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2127,157 +3051,315 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-discovery" +version = "1.4.4" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe"}, + {file = "python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3"}, +] + +[package.dependencies] +filelock = ">=3.15.4" +platformdirs = ">=4.3.6,<5" + +[package.extras] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.4)", "towncrier (>=25.8)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + [[package]] name = "python-jose" -version = "2.0.2" +version = "3.5.0" description = "JOSE implementation in Python" optional = false -python-versions = "*" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "python-jose-2.0.2.tar.gz", hash = "sha256:391f860dbe274223d73dd87de25e4117bf09e8fe5f93a417663b1f2d7b591165"}, - {file = "python_jose-2.0.2-py2.py3-none-any.whl", hash = "sha256:3b35cdb0e55a88581ff6d3f12de753aa459e940b50fe7ca5aa25149bc94cb37b"}, + {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, + {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, ] [package.dependencies] -ecdsa = "<1.0" -future = "<1.0" -pycryptodome = ">=3.3.1,<4.0.0" -six = "<2.0" +ecdsa = "!=0.15" +pyasn1 = ">=0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" [package.extras] -cryptography = ["cryptography"] +cryptography = ["cryptography (>=3.4.0)"] pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] [[package]] name = "pytz" -version = "2025.1" +version = "2026.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["main"] files = [ - {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, - {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, + {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, + {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, ] [[package]] -name = "pywin32" -version = "308" -description = "Python for Window Extensions" +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false -python-versions = "*" +python-versions = ">=3.6" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ - {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, - {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, - {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"}, - {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"}, - {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"}, - {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"}, - {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"}, - {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"}, - {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"}, - {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"}, - {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"}, - {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"}, - {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"}, - {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"}, - {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"}, - {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"}, - {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, - {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, ] [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf"}, + {file = "rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e"}, ] +[package.extras] +all = ["numpy"] + [[package]] name = "requests" -version = "2.32.3" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "requirements-parser" +version = "0.13.1" +description = "This is a small Python module for parsing Pip requirement files." +optional = false +python-versions = "<4.0,>=3.8" +groups = ["dev"] +files = [ + {file = "requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0"}, + {file = "requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3"}, +] + +[package.dependencies] +packaging = ">=23.2" [[package]] name = "responses" -version = "0.25.6" +version = "0.26.2" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "responses-0.25.6-py3-none-any.whl", hash = "sha256:9cac8f21e1193bb150ec557875377e41ed56248aed94e4567ed644db564bacf1"}, - {file = "responses-0.25.6.tar.gz", hash = "sha256:eae7ce61a9603004e76c05691e7c389e59652d91e94b419623c12bbfb8e331d8"}, + {file = "responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364"}, + {file = "responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8"}, ] [package.dependencies] @@ -2286,7 +3368,7 @@ requests = ">=2.30.0,<3.0" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "retry" @@ -2294,6 +3376,7 @@ version = "0.9.2" description = "Easy to use retry decorator." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606"}, {file = "retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4"}, @@ -2303,15 +3386,35 @@ files = [ decorator = ">=3.4.2" py = ">=1.4.26,<2.0.0" +[[package]] +name = "rich" +version = "15.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rsa" -version = "4.9" +version = "4.9.1" description = "Pure-Python RSA implementation" optional = false -python-versions = ">=3.6,<4" +python-versions = "<4,>=3.6" +groups = ["main"] files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, ] [package.dependencies] @@ -2319,40 +3422,50 @@ pyasn1 = ">=0.1.3" [[package]] name = "s3transfer" -version = "0.11.2" +version = "0.19.0" description = "An Amazon S3 Transfer Manager" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "s3transfer-0.11.2-py3-none-any.whl", hash = "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc"}, - {file = "s3transfer-0.11.2.tar.gz", hash = "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f"}, + {file = "s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262"}, + {file = "s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685"}, ] [package.dependencies] -botocore = ">=1.36.0,<2.0a.0" +botocore = ">=1.37.4,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.36.0,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] -name = "setuptools" -version = "75.8.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +name = "secretstorage" +version = "3.5.0" +description = "Python bindings to FreeDesktop.org Secret Service API" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" +groups = ["dev"] +markers = "sys_platform == \"linux\"" files = [ - {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, - {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, + {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, + {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, ] -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] -core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] [[package]] name = "six" @@ -2360,6 +3473,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2371,6 +3485,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2378,113 +3493,286 @@ files = [ [[package]] name = "sqlalchemy" -version = "1.4.54" +version = "2.0.51" description = "Database Abstraction Library" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -files = [ - {file = "sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a"}, -] - -[package.dependencies] -greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0"}, + {file = "sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652"}, + {file = "sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d"}, + {file = "sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84"}, + {file = "sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080"}, + {file = "sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1"}, + {file = "sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86"}, + {file = "sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5"}, + {file = "sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b"}, + {file = "sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"}, + {file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7"}, + {file = "sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-win32.whl", hash = "sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8"}, + {file = "sqlalchemy-2.0.51-cp38-cp38-win_amd64.whl", hash = "sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-win32.whl", hash = "sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0"}, + {file = "sqlalchemy-2.0.51-cp39-cp39-win_amd64.whl", hash = "sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7"}, + {file = "sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5"}, + {file = "sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" [package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] -aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (!=0.4.17)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)", "mariadb (>=1.0.1,!=1.1.2)"] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] mssql = ["pyodbc"] -mssql-pymssql = ["pymssql", "pymssql"] -mssql-pyodbc = ["pyodbc", "pyodbc"] -mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"] -mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"] -mysql-connector = ["mysql-connector-python", "mysql-connector-python"] -oracle = ["cx_oracle (>=7)", "cx_oracle (>=7,<8)"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "asyncpg", "greenlet (!=0.4.17)", "greenlet (!=0.4.17)"] -postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)", "pg8000 (>=1.16.6,!=1.29.0)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] postgresql-psycopg2binary = ["psycopg2-binary"] postgresql-psycopg2cffi = ["psycopg2cffi"] -pymysql = ["pymysql", "pymysql (<1)"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] [[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" +name = "starlette" +version = "1.3.1" +description = "The little ASGI library that shines." optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, + {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, + {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version < \"3.15.0\"" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "tomlkit" +version = "0.15.0" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738"}, + {file = "tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3"}, ] [[package]] -name = "uritemplate" -version = "4.1.1" -description = "Implementation of RFC 6570 URI Templates" +name = "trove-classifiers" +version = "2026.6.1.19" +description = "Canonical source for classifiers on PyPI (pypi.org)." optional = false -python-versions = ">=3.6" +python-versions = "*" +groups = ["dev"] files = [ - {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, - {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, + {file = "trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3"}, + {file = "trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745"}, ] [[package]] -name = "urllib3" -version = "1.26.20" -description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "typer" +version = "0.26.8" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, - {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, + {file = "typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c"}, + {file = "typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e"}, ] -[package.extras] -brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[package.dependencies] +annotated-doc = ">=0.0.2" +colorama = {version = "*", markers = "platform_system == \"Windows\""} +rich = ">=13.8.0" +shellingham = ">=1.3.0" + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "uritemplate" +version = "4.2.0" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, + {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, +] [[package]] name = "urllib3" -version = "2.3.0" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, - {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "userdatamodel" -version = "3.0.1" +version = "3.1.0" description = "PDC data model for user management" optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4,>=3.13" +groups = ["main"] files = [ - {file = "userdatamodel-3.0.1-py3-none-any.whl", hash = "sha256:28e34acf1998e81b32b8e35ac61d950cc48c88ef3545ac45939be0116f20d3d5"}, - {file = "userdatamodel-3.0.1.tar.gz", hash = "sha256:775fa5d11a32c44d11322945a817ae265b7787c90ea05fefcd1742839cca111a"}, + {file = "userdatamodel-3.1.0-py3-none-any.whl", hash = "sha256:cd24b546967e5bb3ed8dac8d912f3dbf3eea3446021273c320728413a85ec2fd"}, + {file = "userdatamodel-3.1.0.tar.gz", hash = "sha256:f57366339112b712eea2dda4d49ee59f995b193001b6e017bb947788b8a2fca0"}, ] [package.dependencies] @@ -2492,158 +3780,139 @@ cdislogging = ">=1,<2" sqlalchemy = ">=1.3.0" [[package]] -name = "werkzeug" -version = "3.1.3" -description = "The comprehensive WSGI web application library." +name = "virtualenv" +version = "21.6.0" +description = "Virtual Python Environment builder" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, - {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, + {file = "virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1"}, + {file = "virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734"}, ] [package.dependencies] -MarkupSafe = ">=2.1.1" - -[package.extras] -watchdog = ["watchdog (>=2.3)"] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +python-discovery = ">=1.4.2" [[package]] -name = "wrapt" -version = "1.17.2" -description = "Module for decorators, wrappers and monkey patching." +name = "werkzeug" +version = "3.1.8" +description = "The comprehensive WSGI web application library." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62"}, - {file = "wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563"}, - {file = "wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72"}, - {file = "wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317"}, - {file = "wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9"}, - {file = "wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9"}, - {file = "wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504"}, - {file = "wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a"}, - {file = "wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f"}, - {file = "wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555"}, - {file = "wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c803c401ea1c1c18de70a06a6f79fcc9c5acfc79133e9869e730ad7f8ad8ef9"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f917c1180fdb8623c2b75a99192f4025e412597c50b2ac870f156de8fb101119"}, - {file = "wrapt-1.17.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ecc840861360ba9d176d413a5489b9a0aff6d6303d7e733e2c4623cfa26904a6"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb87745b2e6dc56361bfde481d5a378dc314b252a98d7dd19a651a3fa58f24a9"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58455b79ec2661c3600e65c0a716955adc2410f7383755d537584b0de41b1d8a"}, - {file = "wrapt-1.17.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e42a40a5e164cbfdb7b386c966a588b1047558a990981ace551ed7e12ca9c2"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:91bd7d1773e64019f9288b7a5101f3ae50d3d8e6b1de7edee9c2ccc1d32f0c0a"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:bb90fb8bda722a1b9d48ac1e6c38f923ea757b3baf8ebd0c82e09c5c1a0e7a04"}, - {file = "wrapt-1.17.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:08e7ce672e35efa54c5024936e559469436f8b8096253404faeb54d2a878416f"}, - {file = "wrapt-1.17.2-cp38-cp38-win32.whl", hash = "sha256:410a92fefd2e0e10d26210e1dfb4a876ddaf8439ef60d6434f21ef8d87efc5b7"}, - {file = "wrapt-1.17.2-cp38-cp38-win_amd64.whl", hash = "sha256:95c658736ec15602da0ed73f312d410117723914a5c91a14ee4cdd72f1d790b3"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061"}, - {file = "wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f"}, - {file = "wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8"}, - {file = "wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9"}, - {file = "wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb"}, - {file = "wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb"}, - {file = "wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8"}, - {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, + {file = "werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"}, + {file = "werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44"}, ] +[package.dependencies] +markupsafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [[package]] name = "wtforms" -version = "3.2.1" +version = "3.2.2" description = "Form validation and rendering for Python web development." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "wtforms-3.2.1-py3-none-any.whl", hash = "sha256:583bad77ba1dd7286463f21e11aa3043ca4869d03575921d1a1698d0715e0fd4"}, - {file = "wtforms-3.2.1.tar.gz", hash = "sha256:df3e6b70f3192e92623128123ec8dca3067df9cfadd43d59681e210cfb8d4682"}, + {file = "wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56"}, + {file = "wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93"}, ] [package.dependencies] -markupsafe = "*" +markupsafe = ">=1.1.1" [package.extras] -email = ["email-validator"] +email = ["email-validator (>=2.0)"] [[package]] -name = "xmltodict" -version = "0.14.2" -description = "Makes working with XML feel like you are working with JSON" +name = "xattr" +version = "1.3.0" +description = "Python wrapper for extended filesystem attributes" optional = false -python-versions = ">=3.6" -files = [ - {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, - {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, -] +python-versions = ">=3.9" +groups = ["dev"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "xattr-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a80c4617e08670cdc3ba71f1dbb275c1627744c5c3641280879cb3bc95a07237"}, + {file = "xattr-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51cdaa359f5cd2861178ae01ea3647b56dbdfd98e724a8aa3c04f77123b78217"}, + {file = "xattr-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2fea070768d7d2d25797817bea93bf0a6fda6449e88cfee8bb3d75de9ed11c7b"}, + {file = "xattr-1.3.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69bca34be2d7a928389aff4e32f27857e1c62d04c91ec7c1519b1636870bd58f"}, + {file = "xattr-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05f8e068409742d246babba60cff8310b2c577745491f498b08bf068e0c867a3"}, + {file = "xattr-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bbd06987102bc11f5cbd08b15d1029832b862cf5bc61780573fc0828812f01ca"}, + {file = "xattr-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b8589744116d2c37928b771c50383cb281675cd6dcfd740abfab6883e3d4af85"}, + {file = "xattr-1.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:331a51bf8f20c27822f44054b0d760588462d3ed472d5e52ba135cf0bea510e8"}, + {file = "xattr-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:196360f068b74fa0132a8c6001ce1333f095364b8f43b6fd8cdaf2f18741ef89"}, + {file = "xattr-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:405d2e4911d37f2b9400fa501acd920fe0c97fe2b2ec252cb23df4b59c000811"}, + {file = "xattr-1.3.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ae3a66ae1effd40994f64defeeaa97da369406485e60bfb421f2d781be3b75d"}, + {file = "xattr-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:69cd3bfe779f7ba87abe6473fdfa428460cf9e78aeb7e390cfd737b784edf1b5"}, + {file = "xattr-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c5742ca61761a99ae0c522f90a39d5fb8139280f27b254e3128482296d1df2db"}, + {file = "xattr-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a04ada131e9bdfd32db3ab1efa9f852646f4f7c9d6fde0596c3825c67161be3"}, + {file = "xattr-1.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd4e63614722d183e81842cb237fd1cc978d43384166f9fe22368bfcb187ebe5"}, + {file = "xattr-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:995843ef374af73e3370b0c107319611f3cdcdb6d151d629449efecad36be4c4"}, + {file = "xattr-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa23a25220e29d956cedf75746e3df6cc824cc1553326d6516479967c540e386"}, + {file = "xattr-1.3.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b4345387087fffcd28f709eb45aae113d911e1a1f4f0f70d46b43ba81e69ccdd"}, + {file = "xattr-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe92bb05eb849ab468fe13e942be0f8d7123f15d074f3aba5223fad0c4b484de"}, + {file = "xattr-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c42ef5bdac3febbe28d3db14d3a8a159d84ba5daca2b13deae6f9f1fc0d4092"}, + {file = "xattr-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2aaa5d66af6523332189108f34e966ca120ff816dfa077ca34b31e6263f8a236"}, + {file = "xattr-1.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:937d8c91f6f372788aff8cc0984c4be3f0928584839aaa15ff1c95d64562071c"}, + {file = "xattr-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e470b3f15e9c3e263662506ff26e73b3027e1c9beac2cbe9ab89cad9c70c0495"}, + {file = "xattr-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f2238b2a973fcbf5fefa1137db97c296d27f4721f7b7243a1fac51514565e9ec"}, + {file = "xattr-1.3.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f32bb00395371f4a3bed87080ae315b19171ba114e8a5aa403a2c8508998ce78"}, + {file = "xattr-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:78df56bfe3dd4912548561ed880225437d6d49ef082fe6ccd45670810fa53cfe"}, + {file = "xattr-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:864c34c14728f21c3ef89a9f276d75ae5e31dd34f48064e0d37e4bf0f671fc6e"}, + {file = "xattr-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1fd185b3f01121bd172c98b943f9341ca3b9ea6c6d3eb7fe7074723614d959ff"}, + {file = "xattr-1.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:630c85020282bd0bcb72c3d031491c4e91d7f29bb4c094ebdfb9db51375c5b07"}, + {file = "xattr-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:95f1e14a4d9ca160b4b78c527bf2bac6addbeb0fd9882c405fc0b5e3073a8752"}, + {file = "xattr-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:88557c0769f64b1d014aada916c9630cfefa38b0be6c247eae20740d2d8f7b47"}, + {file = "xattr-1.3.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6992eb5da32c0a1375a9eeacfab15c66eebc8bd34be63ebd1eae80cc2f8bf03"}, + {file = "xattr-1.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da5954424099ca9d402933eaf6112c29ddde26e6da59b32f0bf5a4e35eec0b28"}, + {file = "xattr-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:726b4d0b66724759132cacdcd84a5b19e00b0cdf704f4c2cf96d0c08dc5eaeb5"}, + {file = "xattr-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:928c49ceb0c70fc04732e46fa236d7c8281bfc3db1b40875e5f548bb14d2668c"}, + {file = "xattr-1.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f3bef26fd2d5d7b17488f4cc4424a69894c5a8ed71dd5f657fbbf69f77f68a51"}, + {file = "xattr-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:64f1fb511f8463851e0d97294eb0e0fde54b059150da90582327fb43baa1bb92"}, + {file = "xattr-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1e6c216927b16fd4b72df655d5124b69b2a406cb3132b5231179021182f0f0d1"}, + {file = "xattr-1.3.0-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c0d9ab346cdd20539afddf2f9e123efee0fe8d54254d9fc580b4e2b4e6d77351"}, + {file = "xattr-1.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2c5e7ba0e893042deef4e8638db7a497680f587ac7bd6d68925f29af633dfa6b"}, + {file = "xattr-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e0dabb39596d8d7b83d6f9f7fa30be68cf15bfb135cb633e2aad9887d308a32"}, + {file = "xattr-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5eeaa944516b7507ec51456751334b4880e421de169bbd067c4f32242670d606"}, + {file = "xattr-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03712f84e056dcd23c36db03a1f45417a26eef2c73d47c2c7d425bf932601587"}, + {file = "xattr-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45f85233a51c71659969ce364abe6bd0c9048a302b7fcdbea675dc63071e47ff"}, + {file = "xattr-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fefcf20d040e79ec3bf6e7dc0fdcfd972f70f740d5a69ed67b20c699bb9cea"}, + {file = "xattr-1.3.0-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9e68a02adde8a5f8675be5e8edc837eb6fdbe214a6ee089956fae11d633c0e51"}, + {file = "xattr-1.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:50c12d92f5214b0416cf4b4fafcd02dca5434166657553b74b8ba6abc66cb4b4"}, + {file = "xattr-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c69999ed70411ac2859f1f8c918eb48a6fd2a71ef41dc03ee846f69e2200bb2"}, + {file = "xattr-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3cf29da6840eb94b881eab692ae83b1421c9c15a0cd92ffb97a0696ceac8cac"}, + {file = "xattr-1.3.0.tar.gz", hash = "sha256:30439fabd7de0787b27e9a6e1d569c5959854cb322f64ce7380fedbfa5035036"}, +] + +[package.dependencies] +cffi = ">=1.16.0" + +[package.extras] +test = ["pytest"] [[package]] -name = "zipp" -version = "3.21.0" -description = "Backport of pathlib-compatible object wrapper for zip files" +name = "xmltodict" +version = "0.15.1" +description = "Makes working with XML feel like you are working with JSON" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, + {file = "xmltodict-0.15.1-py2.py3-none-any.whl", hash = "sha256:dcd84b52f30a15be5ac4c9099a0cb234df8758624b035411e329c5c1e7a49089"}, + {file = "xmltodict-0.15.1.tar.gz", hash = "sha256:3d8d49127f3ce6979d40a36dbcad96f8bab106d232d24b49efdd4bd21716983c"}, ] -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] -lock-version = "2.0" -python-versions = ">=3.9,<4.0.0" -content-hash = "ea994fbb0361bd08c0fa17f0dcd0105395a38bcf52d0f73b2c9d6dff76f93765" +lock-version = "2.1" +python-versions = ">=3.13,<4.0.0" +content-hash = "c505e5c2b3ee78d9daed7be06e9622c76e904afe1523a1d09f4f54f47f56be52" diff --git a/pyproject.toml b/pyproject.toml index a5dc44600..e61c1a2cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fence" -version = "11.1.0" +version = "13.3.0" description = "Gen3 AuthN/AuthZ OIDC Service" authors = ["CTDS UChicago "] license = "Apache-2.0" @@ -11,63 +11,81 @@ include = [ ] [tool.poetry.dependencies] -python = ">=3.9,<4.0.0" -alembic = "^1.7.7" -authlib = "<1.3.2" # let authutils decide which version to use, but 1.3.2 will cause some unit tests to fail -authutils = "^6.2.3" -bcrypt = "^3.1.4" +python = ">=3.13,<4.0.0" +alembic = ">=1.7.7" +authlib = ">=1.6.10,<=1.6.12" +authutils = ">=8.0.0" +bcrypt = ">=3.1.4" boto3 = "*" botocore = "*" -cached_property = "^1.5.1" -cdiserrors = "<2.0.0" -cdislogging = "^1.0.0" -cdispyutils = "^2.0.1" +cached_property = ">=1.5.1" +cdiserrors = "<3.0.0" +cdislogging = ">=1.2.0" +cdispyutils = ">=2.0.1" +defusedxml = "<1" flask = ">=3.0.0" -cryptography = ">=42.0.5" -flask-cors = "<6" +cryptography = ">=44.0.1" +flask-cors = "<7" flask-restful = ">=0.3.8" -email_validator = "^1.1.1" -gen3authz = "^1.5.1" -gen3cirrus = "^3.1.0" -gen3config = ">=1.1.0" -gen3users = "^1.0.2" -idna = "^3.7" -markdown = "^3.1.1" - -# this markupsafe pin is due to an error somewhere between Python 3.9.6 and 3.9.16 -markupsafe = "^2.0.1" - +email_validator = ">=1.1.1" +gen3authz = ">=3.1.0" +gen3cirrus = ">=4.0.1" +gen3config = "<3" +gen3users = ">=1.0.2" +markdown = ">=3.1.1" paramiko = ">=2.6.0" prometheus-client = "<1" psycopg2-binary = "<3" +pydantic = ">=2.12" gunicorn = ">=22.0.0" -PyJWT = "^2.4.0" -python_dateutil = "^2.6.1" -python-jose = "^2.0.2" -pyyaml = "^6.0.1" -requests = ">=2.18.0" -retry = "^0.9.2" -sqlalchemy = "^1.3.3" -userdatamodel = ">=3.0.1" +PyJWT = ">=2.4.0" +python_dateutil = ">=2.9.0" +python-jose = ">=3.5.0" +pyyaml = ">=6.0.1" +requests = ">=2.32.4,<3" +retry = ">=0.9.2" +sqlalchemy = ">=1.3.3" +userdatamodel = ">=3,<4" werkzeug = ">=3.0.0" -cachelib = "^0.2.0" -azure-storage-blob = "^12.6.0" -Flask-WTF = "^1.0.0" -boto = "*" -# NOTE: -# for testing with updated libaries as git repos: +cachelib = ">=0.2.0" +azure-storage-blob = ">=12.6.0" +# azure-storage-blob sub-dependency azure-core requires a security bump but azure-storage-blob hasn't included it yet. +# Force it for now, try removing this later. +# TODO: Remove this explicit sub-dep when azure-storage-blob is updated beyond 12.28.0 and +# has azure-core >= 1.38.0 +azure-core = ">=1.38.0" + +Flask-WTF = ">=1.0.0" +itsdangerous = '*' # Use Flask-WTF's itsdangerous +# NOTE: for testing with updated libaries as git repos: # foobar = {git = "https://github.com/uc-cdis/some-repo", rev = "feat/test"} +wtforms = ">=3.2.1" +backoff = ">=2" +pytz = ">=2025.2" +mock = ">=2.0.0" +httpx = ">=0.28.1" +urllib3 = ">=2.6.3, <3" [tool.poetry.group.dev.dependencies] -addict = "^2.2.1" +addict = ">=2.2.1" cdisutilstest = {git = "https://github.com/uc-cdis/cdisutils-test", tag = "2.0.0"} -codacy-coverage = "^1.3.11" -coveralls = "^2.1.1" -mock = "^2.0.0" -moto = "^1.1.24" -pytest = "^6.2.5" -pytest-cov = "^2.5.1" +codacy-coverage = ">=1.3.11" +coveralls = ">=2.1.1" +moto = ">=1.1.24" +pytest = ">=6.2.5" +pytest-cov = ">=2.5.1" pytest-flask = ">=1.3.0" +poetry = ">=2.2.1" +deptry = ">=0.23.1" +responses = ">=0.25.8" + +[tool.deptry.per_rule_ignores] +DEP002 = [ + "gunicorn", # not imported but used in Dockerfile + "psycopg2-binary", # not imported but used by sqlalchemy + "urllib3", # Needed to be pinned to a version >=2.6.0 + +] [tool.poetry.scripts] fence-create = 'bin.fence_create:main' diff --git a/run.py b/run.py index 913803c78..696eb7556 100644 --- a/run.py +++ b/run.py @@ -1,7 +1,10 @@ import argparse +import os from alembic.config import main as alembic_main +os.environ["PROMETHEUS_MULTIPROC_DIR"] = "/var/tmp/uwsgi_flask_metrics/" + from fence import app, app_init, config parser = argparse.ArgumentParser() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..67cf88533 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,10 @@ +Set environment variable PROMETHEUS_MULTIPROC_DIR to an arbitrary directory on your machine. + +Example: PROMETHEUS_MULTIPROC_DIR=/Users/someone/.gen3/fence + +Create a postgresql database to run the fence tests + +1. docker pull postgres +1. docker run --name fence_test_db -p 5432:5432 -it -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=fence_test_tmp -d postgres + +At the time of writing, docker pull postgres grabbed version 16.2. diff --git a/tests/admin/test_admin_users.py b/tests/admin/test_admin_users.py index 159f34294..1db6df9ce 100644 --- a/tests/admin/test_admin_users.py +++ b/tests/admin/test_admin_users.py @@ -126,6 +126,47 @@ def test_soft_delete_user_not_found(db_session, awg_users): adm.soft_delete_user(db_session, "non_existing_user") +def test_reactivate_soft_deleted_user(db_session, awg_users): + """ + Tests adm.reactivate_soft_deleted_user() by deactivating the user, + querying it again and asserting it is inactive, and then checking + it became active after it was reactivated. + """ + username = "awg_user" + adm.soft_delete_user(db_session, username) + user = db_session.query(User).filter(User.username == username).first() + assert user != None + assert user.username == username + assert user.active == False + adm.reactivate_soft_deleted_user(db_session, username) + user = db_session.query(User).filter(User.username == username).first() + assert user != None + assert user.username == username + # reactivated user should have "active" explicitly set to True now: + assert user.active == True + + +def test_reactivate_soft_deleted_user_when_user_state_wrong(db_session, awg_users): + """ + Check that adm.reactivate_soft_deleted_user() fails when + user is not in soft-deleted state. + """ + username = "awg_user" + user = db_session.query(User).filter(User.username == username).first() + assert user.active == True + with pytest.raises(UserError, match="Error: user is already active"): + adm.reactivate_soft_deleted_user(db_session, username) + + +def test_reactivate_soft_deleted_user_when_user_not_found(db_session, awg_users): + """ + Check that adm.reactivate_soft_deleted_user() fails with NotFound + when called for a username that is not found in db. + """ + with pytest.raises(NotFound, match="user non_existing_user not found"): + adm.reactivate_soft_deleted_user(db_session, "non_existing_user") + + def test_update_user_without_conflict(db_session, awg_users, oauth_client): user = db_session.query(User).filter(User.username == "awg_user").first() assert user != None diff --git a/tests/admin/test_admin_users_endpoints.py b/tests/admin/test_admin_users_endpoints.py index a598c7b65..79aac2a5f 100644 --- a/tests/admin/test_admin_users_endpoints.py +++ b/tests/admin/test_admin_users_endpoints.py @@ -3,6 +3,8 @@ import json import jwt import pytest +from fence.resources.audit.utils import logger as utils_logger +import logging from unittest.mock import Mock, patch @@ -60,6 +62,18 @@ def encoded_admin_jwt(kid, rsa_private_key): return jwt.encode(claims, key=rsa_private_key, headers=headers, algorithm="RS256") +@pytest.fixture(scope="function") +def encoded_client_jwt(kid, rsa_private_key): + """ + Use this fixture to simulate client connection and its jwt. + """ + headers = {"kid": kid} + claims = utils.default_claims() + del claims["sub"] + claims["azp"] = "somevalue" + return jwt.encode(claims, key=rsa_private_key, headers=headers, algorithm="RS256") + + # Dictionary for all these random magic numbers that the delete user # tests/fixtures are using userd_dict = { @@ -172,6 +186,25 @@ def load_google_specific_user_data(db_session, test_user_d): db_session.commit() +@pytest.fixture +def log_capture(): + + log_records = [] + + class TempLogHandler(logging.Handler): + def emit(self, record): + log_records.append(record) + + tmp_handler = TempLogHandler() + utils_logger.disabled = False + utils_logger.addHandler(tmp_handler) + + yield log_records + + # Clean up: + utils_logger.removeHandler(tmp_handler) + + # GET /users/ tests @@ -199,16 +232,24 @@ def test_get_user_username_no_admin_auth( def test_get_user_long_username( - client, admin_user, encoded_admin_jwt, db_session, test_user_long + client, admin_user, encoded_admin_jwt, db_session, test_user_long, log_capture ): """GET /users/: [get_user]: happy path""" + username = "test_amazing_user_with_an_fancy_but_extremely_long_name" r = client.get( - "/admin/users/test_amazing_user_with_an_fancy_but_extremely_long_name", + f"/admin/users/{username}", headers={"Authorization": "Bearer " + encoded_admin_jwt}, ) assert r.status_code == 200 - assert ( - r.json["username"] == "test_amazing_user_with_an_fancy_but_extremely_long_name" + assert r.json["username"] == username + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = f"INFO - Incoming request: user=admin_user, client=test-client, method=GET, endpoint=/admin/users/{username}, request_url=/admin/users/{username}" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) ) @@ -223,11 +264,14 @@ def test_get_user_username_nonexistent( assert r.status_code == 404 -def test_get_user_username_noauth(client, db_session): +def test_get_user_username_noauth(client, db_session, log_capture): """GET /users/: [get_user] but without authorization""" - # This creates a "test" user, so don't remove db_session fixture + # This creates a "test" user, so don't remove db_session fixture (this will show that 401 is because of noauth and + # not because user doesn't exist in DB): r = client.get("/admin/users/test_a") assert r.status_code == 401 + # also assert that the logs are not recorded if noauth: + assert len(log_capture) == 0 # GET /user tests @@ -265,7 +309,7 @@ def test_get_user_noauth(client, db_session): # POST /user tests -def test_post_user(client, admin_user, encoded_admin_jwt, db_session): +def test_post_user(client, admin_user, encoded_admin_jwt, db_session, log_capture): """POST /user: [create_user]""" r = client.post( "/admin/user", @@ -294,6 +338,47 @@ def test_post_user(client, admin_user, encoded_admin_jwt, db_session): assert new_test_user.is_admin == False assert new_test_user.email == "new_test_user@fake.com" assert new_test_user.active == True + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = "INFO - Incoming request: user=admin_user, client=test-client, method=POST, endpoint=/admin/user, request_url=/admin/user" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) + ) + + +def test_post_user_client_jwt(client, encoded_client_jwt, db_session, log_capture): + """Test if call to an admin endpoint (in this case POST /user: [create_user] ) + with a client jwt (instead of regular jwt) gets the right information logged, + where we expect the username to be "None" and the client name to + be "somevalue" (according to what is in encoded_client_jwt). + """ + r = client.post( + "/admin/user", + headers={ + "Authorization": "Bearer " + encoded_client_jwt, + "Content-Type": "application/json", + }, + data=json.dumps( + { + "username": "new_test_user", + "role": "user", + "email": "new_test_user@fake.com", + } + ), + ) + assert r.status_code == 200 + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = "INFO - Incoming request: user=None, client=somevalue, method=POST, endpoint=/admin/user, request_url=/admin/user" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) + ) def test_post_user_no_fields_defined(client, admin_user, encoded_admin_jwt, db_session): @@ -693,9 +778,11 @@ def assert_google_proxy_group_data_deleted(db_session): def test_soft_delete_user_username( client, + admin_user, encoded_admin_jwt, db_session, load_non_google_user_data, + log_capture, ): """ Test soft-delete user endpoint by checking that the result is an @@ -716,12 +803,23 @@ def test_soft_delete_user_username( user = db_session.query(User).filter_by(username=username).one() assert user.username == username assert user.active == False + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = f"INFO - Incoming request: user=admin_user, client=test-client, method=DELETE, endpoint=/admin/users/{username}/soft, request_url=/admin/users/{username}/soft" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) + ) def test_soft_delete_user_user_not_found( client, + admin_user, encoded_admin_jwt, db_session, + log_capture, ): """ Test soft-delete user endpoint returns error when user is not found. @@ -735,6 +833,98 @@ def test_soft_delete_user_user_not_found( headers={"Authorization": "Bearer " + encoded_admin_jwt}, ) assert r.status_code == 404 + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = f"INFO - Incoming request: user=admin_user, client=test-client, method=DELETE, endpoint=/admin/users/{username}/soft, request_url=/admin/users/{username}/soft" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) + ) + + +def test_reactivate_user_username( + client, + admin_user, + encoded_admin_jwt, + db_session, + load_non_google_user_data, + log_capture, +): + """ + Test reactivate user endpoint by deactivating a user first and then + calling the endpoint and checking that the result is an + activated user. + """ + username = "test_user_d" + user = db_session.query(User).filter_by(username=username).one() + assert user.username == username + assert user.active == True + # call soft-delete to deactivate user first: + r = client.delete( + f"/admin/users/{username}/soft", + headers={"Authorization": "Bearer " + encoded_admin_jwt}, + ) + assert r.status_code == 200 + user = db_session.query(User).filter_by(username=username).one() + assert user.username == username + assert user.active == False + # now reactivate user: + r = client.post( + f"/admin/users/{username}/reactivate", + headers={"Authorization": "Bearer " + encoded_admin_jwt}, + ) + assert r.status_code == 200 + assert r.json["username"] == username + assert r.json["active"] == True + user = db_session.query(User).filter_by(username=username).one() + assert user.username == username + assert user.active == True + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = f"INFO - Incoming request: user=admin_user, client=test-client, method=POST, endpoint=/admin/users/{username}/reactivate, request_url=/admin/users/{username}/reactivate" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) + ) + + +def test_reactivate_user_user_state_wrong( + client, + admin_user, + encoded_admin_jwt, + db_session, + load_non_google_user_data, + log_capture, +): + """ + Test reactivate user endpoint when the user is already active... + it should result in an error. + """ + username = "test_user_d" + # assert user is active: + user = db_session.query(User).filter_by(username=username).one() + assert user.username == username + assert user.active == True + # now try reactivate user - should fail: + r = client.post( + f"/admin/users/{username}/reactivate", + headers={"Authorization": "Bearer " + encoded_admin_jwt}, + ) + assert r.status_code == 400 + assert "Error: user is already active" in r.text + # also assert that the logs were recorded: + assert len(log_capture) >= 1 + # Now check for the specific message: + messages = [f"{r.levelname} - {r.getMessage()}" for r in log_capture] + expected_log_message = f"INFO - Incoming request: user=admin_user, client=test-client, method=POST, endpoint=/admin/users/{username}/reactivate, request_url=/admin/users/{username}/reactivate" + assert expected_log_message in messages, ( + f"\n{expected_log_message} -> not found in INFO logs. Actual messages:\n" + + "\n".join(messages) + ) def test_delete_user_username( diff --git a/tests/ci_commands_script.sh b/tests/ci_commands_script.sh index fef98a668..4731cd73b 100755 --- a/tests/ci_commands_script.sh +++ b/tests/ci_commands_script.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash mkdir -p /var/tmp/uwsgi_flask_metrics/ || true -export PROMETHEUS_MULTIPROC_DIR="/var/tmp/uwsgi_flask_metrics/" -poetry run pytest -vv --cov=fence --cov-report xml tests +export PROMETHEUS_CONFIGURED_FOR_TESTS=1; export PROMETHEUS_MULTIPROC_DIR="/var/tmp/uwsgi_flask_metrics/" + +poetry run pytest -vv --cov=fence --cov=migrations/versions --cov-report xml tests diff --git a/tests/conftest.py b/tests/conftest.py index 9baba01a1..55f109c6f 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import copy import time import flask -from datetime import datetime +from datetime import datetime, UTC import mock import uuid import random @@ -26,6 +26,7 @@ rsa_public_key, rsa_public_key_2, ) +from cdislogging import get_logger from cryptography.fernet import Fernet import bcrypt import jwt @@ -49,9 +50,8 @@ from fence.jwt.validate import validate_jwt import tests -from tests import test_settings from tests import utils -from tests.utils import TEST_RAS_USERNAME, TEST_RAS_SUB +from tests.utils import TEST_RAS_SUB from tests.utils.oauth2.client import OAuth2TestClient from tests.storageclient.storage_client_mock import get_client @@ -60,23 +60,34 @@ os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "true" -# all the IDPs we want to test. -# any newly added custom OIDC IDP should be added here. -# generic OIDC IDPs should start with "generic" so the tests work. -LOGIN_IDPS = [ - "fence", - "google", - "shib", - "orcid", - "synapse", - "microsoft", - "okta", - "cognito", - "ras", - "cilogon", - "generic1", - "generic2", -] +logger = get_logger(__name__) + + +# Python 3.13+ turns async methods on spec'ed mocks into AsyncMocks. +# Our tests expect normal MagicMocks instead, so we override `_get_child_mock` +# to always return MagicMocks and avoid automatic AsyncMock creation. +class NoAsyncMagicMock(MagicMock): + def _get_child_mock(self, **kwargs): + return MagicMock(**kwargs) + + +# some tests run on all the IdPs for which a login blueprint exists +def all_available_idps(): + idps = ["generic_with_discovery_url"] # to test the generic implementation + subdir = "blueprints/login" + current_dir = os.path.dirname(os.path.realpath(__file__)) + path = os.path.join(current_dir, "../fence", subdir) + assert os.path.exists(path) + for _, _, files in os.walk(path): + for f in files: + if f.startswith("__") or f in ["base.py", "redirect.py", "utils.py"]: + continue + idp = f.split(".py")[0].split("_login")[0] + if idp == "shib": + idp = "shibboleth" # see `get_idp_route_name` function + idps.append(idp) + break # not getting into subdirectories + return idps def mock_get_bucket_location(self, bucket, config): @@ -245,7 +256,7 @@ def get_container_properties(self): """ return { "name": self.container_name, - "last_modified": datetime.utcnow(), + "last_modified": datetime.now(UTC), "public_access": None, } @@ -395,18 +406,33 @@ def do_patch(urls_to_responses=None): urls_to_responses = urls_to_responses or {} defaults = { "arborist/health": {"GET": ("", 200)}, - "arborist/auth/mapping": {"POST": ({}, "200")}, + "arborist/user/admin_user": {"GET": ("", 200), "DELETE": ("", 204)}, + "arborist/auth/mapping": {"POST": ({}, 200)}, + "arborist/group": { + "GET": ( + {"groups": [{"name": "data_uploaders", "users": ["test_user"]}]}, + 200, + ) + }, } - defaults.update(urls_to_responses) - urls_to_responses = defaults + # the provided `urls_to_responses` override the defaults + for url in defaults.keys(): + if url in urls_to_responses: + urls_to_responses[url] = defaults[url] | urls_to_responses[url] + else: + urls_to_responses[url] = defaults[url] def response_for(method, url, *args, **kwargs): method = method.upper() mocked_response = MagicMock(requests.Response) if url not in urls_to_responses: + logger.debug(f"[mock_arborist_requests] URL '{url}' not configured") mocked_response.status_code = 404 mocked_response.text = "NOT FOUND" elif method not in urls_to_responses[url]: + logger.debug( + f"[mock_arborist_requests] Method '{method}' not configured for URL '{url}'" + ) mocked_response.status_code = 405 mocked_response.text = "METHOD NOT ALLOWED" else: @@ -448,7 +474,6 @@ def app(kid, rsa_private_key, rsa_public_key): ] app_init( fence.app, - test_settings, root_dir=root_dir, config_path=os.path.join(root_dir, "test-fence-config.yaml"), ) @@ -479,6 +504,33 @@ def app(kid, rsa_private_key, rsa_public_key): mocker.unmock_functions() +@pytest.fixture +def mock_app(): + return MagicMock() + + +@pytest.fixture +def mock_user(): + return MagicMock() + + +@pytest.fixture +def mock_db_session(): + """Mock the database session.""" + db_session = MagicMock() + return db_session + + +@pytest.fixture +def expired_mock_user(): + """Mock a user object with upstream refresh tokens.""" + user = MagicMock() + user.upstream_refresh_tokens = [ + MagicMock(refresh_token="expired_token", expires=0), # Expired token + ] + return user + + @pytest.fixture(scope="function") def auth_client(request): """ @@ -544,7 +596,7 @@ def drop_all(): connection.begin() for table in reversed(models.Base.metadata.sorted_tables): # Delete table only if it exists - if app.db.engine.dialect.has_table(connection, table): + if app.db.engine.dialect.has_table(connection, table.name): connection.execute(table.delete()) connection.close() @@ -1292,7 +1344,7 @@ def do_patch(session): @pytest.fixture(scope="function") -def oauth_client(app, db_session, oauth_user, get_all_shib_idps_patcher): +def oauth_client(app, db_session, oauth_user, get_all_upstream_idps_data_patcher): """ Create a confidential OAuth2 client and add it to the database along with a test user for the client. @@ -1369,7 +1421,9 @@ def oauth_client_B(app, request, db_session): @pytest.fixture(scope="function") -def oauth_client_public(app, db_session, oauth_user, get_all_shib_idps_patcher): +def oauth_client_public( + app, db_session, oauth_user, get_all_upstream_idps_data_patcher +): """ Create a public OAuth2 client. """ @@ -1394,7 +1448,9 @@ def oauth_client_public(app, db_session, oauth_user, get_all_shib_idps_patcher): @pytest.fixture(scope="function") -def oauth_client_with_client_credentials(db_session, get_all_shib_idps_patcher): +def oauth_client_with_client_credentials( + db_session, get_all_upstream_idps_data_patcher +): """ Create a confidential OAuth2 client and add it to the database along with a test user for the client. @@ -1439,7 +1495,9 @@ def oauth_test_client_B(client, oauth_client_B): @pytest.fixture(scope="function") -def oauth_test_client_public(client, oauth_client_public, get_all_shib_idps_patcher): +def oauth_test_client_public( + client, oauth_client_public, get_all_upstream_idps_data_patcher +): return OAuth2TestClient(client, oauth_client_public, confidential=False) @@ -1744,35 +1802,48 @@ def restore_config(): @pytest.fixture(scope="function") -def get_all_shib_idps_patcher(): - """ - Don't make real requests to the list of InCommon IDPs exposed - by login.bionimbus - """ - mock = MagicMock() - mock.return_value = [ - { - "idp": "some-incommon-entity-id", - "name": "Some InCommon Provider", - }, - { - "idp": "urn:mace:incommon:nih.gov", - "name": "National Institutes of Health (NIH)", - }, - { - "idp": "urn:mace:incommon:uchicago.edu", - "name": "University of Chicago", - }, - ] - get_all_shib_idps_patch = patch( - "fence.blueprints.login.get_all_shib_idps", - mock, +def get_all_upstream_idps_data_patcher(): + def mocked_fetch_url_data(*args, **kwargs): + url = args[0] if args else "" + if "shibboleth" in url: + return [ + {"entityID": "entity-id-without-display-name"}, + { + "entityID": "https://idp.uca.fr/idp/shibboleth", + "DisplayNames": [ + {"value": "Université Clermont Auvergne", "lang": "fr"} + ], + }, + { + "entityID": "urn:mace:incommon:uchicago.edu", + "DisplayNames": [ + {"value": "University of Chicago", "lang": "en"}, + {"value": "Universidad de Chicago", "lang": "es"}, + ], + }, + ] + elif "generic_mdq_discovery" in url: + with open( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "data/incommon_mdq_data_extract.xml", + # ^ subset of the data from http://mdq.incommon.org/entities/idps/all + ), + "r", + ) as f: + return f.read() + else: + return f"Update get_all_upstream_idps_data_patcher() to handle URL '{url}'" + + fetch_url_data_patch = patch( + "fence.blueprints.login.fetch_url_data", + MagicMock(side_effect=mocked_fetch_url_data), ) - get_all_shib_idps_patch.start() + fetch_url_data_patch.start() yield mock - get_all_shib_idps_patch.stop() + fetch_url_data_patch.stop() @pytest.fixture(scope="function") @@ -1807,3 +1878,93 @@ def mock_authn_user_flask_context(app): g = g_before session = session_before + + +@pytest.fixture +def mocks_for_idp_oauth2_callbacks(idp, rsa_private_key, mock_arborist_requests): + """ + Setup mocks necessary to make calls to OAuth2 callback endpoints, depending on which IdP + is being used. Return values needed by the calling test. + + This fixture requires the calling test to have an `idp` parameter. + """ + username = "test@test" + callback_endpoint = "login" + idp_name = idp + headers = {} + get_auth_info_value = {} + jwt_string = jwt.encode( + {"iat": int(time.time())}, key=rsa_private_key, algorithm="RS256" + ) + + if idp == "synapse": + mock_arborist_requests() + get_auth_info_value = { + "fence_username": username, + "sub": username, + "given_name": username, + "family_name": username, + } + elif idp == "orcid": + get_auth_info_value = {"orcid": username} + elif idp == "cilogon": + get_auth_info_value = {"sub": username} + elif idp == "shibboleth": + headers["persistent_id"] = username + idp_name = "itrust" + elif idp == "okta": + get_auth_info_value = {"okta": username} + elif idp == "fence": + mocked_fetch_access_token = MagicMock(return_value={"id_token": jwt_string}) + patch( + f"authlib.integrations.flask_client.apps.FlaskOAuth2App.fetch_access_token", + mocked_fetch_access_token, + ).start() + mocked_validate_jwt = MagicMock( + return_value={"context": {"user": {"name": username}}} + ) + patch( + f"fence.blueprints.login.fence_login.validate_jwt", mocked_validate_jwt + ).start() + elif idp == "ras": + get_auth_info_value = {"username": username} + callback_endpoint = "callback" + # these should be populated by a /login/ call that we're skipping: + flask.g.userinfo = {"sub": "testSub123"} + flask.g.tokens = { + "refresh_token": jwt_string, + "id_token": jwt_string, + } + flask.g.encoded_visas = "" + elif idp == "generic_with_discovery_url": + get_auth_info_value = {"generic_with_discovery_url_username": username} + elif idp == "generic_additional_params": + # get_auth_info_value specific to generic_additional_params + # TODO: Need test when is_authz_groups_sync_enabled == true + get_auth_info_value = { + "username": username, + "sub": username, + "email_verified": True, + "iat": 1609459200, + "exp": 1609462800, + "refresh_token": "mock_refresh_token", + "groups": ["group1", "group2"], + } + elif idp.startswith("generic_"): + get_auth_info_value = {"sub": username} + + if idp in ["google", "microsoft", "okta", "synapse", "cognito"]: + get_auth_info_value["email"] = username + + get_auth_info_patch = None + if get_auth_info_value: + mocked_get_auth_info = MagicMock(return_value=get_auth_info_value) + get_auth_info_patch = patch( + f"flask.current_app.{idp}_client.get_auth_info", mocked_get_auth_info + ) + get_auth_info_patch.start() + + yield idp_name, username, callback_endpoint, headers + + if get_auth_info_patch: + get_auth_info_patch.stop() diff --git a/tests/credentials/api_key/test_access.py b/tests/credentials/api_key/test_access.py index d52079081..81b0b5ece 100644 --- a/tests/credentials/api_key/test_access.py +++ b/tests/credentials/api_key/test_access.py @@ -2,38 +2,293 @@ Test using an API key to generate an access token. """ +import pytest +import random +import time + +from fence.authz.auth import can_user_get_task_token +from fence.config import config +from fence.jwt.validate import validate_jwt from tests.utils.api_key import get_api_key +TASK_TOKEN_EXPIRES_IN = config["MAX_ACCESS_TOKEN_TTL"] + 60 + +# margin of error for the generated token expiration +EXP_ERR_TOLERANCE = 1 + -def test_cdis_get_access_token(client, oauth_client, encoded_creds_jwt): +def test_get_access_token(client, encoded_creds_jwt): """ - Test ``POST /credentials/cdis/access_token``. + Test ``POST /credentials/api/access_token``. """ encoded_credentials_jwt = encoded_creds_jwt["jwt"] response = get_api_key(client, encoded_credentials_jwt) + assert response.status_code == 200, response.text api_key = response.json["api_key"] - path = "/credentials/cdis/access_token" + + path = "/credentials/api/access_token" data = {"api_key": api_key} response = client.post( path, data=data, headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, ) + assert response.status_code == 200, response.text assert "access_token" in response.json -def test_cdis_get_access_token_with_formdata(client, oauth_client, encoded_creds_jwt): +def test_get_access_token_with_formdata(client, encoded_creds_jwt): """ - Test ``POST /credentials/cdis``. + Test ``POST /credentials/api/access_token``. """ encoded_credentials_jwt = encoded_creds_jwt["jwt"] response = get_api_key(client, encoded_credentials_jwt) + assert response.status_code == 200, response.text api_key = response.json["api_key"] - path = "/credentials/cdis/access_token" + + path = "/credentials/api/access_token" data = {"api_key": api_key} response = client.post( path, data=data, headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, ) + assert response.status_code == 200, response.text + assert "access_token" in response.json + + +@pytest.mark.parametrize("expires_in", [None, 60, TASK_TOKEN_EXPIRES_IN]) +def test_get_access_token_with_expires_in(client, encoded_creds_jwt, expires_in): + """ + Test ``POST /credentials/api/access_token`` with the `expires_in` query parameter. + """ + encoded_credentials_jwt = encoded_creds_jwt["jwt"] + response = get_api_key(client, encoded_credentials_jwt) + assert response.status_code == 200, response.text + api_key = response.json["api_key"] + + now = int(time.time()) + path = f"/credentials/api/access_token{f'?expires_in={expires_in}' if expires_in else ''}" + data = {"api_key": api_key} + response = client.post( + path, + data=data, + headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, + ) + assert response.status_code == 200, response.text + assert "access_token" in response.json + claims = validate_jwt(response.json["access_token"]) + + if expires_in in [None, TASK_TOKEN_EXPIRES_IN]: + # if the expiration is not specified or larger than the configured max, the token lifetime + # is the configured max + expected_exp = now + config["MAX_ACCESS_TOKEN_TTL"] + else: + # otherwise, the token lifetime is as requested + expected_exp = now + expires_in + + # EXP_ERR_TOLERANCE accounts for the margin of error between the test's + # expected token expiration and the actual token's expiration time. + assert expected_exp <= claims["exp"] <= expected_exp + EXP_ERR_TOLERANCE + + +@pytest.mark.parametrize( + ("task_token_type", "expires_in", "authorized"), + [ + ("FOO", None, True), + ("FOO", TASK_TOKEN_EXPIRES_IN, True), + ("FOO", config["MAX_TASK_TOKEN_TTL"]["FOO"] + 60, True), + ("NOT_IN_CONFIG", config["MAX_TASK_TOKEN_TTL"]["FOO"] + 60, True), + ("NOT_IN_MAPPING", TASK_TOKEN_EXPIRES_IN, False), + ], +) +def test_task_token( + client, + encoded_creds_jwt, + mock_arborist_requests, + expires_in, + task_token_type, + authorized, +): + """ + Test that a task token can be generated with a longer life than a regular token, and that the configured max expiration is respected + """ + mock_arborist_requests( + { + "arborist/auth/request": {"POST": ({"auth": authorized}, 200)}, + } + ) + + encoded_credentials_jwt = encoded_creds_jwt["jwt"] + response = get_api_key(client, encoded_credentials_jwt) + assert response.status_code == 200, response.text + api_key = response.json["api_key"] + + # request a task token + now = int(time.time()) + path = f"/credentials/api/access_token?task_token={task_token_type}{f'&expires_in={expires_in}' if expires_in else ''}" + data = {"api_key": api_key} + response = client.post( + path, + data=data, + headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, + ) + + if task_token_type == "NOT_IN_MAPPING": + assert response.status_code == 403, response.text + return + + # the returned access token should have the task token type as audience + assert response.status_code == 200, response.text assert "access_token" in response.json + claims = validate_jwt(response.json["access_token"], aud=task_token_type) + assert claims["aud"] == [task_token_type] + + # check that the returned token's expiration matches + if expires_in is None: + # not specified: min of MAX_ACCESS_TOKEN_TTL and MAX_TASK_TOKEN_TTL + expected_exp = now + config["MAX_ACCESS_TOKEN_TTL"] + elif task_token_type not in config["MAX_TASK_TOKEN_TTL"]: + # ttl not configured for this task token type: fallback to MAX_ACCESS_TOKEN_TTL + expected_exp = now + config["MAX_ACCESS_TOKEN_TTL"] + elif expires_in >= config["MAX_TASK_TOKEN_TTL"][task_token_type]: + # cannot be longer than the configured MAX_TASK_TOKEN_TTL + expected_exp = now + config["MAX_TASK_TOKEN_TTL"][task_token_type] + else: + # otherwise, the requested lifetime is applied + expected_exp = now + TASK_TOKEN_EXPIRES_IN + + # EXP_ERR_TOLERANCE accounts for the margin of error between the test's + # expected token expiration and the actual token's expiration time. + assert expected_exp <= claims["exp"] <= expected_exp + EXP_ERR_TOLERANCE + + +def test_get_access_token_with_almost_expired_key( + client, encoded_creds_jwt, mock_arborist_requests +): + """ + Test that both access tokens or task tokens cannot be generated if the provided API key would expire before the + token does + """ + mock_arborist_requests( + { + # Assuming that the user is authorized to fetch a task token + "arborist/auth/request": {"POST": ({"auth": True}, 200)}, + } + ) + + encoded_credentials_jwt = encoded_creds_jwt["jwt"] + response = get_api_key( + client, encoded_credentials_jwt, expires_in=60 # expires soon + ) + assert response.status_code == 200, response.text + api_key = response.json["api_key"] + + # the soon-to-be-expired API key is accepted when generating tokens for a shorter lifetime than API key's lifetime + path = f"/credentials/api/access_token?expires_in=30" + data = {"api_key": api_key} + response = client.post( + path, + data=data, + headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, + ) + assert response.status_code == 200, response.text + assert "access_token" in response.json + + # the soon-to-be-expired API key is NOT accepted when generating regular tokens + path = f"/credentials/api/access_token?expires_in={TASK_TOKEN_EXPIRES_IN}" + data = {"api_key": api_key} + response = client.post( + path, + data=data, + headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, + ) + assert response.status_code == 400, response.text + assert ( + "Cannot issue an access token that would expire after the provided API key does. Please obtain a new API key and try again" + in response.text + ) + + # the soon-to-be-expired API key is NOT accepted when generating task tokens + path = f"/credentials/api/access_token?task_token=FOO&expires_in={TASK_TOKEN_EXPIRES_IN}" + data = {"api_key": api_key} + response = client.post( + path, + data=data, + headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, + ) + assert response.status_code == 400, response.text + assert ( + "Cannot issue an access token that would expire after the provided API key does. Please obtain a new API key and try again" + in response.text + ) + + +@pytest.mark.parametrize( + ("task_token_type", "expires_in", "authorized", "expected_can_user_get_task_token"), + [ + ("FOO", None, True, False), + ("FOO", random.randint(1, config["MAX_TASK_TOKEN_TTL"]["FOO"]), True, True), + ("FOO", config["MAX_TASK_TOKEN_TTL"]["FOO"], True, True), + ("FOO", config["MAX_TASK_TOKEN_TTL"]["FOO"] + 1, True, False), + ("TASK_TOKEN_TYPE_WITH_FIXED_EXPIRATION", None, False, False), + ("TASK_TOKEN_TYPE_WITH_FIXED_EXPIRATION", 200, True, True), + ("TASK_TOKEN_TYPE_WITH_FIXED_EXPIRATION", 260, False, False), + ( + "BLANKET_ACCESS_NOT_IN_CONFIG", + random.randint(1, config["MAX_ACCESS_TOKEN_TTL"]), + True, + True, + ), + ( + "BLANKET_ACCESS_NOT_IN_CONFIG", + config["MAX_ACCESS_TOKEN_TTL"] + 1, + False, + False, + ), + ("WRONG_METHOD", 200, False, False), + ], +) +def test_can_user_get_task_token( + app, + mock_arborist_requests, + task_token_type, + expires_in, + authorized, + expected_can_user_get_task_token, +): + """ + Test the logic that checks a requested expiration against the user's authz response from arborist while considering the constraints of the configured max expiration for task tokens. + """ + mock_arborist_requests( + { + "arborist/auth/mapping": { + "POST": ( + # Note: The mapping here is only for representation purposes, the actual response + # for authorization check is mocked by the "authorized" parameter below + # based on the mapping given here. + { + "/services/fence/task-token/FOO": [ + {"service": "fence", "method": "create"} + ], + "/services/fence/task-token/TASK_TOKEN_TYPE_WITH_FIXED_EXPIRATION/200": [ + {"service": "fence", "method": "create"} + ], + "/services/fence/task-token/BLANKET_ACCESS_NOT_IN_CONFIG": [ + {"service": "fence", "method": "*"} + ], + "/services/fence/task-token/WRONG_METHOD/200": [ + {"service": "fence", "method": "delete"} + ], + }, + 200, + ) + }, + "arborist/auth/request": {"POST": ({"auth": authorized}, 200)}, + } + ) + with app.app_context(): + assert ( + can_user_get_task_token(task_token_type, expires_in) + == expected_can_user_get_task_token + ) diff --git a/tests/credentials/api_key/test_api_key.py b/tests/credentials/api_key/test_api_key.py index 8d7a4c258..bd4026b79 100644 --- a/tests/credentials/api_key/test_api_key.py +++ b/tests/credentials/api_key/test_api_key.py @@ -1,9 +1,9 @@ from tests.utils.api_key import get_api_key, get_api_key_with_json -def test_cdis_create_api_key(client, oauth_client, encoded_creds_jwt): +def test_create_api_key(client, oauth_client, encoded_creds_jwt): """ - Test ``POST /credentials/cdis``. + Test ``POST /credentials/api``. """ encoded_credentials_jwt = encoded_creds_jwt["jwt"] response = get_api_key_with_json(client, encoded_credentials_jwt).json @@ -11,26 +11,27 @@ def test_cdis_create_api_key(client, oauth_client, encoded_creds_jwt): assert "api_key" in response -def test_cdis_create_api_key_with_disallowed_scope( - client, oauth_client, encoded_creds_jwt -): +def test_create_api_key_with_disallowed_scope(client, oauth_client, encoded_creds_jwt): """ - Test ``POST /credentials/cdis``. + Test ``POST /credentials/api``. """ encoded_credentials_jwt = encoded_creds_jwt["jwt"] response = get_api_key(client, encoded_credentials_jwt, scope=["credentials"]) - assert response.status_code == 400 + assert response.status_code == 400, response.text -def test_cdis_list_api_key(client, oauth_client, encoded_creds_jwt): +def test_list_api_key(client, oauth_client, encoded_creds_jwt): + # create API keys encoded_credentials_jwt = encoded_creds_jwt["jwt"] n_keys = 3 for _ in range(n_keys): get_api_key(client, encoded_credentials_jwt) + + # list existing API keys response = client.get( - "/credentials/cdis/", + "/credentials/api/", headers={"Authorization": "Bearer " + str(encoded_credentials_jwt)}, ) + assert response.status_code == 200, response.text assert "jtis" in response.json assert len(response.json["jtis"]) == n_keys - assert response.status_code == 200 diff --git a/tests/data/incommon_mdq_data_extract.xml b/tests/data/incommon_mdq_data_extract.xml new file mode 100644 index 000000000..69141912f --- /dev/null +++ b/tests/data/incommon_mdq_data_extract.xml @@ -0,0 +1,388 @@ +Wb0mjpUDGk78C+TL46ARugzvcntt2+GlSg54iMfiJ8g=SIGNATURECERTIFICATE + + + + + + + + + http://refeds.org/category/research-and-scholarship + + + https://refeds.org/sirtfi + + + http://id.incommon.org/category/registered-by-incommon + + + + + + osu.edu + + Ohio State University + https://webauth.service.ohio-state.edu/info.html + https://it.osu.edu/sites/default/files/files-1477502394/disclosurepolicy.pdf + https://webauth.service.ohio-state.edu/images/osu_mdui.png + + + + + + + CERTIFICATE + + + + + + + + CERTIFICATE + + + + + + + + + + + + The Ohio State University + Ohio State University + http://www.osu.edu/ + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + + + + + + http://refeds.org/category/research-and-scholarship + + + https://refeds.org/sirtfi + + + http://id.incommon.org/category/registered-by-incommon + + + + + + uchicago.edu + + University of Chicago + The University of Chicago Web Single Sign-On servce + https://its.uchicago.edu/acceptable-use-policy/ + https://cnet.uchicago.edu/static/idplogo.png + + + + + + + CERTIFICATE + + + + + + + + + + + + + + uchicago.edu + + + + + + CERTIFICATE + + + + + + + + University of Chicago + University of Chicago + http://www.uchicago.edu/ + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + + + + https://services.renater.fr/federation/en/documentation/engagement-conformite/metadata_registration_practice_statement + + + + http://refeds.org/category/research-and-scholarship + https://myacademicid.org/entity-categories/esi + + + https://federation.renater.fr/edugain/ + + + https://refeds.org/sirtfi + + + + + + uca.fr + + Université Clermont Auvergne + Université Clermont Auvergne DE + Université Clermont Auvergne + Université Clermont Auvergne DE + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACw0lEQVQ4y12TTWhcZRSGn/Pd707uZJLJTJs4lUlb7I8WSnVTfxClgqA1tHRTulEorisuRFy5EFy5Ltl007SoBW0QW2NThKZgF6IgRUuaH9tGUStJZvI3k7lz537fcXHTavrC2Rxeznk5532F4REVQIFCaKkWujEiLLRa1OIEASBjKJshgEEVTR3EbfrCkMQ58J5tUZ5QBFVQ7zJOu51VkoL3KIotBIaT+5/itR1VtkZdTC4t8+nUHRbimECh00mIcjlO7t/D4R3b6Q4tNxdrjEzOcnu+hqx3Opq3dpO0+VaLl74YY7a+zNHdO7l45FVyxvAozk7OgG7g70ZT/1hdU1XVa7//pYNnPtejX13V/+Of5rpO11c29WzHO17/coyJe3+yq79MOZdjJenQEwR8NvQKAKrK0OgVxmfugvf0l/r49vgbPLvtMcyx0XEmZuYAoQtothPEeQ5VK/TmcgCc+Po7xm9Nc+TJXbz34kEWG02ePzfKajvBXpm+ByYA78B5RIWOSzlYGQAgTh0Xf53iuZ1VLh8fAuBAf5m3L1xidOoOFudADIKiziMiuNQxkI8AWIqzt3Wb/w5dDENwsNhsYfeW+phdqEMgiFdAsV55YbACwNzSMqSO67N3eeebCQ5UtvD+1RsQZEvt2FvHeOb0eVqNJo1CTGAMnxx+mUqhAMCZH3/JbIow/P1P4D3kQnAO8R67d2uZ+oenuDT5Gx449MQgj/f2AHB7vsbIDzchCCD1YAyIAQ84DwoWILKWE0/v22SSufoKb164jIig3m+YXx+qwSlxkhJcH9jzUbXUSzGKcN4zV19h+MbPfDB2Dec8xaiL5fU4k+4fDFAQ4db9eYR3P1ZSB9YCnv5iD6V8ntAYNqKI9577qw3W4iTLpII1hlIhjxURNLQPA1pbW6ecj8himHUNwva+Ir6opN6DQGgMgvAvQ1lgYxylh5kAAAAASUVORK5CYII= + + + + + + CERTIFICATE + + + + + + + urn:mace:shibboleth:1.0:nameIdentifier + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + Université Clermont Auvergne + Université Clermont Auvergne + http://www.uca.fr + + + mailto:test@test + + + Test Test + mailto:test@test + + + + + + + + https://refeds.org/sirtfi + + + http://refeds.org/category/research-and-scholarship + + + http://id.incommon.org/category/registered-by-incommon + + + + + + nmu.edu + + Northern Michigan University + https://idp.nmu.edu/privacy_policy.php + https://www.nmu.edu/Webb/Images/NMULogos/NMU_Square_Transparent.png + + + + + + + CERTIFICATE + + + + + + + + + + Northern Michigan University + Northern Michigan University + http://www.nmu.edu + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + Test Test + mailto:test@test + + + + + + https://eduid.lu/media/eduid-mrps-02.pdf + + education.lu + + + + + RESTENA Users + RESTENA Utilisateurs + RESTENA Benutzer + RESTENA Benotzeren + Identity Provider for end users with an account at RESTENA Foundation + Fournisseur d'Identité pour utilisateurs avec un compte de la Fondation RESTENA + Identitätsprovider für Nutzer mit einem Benutzerkonto bei der Stiftung RESTENA + RESTENA user users login education + https://login.restena.lu/restena.png + + + 158.64.0.0/16 + 2001:a18::0/32 + education.lu + lu + geo:49.50,6.00 + + + + + + CERTIFICATE + + + + + + + CERTIFICATE + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + + + + RESTENA Foundation + Fondation RESTENA + Stiftung + Fondatioun RESTENA + RESTENA Foundation + Fondation RESTENA + Stiftung RESTENA + Fondatioun RESTENA + http://www.restena.lu/restena/en/EN-Presentation.html + http://www.restena.lu/restena/fr/FR-Presentation.html + + + RESTENA Foundation + Test Test + CSIRT + mailto:test@test + + + Test Test + mailto:test@test + + + + + + + + + + https://www.ugd.edu.mk/wp-content/uploads/2023/11/ugd-logo-slogan.png + + + + + + CERTIFICATE + + + + + + + CERTIFICATE + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + Универзитетот „Гоце Делчев“ – Штип + Goce Delcev University, Stip + Универзитетот „Гоце Делчев“ – Штип + Goce Delcev University, Stip + https://www.ugd.edu.mk/ + https://www.ugd.edu.mk/en/ + + + Test Test + mailto:test@test + + + diff --git a/tests/data/test_bulk_indexed_records.py b/tests/data/test_bulk_indexed_records.py new file mode 100644 index 000000000..87d287002 --- /dev/null +++ b/tests/data/test_bulk_indexed_records.py @@ -0,0 +1,337 @@ +""" +Test suite for the Bulk Content Retrieval support +""" + +from unittest.mock import MagicMock + +import pytest +from requests import JSONDecodeError + +from fence.config import config +from fence.blueprints.data.indexd import BulkIndexedRecords +from fence.errors import NotFound, NotSupported, UnavailableError + + +@pytest.fixture +def guid_list() -> list[str]: + """Return a list of dummy GUIDs used in the tests""" + return ["guid-1", "guid-2", "guid-3"] + + +@pytest.fixture +def mock_embeddings_config(monkeypatch): + """Set up mock configuration for Gen3 Embeddings""" + import fence.blueprints.data.indexd as indexd_module + + monkeypatch.setitem( + indexd_module.config, + "ALLOWED_GEN3_EMBEDDINGS_BULK_URL_PREFIXES", + ["https://example.com/ai"], + ) + monkeypatch.setitem( + indexd_module.config, + "GEN3_EMBEDDINGS_API_REGEX", + "/collections/(?P[\w-]+)/embeddings/(?P[\w-]+)", + ) + + +def test_bulk_indexed_records_successful(guid_list, monkeypatch): + """Verify a successful bulk call returns a mapping of GUIDs to records""" + expected_records = [{"did": guid, "foo": f"bar-{guid}"} for guid in guid_list] + + monkeypatch.setattr( + "fence.blueprints.data.indexd.requests.post", + lambda url, **kwargs: _mock_response(200, expected_records), + ) + import fence.blueprints.data.indexd as indexd_module + + monkeypatch.setitem(indexd_module.config, "INDEXD", "http://example.com") + + bulk_records = BulkIndexedRecords(guid_list) + + assert isinstance(bulk_records.bulk_indexed_records, dict) + assert set(bulk_records.bulk_indexed_records.keys()) == set(guid_list) + for guid in guid_list: + assert bulk_records.bulk_indexed_records[guid] == { + "did": guid, + "foo": f"bar-{guid}", + } + + +def test_bulk_indexed_records_non_json(guid_list, monkeypatch): + """A response that cannot be parsed as JSON should raise InternalError""" + monkeypatch.setattr( + "fence.blueprints.data.indexd.requests.post", + lambda url, data: _mock_response(200, raise_json_error=True), + ) + import fence.blueprints.data.indexd as indexd_module + + monkeypatch.setitem(indexd_module.config, "INDEXD", "http://example.com") + + bulk_records = BulkIndexedRecords(guid_list) + with pytest.raises(Exception): + bulk_records.bulk_indexed_records + + +def test_bulk_indexed_records_400(guid_list, monkeypatch): + """A 400 status from indexd translates to NotFound""" + monkeypatch.setattr( + "fence.blueprints.data.indexd.requests.post", + lambda url, **kwargs: _mock_response(400), + ) + import fence.blueprints.data.indexd as indexd_module + + monkeypatch.setitem(indexd_module.config, "INDEXD", "http://example.com") + + bulk_records = BulkIndexedRecords(guid_list) + with pytest.raises(NotFound): + bulk_records.bulk_indexed_records + + +def test_bulk_indexed_records_unavailable(guid_list, monkeypatch): + """Any status other than 200 or 400 raises UnavailableError""" + monkeypatch.setattr( + "fence.blueprints.data.indexd.requests.post", + lambda url, data: _mock_response(503), + ) + import fence.blueprints.data.indexd as indexd_module + + monkeypatch.setitem(indexd_module.config, "INDEXD", "http://example.com") + + bulk_records = BulkIndexedRecords(guid_list) + with pytest.raises(UnavailableError): + bulk_records.bulk_indexed_records + + +def test_get_bulk_requests_and_mapping_success(mock_embeddings_config): + """Verify successful parsing and mapping of a single valid GUID record""" + bulk_records = BulkIndexedRecords(["guid-1"]) + + # bypass indexd network call by mocking the cached_property's internal dict cache + bulk_records.__dict__["bulk_indexed_records"] = { + "guid-1": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-123" + ] + } + } + + urls_and_payloads, id_to_guid = bulk_records.get_bulk_requests_and_mapping() + + assert urls_and_payloads == { + "https://example.com/ai/vectorstore/collections/collection-A/embeddings/bulk": [ + "uuid-123" + ] + } + assert id_to_guid == {"uuid-123": "guid-1"} + + +def test_get_bulk_requests_and_mapping_multiple_records(mock_embeddings_config): + """Verify multiple IDs under the same collection group correctly together""" + bulk_records = BulkIndexedRecords(["guid-1", "guid-2"]) + bulk_records.__dict__["bulk_indexed_records"] = { + "guid-1": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-1" + ] + }, + "guid-2": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-2" + ] + }, + } + + urls_and_payloads, id_to_guid = bulk_records.get_bulk_requests_and_mapping() + + # both payloads should aggregate into the list under the matching collection endpoint + assert urls_and_payloads == { + "https://example.com/ai/vectorstore/collections/collection-A/embeddings/bulk": [ + "uuid-1", + "uuid-2", + ] + } + assert id_to_guid == {"uuid-1": "guid-1", "uuid-2": "guid-2"} + + +def test_get_bulk_requests_and_mapping_invalid_prefix(mock_embeddings_config): + """ + An unallowed URL prefix should immediately trigger NotSupported, even if a valid one is + also present + """ + bulk_records = BulkIndexedRecords(["guid-1", "guid-2"]) + bulk_records.__dict__["bulk_indexed_records"] = { + "guid-1": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-1" + ] + }, + "guid-2": { + "urls": ["s3://forbidden-bucket/collections/collection-A/embeddings/uuid-1"] + }, + } + + with pytest.raises(NotSupported): + bulk_records.get_bulk_requests_and_mapping() + + +def test_get_bulk_requests_and_mapping_regex_mismatch( + monkeypatch, mock_embeddings_config +): + """A valid prefix but malformed API path (regex mismatch) triggers NotSupported""" + + def mock_bad_path_from_url(url): + mock_loc = MagicMock() + mock_loc.url = url + mock_loc.parsed_url.path = "/malformed/path/structure/here" + return mock_loc + + monkeypatch.setattr( + "fence.blueprints.data.indexd.IndexedFileLocation.from_url", + mock_bad_path_from_url, + ) + + bulk_records = BulkIndexedRecords(["guid-1"]) + bulk_records.__dict__["bulk_indexed_records"] = { + "guid-1": {"urls": ["https://example.com/ai/malformed/path/structure/here"]} + } + + with pytest.raises(NotSupported): + bulk_records.get_bulk_requests_and_mapping() + + +def test_get_bulk_requests_and_mapping_empty_urls(mock_embeddings_config): + """Records containing zero URLs should raise NotSupported""" + bulk_records = BulkIndexedRecords(["guid-1"]) + bulk_records.__dict__["bulk_indexed_records"] = {"guid-1": {"urls": []}} + + with pytest.raises(NotSupported): + bulk_records.get_bulk_requests_and_mapping() + + +def test_get_bulk_content_exceeds_max_guids( + client, monkeypatch, mock_embeddings_config +): + """If request has too many GUIDs (beyond configured max), should error""" + guids = ["guid-1", "guid-2"] + bulk_records = BulkIndexedRecords(guids) + bulk_records.__dict__["bulk_indexed_records"] = { + "guid-1": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-1" + ] + }, + "guid-2": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-2" + ] + }, + } + data = {"guids": guids} + + monkeypatch.setitem(config, "MAX_BULK_CONTENT_GUIDS_COUNT", 1) + response = client.post("/data/content", json=data) + assert response.status_code == 413 + + +def test_get_bulk_content_success(client, monkeypatch, mock_embeddings_config): + """Verify a successful bulk content retrieval return structured mapping""" + mock_bulk_instance = MagicMock() + mock_bulk_instance.bulk_indexed_records = { + "guid-1": { + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-123" + ], + }, + } + mock_bulk_instance.get_bulk_requests_and_mapping.return_value = ( + [ + ( + "https://example.com/ai/collections/collection-A/embeddings/bulk", + ["uuid-123"], + ) + ], + {"uuid-123": "guid-1"}, + ) + + monkeypatch.setattr( + "fence.blueprints.data.indexd.BulkIndexedRecords", + MagicMock(return_value=mock_bulk_instance), + ) + + def post(url, *args, **kwargs): + # Gen3 AI Embeddings Bulk Service + if "embeddings/bulk" in url: + mock_ai_resp = MagicMock() + mock_ai_resp.status_code = 200 + mock_ai_resp.json.return_value = { + "embeddings": [ + {"embedding_id": uuid, "vector": [0.1, 0.2, 0.3]} + for uuid in kwargs.get("json", []) + ] + } + return mock_ai_resp + + # indexd bulk documents resolution + if "/bulk/documents" in url: + mock_indexd_resp = MagicMock() + mock_indexd_resp.status_code = 200 + mock_indexd_resp.json.return_value = [ + { + "did": "guid-1", + "urls": [ + "https://example.com/ai/collections/collection-A/embeddings/uuid-123" + ], + } + ] + return mock_indexd_resp + + # fallback for other framework POST requests + fallback_resp = MagicMock() + fallback_resp.status_code = 200 + fallback_resp.json.return_value = {} + return fallback_resp + + monkeypatch.setattr("fence.blueprints.data.indexd.requests.post", post) + + data = {"guids": ["guid-1"]} + response = client.post("/data/content", json=data) + + assert response.status_code == 200 + + json_data = response.get_json() + assert json_data["total_guids"] == 1 + assert "guids" in json_data + assert "guid-1" in json_data.get("guids", {}) + assert json_data["guids"]["guid-1"].get("embedding_id") == "uuid-123" + + +def test_get_bulk_content_unallowed_url(client, monkeypatch, mock_embeddings_config): + """If the bulk URL returned doesn't match allowlisted prefixes, return UserError""" + mock_bulk_instance = MagicMock() + # provide a rogue/forbidden URL structure + mock_bulk_instance.get_bulk_requests_and_mapping.return_value = ( + [("https://forbidden-domain.com/bulk", ["uuid-123"])], + {"uuid-123": "guid-1"}, + ) + + monkeypatch.setattr( + "fence.blueprints.data.indexd.BulkIndexedRecords", + MagicMock(return_value=mock_bulk_instance), + ) + + data = {"guids": ["guid-1"]} + + response = client.post("/data/content", json=data) + assert response.status_code != 200 + + +def _mock_response(status_code: int, data=None, raise_json_error: bool = False): + """Return a mock requests response object""" + mock_resp = MagicMock() + mock_resp.status_code = status_code + if raise_json_error: + mock_resp.json.side_effect = JSONDecodeError("not json") + else: + mock_resp.json.return_value = data + return mock_resp diff --git a/tests/data/test_data.py b/tests/data/test_data.py index f0a3a9852..fdf610d4a 100755 --- a/tests/data/test_data.py +++ b/tests/data/test_data.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +from sqlalchemy import text import urllib.parse import uuid @@ -1378,7 +1379,8 @@ def mock_assume_role(self, role_arn, duration_seconds, config=None): with flask.current_app.db.session as session: session.execute( - "UPDATE assume_role_cache SET expires_at = :ts", dict(ts=time.time() - 10) + text("UPDATE assume_role_cache SET expires_at = :ts"), + dict(ts=time.time() - 10), ) response = client.get(path, headers=headers, query_string=query_string) assert response.status_code == 200 @@ -1495,10 +1497,6 @@ def test_delete_file_locations( arborist_requests_mocker = mock.patch( "gen3authz.client.arborist.client.httpx.Client.request", new_callable=mock.Mock ) - mock_indexed_file_delete_file = mock.patch( - "fence.blueprints.data.indexd.IndexedFile.delete_files", - mock.MagicMock(return_value=("", 204)), - ) mock_index_document = mock.patch( "fence.blueprints.data.indexd.IndexedFile.index_document", index_document ) @@ -1509,9 +1507,8 @@ def test_delete_file_locations( ) mock_index_document.start() - mock_indexed_file_delete_file.start() mock_check_auth.start() - mock_boto_delete = mock.MagicMock() + mock_boto_delete = mock.MagicMock(return_value=("", 204)) monkeypatch.setattr(app.boto, "delete_data_file", mock_boto_delete) class MockResponse(object): @@ -1533,11 +1530,11 @@ def json(self): headers = {"Authorization": "Bearer " + encoded_creds_jwt.jwt} response = client.delete("/data/{}".format(did), headers=headers) assert response.status_code == 204 - assert mock_boto_delete.called_once() + mock_delete.assert_called_once() + mock_boto_delete.assert_called_once() mock_check_auth.stop() mock_index_document.stop() - mock_indexed_file_delete_file.stop() def test_delete_file_locations_by_uploader( @@ -1565,34 +1562,16 @@ def test_delete_file_locations_by_uploader( mock_index_document = mock.patch( "fence.blueprints.data.indexd.IndexedFile.index_document", index_document ) - mock_indexed_file_delete_file = mock.patch( - "fence.blueprints.data.indexd.IndexedFile.delete_files", - mock.MagicMock(return_value=("", 204)), - ) mock_check_auth = mock.patch.object( fence.blueprints.data.indexd.IndexedFile, "check_legacy_authorization", return_value=True, ) - class FakeGCM(object): - def __enter__(self): - return self - - def __exit__(self, a, b, c): - return - - def delete_data_file(self, bucket, file_id): - return "", 200 - - mock_gcm = mock.patch( - "fence.blueprints.data.indexd.GoogleCloudManager", return_value=FakeGCM() - ) - mock_index_document.start() - mock_indexed_file_delete_file.start() mock_check_auth.start() mock_boto_delete = mock.MagicMock() + mock_boto_delete.return_value = ("", 204) monkeypatch.setattr(app.boto, "delete_data_file", mock_boto_delete) class MockResponse(object): @@ -1608,17 +1587,17 @@ def json(self): mock_delete = mock.MagicMock(requests.put, return_value=mock_delete_response) with mock.patch( "fence.blueprints.data.indexd.requests.delete", mock_delete - ), arborist_requests_mocker as arborist_requests, mock_gcm as mock_gcm_2: + ), arborist_requests_mocker as arborist_requests: arborist_requests.return_value = MockResponse({"auth": True}) arborist_requests.return_value.status_code = 200 headers = {"Authorization": "Bearer " + encoded_creds_jwt.jwt} response = client.delete("/data/{}".format(did), headers=headers) assert response.status_code == 204 - assert mock_boto_delete.called_once() + mock_delete.assert_called_once() + mock_boto_delete.assert_called_once() mock_check_auth.stop() mock_index_document.stop() - mock_indexed_file_delete_file.stop() def test_blank_index_upload_unauthorized( @@ -1657,6 +1636,100 @@ def json(self): assert response.status_code == 403, response +def test_blank_index_upload_failed( + app, + client, + auth_client, + encoded_creds_jwt, + user_client, + aws_signed_url, +): + """Test that data/upload does not return a pre-signed url if blank creation failed.""" + + class MockResponse(object): + def __init__(self, data, status_code=200): + self.data = data + self.status_code = status_code + + def json(self): + return self.data + + data_requests_mocker = mock.patch( + "fence.blueprints.data.indexd.requests", new_callable=mock.Mock + ) + arborist_requests_mocker = mock.patch( + "gen3authz.client.arborist.client.httpx.Client.request", new_callable=mock.Mock + ) + with data_requests_mocker as data_requests, arborist_requests_mocker as arborist_requests: + # user is authorized by arborist + arborist_requests.return_value = MockResponse({"auth": True}) + arborist_requests.return_value.status_code = 200 + headers = { + "Authorization": "Bearer " + encoded_creds_jwt.jwt, + "Content-Type": "application/json", + } + data = json.dumps({"file_name": "doesn't matter"}) + # failure from indexd create blank record + data_requests.return_value.status_code = 500 + + response = client.post("/data/upload", headers=headers, data=data) + + data_requests.post.assert_called() + # assert that we do not get a pre-signed url + assert response.status_code == 500, response + + +def test_blank_index_upload_missing_did( + app, + client, + auth_client, + encoded_creds_jwt, + user_client, + aws_signed_url, +): + """ + Test that data/upload does not return a pre-signed url + if blank creation does not have a 'did' key. + """ + + class MockResponse(object): + def __init__(self, data, status_code=200): + self.data = data + self.status_code = status_code + + def json(self): + return self.data + + data_requests_mocker = mock.patch( + "fence.blueprints.data.indexd.requests", new_callable=mock.Mock + ) + arborist_requests_mocker = mock.patch( + "gen3authz.client.arborist.client.httpx.Client.request", new_callable=mock.Mock + ) + with data_requests_mocker as data_requests, arborist_requests_mocker as arborist_requests: + # user is authorized by arborist + arborist_requests.return_value = MockResponse({"auth": True}) + arborist_requests.return_value.status_code = 200 + headers = { + "Authorization": "Bearer " + encoded_creds_jwt.jwt, + "Content-Type": "application/json", + } + data = json.dumps({"file_name": "doesn't matter"}) + data_requests.return_value.status_code = 200 + # Missing 'did' in response. + data_requests.post.return_value = MockResponse( + { + "baseid": str(uuid.uuid4()), + } + ) + + response = client.post("/data/upload", headers=headers, data=data) + + data_requests.post.assert_called() + # assert that we do not get a pre-signed url + assert response.status_code == 500, response + + @pytest.mark.parametrize( "indexd_client_with_arborist", ["gs", "s3", "gs_acl", "s3_acl", "s3_external"], diff --git a/tests/data/test_indexed_file.py b/tests/data/test_indexed_file.py index 20937855e..0a94ead0c 100755 --- a/tests/data/test_indexed_file.py +++ b/tests/data/test_indexed_file.py @@ -1,6 +1,7 @@ """ Test fence.blueprints.data.indexd.IndexedFile """ + import json from unittest import mock from mock import patch, MagicMock @@ -397,6 +398,65 @@ def test_get_signed_url_s3_bucket_name(mock_get_value, s3_indexed_file_location, assert "validbucketname-alreadyvalid" in result_url +@patch("fence.blueprints.data.indexd.get_value") +def test_explicit_over_regex(mock_get_value, app): + """ + Test that an explicit bucket name is preferred over a regex pattern + """ + s3_file_location = S3IndexedFileLocation( + url="s3://foo-bar-datacommons/some_folder/somefile.txt" + ) + mock_get_value.side_effect = lambda config, key, error: { + "S3_BUCKETS": { + "foo-bar-.*": { + "cred": "fence-bot", + "region": "us-east-1", + }, + "foo-bar-datacommons": { + "cred": "fence-bot", + "role-arn": "arn:aws:iam::12345:role/foobar", + "region": "us-east-1", + }, + }, + "AWS_CREDENTIALS": { + "fence-bot": { + "aws_access_key_id": "key", + "aws_secret_access_key": "secret", + } + }, + }.get(key, error) + + assert s3_file_location.bucket_name() == "foo-bar-datacommons" + + +@patch("fence.blueprints.data.indexd.get_value") +def test_regex_match_when_no_explicit(mock_get_value, app): + """ + Test that a regex pattern is used when no explicit bucket exists + """ + s3_file_location = S3IndexedFileLocation( + url="s3://foo-bar-xyz/some_folder/somefile.txt" + ) + mock_get_value.side_effect = lambda config, key, error: { + "S3_BUCKETS": { + "foo-bar-.*": { + "cred": "fence-bot", + "region": "us-east-1", + }, + "fizz-buzz-datacommons": { + "cred": "fence-bot", + "role-arn": "arn:aws:iam::12345:role/fizzbuzz", + "region": "us-east-1", + }, + }, + "AWS_CREDENTIALS": { + "fence-bot": {"aws_access_key_id": "key", "aws_secret_access_key": "secret"} + }, + }.get(key, error) + + assert s3_file_location.bucket_name() == "foo-bar-.*" + + @pytest.mark.parametrize("supported_action", ["download"], indirect=True) def test_internal_get_signed_url_no_location_match( app, supported_action, supported_protocol, indexd_client_accepting_record @@ -687,38 +747,37 @@ def delete_blob(self): """ raise Exception("url not available") - with patch("fence.blueprints.data.indexd.flask.current_app", return_value=app): + with patch( + "fence.blueprints.data.indexd.S3IndexedFileLocation.file_name", + side_effect=Exception("url not available"), + ): with patch( - "fence.blueprints.data.indexd.S3IndexedFileLocation.file_name", + "fence.blueprints.data.indexd.GoogleStorageIndexedFileLocation.file_name", side_effect=Exception("url not available"), ): with patch( - "fence.blueprints.data.indexd.GoogleStorageIndexedFileLocation.file_name", + "fence.blueprints.data.indexd.AzureBlobStorageIndexedFileLocation.file_name", side_effect=Exception("url not available"), ): with patch( - "fence.blueprints.data.indexd.AzureBlobStorageIndexedFileLocation.file_name", - side_effect=Exception("url not available"), + "fence.resources.user.user_session.UserSession.create_initial_token" ): with patch( - "fence.resources.user.user_session.UserSession.create_initial_token" + "fence.blueprints.data.indexd.flask.current_app.boto.delete_data_file", + side_effect=Exception("url not available"), ): with patch( - "fence.blueprints.data.indexd.flask.current_app.boto.delete_data_file", + "gen3cirrus.GoogleCloudManager.delete_data_file", side_effect=Exception("url not available"), ): with patch( - "gen3cirrus.GoogleCloudManager.delete_data_file", - side_effect=Exception("url not available"), + "fence.blueprints.data.indexd.BlobServiceClient.from_connection_string", + return_value=MockBlobServiceClient( + conn_str="some_connection_string" + ), ): - with patch( - "fence.blueprints.data.indexd.BlobServiceClient.from_connection_string", - return_value=MockBlobServiceClient( - conn_str="some_connection_string" - ), - ): - indexed_file = IndexedFile(file_id="some id") - assert indexed_file.delete_files() + indexed_file = IndexedFile(file_id="some id") + assert indexed_file.delete_files() @pytest.mark.parametrize( @@ -766,26 +825,23 @@ def delete_blob(self): """ return - with patch("fence.blueprints.data.indexd.flask.current_app", return_value=app): + with patch("fence.resources.user.user_session.UserSession.create_initial_token"): with patch( - "fence.resources.user.user_session.UserSession.create_initial_token" + "fence.blueprints.data.indexd.flask.current_app.boto.delete_data_file", + return_value=("", 204), ): with patch( - "fence.blueprints.data.indexd.flask.current_app.boto.delete_data_file", + "gen3cirrus.GoogleCloudManager.delete_data_file", return_value=("", 204), ): with patch( - "gen3cirrus.GoogleCloudManager.delete_data_file", - return_value=("", 204), + "fence.blueprints.data.indexd.BlobServiceClient.from_connection_string", + return_value=MockBlobServiceClient( + conn_str="some_connection_string" + ), ): - with patch( - "fence.blueprints.data.indexd.BlobServiceClient.from_connection_string", - return_value=MockBlobServiceClient( - conn_str="some_connection_string" - ), - ): - indexed_file = IndexedFile(file_id="some id") - assert indexed_file.delete_files() + indexed_file = IndexedFile(file_id="some id") + assert indexed_file.delete_files() @pytest.mark.parametrize( @@ -817,28 +873,25 @@ def from_connection_string(cls, container_name, blob_name): """ raise ValueError("Connection string is either blank or malformed.") - with patch("fence.blueprints.data.indexd.flask.current_app", return_value=app): + with patch("fence.resources.user.user_session.UserSession.create_initial_token"): with patch( - "fence.resources.user.user_session.UserSession.create_initial_token" + "fence.blueprints.data.indexd.flask.current_app.boto.delete_data_file", + side_effect=ValueError("Invalid connection string"), ): with patch( - "fence.blueprints.data.indexd.flask.current_app.boto.delete_data_file", + "gen3cirrus.GoogleCloudManager.delete_data_file", side_effect=ValueError("Invalid connection string"), ): with patch( - "gen3cirrus.GoogleCloudManager.delete_data_file", - side_effect=ValueError("Invalid connection string"), + "fence.blueprints.data.indexd.BlobServiceClient.from_connection_string", + return_value=MockBlobServiceClient( + conn_str="invalid connection string" + ), ): - with patch( - "fence.blueprints.data.indexd.BlobServiceClient.from_connection_string", - return_value=MockBlobServiceClient( - conn_str="invalid connection string" - ), - ): - indexed_file = IndexedFile(file_id="some id") - message, status_code = indexed_file.delete_files() - assert message == "Failed to delete data file." - assert status_code == 500 + indexed_file = IndexedFile(file_id="some id") + message, status_code = indexed_file.delete_files() + assert message == "Failed to delete data file." + assert status_code == 500 @pytest.mark.parametrize( @@ -869,13 +922,91 @@ def json(self): """ return self.data - with patch("fence.blueprints.data.indexd.flask.current_app", return_value=app): + with patch("fence.resources.user.user_session.UserSession.create_initial_token"): with patch( - "fence.resources.user.user_session.UserSession.create_initial_token" + "fence.blueprints.data.indexd.requests.delete", + return_value=MockResponse(data=None, status_code=503), ): + indexed_file = IndexedFile(file_id="some id") + assert indexed_file.delete() + + +def test_prepare_bulk_presigned_url_audit_log_combines_authz_and_acl(app): + """ + Test fence.blueprints.data.indexd.prepare_bulk_presigned_url_audit_log populates + audit data correctly for bulk file requests. + """ + + class DummyBulkIndexedFiles: + index_document = { + "guid1": { + "urls": ["s3://bucket/key1"], + "authz": ["/programs/DEV/projects/test"], + }, + "guid2": {"urls": ["gs://bucket/key2"], "acl": ["*"]}, + } + + with app.test_request_context(): + indexd.flask.g.audit_data = {} + indexd.prepare_bulk_presigned_url_audit_log("s3", DummyBulkIndexedFiles()) + + assert set(indexd.flask.g.audit_data["resource_paths"]) == { + "/programs/DEV/projects/test", + "*", + } + assert indexd.flask.g.audit_data["protocol"] == "s3" + assert indexd.flask.g.audit_data["bulk"] is True + assert len(indexd.flask.g.audit_data["bulk_files"]) == 2 + assert { + entry["file_id"] for entry in indexd.flask.g.audit_data["bulk_files"] + } == { + "guid1", + "guid2", + } + assert all( + entry["protocol"] == "s3" + for entry in indexd.flask.g.audit_data["bulk_files"] + ) + + +def test_bulk_get_signed_url_for_file_calls_bulk_indexed_files_and_returns_results( + app, monkeypatch +): + """ + Test fence.blueprints.data.indexd.bulk_get_signed_url_for_file delegates to + BulkIndexedFiles.get_signed_urls and returns the expected bulk response. + """ + + monkeypatch.setattr(app, "scoped_session", lambda: MagicMock()) + expected_urls = ["https://signed1", "https://signed2"] + expected_response = {"urls": expected_urls, "failed_guids": []} + + with app.test_request_context( + "/?userProject=test-project", headers={"User-Agent": "pytest"} + ): + with patch( + "fence.blueprints.data.indexd.BulkIndexedFiles.index_document", + new_callable=mock.PropertyMock, + ) as mock_index_document: + mock_index_document.return_value = { + "guid1": {"urls": ["s3://bucket/key1"], "authz": ["/resource1"]}, + "guid2": {"urls": ["s3://bucket/key2"], "authz": ["/resource2"]}, + } with patch( - "fence.blueprints.data.indexd.requests.delete", - return_value=MockResponse(data=None, status_code=503), - ): - indexed_file = IndexedFile(file_id="some id") - assert indexed_file.delete() + "fence.blueprints.data.indexd.BulkIndexedFiles.get_signed_urls", + return_value=(expected_urls, [None, None], []), + ) as mock_get_signed_urls: + response = indexd.bulk_get_signed_url_for_file( + ["guid1", "guid2"], + requested_protocol="s3", + r_pays_project="test-project", + ) + + assert response == expected_response + mock_get_signed_urls.assert_called_once_with( + "s3", + mock.ANY, + force_signed_url=True, + r_pays_project="test-project", + users_from_passports={}, + ) diff --git a/tests/dbgap_sync/conftest.py b/tests/dbgap_sync/conftest.py index 428fd2639..7f8cc6872 100644 --- a/tests/dbgap_sync/conftest.py +++ b/tests/dbgap_sync/conftest.py @@ -19,7 +19,7 @@ from fence.config import config from fence.resources.openid.ras_oauth2 import RASOauth2Client -from fence.auth import login_user +from fence.auth import login_user_or_require_registration from fence.sync.sync_users import UserSyncer from fence.resources import userdatamodel as udm from fence.models import ( @@ -31,7 +31,7 @@ User, ) -from tests.conftest import random_txn +from tests.conftest import random_txn, NoAsyncMagicMock logger = get_logger(__name__) @@ -154,7 +154,7 @@ def syncer(db_session, request, rsa_private_key, kid): sync_from_local_csv_dir=LOCAL_CSV_DIR, sync_from_local_yaml_file=LOCAL_YAML_DIR, ) - syncer_obj.arborist_client = MagicMock(ArboristClient) + syncer_obj.arborist_client = NoAsyncMagicMock(ArboristClient) def mocked_update(parent_path, resource, **kwargs): resource["tag"] = "123456" @@ -362,6 +362,9 @@ def fake_ras_login(username, subject, email=None, db_session=None): logger.debug( f"subject: {subject}, username: {username}, actual_username: {actual_username}" ) - login_user(actual_username, provider="ras", email=None, id_from_idp=subject) + is_logged_in = login_user_or_require_registration( + actual_username, provider="ras", email=None, id_from_idp=subject + ) + assert is_logged_in # todo sub to iss table diff --git a/tests/dbgap_sync/test_user_sync.py b/tests/dbgap_sync/test_user_sync.py index 13b07d45b..8fcbbdf97 100644 --- a/tests/dbgap_sync/test_user_sync.py +++ b/tests/dbgap_sync/test_user_sync.py @@ -3,14 +3,16 @@ import collections import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, call import mock -from userdatamodel.user import IdentityProvider +import time + +from gen3authz.client.arborist.errors import ArboristError from fence import models from fence.resources.google.access_utils import GoogleUpdateException from fence.config import config -from fence.job.visa_update_cronjob import Visa_Token_Update +from fence.job.access_token_updater import TokenAndAuthUpdater from fence.utils import DEFAULT_BACKOFF_SETTINGS from tests.dbgap_sync.conftest import ( @@ -51,9 +53,9 @@ def test_sync_missing_file(syncer, monkeypatch, db_session): monkeypatch.setattr(syncer, "sync_from_local_yaml_file", "this-file-is-not-real") with pytest.raises(FileNotFoundError): syncer.sync() - assert syncer.arborist_client.create_resource.not_called() - assert syncer.arborist_client.create_role.not_called() - assert syncer.arborist_client.create_policy.not_called() + syncer.arborist_client.create_resource.assert_not_called() + syncer.arborist_client.create_role.assert_not_called() + syncer.arborist_client.create_policy.assert_not_called() @pytest.mark.parametrize("syncer", ["google", "cleversafe"], indirect=True) @@ -68,9 +70,9 @@ def test_sync_incorrect_user_yaml_file(syncer, monkeypatch, db_session): monkeypatch.setattr(syncer, "sync_from_local_yaml_file", path) with pytest.raises(AssertionError): syncer.sync() - assert syncer.arborist_client.create_resource.not_called() - assert syncer.arborist_client.create_role.not_called() - assert syncer.arborist_client.create_policy.not_called() + syncer.arborist_client.create_resource.assert_not_called() + syncer.arborist_client.create_role.assert_not_called() + syncer.arborist_client.create_policy.assert_not_called() @pytest.mark.parametrize("allow_non_dbgap_whitelist", [False, True]) @@ -783,13 +785,13 @@ def test_update_arborist(syncer, db_session): { "id": permission, "permissions": [ - {"id": permission, "action": {"method": permission, "service": ""}} + {"id": permission, "action": {"method": permission, "service": "*"}} ], } for permission in permissions ] for role in expect_roles: - assert syncer.arborist_client.create_role.called_with(role) + syncer.arborist_client.create_role.assert_any_call(role) @pytest.mark.parametrize("syncer", ["google", "cleversafe"], indirect=True) @@ -1011,14 +1013,20 @@ def test_user_sync_with_visa_sync_job( # use refresh tokens from users to call access token polling "fence-create update-visa" # and sync authorization from visas - job = Visa_Token_Update() + job = TokenAndAuthUpdater() job.pkey_cache = { "https://stsstg.nih.gov": { kid: rsa_public_key, } } - loop = asyncio.get_event_loop() - loop.run_until_complete(job.update_tokens(db_session)) + loop = asyncio.new_event_loop() + + try: + asyncio.set_event_loop(loop) + loop.run_until_complete(job.update_tokens(db_session)) + finally: + loop.close() + asyncio.set_event_loop(None) users_after_visas_sync = db_session.query(models.User).all() @@ -1062,121 +1070,272 @@ def test_user_sync_with_visa_sync_job( ) -@pytest.mark.parametrize("syncer", ["cleversafe", "google"], indirect=True) -def test_revoke_all_policies_no_user(db_session, syncer): +@pytest.mark.parametrize("syncer", ["google"], indirect=True) +def test_sync_grant_arborist_policies_check_add_and_revoke( + syncer, + db_session, + monkeypatch, +): """ - Test that function returns even when there's no user + Test that the arborist policies are added and revoked correctly """ - # no arborist user with that username - user_that_doesnt_exist = "foobar" - syncer.arborist_client.get_user.return_value = None - syncer._revoke_all_policies_preserve_mfa(user_that_doesnt_exist, "mock_idp") - - # we only care that this doesn't error - assert True + syncer.arborist_client = MagicMock() + user_existing_policies = [ + {"policy": "phs000178.c1-read"}, + {"policy": "phs000178.c1-read-storage"}, + {"policy": "phs000179.c1-read"}, + {"policy": "phs000179.c1-read-storage"}, + {"policy": "phs000180.c1-read"}, + {"policy": "phs000180.c1-read-storage"}, + ] -@pytest.mark.parametrize("syncer", ["cleversafe", "google"], indirect=True) -def test_revoke_all_policies_preserve_mfa(monkeypatch, db_session, syncer): - """ - Test that the mfa_policy is re-granted to the user after revoking all their policies. - """ - monkeypatch.setitem( - config, - "OPENID_CONNECT", - { - "mock_idp": { - "multifactor_auth_claim_info": {"claim": "acr", "values": ["mfa"]} - } + syncer.arborist_client.get_user.return_value = {"policies": user_existing_policies} + + syncer._grant_arborist_policies( + username="TESTUSERB", + incoming_policies={ + "phs000178.c1-read", + "phs000178.c1-read-storage", + "phs000179.c1-read", + "phs000179.c1-read-storage", + "phs000181.c1-read", + "phs000181.c1-read-storage", }, + user_yaml=None, + expires=10, ) - user = User( - username="mockuser", identity_provider=IdentityProvider(name="mock_idp") - ) - syncer.arborist_client.get_user.return_value = {"policies": ["mfa_policy"]} - syncer._revoke_all_policies_preserve_mfa(user.username, user.identity_provider.name) - syncer.arborist_client.revoke_all_policies_for_user.assert_called_with( - user.username + # Check if correct policies were added + expected_added_policies = { + "phs000181.c1-read", + "phs000181.c1-read-storage", + } + + syncer.arborist_client.grant_bulk_user_policy.assert_called_once_with( + "TESTUSERB", expected_added_policies, 10 ) - syncer.arborist_client.grant_user_policy.assert_called_with( - user.username, "mfa_policy" + + # Check if correct policies were revoked + expected_revoke_calls = [ + call("TESTUSERB", "phs000180.c1-read"), + call("TESTUSERB", "phs000180.c1-read-storage"), + ] + + syncer.arborist_client.revoke_user_policy.assert_has_calls( + expected_revoke_calls, any_order=True ) + assert syncer.arborist_client.revoke_user_policy.call_count == 2 -@pytest.mark.parametrize("syncer", ["cleversafe", "google"], indirect=True) -def test_revoke_all_policies_preserve_mfa_no_mfa(monkeypatch, db_session, syncer): + +@pytest.mark.parametrize("syncer", ["google"], indirect=True) +def test_sync_grant_arborist_policies_check_revoke_all( + syncer, + db_session, + monkeypatch, +): """ - Test to ensure the mfa_policy preservation does not occur if the user does not have the mfa resource granted. + Test that all arborist policies are revoked correctly for a user. """ - monkeypatch.setitem( - config, - "OPENID_CONNECT", - { - "mock_idp": { - "multifactor_auth_claim_info": {"claim": "acr", "values": ["mfa"]} - } - }, + + syncer.arborist_client = MagicMock() + + user_existing_policies = [ + {"policy": "phs000178.c1-read"}, + {"policy": "phs000178.c1-read-storage"}, + {"policy": "phs000179.c1-read"}, + {"policy": "phs000179.c1-read-storage"}, + ] + + syncer.arborist_client.get_user.return_value = {"policies": user_existing_policies} + + syncer._grant_arborist_policies( + username="TESTUSERB", + incoming_policies=set(), + user_yaml=None, + expires=10, ) - user = User( - username="mockuser", identity_provider=IdentityProvider(name="mock_idp") + + syncer.arborist_client.revoke_all_policies_for_user.assert_called_once_with( + "TESTUSERB" ) - syncer.arborist_client.list_resources_for_user.return_value = [ - "/programs/phs0001111" - ] - syncer._revoke_all_policies_preserve_mfa(user.username, user.identity_provider.name) - syncer.arborist_client.revoke_all_policies_for_user.assert_called_with( - user.username + + +@pytest.mark.parametrize("syncer", ["google"], indirect=True) +@pytest.mark.parametrize("remove_users_with_no_policies", [True, False]) +def test_sync_grant_arborist_policies_remove_users_with_no_policies( + syncer, + db_session, + monkeypatch, + remove_users_with_no_policies, +): + """ + Test that a user without any access is deleted if `remove_users_with_no_policies` is True. + """ + + syncer.arborist_client = MagicMock() + + # the user has no new policies and no existing policies + syncer.arborist_client.get_user.return_value = {"policies": []} + syncer._grant_arborist_policies( + username="TESTUSERB", + incoming_policies=set(), + user_yaml=None, + remove_users_with_no_policies=remove_users_with_no_policies, ) - syncer.arborist_client.grant_user_policy.assert_not_called() + + if remove_users_with_no_policies: + syncer.arborist_client.delete_user.assert_called_once_with("TESTUSERB") + else: + syncer.arborist_client.delete_user.assert_not_called() -@pytest.mark.parametrize("syncer", ["cleversafe", "google"], indirect=True) -def test_revoke_all_policies_preserve_mfa_no_idp(monkeypatch, db_session, syncer): +@pytest.mark.parametrize("syncer", ["google"], indirect=True) +def test_sync_grant_arborist_policies_check_no_calls_made( + syncer, + db_session, + monkeypatch, +): """ - Tests for when no IDP is associated with the user + Test that no calls are made to arborist if there are no policies to add or revoke. """ - monkeypatch.setitem( - config, - "OPENID_CONNECT", - { - "mock_idp": { - "multifactor_auth_claim_info": {"claim": "acr", "values": ["mfa"]} - } + + syncer.arborist_client = MagicMock() + + user_existing_policies = [ + {"policy": "phs000178.c1-read"}, + {"policy": "phs000178.c1-read-storage"}, + {"policy": "phs000179.c1-read"}, + {"policy": "phs000179.c1-read-storage"}, + ] + + syncer.arborist_client.get_user.return_value = {"policies": user_existing_policies} + + syncer._grant_arborist_policies( + username="TESTUSERB", + incoming_policies={ + "phs000178.c1-read", + "phs000178.c1-read-storage", + "phs000179.c1-read", + "phs000179.c1-read-storage", }, + user_yaml=None, + expires=10, ) - user = User(username="mockuser") - syncer._revoke_all_policies_preserve_mfa(user.username) - syncer.arborist_client.revoke_all_policies_for_user.assert_called_with( - user.username + + syncer.arborist_client.grant_bulk_user_policy.assert_not_called() + syncer.arborist_client.revoke_user_policy.assert_not_called() + syncer.arborist_client.revoke_all_policies_for_user.assert_not_called() + + +@pytest.mark.parametrize("syncer", ["google"], indirect=True) +def test_sync_grant_arborist_policies_arborist_errors( + syncer, + db_session, + monkeypatch, +): + """ + Test that arborist errors are handled correctly when getting user's current policies. + """ + + syncer.arborist_client = MagicMock() + + user_policies = { + "phs000178.c1-read", + "phs000178.c1-read-storage", + "phs000179.c1-read", + "phs000179.c1-read-storage", + "phs000181.c1-read", + "phs000181.c1-read-storage", + } + + # Simulate an error when trying to get user policies + syncer.arborist_client.get_user.side_effect = ArboristError("Arborist error", 500) + + syncer._grant_arborist_policies( + username="TESTUSERB", + incoming_policies=set(user_policies), + user_yaml=None, + expires=10, ) - syncer.arborist_client.grant_user_policy.assert_not_called() - syncer.arborist_client.list_resources_for_user.assert_not_called() + # Ensure no policies were granted or revoked due to the error + syncer.arborist_client.grant_bulk_user_policy.assert_called_once_with( + "TESTUSERB", + user_policies, + 10, + ) + syncer.arborist_client.revoke_user_policy.assert_not_called() + syncer.arborist_client.revoke_all_policies_for_user.assert_called_once_with( + "TESTUSERB" + ) -@pytest.mark.parametrize("syncer", ["cleversafe", "google"], indirect=True) -def test_revoke_all_policies_preserve_mfa_ensure_revoke_on_error( - monkeypatch, db_session, syncer + +@pytest.mark.parametrize("syncer", ["google", "cleversafe"], indirect=True) +def test_sync_single_user_visas_updates_arborist_with_child_study( + syncer, + db_session, + monkeypatch, ): """ - Tests that arborist_client.revoke_all_policies is still called when an error occurs + Verify that sync_single_user_visas updates arborist with child study permissions based on + parent_to_child_studies_mapping """ - monkeypatch.setitem( - config, - "OPENID_CONNECT", + + parent_to_child_studies_mapping = {"phs000991": ["phs099991"]} + monkeypatch.setattr( + syncer, "parent_to_child_studies_mapping", parent_to_child_studies_mapping + ) + + user = models.User(username="testuser") + user.id = 123 + + visa = MagicMock() + visa.ga4gh_visa = "encoded-visa" + visa.expires = int(time.time()) + 1000 + + fake_visa_type = MagicMock() + fake_visa_type._parse_single_visa.return_value = ( { - "mock_idp": { - "multifactor_auth_claim_info": {"claim": "acr", "values": ["mfa"]} - } + "phs000991.c1": ["read", "read-storage"], }, + {}, ) - user = User( - username="mockuser", identity_provider=IdentityProvider(name="mock_idp") - ) - syncer.arborist_client.list_resources_for_user.side_effect = Exception( - "Unknown error" + + monkeypatch.setattr( + syncer, "_pick_sync_type", MagicMock(return_value=fake_visa_type) ) - syncer._revoke_all_policies_preserve_mfa(user.username, user.identity_provider.name) - syncer.arborist_client.revoke_all_policies_for_user.assert_called_with( - user.username + monkeypatch.setattr(syncer, "sync_to_storage_backend", MagicMock()) + + expected_user_projects = { + "testuser": { + "phs000991.c1": {"read", "read-storage"}, + "phs099991.c1": {"read", "read-storage"}, + } + } + + syncer.arborist_client = MagicMock() + syncer._update_authz_in_arborist = MagicMock(return_value=True) + + syncer.sync_single_user_visas( + user=user, + ga4gh_visas=[visa], + sess=db_session, ) + + actual_user_projects = syncer._update_authz_in_arborist.call_args.args[1] + assert actual_user_projects == expected_user_projects + assert "phs099991.c1" in actual_user_projects["testuser"] + +@pytest.mark.parametrize("syncer", ["google"], indirect=True) +@pytest.mark.parametrize("prune_users", [True, False]) +def test_sync_propagates_prune_users(syncer, monkeypatch, prune_users): + """The public sync entry point passes the pruning mode to the full sync.""" + + syncer._sync = MagicMock() + syncer.session = MagicMock() + + syncer.sync(prune_users=prune_users) + + syncer._sync.assert_called_once_with(syncer.session, prune_users=prune_users) diff --git a/tests/job/test_access_token_updater.py b/tests/job/test_access_token_updater.py new file mode 100644 index 000000000..b5b303980 --- /dev/null +++ b/tests/job/test_access_token_updater.py @@ -0,0 +1,206 @@ +import pytest +import asyncio +from unittest.mock import AsyncMock, patch, MagicMock +from fence.models import User +from fence.resources.openid.idp_oauth2 import Oauth2ClientBase as OIDCClient +from fence.resources.openid.ras_oauth2 import RASOauth2Client as RASClient +from fence.job.access_token_updater import TokenAndAuthUpdater + + +@pytest.fixture(scope="session", autouse=True) +def event_loop(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() + + +@pytest.fixture +def run_async(event_loop): + """Run an async coroutine in the current event loop.""" + + def _run(coro): + return event_loop.run_until_complete(coro) + + return _run + + +@pytest.fixture +def mock_db_session(): + """Fixture to mock the DB session.""" + return MagicMock() + + +@pytest.fixture +def mock_users(): + """Fixture to mock the user list.""" + user1 = MagicMock(spec=User) + user1.username = "testuser1" + user1.identity_provider.name = "ras" + + user2 = MagicMock(spec=User) + user2.username = "testuser2" + user2.identity_provider.name = "test_oidc" + + return [user1, user2] + + +@pytest.fixture +def mock_oidc_clients(): + """Fixture to mock OIDC clients.""" + ras_client = MagicMock(spec=RASClient) + ras_client.idp = "ras" + + oidc_client = MagicMock(spec=OIDCClient) + oidc_client.idp = "test_oidc" + + return [ras_client, oidc_client] + + +@pytest.fixture +def access_token_updater_config(mock_oidc_clients): + """Fixture to instantiate TokenAndAuthUpdater with mocked OIDC clients.""" + with patch( + "fence.config", + { + "OPENID_CONNECT": { + "ras": {}, + "test_oidc": {"groups": {"read_authz_groups_from_tokens": True}}, + }, + "ENABLE_AUTHZ_GROUPS_FROM_OIDC": True, + }, + ): + updater = TokenAndAuthUpdater() + + # Ensure this is a dictionary rather than a list + updater.oidc_clients_requiring_token_refresh = { + client.idp: client for client in mock_oidc_clients + } + + return updater + + +def test_get_user_from_db( + run_async, access_token_updater_config, mock_db_session, mock_users +): + """Test the get_user_from_db method.""" + mock_db_session.query().slice().all.return_value = mock_users + + users = run_async( + access_token_updater_config.get_user_from_db(mock_db_session, chunk_idx=0) + ) + assert len(users) == 2 + assert users[0].username == "testuser1" + assert users[1].username == "testuser2" + + +def test_producer(run_async, access_token_updater_config, mock_db_session, mock_users): + """Test the producer method.""" + queue = asyncio.Queue() + mock_db_session.query().slice().all.return_value = mock_users + + # Run producer to add users to queue + run_async(access_token_updater_config.producer(mock_db_session, queue, chunk_idx=0)) + + assert queue.qsize() == len(mock_users) + assert not queue.empty() + + # Dequeue to check correctness + user = run_async(queue.get()) + assert user.username == "testuser1" + + +def test_worker(run_async, access_token_updater_config, mock_users): + """Test the worker method.""" + queue = asyncio.Queue() + updater_queue = asyncio.Queue() + + # Add users to the queue + for user in mock_users: + run_async(queue.put(user)) + + # Run the worker to transfer users from queue to updater_queue + run_async(access_token_updater_config.worker("worker_1", queue, updater_queue)) + + assert updater_queue.qsize() == len(mock_users) + assert queue.empty() + + +async def updater_with_timeout(updater, queue, db_session, timeout=5): + return await asyncio.wait_for(updater(queue, db_session), timeout) + + +def test_updater( + run_async, + access_token_updater_config, + mock_users, + mock_db_session, + mock_oidc_clients, +): + """Test the updater method.""" + updater_queue = asyncio.Queue() + + # Add a user to the updater_queue + run_async(updater_queue.put(mock_users[0])) + + # Mock the client to return a valid update process + mock_oidc_clients[0].update_user_authorization = MagicMock() + + # Ensure _pick_client returns the correct client + with patch.object( + access_token_updater_config, "_pick_client", return_value=mock_oidc_clients[0] + ): + # Signal the updater to stop after processing + run_async(updater_queue.put(None)) # This should be an awaited call + + # Run the updater to process the user and update authorization + run_async( + access_token_updater_config.updater( + "updater_1", updater_queue, mock_db_session + ) + ) + + # Verify that the OIDC client was called with the correct user + mock_oidc_clients[0].update_user_authorization.assert_called_once_with( + mock_users[0], + pkey_cache=access_token_updater_config.pkey_cache, + db_session=mock_db_session, + ) + + +def test_no_client_found(run_async, access_token_updater_config, mock_users): + """Test that updater does not crash if no client is found.""" + updater_queue = asyncio.Queue() + + # Modify the user to have an unrecognized identity provider + mock_users[0].identity_provider.name = "unknown_provider" + + run_async(updater_queue.put(mock_users[0])) # Ensure this is awaited + run_async(updater_queue.put(None)) # Signal the updater to terminate + + # Mock the client selection to return None + with patch.object(access_token_updater_config, "_pick_client", return_value=None): + # Run the updater and ensure it skips the user with no client + run_async( + access_token_updater_config.updater("updater_1", updater_queue, MagicMock()) + ) + + assert updater_queue.empty() # The user should still be dequeued + + +def test_pick_client( + run_async, access_token_updater_config, mock_users, mock_oidc_clients +): + """Test that the correct OIDC client is selected based on the user's IDP.""" + # Pick the client for a RAS user + client = access_token_updater_config._pick_client(mock_users[0]) + assert client.idp == "ras" + + # Pick the client for a test OIDC user + client = access_token_updater_config._pick_client(mock_users[1]) + assert client.idp == "test_oidc" + + # Ensure no client is returned for a user with no matching IDP + mock_users[0].identity_provider.name = "nonexistent_idp" + client = access_token_updater_config._pick_client(mock_users[0]) + assert client is None diff --git a/tests/jwt/test_blacklist.py b/tests/jwt/test_blacklist.py index fec68c918..fdf02f9b1 100644 --- a/tests/jwt/test_blacklist.py +++ b/tests/jwt/test_blacklist.py @@ -25,4 +25,5 @@ def test_normal_token_not_blacklisted(app, encoded_jwt_refresh_token): Test that a (refresh) token which was not blacklisted returns not blacklisted. """ - assert not is_token_blacklisted(encoded_jwt_refresh_token) + _, is_blacklisted = is_token_blacklisted(encoded_jwt_refresh_token) + assert not is_blacklisted diff --git a/tests/jwt/test_tokens.py b/tests/jwt/test_tokens.py index e8e6dcf94..b12b3a871 100644 --- a/tests/jwt/test_tokens.py +++ b/tests/jwt/test_tokens.py @@ -38,3 +38,40 @@ def test_passport_access_token(app, kid, rsa_private_key, test_user_a): assert isinstance(payload["aud"], list) # assert client_id in audiences assert "client_a" in payload["aud"] + + +@pytest.mark.parametrize("task_token_type", [None, "test_task_token"]) +def test_task_token_access_token( + app, kid, rsa_private_key, test_user_a, task_token_type +): + """ + Test that generate_signed_access_token has a context field with + task_token_type when task_token_type is provided and no field when task_token_type is not provided. + """ + _, exp = iat_and_exp() + jwt_token = generate_signed_access_token( + kid, + rsa_private_key, + exp, + ["openid", "user"], + user=test_user_a, + task_token_type=task_token_type, + ) + payload = jwt.decode( + jwt_token.token, options={"verify_signature": False}, algorithms=["RS256"] + ) + + # assert task_token_type in context + assert "context" in payload + + if task_token_type is None: + # assert task_token_type is not in context + assert ( + "task_token_type" not in payload["context"] + ), f"Expected task_token_type not to be present, but got {payload['context']['task_token_type']}" + else: + # assert task_token_type is in context and matches the provided value + assert "task_token_type" in payload["context"] + assert ( + payload["context"]["task_token_type"] == task_token_type + ), f"Expected task_token_type '{task_token_type}', but got {payload['context']['task_token_type']}" diff --git a/tests/login/test_cilogon_oauth2.py b/tests/login/test_cilogon_oauth2.py new file mode 100644 index 000000000..5e708ef87 --- /dev/null +++ b/tests/login/test_cilogon_oauth2.py @@ -0,0 +1,157 @@ +import pytest +from unittest.mock import MagicMock, patch + +from fence import Oauth2ClientBase +from fence.resources.openid.cilogon_oauth2 import CilogonOauth2Client + + +@pytest.fixture +def mock_settings(): + """Fixture to create mock settings.""" + return { + "client_id": "mock_client_id", + "client_secret": "mock_client_secret", + "redirect_url": "https://mock-redirect.com", + "scope": "openid email profile", + } + + +@pytest.fixture +def mock_logger(): + """Fixture to create a mock logger.""" + return MagicMock() + + +@pytest.fixture +def cilogon_client(mock_settings, mock_logger): + """Fixture to create a CilogonOauth2Client instance.""" + return CilogonOauth2Client(mock_settings, mock_logger) + + +@pytest.fixture +def oauth2_client(): + """Fixture to create an instance of Oauth2ClientBase with a mocked session.""" + mock_settings = { + "client_id": "mock_client_id", + "client_secret": "mock_client_secret", + "redirect_url": "https://mock-redirect.com", + "scope": "openid email profile", + "discovery_url": "https://mock-discovery.com", + } + mock_logger = MagicMock() + + client = Oauth2ClientBase(mock_settings, mock_logger, idp="MockIDP") + client.session = MagicMock() + + return client + + +@patch("fence.resources.openid.cilogon_oauth2.Oauth2ClientBase.__init__") +def test_cilogon_client_init(mock_super_init, mock_settings, mock_logger): + """ + Test that the CilogonOauth2Client initializes correctly and calls the parent class. + """ + client = CilogonOauth2Client(mock_settings, mock_logger) + + mock_super_init.assert_called_once_with( + mock_settings, + mock_logger, + scope="openid email profile", + idp="CILogon", + HTTP_PROXY=None, + ) + + assert ( + client.DISCOVERY_URL == "https://cilogon.org/.well-known/openid-configuration" + ) + + +@patch( + "fence.resources.openid.idp_oauth2.Oauth2ClientBase.get_value_from_discovery_doc" +) +def test_get_auth_url(mock_get_value_from_discovery_doc, app, oauth2_client): + """ + Test that get_auth_url correctly constructs the authorization URL. + """ + mock_get_value_from_discovery_doc.return_value = "https://cilogon.org/authorize" + oauth2_client.session.create_authorization_url.return_value = ( + "https://mock-auth-url.com", + None, + ) + + auth_url = oauth2_client.get_auth_url() + + assert auth_url == "https://mock-auth-url.com" + mock_get_value_from_discovery_doc.assert_called_once_with( + "authorization_endpoint", "" + ) + oauth2_client.session.create_authorization_url.assert_called_once_with( + "https://cilogon.org/authorize", prompt="login" + ) + + +@patch( + "fence.resources.openid.cilogon_oauth2.Oauth2ClientBase.get_value_from_discovery_doc" +) +@patch("fence.resources.openid.cilogon_oauth2.Oauth2ClientBase.get_jwt_claims_identity") +def test_get_auth_info_success( + mock_get_jwt_claims_identity, mock_get_value_from_discovery_doc, cilogon_client +): + """ + Test that get_auth_info correctly extracts user claims when authentication is successful. + """ + mock_get_value_from_discovery_doc.side_effect = [ + "https://cilogon.org/oauth2/token", + "https://cilogon.org/oauth2/certs", + ] + + mock_get_jwt_claims_identity.return_value = ( + {"sub": "mock_user_id"}, + "mock_refresh_token", + "mock_access_token", + ) + + auth_info = cilogon_client.get_auth_info("mock_code") + + assert auth_info == {"sub": "mock_user_id"} + mock_get_value_from_discovery_doc.assert_any_call( + "token_endpoint", "https://cilogon.org/oauth2/token" + ) + mock_get_value_from_discovery_doc.assert_any_call( + "jwks_uri", "https://cilogon.org/oauth2/certs" + ) + mock_get_jwt_claims_identity.assert_called_once_with( + "https://cilogon.org/oauth2/token", + "https://cilogon.org/oauth2/certs", + "mock_code", + ) + + +@patch("fence.resources.openid.cilogon_oauth2.Oauth2ClientBase.get_jwt_claims_identity") +def test_get_auth_info_missing_sub(mock_get_jwt_claims_identity, cilogon_client): + """ + Test that get_auth_info returns an error when 'sub' claim is missing. + """ + mock_get_jwt_claims_identity.return_value = ( + {}, # No 'sub' in claims + "mock_refresh_token", + "mock_access_token", + ) + + auth_info = cilogon_client.get_auth_info("mock_code") + + assert auth_info == {"error": "Can't get user's CILogon sub"} + + +@patch("fence.resources.openid.cilogon_oauth2.Oauth2ClientBase.get_jwt_claims_identity") +def test_get_auth_info_exception(mock_get_jwt_claims_identity, cilogon_client): + """ + Test that get_auth_info handles exceptions and logs an error. + """ + mock_get_jwt_claims_identity.side_effect = Exception("Test Exception") + + auth_info = cilogon_client.get_auth_info("mock_code") + + assert "error" in auth_info + assert "Can't get your CILogon sub" in auth_info["error"] + cilogon_client.logger.exception.assert_called_once_with("Can't get user info") diff --git a/tests/login/test_conftest_idps.py b/tests/login/test_conftest_idps.py index 9a1ea2eb4..b4563058a 100644 --- a/tests/login/test_conftest_idps.py +++ b/tests/login/test_conftest_idps.py @@ -1,20 +1,21 @@ import os -from tests.conftest import LOGIN_IDPS +from fence.config import config +from tests.conftest import all_available_idps -def test_contfest_idps(): +def test_conftest_idps(): """ - This test checks that any newly added custom OIDC IDP has been added to - the list of IDPs to test (LOGIN_IDPS). + This test checks that any newly added custom OIDC IdP is in + the list of IdPs to test (all_enabled_login_idps). - If you added an IDP and this test fails, do not edit this test. Either the - new IDP should be added to LOGIN_IDPS and test-fence-config.yaml's - OPENID_CONNECT, or the new file names do not match the convention and - cannot be parsed by this test. + If you added an IdP and this test fails, DO NOT edit this test. Either the + new IdP should be added to test-fence-config.yaml's OPENID_CONNECT and LOGIN_OPTIONS, + or the new file names do not match the convention and cannot be parsed by this test. """ + all_enabled_login_idps = list(set(config["OPENID_CONNECT"].keys())) current_dir = os.path.dirname(os.path.realpath(__file__)) - err_msg = "For IDP file '{}': IDP '{}' should be added to the tests' 'LOGIN_IDPS' to be tested" + err_msg = "For IdP file '{}': IdP '{}' must be added to test-fence-config.yaml's OPENID_CONNECT and LOGIN_OPTIONS to be tested" subdir = "resources/openid" path = os.path.join(current_dir, "../../fence", subdir) @@ -23,15 +24,15 @@ def test_contfest_idps(): if f.startswith("__") or f == "idp_oauth2.py": continue idp = f.split("_")[0] - assert idp in LOGIN_IDPS, err_msg.format(os.path.join(subdir, f), idp) + if idp == "shib": + idp = "shibboleth" # see `get_idp_route_name` function + assert idp in all_enabled_login_idps, err_msg.format( + os.path.join(subdir, f), idp + ) break # no need to check subdirectories - subdir = "blueprints/login" - path = os.path.join(current_dir, "../../fence", subdir) - for _, _, files in os.walk(path): - for f in files: - if f.startswith("__") or f in ["base.py", "redirect.py", "utils.py"]: - continue - idp = f.split(".py")[0].split("_login")[0] - assert idp in LOGIN_IDPS, err_msg.format(os.path.join(subdir, f), idp) - break # no need to check subdirectories + # `all_available_idps` == `blueprints/login` subdir + for idp in all_available_idps(): + assert idp in all_enabled_login_idps, err_msg.format( + os.path.join(subdir, f), idp + ) diff --git a/tests/login/test_default_login.py b/tests/login/test_default_login.py deleted file mode 100644 index 2ef132a69..000000000 --- a/tests/login/test_default_login.py +++ /dev/null @@ -1,64 +0,0 @@ -from urllib.parse import urlencode - -from fence.config import config - - -def test_default_login(app, client): - response_json = client.get("/login").json - assert "default_provider" in response_json - response_default = response_json["default_provider"] - configured_logins = config["LOGIN_OPTIONS"] - default_idp = config["DEFAULT_LOGIN_IDP"] - - # Check default IDP is correct. - assert response_default["idp"] == default_idp - names_for_this_idp = [ - login_details["name"] - for login_details in configured_logins - if login_details["idp"] == default_idp - ] - assert response_default["name"] in names_for_this_idp - - -def test_enabled_logins(app, client): - response_json = client.get("/login").json - assert "providers" in response_json - response_providers = response_json["providers"] - configured_logins = config["LOGIN_OPTIONS"] - - # Check all providers in the response have the expected idp, name, URLs, - # desc and secondary information - app_urls = [url_map_rule.rule for url_map_rule in app.url_map._rules] - for configured in configured_logins: - # this assumes (idp, name) couples in test config are unique - response_provider = next( - ( - provider - for provider in response_providers - if provider["idp"] == configured["idp"] - and provider["name"] == configured["name"] - ), - None, - ) - assert ( - response_provider - ), 'Configured login option "{}" not in /login response: {}'.format() - if "desc" in configured: - assert response_provider["desc"] == configured["desc"] - if "secondary" in configured: - assert response_provider["secondary"] == configured["secondary"] - if "shib_idps" in configured: - for shib_idp in configured["shib_idps"]: - assert any( - urlencode({"shib_idp": shib_idp}) in url_info["url"] - for url_info in response_provider["urls"] - ), 'shib_idp param "{}", encoded "{}", is not in provider\'s login URLs: {}'.format( - shib_idp, - urlencode({"shib_idp": shib_idp}), - response_provider["urls"], - ) - login_urls = [ - url_info["url"].replace(config["BASE_URL"], "").split("?")[0] - for url_info in response_provider["urls"] - ] - assert all(url in app_urls for url in login_urls) diff --git a/tests/login/test_fence_login.py b/tests/login/test_fence_login.py index d3d60b314..55c1a05d3 100644 --- a/tests/login/test_fence_login.py +++ b/tests/login/test_fence_login.py @@ -1,8 +1,10 @@ +import copy + from addict import Dict from authutils.oauth2.client import OAuthClient from collections import OrderedDict +import flask import pytest -from unittest.mock import MagicMock, patch from fence.config import config from fence.jwt.keys import Keypair @@ -29,6 +31,8 @@ def config_idp_in_client( saved_db_Session = app.db.Session app.db.Session = lambda: db_session + saved_config = copy.deepcopy(app.config) + saved_fence_clients = flask.current_app.fence_client._clients config.update( { "BASE_URL": "/", @@ -39,7 +43,7 @@ def config_idp_in_client( "name": "InCommon login", "idp": "fence", "fence_idp": "shibboleth", - "shib_idps": ["some-incommon-entity-id"], + "shib_idps": ["entity-id-without-display-name"], } ], "OPENID_CONNECT": { @@ -49,6 +53,7 @@ def config_idp_in_client( "client_secret": "other_fence_client_secret", "api_base_url": "http://other-fence", "authorize_url": "http://other-fence/oauth2/authorize", + "shibboleth_discovery_url": "https://shibboleth_discovery_url/DiscoFeed", } }, } @@ -67,9 +72,12 @@ def config_idp_in_client( app.jwt_public_keys = saved_jwtpks app.db.Session = saved_db_Session + app.config = saved_config + flask.current_app.fence_client._clients = saved_fence_clients + def test_redirect_oauth2_authorize( - app, client, config_idp_in_client, get_all_shib_idps_patcher + app, client, config_idp_in_client, get_all_upstream_idps_data_patcher ): """ Test that the ``/oauth2/authorize`` endpoint on the client fence redirects to the @@ -83,7 +91,7 @@ def test_redirect_oauth2_authorize( def test_redirect_oauth2_authorize_default_params( - client, app, config_idp_in_client, get_all_shib_idps_patcher + client, app, config_idp_in_client, get_all_upstream_idps_data_patcher ): """ Test that when the `/oauth2/authorize` endpoint redirects to the default @@ -93,7 +101,7 @@ def test_redirect_oauth2_authorize_default_params( assert r.status_code == 302 assert "/login/fence" in r.location assert "idp=shibboleth" in r.location - assert "shib_idp=some-incommon-entity-id" in r.location + assert "shib_idp=entity-id-without-display-name" in r.location def test_redirect_login_fence(app, client, config_idp_in_client): diff --git a/tests/login/test_idp_oauth2.py b/tests/login/test_idp_oauth2.py index 40ae2349a..16dc601d5 100644 --- a/tests/login/test_idp_oauth2.py +++ b/tests/login/test_idp_oauth2.py @@ -1,7 +1,16 @@ +import jwt import pytest +import datetime +from jose.exceptions import JWTClaimsError, JWTError +from unittest.mock import ANY +from flask import Flask, g from cdislogging import get_logger +from unittest.mock import MagicMock, Mock, patch + +from fence.resources.openid.idp_oauth2 import Oauth2ClientBase, AuthError +from fence.blueprints.login.base import DefaultOAuth2Callback +from fence.config import config -from fence import Oauth2ClientBase MOCK_SETTINGS_ACR = { "client_id": "client", @@ -39,11 +48,6 @@ def test_has_mfa_claim_acr(oauth_client_acr): assert has_mfa -def test_has_mfa_claim_acr(oauth_client_acr): - has_mfa = oauth_client_acr.has_mfa_claim({"acr": "mfa"}) - assert has_mfa - - def test_has_mfa_claim_multiple_acr(oauth_client_acr): has_mfa = oauth_client_acr.has_mfa_claim({"acr": "mfa otp duo"}) assert has_mfa @@ -83,3 +87,369 @@ def test_does_not_has_mfa_claim_amr(oauth_client_amr): def test_does_not_has_mfa_claim_multiple_amr(oauth_client_amr): has_mfa = oauth_client_amr.has_mfa_claim({"amr": ["pwd, trustme"]}) assert not has_mfa + + +# To test the store_refresh_token method of the Oauth2ClientBase class +def test_store_refresh_token(mock_user, mock_app): + """ + Test the `store_refresh_token` method of the `Oauth2ClientBase` class to ensure that + refresh tokens are correctly stored in the database using the `UpstreamRefreshToken` model. + """ + mock_logger = MagicMock() + mock_settings = { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "redirect_url": "http://localhost/callback", + "discovery_url": "http://localhost/.well-known/openid-configuration", + "groups": {"read_authz_groups_from_tokens": True, "group_prefix": "/"}, + "user_id_field": "sub", + } + + # Ensure oauth_client is correctly instantiated + oauth_client = Oauth2ClientBase( + settings=mock_settings, logger=mock_logger, idp="test_idp" + ) + + refresh_token = "mock_refresh_token" + expires = 1700000000 + + # Patch the UpstreamRefreshToken to prevent actual database interactions + with patch( + "fence.resources.openid.idp_oauth2.UpstreamRefreshToken", autospec=True + ) as MockUpstreamRefreshToken: + + # Call the method to test + oauth_client.store_refresh_token( + mock_user, refresh_token, expires, db_session=mock_app.arborist + ) + + # Check if UpstreamRefreshToken was instantiated correctly + MockUpstreamRefreshToken.assert_called_once_with( + user=mock_user, + refresh_token=refresh_token, + expires=expires, + ) + + # Check if the mock_app.arborist sent as a db_session's `add` and `commit` methods were called + mock_app.arborist.add.assert_called_once_with( + MockUpstreamRefreshToken.return_value + ) + mock_app.arborist.commit.assert_called_once() + + +# To test if a user is granted access using the get_auth_info method in Oauth2ClientBase +@patch("fence.resources.openid.idp_oauth2.Oauth2ClientBase.get_jwt_keys") +@patch("jwt.decode") +@patch("authlib.integrations.requests_client.OAuth2Session.fetch_token") +@patch( + "fence.resources.openid.idp_oauth2.Oauth2ClientBase.get_value_from_discovery_doc" +) +def test_get_auth_info_granted_access( + mock_get_value_from_discovery_doc, + mock_fetch_token, + mock_jwt_decode, + mock_get_jwt_keys, + app, +): + """ + Test that the `get_auth_info` method correctly retrieves, processes, and decodes + an OAuth2 authentication token, including access, refresh, and ID tokens, while also + handling JWT decoding and discovery document lookups. + + Raises: + AssertionError: If the expected claims or tokens are not present in the returned authentication information. + """ + + mock_settings = { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "redirect_url": "http://localhost/callback", + "discovery_url": "http://localhost/.well-known/openid-configuration", + "is_authz_groups_sync_enabled": True, + "authz_groups_sync": {"group_prefix": "/"}, + "user_id_field": "sub", + } + + # Mock logger + mock_logger = MagicMock() + + with app.app_context(): + yield + oauth2_client = Oauth2ClientBase( + settings=mock_settings, logger=mock_logger, idp="test_idp" + ) + + # Mock token endpoint and jwks_uri + mock_get_value_from_discovery_doc.side_effect = lambda key, default=None: ( + "http://localhost/token" + if key == "token_endpoint" + else "http://localhost/jwks" + ) + + # Setup mock response for fetch_token + mock_fetch_token.return_value = { + "access_token": "mock_access_token", + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtb2NrX3VzZXJfaWQiLCJpYXQiOjE2MDk0NTkyMDAsImV4cCI6MTYwOTQ2MjgwMCwiZ3JvdXBzIjpbImdyb3VwMSIsImdyb3VwMiJdfQ.XYZ", + "refresh_token": "mock_refresh_token", + } + + # Setup mock JWT keys response + mock_get_jwt_keys.return_value = [ + { + "kty": "RSA", + "kid": "1e9gdk7", + "use": "sig", + "n": "example-key", + "e": "AQAB", + } + ] + + # Setup mock decoded JWT token + mock_jwt_decode.return_value = { + "sub": "mock_user_id", + "email_verified": True, + "iat": 1609459200, + "exp": 1609462800, + "groups": ["group1", "group2"], + } + + # Log mock setups + logger.debug( + f"Mock token endpoint: {mock_get_value_from_discovery_doc('token_endpoint', '')}" + ) + logger.debug( + f"Mock jwks_uri: {mock_get_value_from_discovery_doc('jwks_uri', '')}" + ) + logger.debug(f"Mock fetch_token response: {mock_fetch_token.return_value}") + logger.debug(f"Mock JWT decode response: {mock_jwt_decode.return_value}") + + # Call the method + code = "mock_code" + auth_info = oauth2_client.get_auth_info(code) + logger.debug(f"Mock auth_info: {auth_info}") + + # Debug: Check if decode was called + logger.debug(f"JWT decode call count: {mock_jwt_decode.call_count}") + logger.debug(f"Returned auth_info: {auth_info}") + logger.debug(f"JWT decode call args: {mock_jwt_decode.call_args_list}") + logger.debug(f"Fetch token response: {mock_fetch_token.return_value}") + + # Assertions + assert "sub" in auth_info, f"Expected 'sub' in auth_info, got {auth_info}" + assert auth_info["sub"] == "mock_user_id" + assert "refresh_token" in auth_info + assert auth_info["refresh_token"] == "mock_refresh_token" + assert "iat" in auth_info + assert auth_info["iat"] == 1609459200 + assert "exp" in auth_info + assert auth_info["exp"] == 1609462800 + assert "groups" in auth_info + assert auth_info["groups"] == ["group1", "group2"] + + +def test_get_access_token_expired(expired_mock_user, mock_db_session): + """ + Test that attempting to retrieve an access token for a user with an expired refresh token + results in an `AuthError`, the user's token is deleted, and the session is committed. + + + Raises: + AuthError: When the user does not have a valid, non-expired refresh token. + """ + mock_settings = { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "redirect_url": "http://localhost/callback", + "discovery_url": "http://localhost/.well-known/openid-configuration", + "is_authz_groups_sync_enabled": True, + "authz_groups_sync:": {"group_prefix": "/"}, + "user_id_field": "sub", + } + + # Initialize the Oauth2 client object + oauth2_client = Oauth2ClientBase( + settings=mock_settings, logger=MagicMock(), idp="test_idp" + ) + + # Simulate the token expiration and user not having access + with pytest.raises(AuthError) as excinfo: + logger.debug("get_access_token about to be called") + oauth2_client.get_access_token( + expired_mock_user, + token_endpoint="https://token.endpoint", + db_session=mock_db_session, + ) + + logger.debug(f"Raised exception message: {excinfo.value}") + + assert "User doesn't have a valid, non-expired refresh token" in str(excinfo.value) + + mock_db_session.delete.assert_called() + mock_db_session.commit.assert_called() + + +@patch("fence.resources.openid.idp_oauth2.Oauth2ClientBase.get_auth_info") +def test_post_login_with_group_prefix(mock_get_auth_info, app): + """ + Test the `post_login` method of the `DefaultOAuth2Callback` class, ensuring that user groups + fetched from an identity provider (IdP) are processed correctly and prefixed before being added + to the user in the Arborist service. + """ + with app.app_context(): + yield + with patch.dict(config, {"ENABLE_AUTHZ_GROUPS_FROM_OIDC": True}, clear=False): + mock_user = MagicMock() + mock_user.username = "test_user" + mock_user.id = "user_id" + g.user = mock_user + + # Set up mock responses for user info and groups from the IdP + mock_get_auth_info.return_value = { + "username": "test_user", + "groups": ["group1", "group2", "covid/group3", "group4", "group5"], + "exp": datetime.datetime.now(tz=datetime.timezone.utc).timestamp(), + "group_prefix": "covid/", + } + + # Mock the Arborist client and its methods + mock_arborist = MagicMock() + mock_arborist.list_groups.return_value = { + "groups": [ + {"name": "group1"}, + {"name": "group2"}, + {"name": "group3"}, + {"name": "reviewers"}, + ] + } + mock_arborist.add_user_to_group = MagicMock() + mock_arborist.remove_user_from_group = MagicMock() + + # Mock the Flask app + app = MagicMock() + app.arborist = mock_arborist + + # Create the callback object with the mock app + callback = DefaultOAuth2Callback( + idp_name="generic_additional_params", client=MagicMock(), app=app + ) + + # Mock user and call post_login + mock_user = MagicMock() + mock_user.username = "test_user" + + # Simulate calling post_login + callback.post_login( + user=g.user, + token_result=mock_get_auth_info.return_value, + groups_from_idp=mock_get_auth_info.return_value["groups"], + group_prefix=mock_get_auth_info.return_value["group_prefix"], + expires_at=mock_get_auth_info.return_value["exp"], + username=mock_user.username, + ) + + # Assertions to check if groups were processed with the correct prefix + mock_arborist.add_user_to_group.assert_any_call( + username="test_user", + group_name="group1", + expires_at=datetime.datetime.fromtimestamp( + mock_get_auth_info.return_value["exp"], tz=datetime.timezone.utc + ), + ) + mock_arborist.add_user_to_group.assert_any_call( + username="test_user", + group_name="group2", + expires_at=datetime.datetime.fromtimestamp( + mock_get_auth_info.return_value["exp"], tz=datetime.timezone.utc + ), + ) + mock_arborist.add_user_to_group.assert_any_call( + username="test_user", + group_name="group3", + expires_at=datetime.datetime.fromtimestamp( + mock_get_auth_info.return_value["exp"], tz=datetime.timezone.utc + ), + ) + + # Ensure the mock was called exactly three times (once for each group that was added) + assert mock_arborist.add_user_to_group.call_count == 3 + + +@patch("fence.resources.openid.idp_oauth2.Oauth2ClientBase.get_jwt_keys") +@patch("authlib.integrations.requests_client.OAuth2Session.fetch_token") +@patch("fence.resources.openid.idp_oauth2.jwt.decode") # Mock jwt.decode +def test_jwt_audience_verification_fails( + mock_jwt_decode, mock_fetch_token, mock_get_jwt_keys +): + """ + Test the JWT audience verification failure scenario. + + This test mocks various components used in the OIDC flow to simulate the + process of obtaining a token, fetching JWKS (JSON Web Key Set), and verifying + the JWT token's claims. Specifically, it focuses on the audience verification + step and tests that an invalid audience raises the expected `JWTClaimsError`. + + + Raises: + JWTClaimsError: When the audience in the JWT token is invalid. + """ + # Mock fetch_token to simulate a successful token fetch + mock_fetch_token.return_value = { + "id_token": "mock-id-token", + "access_token": "mock_access_token", + "refresh_token": "mock-refresh-token", + } + + # Mock JWKS response + mock_jwks_response = { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-id", + "use": "sig", + # Simulate RSA public key values + "n": "mock-n-value", + "e": "mock-e-value", + } + ] + } + + mock_get_jwt_keys.return_value = MagicMock() + mock_get_jwt_keys.return_value = mock_jwks_response + + # Mock jwt.decode to raise JWTClaimsError for audience verification failure + mock_jwt_decode.side_effect = JWTError("Invalid audience") + + # Setup the mock instance of Oauth2ClientBase + client = Oauth2ClientBase( + settings={ + "client_id": "mock-client-id", + "client_secret": "mock-client-secret", + "redirect_url": "mock-redirect-url", + "discovery_url": "http://localhost/discovery", + "audience": "expected-audience", + "verify_aud": True, + }, + logger=MagicMock(), + idp="mock-idp", + ) + + # Invoke the method and expect JWTClaimsError to be raised + with pytest.raises(JWTError, match="Invalid audience"): + client.get_jwt_claims_identity( + token_endpoint="https://token.endpoint", + jwks_endpoint="https://jwks.uri", + code="auth_code", + ) + + # Verify fetch_token was called correctly + mock_fetch_token.assert_called_once_with( + url="https://token.endpoint", code="auth_code", proxies=None + ) + + # Verify jwt.decode was called with the mock id_token + mock_jwt_decode.assert_called_with( + "mock-id-token", # The mock token + key=mock_jwks_response, + options={"verify_signature": False}, + algorithms=["RS256"], + ) diff --git a/tests/login/test_login_blueprint.py b/tests/login/test_login_blueprint.py new file mode 100644 index 000000000..3d3ceb58c --- /dev/null +++ b/tests/login/test_login_blueprint.py @@ -0,0 +1,222 @@ +from urllib.parse import urlencode + +import pytest + +from fence.config import config +from fence.blueprints.login import get_all_upstream_idps, UPSTREAM_IDP_CACHE + + +def test_get_all_upstream_idps(get_all_upstream_idps_data_patcher): + """ + Check that `get_all_upstream_idps` parses the XML MDQ data at + `tests/data/incommon_mdq_data_extract.xml` as expected. + """ + res = get_all_upstream_idps( + "generic_mdq_discovery", + "https://generic_mdq_discovery/get-all-idps", + "xml-mdq-v1.0", + ) + assert res == [ + {"idp": "urn:mace:incommon:osu.edu", "name": "Ohio State University"}, + {"idp": "urn:mace:incommon:uchicago.edu", "name": "University of Chicago"}, + { + # example of choosing the 1st provided value when the display name is not + # available in English + "idp": "https://idp.uca.fr/idp/shibboleth", + "name": "Université Clermont Auvergne", + }, + {"idp": "urn:mace:incommon:nmu.edu", "name": "Northern Michigan University"}, + { + "idp": "https://login.restena.lu/simplesamlphp/saml2/idp/metadata.php", + "name": "RESTENA Users", + }, + { + # example of falling back on OrganizationDisplayName because DisplayName + # is not provided, + # and example of choosing English when the display name is available in + # multiple languages including English + "idp": "https://idp-proxy.ugd.edu.mk", + "name": "Goce Delcev University, Stip", + }, + ] + + +def test_default_login(app, client, get_all_upstream_idps_data_patcher): + r = client.get("/login") + assert r.status_code == 200, r.data + response_json = r.json + assert "default_provider" in response_json + response_default = response_json["default_provider"] + configured_logins = config["LOGIN_OPTIONS"] + default_idp = config["DEFAULT_LOGIN_IDP"] + + # Check default IdP is correct. + assert response_default["idp"] == default_idp + names_for_this_idp = [ + login_details["name"] + for login_details in configured_logins + if login_details["idp"] == default_idp + ] + assert response_default["name"] in names_for_this_idp + + +@pytest.mark.parametrize( + "login_option", + [ + pytest.param(login_option, id=login_option["name"]) + for login_option in config["LOGIN_OPTIONS"] + ], +) +def test_enabled_logins(app, client, login_option, get_all_upstream_idps_data_patcher): + r = client.get("/login") + assert r.status_code == 200, r.data + response_json = r.json + assert "providers" in response_json + response_providers = response_json["providers"] + + # Check all providers in the response have the expected idp, name, URLs, + # description and secondary information + response_provider = next( + ( + # this assumes (idp, name) couples in test config are unique + provider + for provider in response_providers + if provider["idp"] == login_option["idp"] + and provider["name"] == login_option["name"] + ), + None, + ) + assert ( + response_provider + ), 'Configured login option "{}" not in /login response: {}'.format() + if "desc" in login_option: + assert response_provider["desc"] == login_option["desc"] + if "secondary" in login_option: + assert response_provider["secondary"] == login_option["secondary"] + if "upstream_idps" in login_option or "shib_idps" in login_option: + # check `upstream_idps`, fallback to `shib_idps`. Handle "*" which means "all IdPs". + login_option_upstream_idps = login_option.get( + "upstream_idps", login_option.get("shib_idps") + ) + if login_option_upstream_idps == "*": + # UPSTREAM_IDP_CACHE should have been populated during `client.get("/login")` + login_option_upstream_idps = [ + idp["idp"] + for idp in UPSTREAM_IDP_CACHE.get( + f"all_{login_option['idp']}_upstream_idps" + ) + or [] + ] + + # each IdP in the login_option "upstream_idps" should have a corresponding URL in the + # list of IdP URLs returned by the /login endpoint + login_urls = [url_info["url"] for url_info in response_provider["urls"]] + for upstream_idp in login_option_upstream_idps: + assert any( + urlencode({"idp": upstream_idp}) in url for url in login_urls + ), 'IdP "{}": upstream_idp param "{}", encoded "{}", is not in provider\'s login URLs: {}'.format( + login_option["name"], + upstream_idp, + urlencode({"idp": upstream_idp}), + response_provider["urls"], + ) + + # the /login endpoint should only return URLs for IdPs configured as "upstream_idps" + if login_option_upstream_idps: + assert len(login_option_upstream_idps) == len( + login_urls + ), f"URL mismatch for IdP '{login_option['name']}'" + + # all IdP URLs returned by the /login endpoint should match an existing app route + app_routes = [url_map_rule.rule for url_map_rule in app.url_map._rules] + login_urls = { + url_info["url"].replace(config["BASE_URL"], "").split("?")[0] + for url_info in response_provider["urls"] + } + assert all(url in app_routes for url in login_urls) + + +@pytest.mark.parametrize( + "description, target_provider, hide_idps, expected_hidden_names", + [ + ( + "Provider with shib_idps=*", + "MDQ discovery all providers", + ["https://idp.uca.fr/idp/shibboleth", "urn:mace:incommon:osu.edu"], + ["Université Clermont Auvergne", "Ohio State University"], + ), + ( + "Provider with shib_idps=[list_values]", + "Shibboleth Login some providers", + ["urn:mace:incommon:uchicago.edu"], + ["University of Chicago"], + ), + ( + "hide_idps value not in provider shib_idps", + "Shibboleth Login some providers", + ["https://idp-proxy.ugd.edu.mk"], + [], + ), + ], +) +def test_hide_idps_logins( + app, + client, + description, + target_provider, + hide_idps, + expected_hidden_names, + get_all_upstream_idps_data_patcher, +): + + # Start with empty HIDE_IDPS in config + config["HIDE_IDPS"] = [] + r = client.get("/login") + + assert r.status_code == 200, r.data + response_json = r.json + assert "providers" in response_json + response_providers = response_json["providers"] + response_provider = next( + ( + provider + for provider in response_providers + if provider["name"] == target_provider + ), + None, + ) + assert ( + response_provider + ), 'Configured login option "{}" not in /login response: {}'.format() + all_names = [x["name"] for x in response_provider["urls"]] + length_no_hides = len(all_names) + # expected length depends on patcher values. + assert length_no_hides >= 2, "Provider name list is shorter than expected" + assert all(name in all_names for name in expected_hidden_names) + + # set HIDE_IDPS in config + config["HIDE_IDPS"] = hide_idps + r = client.get("/login") + + assert r.status_code == 200, r.data + response_json = r.json + assert "providers" in response_json + response_providers = response_json["providers"] + response_provider = next( + ( + provider + for provider in response_providers + if provider["name"] == target_provider + ), + None, + ) + assert ( + response_provider + ), 'Configured login option "{}" not in /login response: {}'.format() + new_names = [x["name"] for x in response_provider["urls"]] + assert len(new_names) == length_no_hides - len( + expected_hidden_names + ), "Provider name list is incorrect length" + # names have been removed + assert all(name not in new_names for name in expected_hidden_names) + assert set(new_names) == set(all_names) - set(expected_hidden_names) diff --git a/tests/login/test_login_redirect.py b/tests/login/test_login_redirect.py index 6915fe1b2..24ab53848 100644 --- a/tests/login/test_login_redirect.py +++ b/tests/login/test_login_redirect.py @@ -12,7 +12,9 @@ import pytest from unittest.mock import MagicMock, patch -from tests.conftest import LOGIN_IDPS +from fence.blueprints.login import get_idp_route_name +from fence.config import config +from tests.conftest import all_available_idps def test_get_value_from_discovery_doc(app): @@ -21,38 +23,54 @@ def test_get_value_from_discovery_doc(app): the discovery doc in `OPENID_CONNECT`, and that default values are used when the requested key is not in the discovery doc. """ - # Scenario: config with `discovery` and no `discovery_url` (IDP generic2) + # Scenario: config with `discovery` and no `discovery_url` (IDP generic_with_discovery_block) # - get a key that is in the discovery data - authorization_endpoint = app.generic2_client.get_value_from_discovery_doc( - "authorization_endpoint", "default" + authorization_endpoint = ( + app.generic_with_discovery_block_client.get_value_from_discovery_doc( + "authorization_endpoint", "default" + ) + ) + assert ( + authorization_endpoint + == "https://generic_with_discovery_block/authorization_endpoint" ) - assert authorization_endpoint == "https://generic2/authorization_endpoint" # - get a key that is not in the discovery data - other_endpoint = app.generic2_client.get_value_from_discovery_doc( - "other_endpoint", "default" + other_endpoint = ( + app.generic_with_discovery_block_client.get_value_from_discovery_doc( + "other_endpoint", "default" + ) ) assert other_endpoint == "default" - # Scenario: config with `discovery_url` (IDP generic1) + # Scenario: config with `discovery_url` (IDP generic_with_discovery_url) class MockResponse: def __init__(self): self.status_code = 200 def json(self): - return {"authorization_endpoint": "https://generic1/authorization_endpoint"} + return { + "authorization_endpoint": "https://generic_with_discovery_url/authorization_endpoint" + } - app.generic1_client.discovery_doc = MockResponse() + app.generic_with_discovery_url_client.discovery_doc = MockResponse() # - get a key that is in the discovery data - authorization_endpoint = app.generic1_client.get_value_from_discovery_doc( - "authorization_endpoint", "default" + authorization_endpoint = ( + app.generic_with_discovery_url_client.get_value_from_discovery_doc( + "authorization_endpoint", "default" + ) + ) + assert ( + authorization_endpoint + == "https://generic_with_discovery_url/authorization_endpoint" ) - assert authorization_endpoint == "https://generic1/authorization_endpoint" # - get a key that is not in the discovery data - authorization_endpoint = app.generic1_client.get_value_from_discovery_doc( - "other_endpoint", "default" + authorization_endpoint = ( + app.generic_with_discovery_url_client.get_value_from_discovery_doc( + "other_endpoint", "default" + ) ) assert authorization_endpoint == "default" @@ -60,7 +78,7 @@ def json(self): @pytest.fixture(scope="function") def get_value_from_discovery_doc_patcher(): mocks = [] - to_patch = [e for e in LOGIN_IDPS if e not in ["fence", "shib"]] + to_patch = [e for e in all_available_idps() if e not in ["fence", "shibboleth"]] for idp in to_patch: mock = MagicMock() mock.return_value = "" @@ -83,7 +101,7 @@ def get_value_from_discovery_doc_patcher(): mock.stop() -@pytest.mark.parametrize("idp", LOGIN_IDPS) +@pytest.mark.parametrize("idp", all_available_idps()) def test_valid_redirect_base(app, client, idp, get_value_from_discovery_doc_patcher): """ Check that a valid redirect is allowed, using the base URL for this application as @@ -98,15 +116,40 @@ def test_valid_redirect_base(app, client, idp, get_value_from_discovery_doc_patc mocked_generate_authorize_redirect, ).start() - redirect = app.config["BASE_URL"] - response = client.get("/login/{}?redirect={}".format(idp, redirect)) - assert response.status_code == 302 - - if idp == "fence": - mock.stop() - + try: + redirect = app.config["BASE_URL"] + login_url = "/login/{}?redirect={}".format(get_idp_route_name(idp), redirect) -@pytest.mark.parametrize("idp", LOGIN_IDPS) + # test `authorization_url_param_map` functionality + authorization_url_param_map = config["OPENID_CONNECT"][idp].get( + "authorization_url_param_map", {} + ) + for in_param in authorization_url_param_map: + if in_param == "key_not_in_login_url": + # do not add this parameter to the login URL + continue + login_url += f"&{in_param}=param_value" + + response = client.get(login_url) + assert response.status_code == 302 + + redirect_location = response.headers["Location"] + for in_param, out_param in authorization_url_param_map.items(): + if in_param == "key_not_in_login_url": + # check that if a parameter configured in `authorization_url_param_map` is not in the + # login URL, it is not added to the redirect URL + assert f"&{out_param}=param_value" not in redirect_location + else: + # other parameters should be mapped to the configured `out_param` and added to the + # redirect URL + assert f"&{out_param}=param_value" in redirect_location + + finally: + if idp == "fence": + mock.stop() + + +@pytest.mark.parametrize("idp", all_available_idps()) def test_valid_redirect_oauth( client, oauth_client, @@ -117,14 +160,18 @@ def test_valid_redirect_oauth( Check that a valid redirect is allowed. Here we use the URL from the test OAuth client. """ - response = client.get("/login/{}?redirect={}".format(idp, oauth_client.url)) + response = client.get( + "/login/{}?redirect={}".format(get_idp_route_name(idp), oauth_client.url) + ) assert response.status_code == 302 -@pytest.mark.parametrize("idp", LOGIN_IDPS) +@pytest.mark.parametrize("idp", all_available_idps()) def test_invalid_redirect_fails(client, idp): """ Check that giving a bogus redirect to the login endpoint returns an error. """ - response = client.get("/login/{}?redirect=https://evil-site.net".format(idp)) + response = client.get( + "/login/{}?redirect=https://evil-site.net".format(get_idp_route_name(idp)) + ) assert response.status_code == 400 diff --git a/tests/login/test_login_shib.py b/tests/login/test_login_shib.py index db18aa483..f0335fcb3 100644 --- a/tests/login/test_login_shib.py +++ b/tests/login/test_login_shib.py @@ -1,6 +1,5 @@ from fence.config import config - def test_shib_redirect(client, app): r = client.get("/login/shib?redirect=http://localhost") assert r.status_code == 302 diff --git a/tests/login/test_login_user.py b/tests/login/test_login_user.py index 5ee65193b..a0cd6b62d 100644 --- a/tests/login/test_login_user.py +++ b/tests/login/test_login_user.py @@ -1,6 +1,11 @@ +from unittest import mock import flask import pytest -from fence.auth import login_user, logout +from fence.auth import ( + _identify_user_and_update_database, + login_user_or_require_registration, + logout, +) from fence.models import User, IdentityProvider import time from datetime import datetime @@ -8,7 +13,8 @@ from fence.errors import Unauthorized -def test_login_user_already_in_db(db_session): +@mock.patch("fence.auth.get_ip_information_string") +def test_login_user_already_in_db(get_ip_information_string_mock, db_session): """ Test that if a user is already in the database and logs in, the session will contain the user's information (including additional information that may have been provided @@ -25,7 +31,10 @@ def test_login_user_already_in_db(db_session): assert not test_user.email assert not test_user.id_from_idp - login_user(email, provider, email=email, id_from_idp=id_from_idp) + is_logged_in = login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) + assert is_logged_in assert test_user.identity_provider.name == provider assert test_user.id_from_idp == id_from_idp @@ -35,6 +44,8 @@ def test_login_user_already_in_db(db_session): assert flask.session["user_id"] == user_id assert flask.g.user == test_user + assert get_ip_information_string_mock.called + def test_login_failure_for_user_already_in_db_but_inactive(db_session): """ @@ -51,10 +62,12 @@ def test_login_failure_for_user_already_in_db_but_inactive(db_session): with pytest.raises( Unauthorized, match="User is known but not authorized/activated in the system" ): - login_user(email, provider, email=email, id_from_idp=id_from_idp) + login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) -def test_login_user_with_idp_already_in_db(db_session): +def test_login_user_with_idp_already_in_db(db_session, monkeypatch): """ Test that if a user is already in the database, has identity_provider configured, and logs in, the session will contain the user's information. @@ -63,6 +76,11 @@ def test_login_user_with_idp_already_in_db(db_session): provider = "Test Provider" id_from_idp = "Provider_ID_0001" + deny_regex = ( + "badactor@example.com|(.*@.*(hotmail|icloud|inbox|outlook|yeah|yahoo).*)" + ) + + monkeypatch.setitem(config, "GLOBAL_USERNAME_DENY_REGEX", deny_regex) test_user = User( username=email, email=email, id_from_idp=id_from_idp, is_admin=False ) @@ -73,7 +91,10 @@ def test_login_user_with_idp_already_in_db(db_session): db_session.commit() user_id = str(test_user.id) - login_user(email, provider, email=email, id_from_idp=id_from_idp) + is_logged_in = login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) + assert is_logged_in assert test_user.identity_provider.name == provider assert test_user.id_from_idp == id_from_idp @@ -84,7 +105,92 @@ def test_login_user_with_idp_already_in_db(db_session): assert flask.g.user == test_user -def test_login_new_user(db_session): +@pytest.mark.parametrize( + "username", + [ + "example@gmail.com", + "example@gmail.org", + "example@subdomain.gmail.com", + "example@subdomain.gmail.org", + "example.com.org.gmail@subdomain.gmail.org", + "ASDALKSJD(*)!&@#(*&^DFGA@gmail.com", + "badactor@example.com", + ], +) +def test_login_user_with_denied_username(db_session, username, monkeypatch): + """ + Test that usernames that are configured to be denied are actually denied + """ + user = "testuser" + email = "example@example.com" + provider = "Test Provider" + id_from_idp = "Provider_ID_0001" + + deny_regex = "badactor@example.com|(.*@.*(126|163|aol|bk|foxmail|gmail|hotmail|icloud|inbox|live|mail|mailinator|outlook|proton|qq|rambler|sina|yeah|yahoo|yandex).*)" + + monkeypatch.setitem(config, "GLOBAL_USERNAME_DENY_REGEX", deny_regex) + test_user = User( + username=username, + email=email, + id_from_idp=id_from_idp, + is_admin=False, + ) + test_idp = IdentityProvider(name=provider) + test_user.identity_provider = test_idp + + db_session.add(test_user) + db_session.commit() + + with pytest.raises(Unauthorized): + _identify_user_and_update_database( + user, username, provider, email=email, id_from_idp=id_from_idp + ) + + +@pytest.mark.parametrize( + "username", + [ + "example@uchicago.edu", + "example@fed.gov", + "example@subdomain.uchicago.edu", + "example@subdomain.fed.gov", + "example.com.org@subdomain.university.edu", + "ASDALKSJD(*)!&@#(*&^DFGA@uchicago.edu", + "goodactor@example.com", + ], +) +def test_login_user_with_allowed_username(db_session, username, monkeypatch): + """ + Test that valid usernames are not denied + """ + user = "testuser" + email = "example@example.com" + provider = "Test Provider" + id_from_idp = "Provider_ID_0001" + + deny_regex = "badactor@example.com|(.*@.*(126|163|aol|bk|foxmail|gmail|hotmail|icloud|inbox|live|mail|mailinator|outlook|proton|qq|rambler|sina|yeah|yahoo|yandex).*)" + + monkeypatch.setitem(config, "GLOBAL_USERNAME_DENY_REGEX", deny_regex) + test_user = User( + username=username, + email=email, + id_from_idp=id_from_idp, + is_admin=False, + ) + test_idp = IdentityProvider(name=provider) + test_user.identity_provider = test_idp + + db_session.add(test_user) + db_session.commit() + + is_logged_in = login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) + assert is_logged_in + + +@mock.patch("fence.auth.get_ip_information_string") +def test_login_new_user(get_ip_information_string_mock, db_session): """ Test that if a user is not in the database and logs in, the user is added to the database and the session will contain the user's information. @@ -93,7 +199,10 @@ def test_login_new_user(db_session): provider = "Test Provider" id_from_idp = "Provider_ID_0001" - login_user(email, provider, email=email, id_from_idp=id_from_idp) + is_logged_in = login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) + assert is_logged_in test_user = db_session.query(User).filter(User.username == email.lower()).first() @@ -105,6 +214,8 @@ def test_login_new_user(db_session): assert flask.session["user_id"] == str(test_user.id) assert flask.g.user == test_user + assert get_ip_information_string_mock.called + def test_login_new_user_not_allowed(db_session, monkeypatch): """ @@ -119,7 +230,9 @@ def test_login_new_user_not_allowed(db_session, monkeypatch): with pytest.raises( Unauthorized, match="New user is not yet authorized/activated in the system" ): - login_user(email, provider, email=email, id_from_idp=id_from_idp) + login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) def test_last_auth_update_in_db(db_session): @@ -139,7 +252,10 @@ def test_last_auth_update_in_db(db_session): time.sleep(5) - login_user(email, provider, email=email, id_from_idp=id_from_idp) + is_logged_in = login_user_or_require_registration( + email, provider, email=email, id_from_idp=id_from_idp + ) + assert is_logged_in test_user_updated = db_session.query(User).filter(User.username == email).first() assert test_user_updated._last_auth > previous_login diff --git a/tests/login/test_microsoft_login.py b/tests/login/test_microsoft_login.py index 972b8a07f..bbe8b9ebf 100755 --- a/tests/login/test_microsoft_login.py +++ b/tests/login/test_microsoft_login.py @@ -1,8 +1,11 @@ """ Tests for fence.resources.openid.microsoft_oauth2.MicrosoftOauth2Client """ + from unittest.mock import patch +from tests.rfc6749.conftest import access_token + def test_get_auth_url(microsoft_oauth2_client): """ @@ -34,9 +37,11 @@ def test_get_auth_info_missing_claim(microsoft_oauth2_client): """ return_value = {"not_email_claim": "user@contoso.com"} expected_value = {"error": "Can't get user's Microsoft email!"} + refresh_token = {} + access_token = {} with patch( "fence.resources.openid.idp_oauth2.Oauth2ClientBase.get_jwt_claims_identity", - return_value=return_value, + return_value=(return_value, refresh_token, access_token), ): user_id = microsoft_oauth2_client.get_auth_info(code="123") assert user_id == expected_value # nosec diff --git a/tests/login/test_user_registration.py b/tests/login/test_user_registration.py new file mode 100644 index 000000000..064874f49 --- /dev/null +++ b/tests/login/test_user_registration.py @@ -0,0 +1,220 @@ +import os +from unittest.mock import MagicMock, patch + +import flask +import pytest + +from fence.blueprints.login import get_idp_route_name +from fence.blueprints.login.base import _login_and_register +from fence.config import config +from fence.errors import UserError +from tests.conftest import all_available_idps +from test_login_redirect import get_value_from_discovery_doc_patcher + + +@pytest.fixture +def enable_user_registration(request): + """ + Temporarily enable `REGISTER_USERS_ON` in the config and, if requested, + `OPENID_CONNECT.mock_idp.enable_idp_users_registration`. + Revert those settings after running the test. + """ + enable_registration = getattr(request, "param", {}).get("enable_registration", True) + auto_register_users = getattr(request, "param", {}).get( + "auto_register_users", False + ) + + if enable_registration: + config["REGISTER_USERS_ON"] = True + if auto_register_users: + config["OPENID_CONNECT"]["mock_idp"] = {"enable_idp_users_registration": True} + + yield enable_registration, auto_register_users + + config["REGISTER_USERS_ON"] = False + config["OPENID_CONNECT"]["mock_idp"] = {"enable_idp_users_registration": False} + + +def test_login_existing_user_without_registration(app, db_session): + """ + Test logging in an existing user without registration. + """ + with app.app_context(): + email = "test@example.com" + provider = "Test Provider" + response, user_is_logged_in = _login_and_register(email, provider) + + assert response.status_code == 200 + assert response.json == {"username": email} + assert user_is_logged_in == True + + +@patch("fence.blueprints.login.base.add_user_registration_info_to_database") +@pytest.mark.parametrize( + "enable_user_registration", [{"auto_register_users": True}], indirect=True +) +def test_login_with_auto_registration( + mock_add_user_registration_info_to_database, app, enable_user_registration +): + """ + Test logging in a user when registration is enabled. + """ + with app.app_context(): + email = "lisa@example.com" + provider = "mock_idp" + token_result = { + "firstname": "Lisa", + "lastname": "Simpson", + "org": "Springfield Elementary", + "email": email, + } + + response, user_is_logged_in = _login_and_register( + email, provider, token_result=token_result + ) + + # Ensure response is a JSON response + assert response.status_code == 200 + assert response.json == {"username": email} + assert user_is_logged_in == True + + # Ensure user was added to the database + mock_add_user_registration_info_to_database.assert_called() + + +@pytest.mark.parametrize( + "enable_user_registration", [{"auto_register_users": True}], indirect=True +) +def test_login_with_auto_registration_and_missing_email(app, enable_user_registration): + """ + Test that a missing email raises a UserError. + """ + with app.app_context(): + provider = "mock_idp" + token_result = { + "firstname": "Lisa", + "lastname": "Simpson", + "org": "Springfield Elementary", + } + + with pytest.raises(UserError, match="OAuth2 id token is missing email claim"): + _login_and_register("lisa", provider, token_result=token_result) + + +def test_login_redirect_after_login_with_registration(app, enable_user_registration): + """ + Test that users are redirected to the registration page when IdP registration is disabled. + """ + with app.app_context(): + response, user_is_logged_in = _login_and_register("lisaferf", "mock_idp") + + assert response.status_code == 302 + assert response.location == "http://localhost/user/user/register/" + assert user_is_logged_in == False + + +def test_login_redirect_after_login_without_registration(app): + """ + Test that users are redirected to their stored session redirect after authentication. + """ + with app.app_context(): + redirect_url = "http://localhost/test-redirect" + flask.session["redirect"] = redirect_url + response, user_is_logged_in = _login_and_register("lisa", "mock_idp") + + assert response.status_code == 302 + assert response.location == redirect_url + assert user_is_logged_in == True + + +@pytest.mark.parametrize("idp", all_available_idps()) +def test_idp_login_stores_post_registration_redirect( + app, client, idp, enable_user_registration, get_value_from_discovery_doc_patcher +): + """ + Test that ALL IdPs store the `post_registration_redirect` in the current session during the + login flow. + """ + if idp == "fence": + mocked_generate_authorize_redirect = MagicMock( + return_value={"url": "authorization_url", "state": "state"} + ) + mock = patch( + f"authlib.integrations.flask_client.apps.FlaskOAuth2App.create_authorization_url", + mocked_generate_authorize_redirect, + ).start() + + try: + url = f"/login/{get_idp_route_name(idp)}?redirect={app.config['BASE_URL']}" + r = client.get(url) + assert r.status_code == 302, r.text + assert ( + flask.session.get("post_registration_redirect") == config["BASE_URL"] + url + ) + + finally: + if idp == "fence": + mock.stop() + + +@pytest.mark.parametrize("idp", all_available_idps()) +@pytest.mark.parametrize( + "enable_user_registration", + [{"enable_registration": True}, {"enable_registration": False}], + ids=["enable_registration", "disable_registration"], + indirect=True, +) +def test_idp_callback_redirects_to_registration( + app, + client, + idp, + enable_user_registration, + mocks_for_idp_oauth2_callbacks, + db_session, +): + """ + Test that ALL IdPs redirect users to the registration page when `REGISTER_USERS_ON` is enabled + in the configuration, and do not when `REGISTER_USERS_ON` is disabled. + """ + _, _, callback_endpoint, headers = mocks_for_idp_oauth2_callbacks + + r = client.get( + f"/login/{get_idp_route_name(idp)}/{callback_endpoint}", headers=headers + ) + registration_enabled, _ = enable_user_registration + if registration_enabled: + assert r.status_code == 302, r.text + assert r.location == "http://localhost/user/user/register/" + else: + assert r.status_code == 200, r.text + + +@pytest.mark.parametrize("idp", [all_available_idps()[0]]) +def test_register_endpoint( + idp, client, enable_user_registration, mocks_for_idp_oauth2_callbacks +): + """ + Before starting the login flow, the registration endpoint should return 401. + After starting the login flow, it should return an HTML page with a form. + """ + # avoid error `A secret key is required to use CSRF` when the registration form + # is created + flask.current_app.config["WTF_CSRF_ENABLED"] = False + + try: + r = client.get("/register") + assert r.status_code == 401 + + _, _, callback_endpoint, headers = mocks_for_idp_oauth2_callbacks + r = client.get( + f"/login/{get_idp_route_name(idp)}/{callback_endpoint}", headers=headers + ) + assert r.status_code == 302 + + r = client.get("/register") + assert r.status_code == 200, r.text + assert ( + "" in r.text and "" in r.text + ), "Expected an HTML form" + finally: + flask.current_app.config["WTF_CSRF_ENABLED"] = True diff --git a/tests/migrations/test_9b3a5a7145d7.py b/tests/migrations/test_9b3a5a7145d7.py index f37fadf5d..79482e492 100644 --- a/tests/migrations/test_9b3a5a7145d7.py +++ b/tests/migrations/test_9b3a5a7145d7.py @@ -5,7 +5,7 @@ from alembic.config import main as alembic_main import pytest from sqlalchemy.exc import IntegrityError -from sqlalchemy import inspect +from sqlalchemy import inspect, text from fence.models import Client from fence.utils import random_str @@ -42,20 +42,24 @@ def test_upgrade(app): ) if constraint_exists: db_session.execute( - """ + text( + """ ALTER TABLE google_service_account - DROP CONSTRAINT google_service_account_client_id_fkey; + DROP CONSTRAINT google_service_account_client_id_fkey """ + ) ) db_session.commit() db_session.execute( - """ + text( + """ ALTER TABLE google_service_account ADD CONSTRAINT google_service_account_client_id_fkey FOREIGN KEY (client_id) - REFERENCES client(client_id); + REFERENCES client(client_id) """ + ) ) db_session.commit() @@ -121,10 +125,12 @@ def test_upgrade_without_fk_constraint(app): if constraint_exists: db_session.execute( - """ + text( + """ ALTER TABLE google_service_account - DROP CONSTRAINT google_service_account_client_id_fkey; + DROP CONSTRAINT google_service_account_client_id_fkey """ + ) ) db_session.commit() diff --git a/tests/privacy-policy/test_privacy_policy.py b/tests/privacy-policy/test_privacy_policy.py index 4a5ea6f2a..4c5e72ea9 100644 --- a/tests/privacy-policy/test_privacy_policy.py +++ b/tests/privacy-policy/test_privacy_policy.py @@ -4,7 +4,7 @@ def test_markdown(client, monkeypatch, privacy_policy_md): - fence.blueprints.privacy.cache.add("privacy-policy-md", privacy_policy_md) + fence.blueprints.privacy.cache.set("privacy-policy-md", privacy_policy_md) response = client.get("/privacy-policy", headers={"Accept": "text/markdown"}) assert response.status_code == 200 assert response.data.decode("utf-8") == privacy_policy_md diff --git a/tests/ras/test_ras.py b/tests/ras/test_ras.py index f3be7575c..54c6baa87 100644 --- a/tests/ras/test_ras.py +++ b/tests/ras/test_ras.py @@ -25,7 +25,7 @@ from tests.utils import add_test_ras_user, TEST_RAS_USERNAME, TEST_RAS_SUB from tests.dbgap_sync.conftest import add_visa_manually -from fence.job.visa_update_cronjob import Visa_Token_Update +from fence.job.access_token_updater import TokenAndAuthUpdater import tests.utils from tests.conftest import get_subjects_to_passports @@ -95,6 +95,7 @@ def test_update_visa_token( """ Test to check visa table is updated when getting new visa """ + # ensure we don't actually try to reach out to external sites to refresh public keys def validate_jwt_no_key_refresh(*args, **kwargs): kwargs.update({"attempt_refresh": False}) @@ -713,7 +714,7 @@ def _get_userinfo(*args, **kwargs): mock_userinfo.side_effect = _get_userinfo # test "fence-create update-visa" - job = Visa_Token_Update() + job = TokenAndAuthUpdater() job.pkey_cache = { "https://stsstg.nih.gov": { kid: rsa_public_key, @@ -765,7 +766,7 @@ def test_map_iss_sub_pair_to_user_with_no_prior_DRS_access(db_session): ) assert username_to_log_in == username - iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get((iss, sub)) + iss_sub_pair_to_user = db_session.get(IssSubPairToUser, (iss, sub)) assert iss_sub_pair_to_user.user.username == username assert iss_sub_pair_to_user.user.email == email iss_sub_pair_to_user_records = db_session.query(IssSubPairToUser).all() @@ -803,7 +804,7 @@ def test_map_iss_sub_pair_to_user_with_prior_DRS_access( get_or_create_gen3_user_from_iss_sub(iss, sub, db_session=db_session) iss_sub_pair_to_user_records = db_session.query(IssSubPairToUser).all() assert len(iss_sub_pair_to_user_records) == 1 - iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get((iss, sub)) + iss_sub_pair_to_user = db_session.get(IssSubPairToUser, (iss, sub)) assert iss_sub_pair_to_user.user.username == "123_abcdomain.tld" username_to_log_in = ras_client.map_iss_sub_pair_to_user( @@ -813,7 +814,7 @@ def test_map_iss_sub_pair_to_user_with_prior_DRS_access( assert username_to_log_in == username iss_sub_pair_to_user_records = db_session.query(IssSubPairToUser).all() assert len(iss_sub_pair_to_user_records) == 1 - iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get((iss, sub)) + iss_sub_pair_to_user = db_session.get(IssSubPairToUser, (iss, sub)) assert iss_sub_pair_to_user.user.username == username assert iss_sub_pair_to_user.user.email == email @@ -884,5 +885,5 @@ def test_map_iss_sub_pair_to_user_with_prior_login_and_prior_DRS_access( iss, sub, username, email, db_session=db_session ) assert username_to_log_in == "123_abcdomain.tld" - iss_sub_pair_to_user = db_session.query(IssSubPairToUser).get((iss, sub)) + iss_sub_pair_to_user = db_session.get(IssSubPairToUser, (iss, sub)) assert iss_sub_pair_to_user.user.username == "123_abcdomain.tld" diff --git a/tests/resources/awg_user.json b/tests/resources/awg_user.json index e5d3e1101..db894d7f6 100644 --- a/tests/resources/awg_user.json +++ b/tests/resources/awg_user.json @@ -1,31 +1,35 @@ { - "awg_user" : { - "groups":[ - { - "name": "test_group_1", - "description": "the first test group", - "projects": ["test_project_1"] - }, - { - "name": "test_group_2", - "description": "the second test group", - "projects": ["test_project_1", "test_project_2"] - } - ], - "projects":[ + "awg_user": { + "groups": [ + { + "name": "test_group_1", + "description": "the first test group", + "projects": [ + "test_project_1" + ] + }, + { + "name": "test_group_2", + "description": "the second test group", + "projects": [ + "test_project_1", + "test_project_2" + ] + } + ], + "projects": [ { "name": "test_project_1", "auth_id": "phs_project_1" }, { - "name": "test_project_2", + "name": "test_project_2", "auth_id": "phs_project_2" } ] - } -, - "awg_user_2" : { - "groups":[], - "projects":[] + }, + "awg_user_2": { + "groups": [], + "projects": [] } } diff --git a/tests/resources/providers.json b/tests/resources/providers.json index 9c27f35d3..c106c2535 100644 --- a/tests/resources/providers.json +++ b/tests/resources/providers.json @@ -1,48 +1,46 @@ { -"providers": - [ - { - "name": "provider_a", - "backend": "cleversafe", - "service": "storage" - }, - { - "name": "provider_b", - "backend": "cleversafe", - "service": "storage" - }, - { - "name": "test-cleversafe", - "backend": "cleversafe", - "service": "storage" - } - ], -"users": - { - "user_one": - { - "email": "notreal@pepe.com", - "is_admin": false - } - }, -"projects": - [ - { - "name": "project_with_bucket", - "auth_id": "project_with_bucket_phs", - "storage_access": ["test-cleversafe"], - "users": [ + "providers": [ + { + "name": "provider_a", + "backend": "cleversafe", + "service": "storage" + }, { - "name": "user_one", - "privileges": "read-storage" + "name": "provider_b", + "backend": "cleversafe", + "service": "storage" + }, + { + "name": "test-cleversafe", + "backend": "cleversafe", + "service": "storage" + } + ], + "users": { + "user_one": { + "email": "notreal@pepe.com", + "is_admin": false } - ], - "buckets": [ + }, + "projects": [ { - "name": "first_bucket", - "provider": "test-cleversafe" + "name": "project_with_bucket", + "auth_id": "project_with_bucket_phs", + "storage_access": [ + "test-cleversafe" + ], + "users": [ + { + "name": "user_one", + "privileges": "read-storage" + } + ], + "buckets": [ + { + "name": "first_bucket", + "provider": "test-cleversafe" + } + ] } - ] - } - ] + ] } diff --git a/tests/rfc6749/test_oauth2.py b/tests/rfc6749/test_oauth2.py index 89879b550..7ac3316a9 100644 --- a/tests/rfc6749/test_oauth2.py +++ b/tests/rfc6749/test_oauth2.py @@ -91,25 +91,6 @@ def test_oauth2_token_refresh_public_client(oauth_test_client_public, refresh_da oauth_test_client_public.refresh(data=refresh_data) -def test_oauth2_token_post_revoke(oauth_test_client): - """ - Test the following procedure: - - ``POST /oauth2/authorize`` successfully to obtain code - - ``POST /oauth2/token`` successfully to obtain token - - ``POST /oauth2/revoke`` to revoke the refresh token - - Refresh token should no longer be usable at this point. - """ - data = {"confirm": "yes"} - oauth_test_client.authorize(data=data) - oauth_test_client.token() - oauth_test_client.revoke() - # Try to use refresh token. - refresh_token = oauth_test_client.token_response.refresh_token - oauth_test_client.refresh(refresh_token, do_asserts=False) - response = oauth_test_client.refresh_response.response - assert response.status_code == 400 - - def test_oauth2_with_client_credentials( oauth_client_with_client_credentials, oauth_test_client_with_client_credentials ): diff --git a/tests/rfc6749/test_revoke.py b/tests/rfc6749/test_revoke.py index 2b276c881..4102cb084 100644 --- a/tests/rfc6749/test_revoke.py +++ b/tests/rfc6749/test_revoke.py @@ -1,34 +1,378 @@ -from fence.jwt.blacklist import is_token_blacklisted +import time + +import jwt +from fence.auth import GEN3_AUDIENCE +import pytest +from fence.config import config +from fence.jwt.blacklist import is_token_blacklisted +from fence.models import User +from tests import utils +from tests.utils.api_key import get_api_key from tests.utils.oauth2 import create_basic_header_for_client -def test_blacklisted_token(client, oauth_client, encoded_jwt_refresh_token): +def test_oauth2_token_post_revoke(oauth_test_client): """ - Revoke a JWT and test that it registers as blacklisted. + Test the following procedure: + - ``POST /oauth2/authorize`` successfully to obtain code + - ``POST /oauth2/token`` successfully to obtain token + - ``POST /oauth2/revoke`` to revoke the refresh token + - Refresh token should no longer be usable at this point. """ - headers = create_basic_header_for_client(oauth_client) + data = {"confirm": "yes"} + oauth_test_client.authorize(data=data) + oauth_test_client.token() + oauth_test_client.revoke() + # Try to use refresh token. + refresh_token = oauth_test_client.token_response.refresh_token + oauth_test_client.refresh(refresh_token, do_asserts=False) + response = oauth_test_client.refresh_response.response + assert response.status_code == 400 + + +@pytest.mark.parametrize("revoker", ["user", "client"]) +def test_denylisted_token( + client, oauth_client, encoded_jwt, encoded_jwt_refresh_token, revoker +): + """ + Revoke a JWT and test that it registers as denylisted. + """ + if revoker == "user": + headers = { + "Authorization": f"Bearer {encoded_jwt}", + "Content-Type": "application/x-www-form-urlencoded", + } + else: # revoke == "client" + headers = create_basic_header_for_client(oauth_client) data = {"token": encoded_jwt_refresh_token} response = client.post("/oauth2/revoke", headers=headers, data=data) - print(encoded_jwt_refresh_token) - import jwt - - print( - jwt.decode( - encoded_jwt_refresh_token, - options={"verify_signature": False}, - algorithms=["RS256"], - ) - ) assert response.status_code == 200, response.data - assert is_token_blacklisted(encoded_jwt_refresh_token) + _, is_denylisted = is_token_blacklisted(encoded_jwt_refresh_token) + assert is_denylisted -def test_cannot_revoke_access_token(client, oauth_client, encoded_jwt): + +def test_revoke_expired_token(client, oauth_client, kid, rsa_private_key): """ - Test that attempting to revoke an access token fails and return a 200 (per RFC 7009). + Test that attempting to revoke an invalid token (expired token) should return 200 """ headers = create_basic_header_for_client(oauth_client) - data = {"token": encoded_jwt} - response = client.post("/oauth2/revoke", headers=headers, data=data) - assert response.status_code == 200, response.data + expired_access_token = jwt.encode( + {**utils.default_claims(), "exp": int(time.time()) - 1000}, + key=rsa_private_key, + headers={ + "type": "JWT", + "alg": "RS256", + "kid": kid, + }, + algorithm="RS256", + ) + response = client.post( + "/oauth2/revoke", headers=headers, data={"token": expired_access_token} + ) + assert response.status_code == 200, response.text + + +def test_revoke_malformed_token(client, oauth_client): + """ + Test that attempting to revoke an invalid token type through the + "/oauth2/revoke" endpoint fails and returns a 200 (per RFC 7009). + """ + + headers = create_basic_header_for_client(oauth_client) + response = client.post( + "/oauth2/revoke", + headers={**headers, "Content-Type": "application/x-www-form-urlencoded"}, + data={"token": "blah"}, + ) + assert response.status_code == 200, response.text + + +def test_revoke_non_revocable_token(client, oauth_client, kid, rsa_private_key): + """ + Test that attempting to revoke a valid ID token (wrong token type, but not + invalid) through the "/oauth2/revoke" endpoint fails, unlike other invalid + tokens which return 200 per RFC 7009. + + """ + headers = create_basic_header_for_client(oauth_client) + id_token = jwt.encode( + {"iat": int(time.time())}, key=rsa_private_key, algorithm="RS256" + ) + response = client.post("/oauth2/revoke", headers=headers, data={"token": id_token}) + assert response.status_code == 400, response.text + + +def test_denylist_invalid_token(client): + """ + Test that checking if an invalid token is denylisted returns 200 + """ + response = client.post( + "/credentials/token/denylisted", headers={"Authorization": "bearer blah"} + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("revoker", ["user", "client"]) +def test_revoke_regular_access_token( + client, oauth_client, encoded_jwt, db_session, mock_arborist_requests, revoker +): + """ + Test that a user or client cannot revoke a regular access token through the "/oauth2/revoke" + endpoint, since we only support revoking task access tokens, refresh tokens and API keys. + """ + # create a user and check that they can access their own info using their own token + headers = {"Authorization": f"bearer {encoded_jwt}"} + db_session.add(User(id=utils.default_claims()["sub"], username="test-user")) + db_session.commit() + mock_arborist_requests() + response = client.get("/user", headers=headers) + assert response.status_code == 200, response.text + + if revoker == "user": + response = client.post("/oauth2/revoke", headers=headers) + assert response.status_code == 400, response.text + elif revoker == "client": + response = client.post( + "/oauth2/revoke", + headers=create_basic_header_for_client(oauth_client), + data={"token": encoded_jwt}, + ) + assert response.status_code == 400, response.text + + # the token should not be denylisted, since it was not revoked + response = client.post("/credentials/token/denylisted", headers=headers) + assert response.status_code == 200, response.text + + # the token should still be usable + response = client.get("/user", headers={"Authorization": f"bearer {encoded_jwt}"}) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("token_type", ["api_key", "task_token"]) +def test_revoke_token(client, encoded_creds_jwt, mock_arborist_requests, token_type): + """ + Test that a user can revoke an API key or a task access token through the + "/oauth2/revoke" endpoint, and that a revoked token is rejected by the API. + """ + mock_arborist_requests( + { + "arborist/auth/request": {"POST": ({"auth": True}, 200)}, + } + ) + encoded_jwt = encoded_creds_jwt["jwt"] + response = get_api_key(client, encoded_jwt) + assert response.status_code == 200, response.text + api_key = response.json["api_key"] + + if token_type == "task_token": + response = client.post( + "/credentials/api/access_token?task_token=FOO", + data={"api_key": api_key}, + headers={"Authorization": "Bearer " + str(encoded_jwt)}, + ) + assert response.status_code == 200, response.text + token = response.json["access_token"] + else: + token = api_key + + # the token should not be denylisted yet + response = client.post( + "/credentials/token/denylisted", + headers={"Authorization": f"Bearer {token}"}, + json={"token": token}, + ) + assert response.status_code == 200, response.text + + # revoke the token + response = client.post( + "/oauth2/revoke", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/x-www-form-urlencoded", + }, + data={"token": token}, + ) + assert response.status_code == 200, response.text + + # the token should be denylisted now + response = client.post( + "/credentials/token/denylisted", + headers={"Authorization": f"Bearer {token}"}, + json={"token": token}, + ) + assert response.status_code == 403, response.text + + # the token should not be usable anymore + if token_type == "task_token": + response = client.get("/user", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 401, response.text + else: + response = client.post( + "/credentials/api/access_token?task_token=FOO", + data={"api_key": token}, + headers={"Authorization": "Bearer " + str(encoded_jwt)}, + ) + assert response.status_code == 401, response.text + + +def test_revoke_requires_form_urlencoded(client, encoded_creds_jwt): + """/oauth2/revoke only accepts application/x-www-form-urlencoded.""" + + encoded_jwt = encoded_creds_jwt["jwt"] + response = get_api_key(client, encoded_jwt) + api_key = response.json["api_key"] + + response = client.post( + "/oauth2/revoke", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={"token": api_key}, + ) + assert response.status_code == 400, response.text + + +def test_denylisted_expired_task_token(client, oauth_client, kid, rsa_private_key): + """ + Test that denylisted expired task tokens are still detected as denylisted + """ + token_lifetime = 2 + + task_token_claims = utils.default_claims() + task_token_claims["context"]["task_token_type"] = "FOO" + # revoke an access token + expired_access_token = jwt.encode( + { + **task_token_claims, + "aud": [GEN3_AUDIENCE, "FOO"], + "exp": int(time.time()) + token_lifetime, + }, + key=rsa_private_key, + headers={ + "type": "JWT", + "alg": "RS256", + "kid": kid, + }, + algorithm="RS256", + ) + response = client.post( + "/oauth2/revoke", + headers=create_basic_header_for_client(oauth_client), + data={"token": expired_access_token}, + ) + assert response.status_code == 200, response.text + + # wait for the token to expire + time.sleep(token_lifetime + 1) + + # checking if the expired token is denylisted should return 403 + response = client.post( + "/credentials/token/denylisted", + headers={"Authorization": f"bearer {expired_access_token}"}, + ) + assert response.status_code == 403, response.text + + +def test_revoke_endpoint_anonymous(client): + """ + Test the token revoke endpoint with anonymous calls + """ + + response = client.post("/oauth2/revoke") + assert response.status_code == 400, response.text + + +def test_denylisted_endpoint_anonymous(client): + """ + Test the token denylisting endpoints with anonymous calls + """ + + response = client.post("/credentials/token/denylisted") + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize( + "revoker", + [ + "self", + "authorized_user", + "unauthorized_user", + "authorized_anon", + "unauthorized_anon", + ], +) +def test_revoke_task_token_access( + client, encoded_creds_jwt, kid, rsa_private_key, mock_arborist_requests, revoker +): + """ + Test that users can only revoke their own tokens, unless they have admin access in arborist + """ + + # Indicates that all users are authorized to fetch task tokens + authorized_to_fetch_task_token = True + mock_arborist_requests( + { + "arborist/auth/request": { + "POST": ({"auth": authorized_to_fetch_task_token}, 200) + }, + } + ) + + encoded_jwt = encoded_creds_jwt["jwt"] + response = get_api_key(client, encoded_jwt) + assert response.status_code == 200, response.text + api_key = response.json["api_key"] + + # obtain a task token + response = client.post( + f"/credentials/api/access_token?task_token=FOO", + data={"api_key": api_key}, + headers={"Authorization": "Bearer " + str(encoded_jwt)}, + ) + assert response.status_code == 200, response.text + assert "access_token" in response.json + task_token = response.json["access_token"] + + authorized_to_revoke = revoker.startswith("authorized_") + mock_arborist_requests( + { + "arborist/auth/request": {"POST": ({"auth": authorized_to_revoke}, 200)}, + } + ) + # attempt to revoke the token + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if revoker == "self": + headers["Authorization"] = f"bearer {task_token}" + elif revoker.endswith("_user"): + claims = utils.default_claims() + claims["context"]["user"]["name"] = "non-admin-user" + claims["sub"] = "80" + token = jwt.encode( + claims, key=rsa_private_key, headers={"kid": kid}, algorithm="RS256" + ) + headers["Authorization"] = f"bearer {token}" + response = client.post( + "/oauth2/revoke", headers=headers, data={"token": task_token} + ) + assert ( + response.status_code == 200 if authorized_to_revoke else 403 + ), f"{authorized_to_revoke=}; {response.status_code=}" + + # the token should be denylisted, unless the revoker does not have access to revoke it + response = client.post( + "/credentials/token/denylisted", + headers={"Authorization": f"Bearer {task_token}"}, + json={"token": task_token}, + ) + assert ( + response.status_code == 403 if authorized_to_revoke else 200 + ), f"{authorized_to_revoke=}; {response.status_code=}" + + # the token should not be usable anymore, unless the revoker does not have access to revoke it + response = client.get("/user", headers={"Authorization": f"Bearer {task_token}"}) + assert ( + response.status_code == 401 if authorized_to_revoke else 200 + ), f"{authorized_to_revoke=}; {response.status_code=}" diff --git a/tests/scripting/test_fence-create.py b/tests/scripting/test_fence-create.py index deae90d34..5c05cb76b 100644 --- a/tests/scripting/test_fence-create.py +++ b/tests/scripting/test_fence-create.py @@ -819,7 +819,6 @@ def test_delete_expired_service_accounts_with_one_fail_first( from googleapiclient.errors import HttpError import fence - fence.settings = MagicMock() gen3cirrus.config.update = MagicMock() cloud_manager.return_value.__enter__.return_value.remove_member_from_group.side_effect = [ HttpError(mock.Mock(status=403), bytes("Permission denied", "utf-8")), @@ -870,7 +869,6 @@ def test_delete_expired_service_accounts_with_one_fail_second( from googleapiclient.errors import HttpError import fence - fence.settings = MagicMock() cloud_manager.return_value.__enter__.return_value.remove_member_from_group.side_effect = [ {}, HttpError(mock.Mock(status=403), bytes("Permission denied", "utf-8")), @@ -918,7 +916,6 @@ def test_delete_expired_service_accounts(cloud_manager, app, db_session): """ import fence - fence.settings = MagicMock() cloud_manager.return_value.__enter__.return_value.remove_member_from_group.return_value = ( {} ) @@ -968,7 +965,6 @@ def test_delete_not_expired_service_account(app, db_session): """ import fence - fence.settings = MagicMock() _setup_google_access(db_session) service_account = db_session.query(UserServiceAccount).first() google_bucket_access_grp1 = db_session.query(GoogleBucketAccessGroup).first() @@ -1000,8 +996,6 @@ def test_delete_not_expired_google_access(app, db_session): """ import fence - fence.settings = MagicMock() - current_time = int(time.time()) # 1 not expired, 2 not expired access_1_expires = current_time + 3600 @@ -1039,8 +1033,6 @@ def test_delete_not_specified_expiration_google_access(app, db_session): """ import fence - fence.settings = MagicMock() - current_time = int(time.time()) access_1_expires = None access_2_expires = None @@ -1076,7 +1068,6 @@ def test_delete_expired_google_access(cloud_manager, app, db_session): """ import fence - fence.settings = MagicMock() cloud_manager.return_value.__enter__.return_value.remove_member_from_group.return_value = ( {} ) @@ -1130,7 +1121,6 @@ def test_delete_expired_google_access_with_one_fail_first( from googleapiclient.errors import HttpError import fence - fence.settings = MagicMock() gen3cirrus.config.update = MagicMock() cloud_manager.return_value.__enter__.return_value.remove_member_from_group.side_effect = [ HttpError(mock.Mock(status=403), bytes("Permission denied", "utf-8")), @@ -1214,7 +1204,6 @@ def test_verify_bucket_access_group_no_interested_accounts( """ import fence - fence.settings = MagicMock() ( cloud_manager.return_value.__enter__.return_value.get_group_members.return_value ) = [ @@ -1242,7 +1231,6 @@ def test_verify_bucket_access_group(app, cloud_manager, db_session, setup_test_d """ import fence - fence.settings = MagicMock() ( cloud_manager.return_value.__enter__.return_value.get_group_members.return_value ) = [ @@ -1419,7 +1407,6 @@ def test_delete_expired_service_account_keys_for_user( """ import fence - fence.settings = MagicMock() cloud_manager.return_value.__enter__.return_value.delete_service_account_key.return_value = ( {} ) @@ -1470,7 +1457,6 @@ def test_delete_expired_service_account_keys_for_client( """ import fence - fence.settings = MagicMock() cloud_manager.return_value.__enter__.return_value.delete_service_account_key.return_value = ( {} ) @@ -1528,7 +1514,6 @@ def test_delete_expired_service_account_keys_both_user_and_client( """ import fence - fence.settings = MagicMock() cloud_manager.return_value.__enter__.return_value.delete_service_account_key.return_value = ( {} ) diff --git a/tests/storageclient/storage_client_mock.py b/tests/storageclient/storage_client_mock.py index 5867d13da..2459c3db0 100644 --- a/tests/storageclient/storage_client_mock.py +++ b/tests/storageclient/storage_client_mock.py @@ -89,7 +89,7 @@ def delete_user(self, name): if name in self.users.keys(): del self.users[name] else: - raise NotFoundError("User doesn't exists") + raise NotFoundError("User doesn't exist") def delete_keypair(self, name, access_key): """ diff --git a/tests/test-fence-config.yaml b/tests/test-fence-config.yaml index d0638a091..cec3ab45f 100755 --- a/tests/test-fence-config.yaml +++ b/tests/test-fence-config.yaml @@ -44,7 +44,7 @@ ENCRYPTION_KEY: '' # ////////////////////////////////////////////////////////////////////////////////////// # flask's debug setting # WARNING: DO NOT ENABLE IN PRODUCTION -DEBUG: true +DEBUG: false # if true, will automatically login a user with username "test" MOCK_AUTH: true # if true, will only fake a successful login response from Google in /login/google @@ -124,23 +124,56 @@ OPENID_CONNECT: client_id: '' client_secret: '' redirect_url: '{{BASE_URL}}/login/fence/login' + shibboleth_discovery_url: 'https://shibboleth_discovery_url/DiscoFeed' shibboleth: client_id: '' client_secret: '' redirect_url: '{{BASE_URL}}/login/shib/login' - generic1: + generic_with_discovery_url: name: 'Generic OIDC IDP 1' client_id: '' client_secret: '' - redirect_url: '{{BASE_URL}}/login/generic1/login' - user_id_field: 'generic1_username' - discovery_url: 'https://generic1/.well-known' - generic2: + redirect_url: '{{BASE_URL}}/login/generic_with_discovery_url/login' + user_id_field: 'generic_with_discovery_url_username' + discovery_url: 'https://generic_with_discovery_url/.well-known' + generic_with_discovery_block: client_id: '' client_secret: '' - redirect_url: '{{BASE_URL}}/login/generic2/login' + redirect_url: '{{BASE_URL}}/login/generic_with_discovery_block/login' discovery: - authorization_endpoint: 'https://generic2/authorization_endpoint' + authorization_endpoint: 'https://generic_with_discovery_block/authorization_endpoint' + generic_mdq_discovery: + client_id: '' + client_secret: '' + redirect_url: '{{BASE_URL}}/login/generic_mdq_discovery/login' + discovery: + authorization_endpoint: 'https://generic_mdq_discovery/authorization_endpoint' + idp_discovery: + url: 'https://generic_mdq_discovery/get-all-idps' + format: xml-mdq-v1.0 + authorization_url_param_map: + idp: idp_hint + key_not_in_login_url: something + generic_mdq_discovery_without_idp_discovery: + client_id: '' + client_secret: '' + redirect_url: '{{BASE_URL}}/login/generic_mdq_discovery_without_idp_discovery/login' + discovery: + authorization_endpoint: 'https://generic_mdq_discovery_without_idp_discovery/authorization_endpoint' + generic_additional_params: + client_id: '' + client_secret: '' + redirect_url: '{{BASE_URL}}/login/generic_additional_params/login' + discovery_url: 'https://generic_additional_params/.well-known/openid-configuration' + persist_refresh_token: false + is_authz_groups_sync_enabled: false + group_claim_field: "groups" + group_membership_expiration_duration: 604800 + authz_groups_sync: + group_prefix: /covid + verify_aud: false + audience: fence + default_refresh_token_exp: 3600 # these are the *possible* scopes a client can be given, NOT scopes that are # given to all clients. You can be more restrictive during client creation @@ -163,6 +196,11 @@ USER_ALLOWED_SCOPES: - "google_service_account" - "google_link" +TASK_TOKEN_DEFAULT_SCOPES: + - "fence" + - "openid" + - "user" + # these are the scopes that a browser session can create for a user (very # similar to USER_ALLOWED_SCOPES, as the session will actually create access_tokens # for an actively logged in user) @@ -208,15 +246,29 @@ LOGIN_OPTIONS: secondary: True # optional parameter - name: 'Fence Multi-Tenant Login' idp: fence - - name: 'InCommon Login' + - name: 'Shibboleth Login all providers' + idp: fence + fence_idp: shibboleth + shib_idps: "*" + - name: 'Shibboleth Login some providers' idp: fence fence_idp: shibboleth shib_idps: - - urn:mace:incommon:nih.gov - - urn:mace:incommon:uchicago.edu - - name: 'Orcid Login' + - urn:mace:incommon:uchicago.edu + - https://idp.uca.fr/idp/shibboleth + - name: 'Shibboleth Login with upstream_idps' + idp: fence + upstream_idps: + - shibboleth + shib_idps: + - urn:mace:incommon:uchicago.edu + - name: 'Orcid Login with deprecated fence_idp' idp: fence fence_idp: orcid + - name: 'Orcid Login with upstream_idps' + idp: fence + upstream_idps: + - orcid - name: 'CILogon Login' idp: cilogon - name: 'Microsoft Login' @@ -228,9 +280,21 @@ LOGIN_OPTIONS: - name: 'RAS Login' idp: ras - name: 'Generic login 1' - idp: generic1 + idp: generic_with_discovery_url - name: 'Generic login 2' - idp: generic2 + idp: generic_with_discovery_block + - name: 'MDQ discovery all providers' + idp: generic_mdq_discovery + upstream_idps: "*" + - name: 'MDQ discovery some providers' + idp: generic_mdq_discovery + upstream_idps: + - urn:mace:incommon:uchicago.edu + - https://idp.uca.fr/idp/shibboleth + - name: 'MDQ discovery without idp_discovery some providers' + idp: generic_mdq_discovery_without_idp_discovery + upstream_idps: + - urn:mace:incommon:uchicago.edu # ////////////////////////////////////////////////////////////////////////////////////// # LIBRARY CONFIGURATION (flask) @@ -291,9 +355,14 @@ MAX_PRESIGNED_URL_TTL: 3600 # The number of seconds after an API KEY is issued until it expires. MAX_API_KEY_TTL: 2592000 -# The number of seconds after an access token is issued until it expires. +# The number of seconds after an access token is issued from an API key until it expires. MAX_ACCESS_TOKEN_TTL: 3600 +MAX_TASK_TOKEN_TTL: + FOO: 4000 + +ALLOWED_TASK_TOKEN_TYPES: ["FOO", "NOT_IN_CONFIG", "NOT_IN_MAPPING"] + ######################################################################################## # OPTIONAL CONFIGURATIONS # @@ -316,6 +385,10 @@ SHIBBOLETH_HEADER: 'persistent_id' SSO_URL: 'https://auth.nih.gov/affwebservices/public/saml2sso?SPID={{BASE_URL}}/shibboleth&RelayState=' ITRUST_GLOBAL_LOGOUT: 'https://auth.nih.gov/siteminderagent/smlogout.asp?mode=nih&AppReturnUrl=' +# The following names will be hidden from the InCommons drop down list when shib_idps=* +# eg, HIDE_IDPS = ["The University of Texas Southwestern Medical Center"] +HIDE_IDPS: [] + # ////////////////////////////////////////////////////////////////////////////////////// # dbGaP USER SYNCING SUPPORT # - Support syncing authorization information from dbGaP diff --git a/tests/test_app_config.py b/tests/test_app_config.py index ec7b7b8b7..66aef23a1 100755 --- a/tests/test_app_config.py +++ b/tests/test_app_config.py @@ -1,8 +1,10 @@ """ Test App Config from files """ + import os from unittest.mock import MagicMock +from flask import Flask from mock import patch import pytest @@ -10,7 +12,6 @@ import fence from fence import app_init, _check_azure_storage from fence.config import FenceConfig -from tests import test_settings from tests.conftest import FakeBlobServiceClient @@ -103,9 +104,10 @@ def test_app_config(): patcher.start() patchers.append(patcher) + # create a fresh local app + local_app = Flask("test_app_config") app_init( - fence.app, - test_settings, + local_app, root_dir=root_dir, config_path=os.path.join(root_dir, config_path), ) diff --git a/tests/test_drs.py b/tests/test_drs.py index 9e95aaab6..61be217ca 100644 --- a/tests/test_drs.py +++ b/tests/test_drs.py @@ -6,7 +6,15 @@ import pytest import requests import responses + +from fence.blueprints.ga4gh import BulkObjectAccessRequest, BulkObjectAccessIds + +from fence.blueprints.data.indexd import ( + SUPPORTED_PROTOCOLS, + PROTOCOLS_REQUIRING_GOOGLE_SYNC, +) from tests import utils +from tests.conftest import NoAsyncMagicMock import time from unittest.mock import MagicMock, patch @@ -290,7 +298,7 @@ def test_passport_use_disabled( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -449,7 +457,7 @@ def test_get_presigned_url_for_non_public_data_with_passport( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -607,7 +615,7 @@ def test_get_presigned_url_with_passport_with_incorrect_authz( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": False}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -765,7 +773,7 @@ def test_get_presigned_url_for_public_data_with_no_passport( indexd_record_with_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group access_id = indexd_client["indexed_file_location"] @@ -825,7 +833,7 @@ def test_get_presigned_url_for_non_public_data_with_no_passport( indexd_record_with_public_authz_and_non_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": False}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group access_id = indexd_client["indexed_file_location"] @@ -902,7 +910,7 @@ def test_passport_cache_valid_passport( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -1055,7 +1063,7 @@ def test_passport_cache_valid_passport( after_cache_time = after_cache_end - after_cache_start assert res.status_code == 200 # make sure using the cache is faster - assert after_cache_time < before_cache_time + # assert after_cache_time < before_cache_time @responses.activate @@ -1115,7 +1123,7 @@ def test_passport_cache_invalid_passport( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": False}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -1332,7 +1340,7 @@ def test_passport_cache_expired_in_memory_valid_in_db( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -1563,7 +1571,7 @@ def test_passport_cache_expired( indexd_record_with_non_public_authz_and_public_acl_populated ) mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) - mock_arborist.return_value = MagicMock(ArboristClient) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) mock_google_proxy_group.return_value = google_proxy_group # Prepare Passport/Visa @@ -1764,3 +1772,495 @@ def test_get_presigned_url_with_client_token( signed_url = response.json.get("url") assert signed_url + + +@patch("fence.blueprints.ga4gh.bulk_get_signed_url_for_file") +def test_get_ga4gh_signed_urls_success(mock_bulk_get_signed_url_for_file, client): + """ + Tests that the summary, resolved and unresolved counts are correct and the access urls are correct when multiple + presigned urls are requested + """ + mock_bulk_get_signed_url_for_file.return_value = { + "urls": [ + { + "drs_object_id": "object-1", + "url": "https://example.com/object-1", + "headers": [], + }, + { + "drs_object_id": "object-2", + "url": "https://example.com/object-2", + "headers": [], + }, + ], + "failed_guids": [], + } + + data = { + "bulk_object_access_ids": [ + { + "bulk_object_id": "object-1", + "bulk_access_ids": ["s3"], + }, + { + "bulk_object_id": "object-2", + "bulk_access_ids": ["s3"], + }, + ] + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={"Content-Type": "application/json"}, + data=json.dumps(data), + ) + + assert res.status_code == 200 + assert res.json == { + "summary": { + "requested": 2, + "resolved": 2, + "unresolved": 0, + }, + "unresolved_drs_objects": [], + "resolved_drs_object_access_urls": [ + { + "drs_object_id": "object-1", + "drs_access_id": "s3", + "url": "https://example.com/object-1", + "headers": [], + }, + { + "drs_object_id": "object-2", + "drs_access_id": "s3", + "url": "https://example.com/object-2", + "headers": [], + }, + ], + } + + mock_bulk_get_signed_url_for_file.assert_called_once_with( + guids=["object-1", "object-2"], + requested_protocol="s3", + ga4gh_passports=None, + r_pays_project=None, + no_force_sign_param=None, + ) + + +@patch("fence.blueprints.ga4gh.bulk_get_signed_url_for_file") +def test_get_ga4gh_signed_urls_multiple_access_ids( + mock_bulk_get_signed_url_for_file, client +): + """ + Tests that the summary, resolved and unresolved counts are correct and the access urls are correct when multiple + presigned urls are requested with DIFFERING access_ids + """ + mock_bulk_get_signed_url_for_file.side_effect = [ + { + "urls": [ + { + "drs_object_id": "object-1", + "url": "https://example.com/object-1-s3", + "headers": [], + } + ], + "failed_guids": [], + }, + { + "urls": [ + { + "drs_object_id": "object-1", + "url": "https://example.com/object-1-gs", + "headers": [], + } + ], + "failed_guids": [], + }, + ] + + data = { + "bulk_object_access_ids": [ + { + "bulk_object_id": "object-1", + "bulk_access_ids": ["s3", "gs"], + } + ] + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={"Content-Type": "application/json"}, + data=json.dumps(data), + ) + + assert res.status_code == 200 + assert res.json["summary"] == { + "requested": 2, + "resolved": 2, + "unresolved": 0, + } + assert res.json["resolved_drs_object_access_urls"] == [ + { + "drs_object_id": "object-1", + "drs_access_id": "s3", + "url": "https://example.com/object-1-s3", + "headers": [], + }, + { + "drs_object_id": "object-1", + "drs_access_id": "gs", + "url": "https://example.com/object-1-gs", + "headers": [], + }, + ] + + +@patch("fence.blueprints.ga4gh.bulk_get_signed_url_for_file") +def test_get_ga4gh_signed_urls_request_too_large( + mock_bulk_get_signed_url_for_file, client, monkeypatch +): + """ + Tests the response when a bulk request exceeds the configured limit + """ + monkeypatch.setitem(config, "MAX_BULK_DRS_REQUESTS", 1) + + data = { + "bulk_object_access_ids": [ + { + "bulk_object_id": "object-1", + "bulk_access_ids": ["s3"], + }, + { + "bulk_object_id": "object-2", + "bulk_access_ids": ["s3"], + }, + ] + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={"Content-Type": "application/json"}, + data=json.dumps(data), + ) + + assert res.status_code == 413 + assert res.json == { + "error_code": 413, + "error_description": ("Request too large: 2 objects requested, maximum is 1"), + } + + mock_bulk_get_signed_url_for_file.assert_not_called() + + +def test_get_ga4gh_signed_urls_invalid_request_body(client): + """ + Test that the API rejects requests when the body does not conform to spec. + """ + data = { + "bulk_object_access_ids": [ + { + "bulk_object_id": "object-1", + } + ] + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={"Content-Type": "application/json"}, + data=json.dumps(data), + ) + + assert res.status_code == 400 + + +@patch("fence.blueprints.ga4gh.bulk_get_signed_url_for_file") +def test_get_ga4gh_signed_urls_with_auth_header_and_passports( + mock_bulk_get_signed_url_for_file, client +): + """ + Test bulk requests are rejected when given a passport and a bearer token + """ + data = { + "passports": ["passport-token"], + "bulk_object_access_ids": [ + { + "bulk_object_id": "object-1", + "bulk_access_ids": ["s3"], + } + ], + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer token", + }, + data=json.dumps(data), + ) + + assert res.status_code == 400 + mock_bulk_get_signed_url_for_file.assert_not_called() + + +@patch("fence.blueprints.ga4gh.bulk_get_signed_url_for_file") +def test_get_ga4gh_signed_urls_missing_guid(mock_bulk_get_signed_url_for_file, client): + """ + Test when a bulk request contains guids that are not in indexd. + """ + mock_bulk_get_signed_url_for_file.return_value = { + "urls": [ + { + "drs_object_id": "guid1", + "url": "https://example.com/guid1", + "headers": [], + } + ], + "failed_guids": [ + { + "error_code": 404, + "object_ids": ["missing_guid"], + } + ], + } + + data = { + "bulk_object_access_ids": [ + { + "bulk_object_id": "guid1", + "bulk_access_ids": ["s3"], + }, + { + "bulk_object_id": "missing_guid", + "bulk_access_ids": ["s3"], + }, + ] + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={"Content-Type": "application/json"}, + data=json.dumps(data), + ) + + assert res.status_code == 200 + assert res.json == { + "summary": { + "requested": 2, + "resolved": 1, + "unresolved": 1, + }, + "unresolved_drs_objects": [ + { + "error_code": 404, + "object_ids": ["missing_guid"], + } + ], + "resolved_drs_object_access_urls": [ + { + "drs_object_id": "guid1", + "drs_access_id": "s3", + "url": "https://example.com/guid1", + "headers": [], + } + ], + } + + +@patch("fence.blueprints.ga4gh.bulk_get_signed_url_for_file") +def test_get_ga4gh_signed_urls_partial_authz_failure( + mock_bulk_get_signed_url_for_file, client +): + """ + Test when a bulk request has a mix of files that the user can and cannot access + """ + mock_bulk_get_signed_url_for_file.return_value = { + "urls": [ + { + "drs_object_id": "guid1", + "url": "https://example.com/guid1", + "headers": [], + } + ], + "failed_guids": [ + { + "error_code": 403, + "object_ids": ["guid2"], + } + ], + } + + data = { + "bulk_object_access_ids": [ + { + "bulk_object_id": "guid1", + "bulk_access_ids": ["s3"], + }, + { + "bulk_object_id": "guid2", + "bulk_access_ids": ["s3"], + }, + ] + } + + res = client.post( + "/ga4gh/drs/v1/objects/access", + headers={"Content-Type": "application/json"}, + data=json.dumps(data), + ) + + assert res.status_code == 200 + assert res.json == { + "summary": { + "requested": 2, + "resolved": 1, + "unresolved": 1, + }, + "unresolved_drs_objects": [ + { + "error_code": 403, + "object_ids": ["guid2"], + } + ], + "resolved_drs_object_access_urls": [ + { + "drs_object_id": "guid1", + "drs_access_id": "s3", + "url": "https://example.com/guid1", + "headers": [], + } + ], + } + + +@pytest.mark.parametrize("access_id", SUPPORTED_PROTOCOLS) +@responses.activate +@patch("httpx.get") +@patch("fence.resources.google.utils._create_proxy_group") +@patch("fence.scripting.fence_create.ArboristClient") +@patch("fence.blueprints.data.indexd.sync_gen3_users_authz_from_ga4gh_passports") +def test_get_presigned_url_with_passport_sets_skip_google_updates_by_protocol( + mock_sync_gen3_users_authz_from_ga4gh_passports, + mock_arborist, + mock_google_proxy_group, + mock_httpx_get, + access_id, + client, + kid, + rsa_private_key, + rsa_public_key, + indexd_client_accepting_record, + mock_arborist_requests, + google_proxy_group, + primary_google_service_account, + cloud_manager, + google_signed_url, +): + """ + Tests that google group sync is skipped for protocols that don't require google groups for data access, always in + the case where passports are used to access data. + """ + config["GA4GH_PASSPORTS_TO_DRS_ENABLED"] = True + + test_guid = "1" + indexd_record = { + "did": test_guid, + "baseid": "", + "rev": "", + "size": 10, + "file_name": "file1", + "urls": [ + "s3://bucket1/key", + "gs://bucket1/key", + "http://example.com/bucket1/key", + "https://example.com/bucket1/key", + "ftp://example.com/bucket1/key", + "az://bucket1/key", + ], + "hashes": {}, + "metadata": {}, + "authz": ["/orgA/programs/phs000991.c1"], + "acl": ["*"], + "form": "", + "created_date": "", + "updated_date": "", + } + indexd_client_accepting_record(indexd_record) + + mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) + mock_arborist.return_value = NoAsyncMagicMock(ArboristClient) + mock_google_proxy_group.return_value = google_proxy_group + + current_time = int(time.time()) + headers = {"kid": kid} + decoded_visa = { + "iss": "https://stsstg.nih.gov", + "sub": TEST_RAS_SUB, + "iat": current_time, + "exp": current_time + 1000, + "scope": "openid ga4gh_passport_v1 email profile", + "jti": "jtiajoidasndokmasdl", + "txn": "sapidjspa.asipidja", + "name": "", + "ga4gh_visa_v1": { + "type": "https://ras.nih.gov/visas/v1.1", + "asserted": current_time, + "value": "https://stsstg.nih.gov/passport/dbgap/v1.1", + "source": "https://ncbi.nlm.nih.gov/gap", + }, + "ras_dbgap_permissions": [ + { + "consent_name": "Health/Medical/Biomedical", + "phs_id": "phs000991", + "version": "v1", + "participant_set": "p1", + "consent_group": "c1", + "role": "designated user", + "expiration": current_time + 1000, + } + ], + } + encoded_visa = jwt.encode( + decoded_visa, key=rsa_private_key, headers=headers, algorithm="RS256" + ) + + passport_header = { + "type": "JWT", + "alg": "RS256", + "kid": kid, + } + passport = { + "iss": "https://stsstg.nih.gov", + "sub": TEST_RAS_SUB, + "iat": current_time, + "scope": "openid ga4gh_passport_v1 email profile", + "exp": current_time + 1000, + "ga4gh_passport_v1": [encoded_visa], + } + encoded_passport = jwt.encode( + passport, key=rsa_private_key, headers=passport_header, algorithm="RS256" + ) + + keys = [keypair.public_key_to_jwk() for keypair in flask.current_app.keypairs] + mock_httpx_get.return_value = httpx.Response(200, json={"keys": keys}) + + res = client.post( + "/ga4gh/drs/v1/objects/" + test_guid + "/access/" + access_id, + headers={ + "Content-Type": "application/json", + }, + data=json.dumps({"passports": [encoded_passport]}), + ) + + assert res.status_code in [200, 401, 404, 500] + assert mock_sync_gen3_users_authz_from_ga4gh_passports.called + + expected_skip_google_updates = access_id not in PROTOCOLS_REQUIRING_GOOGLE_SYNC + + assert ( + mock_sync_gen3_users_authz_from_ga4gh_passports.call_args.kwargs[ + "skip_google_updates" + ] + == expected_skip_google_updates + ) diff --git a/tests/test_import_fence.py b/tests/test_import_fence.py deleted file mode 100644 index acea5bef1..000000000 --- a/tests/test_import_fence.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -NOTE: the tests use ``pytest.mark.filterwarnings('ignore')`` because SQLAlchemy -isn't happy if you try to load a table a second time, which we have to -inadvertently do when we reload all the fence submodules. -""" - -import importlib -import sys - -import pytest -from sqlalchemy.exc import InvalidRequestError - - -def reload_modules(module_name): - """ - Reload all of fence's submodules. - - Use this after patching ``local_settings.py`` not existing, to make sure - that nothing will remember that it existed. - """ - # First we have to convince fence that ``local_settings.py`` does not - # actually exist, even if it does. To do this, patch-delete the attribute - # and reload all the fence modules. - fence_submodules = [ - module for module in list(sys.modules.keys()) if module.startswith(module_name) - ] - for module in fence_submodules: - if sys.modules[module]: - # SQLAlchemy gets upset when a table is loaded twice, so ignore - # that. - try: - importlib.reload(sys.modules[module]) - except InvalidRequestError: - pass - - -class FakeModule(object): - """ - Define a context manager for instantiating a fake copy of a module under an - arbitrary module name. - - We use this to make a copy of fence from which the local settings module is - removed, without disturbing the normal fence. - """ - - def __init__(self, real_name, fake_name): - """Save a copy of the real module.""" - self.real_name = real_name - self.fake_name = fake_name - # Save a copy of the real module. - importlib.import_module(self.real_name) - self.real_module = sys.modules.pop(self.real_name) - - def __enter__(self): - """ - Insert a copy of the real module into ``sys.modules`` under the fake - name. - """ - sys.modules[self.fake_name] = importlib.import_module(self.real_name) - - def __exit__(self, type, value, traceback): - """ - Remove the fake module and put the real module back in ``sys.modules``. - - (The arguments are required for a context manager.) - """ - sys.modules.pop(self.fake_name) - sys.modules[self.real_name] = self.real_module - - -@pytest.mark.filterwarnings("ignore") -def test_import_without_local_settings(app, monkeypatch): - """ - Simply try to import fence when ``fence.local_settings`` doesn't exist. - """ - with FakeModule("fence", "test_fence"): - # Take out the local settings module and reload ``test_fence``. - monkeypatch.delattr("test_fence.local_settings", raising=False) - reload_modules("test_fence") - # Now try to import fence. - import test_fence - - assert hasattr(test_fence, "app") - - -@pytest.mark.filterwarnings("ignore") -def test_import_fence_would_break(monkeypatch): - """ - Sort of test the previous test by making sure that if ``local_settings.py`` - did not exist and we tried to use it, things would go horribly wrong. - """ - with FakeModule("fence", "test_fence"): - # Take out the local settings module and reload ``test_fence``. - monkeypatch.delattr("test_fence.local_settings", raising=False) - reload_modules("test_fence") - # Import ``test_fence`` and make sure that using the local settings - # would break things. - import test_fence - - assert not hasattr(test_fence, "local_settings") - # Try to get an arbitrary variable from ``local_settings`` and make - # sure it fails. - with pytest.raises(AttributeError): - test_fence.local_settings.DB diff --git a/tests/test_logout.py b/tests/test_logout.py index eb0f8f538..723976c07 100644 --- a/tests/test_logout.py +++ b/tests/test_logout.py @@ -1,7 +1,10 @@ import mock import urllib.request, urllib.parse, urllib.error +from unittest.mock import MagicMock +import flask import pytest +import requests from fence.auth import build_redirect_url from fence.config import config @@ -93,3 +96,102 @@ def test_logout_fence(app, client, user_with_fence_provider, monkeypatch): parsed_url = urllib.parse.urlparse(r.location) result_redirect = urllib.parse.parse_qs(parsed_url.query).get("next")[0] assert result_redirect == redirect + + +def test_logout_cognito(client, db_session): + """ + Test /logout endpoint successfully redirect for logout with cognito + """ + redirect = "https://test-url.com" + mock_well_known = {"end_session_endpoint": "https://cognito.example.com/logout"} + r = client.get("/user/") + with client.session_transaction() as session: + session["provider"] = "cognito" + with mock.patch( + "fence.allowed_login_redirects", + return_value={"test-url.com", "cognito.example.com"}, + ), mock.patch( + "fence.auth.allowed_login_redirects", + return_value={"test-url.com", "cognito.example.com"}, + ), mock.patch( + "requests.get" + ) as mock_get: + mock_resp = MagicMock() + mock_resp.json.return_value = mock_well_known + mock_get.return_value = mock_resp + + r = client.get("/logout?next={}".format(redirect)) + assert r.status_code == 302 + assert "https://cognito.example.com/logout" in r.location + + +def test_logout_cognito_cognito_not_in_allowed_login_redirect(client, db_session): + """ + Test /logout endpoint without correct cognito domain in allow list + """ + redirect = "https://test-url.com" + mock_well_known = {"end_session_endpoint": "https://cognito.example.com/logout"} + r = client.get("/user/") + with client.session_transaction() as session: + session["provider"] = "cognito" + with mock.patch( + "fence.allowed_login_redirects", + return_value={"test-url.com", "not.cognito.example.com"}, + ), mock.patch( + "fence.auth.allowed_login_redirects", + return_value={"test-url.com", "not.cognito.example.com"}, + ), mock.patch( + "requests.get" + ) as mock_get: + mock_resp = MagicMock() + mock_resp.json.return_value = mock_well_known + mock_get.return_value = mock_resp + + r = client.get("/logout?next={}".format(redirect)) + assert r.status_code == 302 + assert "https://cognito.example.com/logout" not in r.location + assert r.location == redirect + + +def test_logout_cognito_http_error_next_url_fallback(client, db_session): + """ + Test /logout endpoint falls back to next_url when cognito well-known returns http error + """ + redirect = "https://test-url.com" + r = client.get("/user/") + with client.session_transaction() as session: + session["provider"] = "cognito" + with mock.patch( + "fence.allowed_login_redirects", return_value={"test-url.com"} + ), mock.patch("requests.get") as mock_get: + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=MagicMock(status_code=500) + ) + mock_get.return_value = mock_resp + r = client.get("/logout?next={}".format(redirect)) + assert r.status_code == 302 + assert "cognito" not in r.location + assert r.location == redirect + + +def test_logout_cognito_connection_error_next_url_fallback(client, db_session): + """ + Test /logout endpoint falls back to next_url when cognito well-known has connection error + """ + redirect = "https://test-url.com" + r = client.get("/user/") + with client.session_transaction() as session: + session["provider"] = "cognito" + with mock.patch( + "fence.allowed_login_redirects", return_value={"test-url.com"} + ), mock.patch("requests.get") as mock_get: + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.exceptions.ConnectionError( + "Cognito Connection Error" + ) + mock_get.return_value = mock_resp + r = client.get("/logout?next={}".format(redirect)) + assert r.status_code == 302 + assert "cognito" not in r.location + assert r.location == redirect diff --git a/tests/test_metrics.py b/tests/test_metrics.py index be7d6b2ab..ca53010c5 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -3,7 +3,7 @@ Tests for the Audit Service integration: - test the creation of presigned URL audit logs -- test the creation of login audit logs +- test the creation of login audit logs, with multiple audit schema model versions - test the SQS flow In Audit Service tests where it makes sense, we also test that Prometheus @@ -22,23 +22,25 @@ tests looking at users are not affected. """ -import boto3 import flask +import boto3 import json import jwt import mock import pytest -import time -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import MagicMock, patch import fence +from fence.blueprints.login import get_idp_route_name from fence.metrics import metrics from fence.config import config from fence.blueprints.data.indexd import get_bucket_from_urls from fence.models import User from fence.resources.audit.utils import _clean_authorization_request_url +from fence.resources.audit.client import AUDIT_SCHEMA_CACHE, AuditServiceClient +from fence.errors import InternalError from tests import utils -from tests.conftest import LOGIN_IDPS +from tests.conftest import all_available_idps # `reset_prometheus_metrics` must be imported even if not used so the autorun fixture gets triggered from tests.utils.metrics import assert_prometheus_metrics, reset_prometheus_metrics @@ -101,7 +103,7 @@ def test_disabled_audit( ) with audit_decorator_mocker as audit_decorator: response = client.get(path, headers=headers) - assert response.status_code == 200, response + assert response.status_code == 200, response.text assert response.json.get("url") audit_decorator.assert_not_called() @@ -115,6 +117,77 @@ class MockResponse(object): def __init__(self, data, status_code=200): self.data = data self.status_code = status_code + # mimic requests.Response enough for the _set_schema_models_cache code + self._json = data + try: + import json as _json + + self.text = ( + _json.dumps(data) if isinstance(data, (dict, list)) else str(data) + ) + except Exception: + self.text = str(data) + + def json(self): + return self._json + + +@pytest.mark.parametrize( + "status_code,payload", + [ + (200, {"login": {"version": 2.0}, "presigned_url": {"version": 1.0}}), + (404, {}), # fall back to default v1 schema (no error) + (500, {}), # should raise InternalError + ], +) +def test_set_schema_models_cache_and_get_audit_schema(app, status_code, payload): + """ + Unit test AuditServiceClient._set_schema_models_cache and _get_audit_schema + for 200/404/500 responses from the audit-service /_schema endpoint. + """ + with app.app_context(): + AUDIT_SCHEMA_CACHE.clear() + client_obj = flask.current_app.audit_service_client + + with mock.patch( + "fence.resources.audit.client.requests.get", + return_value=MockResponse(payload, status_code), + ) as get_mock: + if status_code == 500: + # 500 => InternalError and cache should remain empty + with pytest.raises(InternalError): + client_obj._set_schema_models_cache() + assert AUDIT_SCHEMA_CACHE.get("audit_schema") is None + resp = client_obj._get_audit_schema() + assert resp.status_code == 500 + else: + # 200 or 404 should not raise + client_obj._set_schema_models_cache() + cached = AUDIT_SCHEMA_CACHE.get("audit_schema") + assert cached is not None + + if status_code == 200: + assert cached == payload + else: # 404 -> default v1 schema + assert "login" in cached and "presigned_url" in cached + assert cached["login"]["version"] == 1.0 + assert cached["presigned_url"]["version"] == 1.0 + for k in ("request_url", "status_code", "timestamp", "username"): + assert k in cached["login"]["model"] + for k in ( + "request_url", + "status_code", + "timestamp", + "username", + "guid", + "resource_paths", + "action", + "protocol", + ): + assert k in cached["presigned_url"]["model"] + + resp = client_obj._get_audit_schema() + assert resp.status_code == status_code @pytest.mark.parametrize("indexd_client_with_arborist", ["s3_and_gs"], indirect=True) @@ -174,12 +247,15 @@ def test_presigned_url_log( expected_protocol = protocol or "s3" with audit_service_mocker as audit_service_requests: + audit_service_requests.get.return_value = MockResponse( + {"presigned_url": {"version": 1.0}}, 200 + ) audit_service_requests.post.return_value = MockResponse( data={}, status_code=201, ) response = client.get(path, headers=headers) - assert response.status_code == 200, response + assert response.status_code == 200, response.text assert response.json.get("url") audit_service_requests.post.assert_called_once_with( "http://audit-service/log/presigned_url", @@ -281,7 +357,7 @@ def test_presigned_url_log_acl( status_code=201, ) response = client.get(path, headers=headers) - assert response.status_code == 200, response + assert response.status_code == 200, response.text assert response.json.get("url") audit_service_requests.post.assert_called_once_with( "http://audit-service/log/presigned_url", @@ -323,7 +399,7 @@ def test_presigned_url_log_public(endpoint, client, public_indexd_client, monkey status_code=201, ) response = client.get(path) - assert response.status_code == 200, response + assert response.status_code == 200, response.text assert response.json.get("url") audit_service_requests.post.assert_called_once_with( "http://audit-service/log/presigned_url", @@ -395,7 +471,7 @@ def test_presigned_url_log_disabled( status_code=201, ) response = client.get(path, headers=headers) - assert response.status_code == 200, response + assert response.status_code == 200, response.text assert response.json.get("url") audit_service_requests.post.assert_not_called() @@ -443,102 +519,156 @@ def test_presigned_url_log_unauthorized( ) +@pytest.mark.parametrize( + "indexd_client_with_arborist", ["s3_and_gs_acl_no_authz"], indirect=True +) +@pytest.mark.parametrize("endpoint", ["download", "ga4gh-drs"]) +def test_presigned_url_log_audit_headers( + endpoint, + client, + user_client, + mock_arborist_requests, + indexd_client_with_arborist, + kid, + rsa_private_key, + primary_google_service_account, + cloud_manager, + google_signed_url, + monkeypatch, +): + """ + Same as `test_presigned_url_log_acl`,but the request contains X-forwarded + headers. + """ + # Mock Arborist authz check + mock_arborist_requests({"arborist/auth/request": {"POST": ({"auth": True}, 200)}}) + + # Mock audit-service client + audit_service_mocker = mock.patch( + "fence.resources.audit.client.requests", new_callable=mock.Mock + ) + + # Enable presigned_url audit logging + monkeypatch.setitem(config, "ENABLE_AUDIT_LOGS", {"presigned_url": True}) + + protocol = "gs" + guid = "dg.hello/abc" + + # Build the request path depending on endpoint type + if endpoint == "download": + path = f"/data/download/{guid}?protocol={protocol}" + else: + path = f"/ga4gh/drs/v1/objects/{guid}/access/{protocol}" + + # Create indexd record with ACLs + indexd_client_with_arborist(None) + + # Build Authorization header with JWT + headers = { + "Authorization": "Bearer " + + jwt.encode( + utils.authorized_download_context_claims( + user_client.username, str(user_client.user_id) + ), + key=rsa_private_key, + headers={"kid": kid}, + algorithm="RS256", + ), + # Simulate X-Forwarded-For coming from a proxy chain + "X-Forwarded-For": "1.2.3.4, 5.6.7.8", + } + + with audit_service_mocker as audit_service_requests: + AUDIT_SCHEMA_CACHE.clear() + AUDIT_SCHEMA_CACHE.set( + "audit_schema", + { + "login": {"version": 2.1}, + "presigned_url": {"version": 1.1}, + }, + ) + # Mock audit-service POST response + audit_service_requests.post.return_value = MockResponse( + data={}, + status_code=201, + ) + + # Call the endpoint + response = client.get(path, headers=headers) + + # Validate response + assert response.status_code == 200, response.text + assert response.json.get("url") + + audit_service_requests.post.assert_called_once() + + (args, kwargs) = audit_service_requests.post.call_args + assert args[0] == "http://audit-service/log/presigned_url" + + payload = kwargs["json"] + + # Assert the stable fields exactly + assert payload["request_url"] == path + assert payload["status_code"] == 200 + assert payload["username"] == user_client.username + assert payload["sub"] == user_client.user_id + assert payload["guid"] == guid + assert payload["resource_paths"] == ["phs000178", "phs000218"] + assert payload["action"] == "download" + assert payload["protocol"] == protocol + + additional = payload.get("additional_data", []) + assert "X-Forwarded-For:1.2.3.4, 5.6.7.8" in additional + # Check for the inclusion for User-Agent, duration and bytes, but not the actual values to make the tests less brittle + assert any(s.startswith("User-Agent:") for s in additional) + assert any(s.startswith("duration:") for s in additional) + assert any(s.startswith("bytes:") for s in additional) + + #################### # Login audit logs # #################### -@pytest.mark.parametrize("idp", LOGIN_IDPS) +@pytest.mark.parametrize("idp", all_available_idps()) +@pytest.mark.parametrize("login_schema_version", [1.0, 2.0]) def test_login_log_login_endpoint( client, idp, - mock_arborist_requests, - rsa_private_key, db_session, # do not remove :-) See note at top of file + mocks_for_idp_oauth2_callbacks, monkeypatch, prometheus_metrics_before, + login_schema_version, ): """ Test that logging in via any of the existing IDPs triggers the creation of a login audit log and of a prometheus metric. """ - mock_arborist_requests() audit_service_mocker = mock.patch( "fence.resources.audit.client.requests", new_callable=mock.Mock ) monkeypatch.setitem(config, "ENABLE_AUDIT_LOGS", {"login": True}) - username = "test@test" - callback_endpoint = "login" - idp_name = idp - headers = {} - get_auth_info_value = {} - jwt_string = jwt.encode( - {"iat": int(time.time())}, key=rsa_private_key, algorithm="RS256" - ) - - if idp == "synapse": - get_auth_info_value = { - "fence_username": username, - "sub": username, - "given_name": username, - "family_name": username, - } - elif idp == "orcid": - get_auth_info_value = {"orcid": username} - elif idp == "cilogon": - get_auth_info_value = {"sub": username} - elif idp == "shib": - headers["persistent_id"] = username - idp_name = "itrust" - elif idp == "okta": - get_auth_info_value = {"okta": username} - elif idp == "fence": - mocked_fetch_access_token = MagicMock(return_value={"id_token": jwt_string}) - patch( - f"authlib.integrations.flask_client.apps.FlaskOAuth2App.fetch_access_token", - mocked_fetch_access_token, - ).start() - mocked_validate_jwt = MagicMock( - return_value={"context": {"user": {"name": username}}} - ) - patch( - f"fence.blueprints.login.fence_login.validate_jwt", mocked_validate_jwt - ).start() - elif idp == "ras": - get_auth_info_value = {"username": username} - callback_endpoint = "callback" - # these should be populated by a /login/ call that we're skipping: - flask.g.userinfo = {"sub": "testSub123"} - flask.g.tokens = { - "refresh_token": jwt_string, - "id_token": jwt_string, - } - flask.g.encoded_visas = "" - elif idp == "generic1": - get_auth_info_value = {"generic1_username": username} - elif idp == "generic2": - get_auth_info_value = {"sub": username} - - if idp in ["google", "microsoft", "okta", "synapse", "cognito"]: - get_auth_info_value["email"] = username - - get_auth_info_patch = None - if get_auth_info_value: - mocked_get_auth_info = MagicMock(return_value=get_auth_info_value) - get_auth_info_patch = patch( - f"flask.current_app.{idp}_client.get_auth_info", mocked_get_auth_info - ) - get_auth_info_patch.start() + idp_name, username, callback_endpoint, headers = mocks_for_idp_oauth2_callbacks with audit_service_mocker as audit_service_requests: + AUDIT_SCHEMA_CACHE.clear() + AUDIT_SCHEMA_CACHE.set( + "audit_schema", + { + "login": {"version": login_schema_version}, + "presigned_url": {"version": 1.0}, + }, + ) audit_service_requests.post.return_value = MockResponse( data={}, status_code=201, ) - path = f"/login/{idp}/{callback_endpoint}" # SEE fence/blueprints/login/fence_login.py L91 + path = f"/login/{get_idp_route_name(idp)}/{callback_endpoint}" response = client.get(path, headers=headers) - assert response.status_code == 200, response + print(f"Response: {response.status_code}, Body: {response.data}") + assert response.status_code == 200, response.text user_sub = db_session.query(User).filter(User.username == username).first().id audit_service_requests.post.assert_called_once_with( "http://audit-service/log/login", @@ -551,12 +681,16 @@ def test_login_log_login_endpoint( "fence_idp": None, "shib_idp": None, "client_id": None, + **( + {} + if login_schema_version == 1.0 + else { + "ip": "flask.request.remote_addr=127.0.0.1 x_forwarded_headers=[]" + } + ), }, ) - if get_auth_info_patch: - get_auth_info_patch.stop() - # check prometheus metrics resp = client.get("/metrics") assert resp.status_code == 200 @@ -655,7 +789,7 @@ def test_presigned_url_log_push_to_sqs( ) } response = client.get(path, headers=headers) - assert response.status_code == 200, response + assert response.status_code == 200, response.text assert response.json.get("url") expected_audit_data = { @@ -670,7 +804,7 @@ def test_presigned_url_log_push_to_sqs( "category": "presigned_url", } mocked_sqs.send_message.assert_called_once_with( - MessageBody=json.dumps(expected_audit_data), QueueUrl=mocked_sqs.url + QueueUrl=mocked_sqs.url, MessageBody=json.dumps(expected_audit_data) ) @@ -697,7 +831,7 @@ def test_login_log_push_to_sqs( path = "/login/google/login" response = client.get(path) - assert response.status_code == 200, response + assert response.status_code == 200, response.text # not checking the parameters here because we can't json.dumps "sub: ANY" mocked_sqs.send_message.assert_called_once() @@ -718,7 +852,7 @@ def test_disabled_prometheus_metrics(client, monkeypatch): metrics.add_login_event( user_sub="123", idp="test_idp", - fence_idp="shib", + upstream_idp="shib", shib_idp="university", client_id="test_azp", ) @@ -740,11 +874,11 @@ def test_record_prometheus_events(prometheus_metrics_before, client): # record a login event and check that we get both a metric for the specific IDP, and an # IDP-agnostic metric for the total number of login events. The latter should have no IDP - # information (no `fence_idp` or `shib_idp`). + # information (no `upstream_idp` or `shib_idp`). metrics.add_login_event( user_sub="123", idp="test_idp", - fence_idp="shib", + upstream_idp="shib", shib_idp="university", client_id="test_azp", ) @@ -756,7 +890,7 @@ def test_record_prometheus_events(prometheus_metrics_before, client): "labels": { "user_sub": "123", "idp": "test_idp", - "fence_idp": "shib", + "upstream_idp": "shib", "shib_idp": "university", "client_id": "test_azp", }, @@ -767,7 +901,7 @@ def test_record_prometheus_events(prometheus_metrics_before, client): "labels": { "user_sub": "123", "idp": "all", - "fence_idp": "None", + "upstream_idp": "None", "shib_idp": "None", "client_id": "test_azp", }, @@ -780,7 +914,7 @@ def test_record_prometheus_events(prometheus_metrics_before, client): metrics.add_login_event( user_sub="123", idp="test_idp", - fence_idp="shib", + upstream_idp="shib", shib_idp="university", client_id="test_azp", ) @@ -788,7 +922,7 @@ def test_record_prometheus_events(prometheus_metrics_before, client): metrics.add_login_event( user_sub="123", idp="another_idp", - fence_idp=None, + upstream_idp=None, shib_idp=None, client_id="test_azp", ) @@ -812,7 +946,7 @@ def test_record_prometheus_events(prometheus_metrics_before, client): "labels": { "user_sub": "123", "idp": "all", - "fence_idp": "None", + "upstream_idp": "None", "shib_idp": "None", "client_id": "test_azp", }, @@ -823,18 +957,18 @@ def test_record_prometheus_events(prometheus_metrics_before, client): "labels": { "user_sub": "123", "idp": "test_idp", - "fence_idp": "shib", + "upstream_idp": "shib", "shib_idp": "university", "client_id": "test_azp", }, - "value": 2.0, # recorded login events for this idp, fence_idp and shib_idp combo + "value": 2.0, # recorded login events for this idp, upstream_idp and shib_idp combo }, { "name": "gen3_fence_login_total", "labels": { "user_sub": "123", "idp": "another_idp", - "fence_idp": "None", + "upstream_idp": "None", "shib_idp": "None", "client_id": "test_azp", }, diff --git a/tests/test_settings.py b/tests/test_settings.py deleted file mode 100644 index 7a4e25dd9..000000000 --- a/tests/test_settings.py +++ /dev/null @@ -1,6 +0,0 @@ -#: ``CONFIG_SEARCH_FOLDERS: List(str)`` -#: Folders to look in for the *config.yaml for fence -from os.path import expanduser - -# Folders to look in for the *config.yaml for fence -CONFIG_SEARCH_FOLDERS = ["/var/www/fence", "{}/.gen3/fence".format(expanduser("~"))] diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 0f663c91f..3c6ad2454 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -3,6 +3,7 @@ import urllib.parse import uuid +from fence.auth import GEN3_AUDIENCE from flask import current_app from fence.config import config @@ -111,7 +112,7 @@ def create_awg_user(users, db_session): grp.description = group_desc s.add(grp) s.flush() - UserToGroup(group=grp, user=user) + s.add(UserToGroup(group=grp, user=user)) for projectname in group["projects"]: gap = ( s.query(AccessPrivilege) @@ -257,13 +258,13 @@ def default_claims(): iat, exp = iat_and_exp() return { "pur": "access", - "aud": [iss], + "aud": [GEN3_AUDIENCE], "sub": "1234", "iss": iss, "iat": iat, "exp": exp, "jti": jti, - "azp": "", + "azp": "test-client", "scope": ["openid", "user"], "context": { "user": { @@ -286,14 +287,14 @@ def unauthorized_context_claims(user_name, user_id): jti = new_jti() iat, exp = iat_and_exp() return { - "aud": [iss], + "aud": [GEN3_AUDIENCE], "sub": str(user_id), "pur": "access", "iss": iss, "iat": iat, "exp": exp, "jti": jti, - "azp": "", + "azp": "test-client", "scope": ["access", "data", "user", "openid"], "context": { "user": { @@ -319,13 +320,13 @@ def authorized_download_context_claims(user_name, user_id): jti = new_jti() iat, exp = iat_and_exp() return { - "aud": [iss], + "aud": [GEN3_AUDIENCE], "sub": str(user_id), "iss": iss, "iat": iat, "exp": exp, "jti": jti, - "azp": "", + "azp": "test-client", "pur": "access", "scope": ["access", "data", "user", "openid"], "context": { @@ -352,7 +353,7 @@ def authorized_service_account_management_claims(user_name, user_id, client_id): jti = new_jti() iat, exp = iat_and_exp() return { - "aud": [iss], + "aud": [GEN3_AUDIENCE], "sub": str(user_id), "iss": iss, "iat": iat, @@ -402,7 +403,7 @@ def authorized_download_credentials_context_claims( jti = new_jti() iat, exp = iat_and_exp() return { - "aud": [iss], + "aud": [GEN3_AUDIENCE], "sub": str(user_id), "iss": iss, "iat": iat, @@ -435,7 +436,7 @@ def authorized_upload_context_claims(user_name, user_id): jti = new_jti() iat, exp = iat_and_exp() return { - "aud": [iss], + "aud": [GEN3_AUDIENCE], "sub": str(user_id), "iss": iss, "pur": "access", @@ -470,7 +471,7 @@ def client_authorized_download_context_claims(): jti = new_jti() iat, exp = iat_and_exp() return { - "aud": [iss], + "aud": [GEN3_AUDIENCE], "iss": iss, "iat": iat, "exp": exp, diff --git a/tests/utils/api_key.py b/tests/utils/api_key.py index 67b9779c2..61315da9e 100644 --- a/tests/utils/api_key.py +++ b/tests/utils/api_key.py @@ -4,7 +4,7 @@ DEFAULT_SCOPE = ["fence", "data", "user"] -def get_api_key(client, encoded_credentials_jwt, scope=None): +def get_api_key(client, encoded_credentials_jwt, scope=None, expires_in=None): """ Args: client: client fixture @@ -12,7 +12,7 @@ def get_api_key(client, encoded_credentials_jwt, scope=None): Return: pytest_flask.plugin.JSONResponse: the response from /oauth2/authorize """ - path = "/credentials/cdis/" + path = f"/credentials/api/{f'?expires_in={expires_in}' if expires_in else ''}" headers = { "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Bearer " + str(encoded_credentials_jwt), @@ -36,6 +36,6 @@ def get_api_key_with_json(client, encoded_credentials_jwt, scope=None): "Authorization": "Bearer " + str(encoded_credentials_jwt), } response = client.post( - "/credentials/cdis/", headers=headers, data=json.dumps({"scope": scope}) + "/credentials/api/", headers=headers, data=json.dumps({"scope": scope}) ) return response diff --git a/tests/utils/metrics.py b/tests/utils/metrics.py index 0443589a0..3f0943141 100644 --- a/tests/utils/metrics.py +++ b/tests/utils/metrics.py @@ -29,6 +29,8 @@ def reset_prometheus_metrics(): logic? Because it doesn't work, the prometheus client also keeps the state, and the mismatch causes errors. This only works when the client is reset too (new process) """ + assert os.environ.get("PROMETHEUS_CONFIGURED_FOR_TESTS"), "Prometheus is not configured correctly. Make sure to run the tests using `tests/ci_commands_script.sh`." + yield folder = os.environ["PROMETHEUS_MULTIPROC_DIR"] diff --git a/ua.yaml b/ua.yaml deleted file mode 100644 index 0558d101b..000000000 --- a/ua.yaml +++ /dev/null @@ -1,65 +0,0 @@ -cloud_providers: - gdc-cleversafe: - backend: 'cleversafe' - service: 'storage' - bpa-cleversafe: - backend: 'cleversafe' - service: 'storage' - -groups: - Test_Group: - projects: - - auth_id: Test_Group - privilege: ['read', 'update', 'create', 'delete', 'read-storage', 'write-storage', 'admin-storage'] - - auth_id: Test_Group_1 - privilege: ['read', 'read-storage'] - Test_Group_1: - projects: - - auth_id: Test_Group_1 - privilege: ['read', 'update', 'create', 'delete', 'read-storage', 'write-storage'] - - auth_id: Test_Group - privilege: ['read', 'read-storage'] - -resources - -users: - test: - admin: True - projects: - - auth_id: bar - resource: /programs/test/projects/bar - privilege: ['read', 'update', 'create', 'delete'] - - auth_id: quux - resource: /programs/test/projects/quux - privilege: ['read', 'update', 'create', 'delete'] - policies: ['data_upload'] - -# This chunk is currently how fence gets some setup information to arborist, the -# service which handles authz. All the content here (inside each subsection, -# policies/resources/roles) is exactly how it would be structured in requests -# to/from the arborist API. -authz: - policies: - - id: 'data_upload' - description: 'upload raw data files to S3' - role_ids: ['file_uploader'] - resource_paths: ['/data_file'] - resources: - - name: 'data_file' - description: 'data files, stored in S3' - - name: 'programs' - subresources: - - name: 'test' - subresources: - - name: 'projects' - subresources: - - name: 'bar' - - name: quux - roles: - - id: 'file_uploader' - description: 'can upload data files' - permissions: - - id: 'file_upload' - action: - service: 'fence' - method: 'file_upload'