From 4371ad15354916eec474e7d615d359efbbd58ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Tue, 6 Aug 2024 18:39:08 +0200 Subject: [PATCH] feat/PRWLR-3999 Implement providers endpoints (#21) * feat(Backend): PRWLR-3989 add RLS to postgresql db and base models * feat(API): PRWLR-3989 add TenantMiddleware * chore(API, Backend): PRWLR-3989 create new db user without RLS bypass on migrations * chore(Backend): PRWLR-3989 fix RLS bypasser for POST requests * fix(Backend): PRWLR-3989 fix user permissions when migrating new models * chore(Backend): PRWLR-3989 add testing view for RLS manual tests * feat(API): PRWLR-3989 add tenant_id to API logging * chore(API, Backend): PRWLR-3989 add TODOs * test(API): PRWLR-3989 add new middleware unit tests * chore(API): PRWLR-3989 refactor RLS code * fix(tests): PRWLR-3989 fix testing db connector * chore: PRWLR-3989 add references to JIRA tickets * fix: PRWLR-3989 remove bypass logic and fix serializers * fix: PRWLR-3989 improve drop SQL query for RLS models * feat(Backend): PRWLR-3989 add specific permissions on each model * fix(Backend): PRWLR-3989 fix database routing and grant select perms * fix(test): PRWLR-3989 fix routing issues with unit tests * chore: PRWLR-3989 remove references to JIRA tickets * feat(Backend): PRWLR-3999 add Provider model * feat: PRWLR-3999 add providers view logic * fix: PRWLR-3999 fix unique index fields * feat(API): PRWLR-3999 add custom exception handlers * feat(API): PRWLR-3999 add /providers/{provider_id}/connection endpoint * test(API): PRWLR-3999 add base unit tests for providers * fix(API, Backend): PRWLR-3999 fix bugs after rebase * chore(API, docker): PRWLR-3999 add dev feats for demo * feat(API): PRWLR-3999 add drf-spectacular-jsonapi and improve docs * test(API): PRWLR-3999 add providers unit tests * chore(Backend): PRWLR-3989 adjust privileges for user on table tenant * chore: PRWLR-3999 rename model custom validation error * chore: PRWLR-3999 remove Test references * chore: PRWLR-3999 update API v1 spec * fix: PRWLR-3999 apply requested changes to filter and models * feat: PRWLR-3999 add validation to PATCH /providers payload * fix: PRWLR-3999 fix providers enum description * chore: add more providers fixtures * fix: PRWLR-3999 make providers.alias optional --- Dockerfile | 2 +- poetry.lock | 316 ++++--- pyproject.toml | 1 + src/backend/api/exceptions.py | 32 + src/backend/api/filters.py | 28 +- src/backend/api/fixtures/dev_providers.json | 109 +++ src/backend/api/migrations/0001_initial.py | 80 ++ .../migrations/0002_test_test_rls_on_test.py | 46 - src/backend/api/models.py | 91 +- src/backend/api/rls.py | 6 + src/backend/api/serializers.py | 88 +- src/backend/api/specs/v1.yaml | 887 +++++++++++++++++- src/backend/api/tests/test_database.py | 3 +- src/backend/api/tests/test_views.py | 348 ++++++- src/backend/api/urls/v1/urls.py | 3 +- src/backend/api/views/base_views.py | 16 + src/backend/api/views/v1/views.py | 286 ++---- src/backend/config/django/base.py | 10 +- src/backend/conftest.py | 17 + 19 files changed, 1888 insertions(+), 481 deletions(-) create mode 100644 src/backend/api/exceptions.py create mode 100644 src/backend/api/fixtures/dev_providers.json delete mode 100644 src/backend/api/migrations/0002_test_test_rls_on_test.py diff --git a/Dockerfile b/Dockerfile index 8f07d7cd72..90df2ff019 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ WORKDIR /home/prowler/backend FROM build as dev USER 0 -RUN apk --no-cache add curl=8.8.0-r0 vim=9.1.0414-r0 +RUN apk --no-cache add curl=8.9.0-r0 vim=9.1.0414-r0 USER prowler diff --git a/poetry.lock b/poetry.lock index be3a6b6bad..9be604e6cf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -27,13 +27,13 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "astroid" -version = "3.2.3" +version = "3.2.4" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.8.0" files = [ - {file = "astroid-3.2.3-py3-none-any.whl", hash = "sha256:3eae9ea67c11c858cdd2c91337d2e816bd019ac897ca07d7b346ac10105fceb3"}, - {file = "astroid-3.2.3.tar.gz", hash = "sha256:7099b5a60985529d8d46858befa103b82d0d05a5a5e8b816b5303ed96075e1d9"}, + {file = "astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"}, + {file = "astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a"}, ] [[package]] @@ -358,43 +358,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.8" +version = "43.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-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, - {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, - {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, - {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, - {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, - {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, - {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, + {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, + {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, + {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, + {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, + {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, + {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, + {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, ] [package.dependencies] @@ -407,7 +402,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -581,6 +576,20 @@ conda = ["pyyaml"] pipenv = ["pipenv"] poetry = ["poetry"] +[[package]] +name = "drf-extensions" +version = "0.7.1" +description = "Extensions for Django REST Framework" +optional = false +python-versions = "*" +files = [ + {file = "drf-extensions-0.7.1.tar.gz", hash = "sha256:90abfc11a2221e8daf4cd54457e41ed38cd71134678de9622e806193db027db1"}, + {file = "drf_extensions-0.7.1-py2.py3-none-any.whl", hash = "sha256:007910437e64aa1d5ad6fc47266a4ac4280e31761e6458eb30fcac7494ac7d4e"}, +] + +[package.dependencies] +djangorestframework = ">=3.9.3" + [[package]] name = "drf-spectacular" version = "0.27.2" @@ -604,6 +613,24 @@ uritemplate = ">=2.0.0" offline = ["drf-spectacular-sidecar"] sidecar = ["drf-spectacular-sidecar"] +[[package]] +name = "drf-spectacular-jsonapi" +version = "0.4.1" +description = "open api 3 schema generator for drf-json-api package based on drf-spectacular package." +optional = false +python-versions = ">=3.7" +files = [ + {file = "drf-spectacular-jsonapi-0.4.1.tar.gz", hash = "sha256:e6af0583a698e04c3bc7260f0768d9cbeb8dd6aac7ad5c58221dd58db948d6e7"}, + {file = "drf_spectacular_jsonapi-0.4.1-py3-none-any.whl", hash = "sha256:793d8e8cafbb72c727563c5dcb92a120ce83fd8a60f9848d6b5da39422ca143e"}, +] + +[package.dependencies] +Django = ">=3.2" +djangorestframework = ">=3.13" +djangorestframework-jsonapi = ">=6.0.0" +drf-extensions = ">=0.7.1" +drf-spectacular = ">=0.25.0" + [[package]] name = "execnet" version = "2.1.1" @@ -1509,110 +1536,114 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.19.0" +version = "0.19.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.8" files = [ - {file = "rpds_py-0.19.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:fb37bd599f031f1a6fb9e58ec62864ccf3ad549cf14bac527dbfa97123edcca4"}, - {file = "rpds_py-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3384d278df99ec2c6acf701d067147320b864ef6727405d6470838476e44d9e8"}, - {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54548e0be3ac117595408fd4ca0ac9278fde89829b0b518be92863b17ff67a2"}, - {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8eb488ef928cdbc05a27245e52de73c0d7c72a34240ef4d9893fdf65a8c1a955"}, - {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5da93debdfe27b2bfc69eefb592e1831d957b9535e0943a0ee8b97996de21b5"}, - {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79e205c70afddd41f6ee79a8656aec738492a550247a7af697d5bd1aee14f766"}, - {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:959179efb3e4a27610e8d54d667c02a9feaa86bbabaf63efa7faa4dfa780d4f1"}, - {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6e605bb9edcf010f54f8b6a590dd23a4b40a8cb141255eec2a03db249bc915b"}, - {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9133d75dc119a61d1a0ded38fb9ba40a00ef41697cc07adb6ae098c875195a3f"}, - {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd36b712d35e757e28bf2f40a71e8f8a2d43c8b026d881aa0c617b450d6865c9"}, - {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354f3a91718489912f2e0fc331c24eaaf6a4565c080e00fbedb6015857c00582"}, - {file = "rpds_py-0.19.0-cp310-none-win32.whl", hash = "sha256:ebcbf356bf5c51afc3290e491d3722b26aaf5b6af3c1c7f6a1b757828a46e336"}, - {file = "rpds_py-0.19.0-cp310-none-win_amd64.whl", hash = "sha256:75a6076289b2df6c8ecb9d13ff79ae0cad1d5fb40af377a5021016d58cd691ec"}, - {file = "rpds_py-0.19.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6d45080095e585f8c5097897313def60caa2046da202cdb17a01f147fb263b81"}, - {file = "rpds_py-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5c9581019c96f865483d031691a5ff1cc455feb4d84fc6920a5ffc48a794d8a"}, - {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1540d807364c84516417115c38f0119dfec5ea5c0dd9a25332dea60b1d26fc4d"}, - {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e65489222b410f79711dc3d2d5003d2757e30874096b2008d50329ea4d0f88c"}, - {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9da6f400eeb8c36f72ef6646ea530d6d175a4f77ff2ed8dfd6352842274c1d8b"}, - {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37f46bb11858717e0efa7893c0f7055c43b44c103e40e69442db5061cb26ed34"}, - {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:071d4adc734de562bd11d43bd134330fb6249769b2f66b9310dab7460f4bf714"}, - {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9625367c8955e4319049113ea4f8fee0c6c1145192d57946c6ffcd8fe8bf48dd"}, - {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e19509145275d46bc4d1e16af0b57a12d227c8253655a46bbd5ec317e941279d"}, - {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d438e4c020d8c39961deaf58f6913b1bf8832d9b6f62ec35bd93e97807e9cbc"}, - {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90bf55d9d139e5d127193170f38c584ed3c79e16638890d2e36f23aa1630b952"}, - {file = "rpds_py-0.19.0-cp311-none-win32.whl", hash = "sha256:8d6ad132b1bc13d05ffe5b85e7a01a3998bf3a6302ba594b28d61b8c2cf13aaf"}, - {file = "rpds_py-0.19.0-cp311-none-win_amd64.whl", hash = "sha256:7ec72df7354e6b7f6eb2a17fa6901350018c3a9ad78e48d7b2b54d0412539a67"}, - {file = "rpds_py-0.19.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:5095a7c838a8647c32aa37c3a460d2c48debff7fc26e1136aee60100a8cd8f68"}, - {file = "rpds_py-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f2f78ef14077e08856e788fa482107aa602636c16c25bdf59c22ea525a785e9"}, - {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7cc6cb44f8636fbf4a934ca72f3e786ba3c9f9ba4f4d74611e7da80684e48d2"}, - {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf902878b4af334a09de7a45badbff0389e7cf8dc2e4dcf5f07125d0b7c2656d"}, - {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:688aa6b8aa724db1596514751ffb767766e02e5c4a87486ab36b8e1ebc1aedac"}, - {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57dbc9167d48e355e2569346b5aa4077f29bf86389c924df25c0a8b9124461fb"}, - {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4cf5a9497874822341c2ebe0d5850fed392034caadc0bad134ab6822c0925b"}, - {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a790d235b9d39c70a466200d506bb33a98e2ee374a9b4eec7a8ac64c2c261fa"}, - {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d16089dfa58719c98a1c06f2daceba6d8e3fb9b5d7931af4a990a3c486241cb"}, - {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bc9128e74fe94650367fe23f37074f121b9f796cabbd2f928f13e9661837296d"}, - {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c8f77e661ffd96ff104bebf7d0f3255b02aa5d5b28326f5408d6284c4a8b3248"}, - {file = "rpds_py-0.19.0-cp312-none-win32.whl", hash = "sha256:5f83689a38e76969327e9b682be5521d87a0c9e5a2e187d2bc6be4765f0d4600"}, - {file = "rpds_py-0.19.0-cp312-none-win_amd64.whl", hash = "sha256:06925c50f86da0596b9c3c64c3837b2481337b83ef3519e5db2701df695453a4"}, - {file = "rpds_py-0.19.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:52e466bea6f8f3a44b1234570244b1cff45150f59a4acae3fcc5fd700c2993ca"}, - {file = "rpds_py-0.19.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e21cc693045fda7f745c790cb687958161ce172ffe3c5719ca1764e752237d16"}, - {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b31f059878eb1f5da8b2fd82480cc18bed8dcd7fb8fe68370e2e6285fa86da6"}, - {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dd46f309e953927dd018567d6a9e2fb84783963650171f6c5fe7e5c41fd5666"}, - {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34a01a4490e170376cd79258b7f755fa13b1a6c3667e872c8e35051ae857a92b"}, - {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcf426a8c38eb57f7bf28932e68425ba86def6e756a5b8cb4731d8e62e4e0223"}, - {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f68eea5df6347d3f1378ce992d86b2af16ad7ff4dcb4a19ccdc23dea901b87fb"}, - {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dab8d921b55a28287733263c0e4c7db11b3ee22aee158a4de09f13c93283c62d"}, - {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6fe87efd7f47266dfc42fe76dae89060038f1d9cb911f89ae7e5084148d1cc08"}, - {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:535d4b52524a961d220875688159277f0e9eeeda0ac45e766092bfb54437543f"}, - {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8b1a94b8afc154fbe36978a511a1f155f9bd97664e4f1f7a374d72e180ceb0ae"}, - {file = "rpds_py-0.19.0-cp38-none-win32.whl", hash = "sha256:7c98298a15d6b90c8f6e3caa6457f4f022423caa5fa1a1ca7a5e9e512bdb77a4"}, - {file = "rpds_py-0.19.0-cp38-none-win_amd64.whl", hash = "sha256:b0da31853ab6e58a11db3205729133ce0df26e6804e93079dee095be3d681dc1"}, - {file = "rpds_py-0.19.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5039e3cef7b3e7a060de468a4a60a60a1f31786da94c6cb054e7a3c75906111c"}, - {file = "rpds_py-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab1932ca6cb8c7499a4d87cb21ccc0d3326f172cfb6a64021a889b591bb3045c"}, - {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2afd2164a1e85226fcb6a1da77a5c8896c18bfe08e82e8ceced5181c42d2179"}, - {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1c30841f5040de47a0046c243fc1b44ddc87d1b12435a43b8edff7e7cb1e0d0"}, - {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f757f359f30ec7dcebca662a6bd46d1098f8b9fb1fcd661a9e13f2e8ce343ba1"}, - {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15e65395a59d2e0e96caf8ee5389ffb4604e980479c32742936ddd7ade914b22"}, - {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb0f6eb3a320f24b94d177e62f4074ff438f2ad9d27e75a46221904ef21a7b05"}, - {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b228e693a2559888790936e20f5f88b6e9f8162c681830eda303bad7517b4d5a"}, - {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2575efaa5d949c9f4e2cdbe7d805d02122c16065bfb8d95c129372d65a291a0b"}, - {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5c872814b77a4e84afa293a1bee08c14daed1068b2bb1cc312edbf020bbbca2b"}, - {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850720e1b383df199b8433a20e02b25b72f0fded28bc03c5bd79e2ce7ef050be"}, - {file = "rpds_py-0.19.0-cp39-none-win32.whl", hash = "sha256:ce84a7efa5af9f54c0aa7692c45861c1667080814286cacb9958c07fc50294fb"}, - {file = "rpds_py-0.19.0-cp39-none-win_amd64.whl", hash = "sha256:1c26da90b8d06227d7769f34915913911222d24ce08c0ab2d60b354e2d9c7aff"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:75969cf900d7be665ccb1622a9aba225cf386bbc9c3bcfeeab9f62b5048f4a07"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8445f23f13339da640d1be8e44e5baf4af97e396882ebbf1692aecd67f67c479"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5a7c1062ef8aea3eda149f08120f10795835fc1c8bc6ad948fb9652a113ca55"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:462b0c18fbb48fdbf980914a02ee38c423a25fcc4cf40f66bacc95a2d2d73bc8"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3208f9aea18991ac7f2b39721e947bbd752a1abbe79ad90d9b6a84a74d44409b"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3444fe52b82f122d8a99bf66777aed6b858d392b12f4c317da19f8234db4533"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb4bac7185a9f0168d38c01d7a00addece9822a52870eee26b8d5b61409213"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b130bd4163c93798a6b9bb96be64a7c43e1cec81126ffa7ffaa106e1fc5cef5"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:a707b158b4410aefb6b054715545bbb21aaa5d5d0080217290131c49c2124a6e"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dc9ac4659456bde7c567107556ab065801622396b435a3ff213daef27b495388"}, - {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:81ea573aa46d3b6b3d890cd3c0ad82105985e6058a4baed03cf92518081eec8c"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f148c3f47f7f29a79c38cc5d020edcb5ca780020fab94dbc21f9af95c463581"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0906357f90784a66e89ae3eadc2654f36c580a7d65cf63e6a616e4aec3a81be"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f629ecc2db6a4736b5ba95a8347b0089240d69ad14ac364f557d52ad68cf94b0"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6feacd1d178c30e5bc37184526e56740342fd2aa6371a28367bad7908d454fc"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b6068ee374fdfab63689be0963333aa83b0815ead5d8648389a8ded593378"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d57546bad81e0da13263e4c9ce30e96dcbe720dbff5ada08d2600a3502e526"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b6683a37338818646af718c9ca2a07f89787551057fae57c4ec0446dc6224b"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e8481b946792415adc07410420d6fc65a352b45d347b78fec45d8f8f0d7496f0"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bec35eb20792ea64c3c57891bc3ca0bedb2884fbac2c8249d9b731447ecde4fa"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:aa5476c3e3a402c37779e95f7b4048db2cb5b0ed0b9d006983965e93f40fe05a"}, - {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:19d02c45f2507b489fd4df7b827940f1420480b3e2e471e952af4d44a1ea8e34"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a3e2fd14c5d49ee1da322672375963f19f32b3d5953f0615b175ff7b9d38daed"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:93a91c2640645303e874eada51f4f33351b84b351a689d470f8108d0e0694210"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b9fc03bf76a94065299d4a2ecd8dfbae4ae8e2e8098bbfa6ab6413ca267709"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a4b07cdf3f84310c08c1de2c12ddadbb7a77568bcb16e95489f9c81074322ed"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba0ed0dc6763d8bd6e5de5cf0d746d28e706a10b615ea382ac0ab17bb7388633"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:474bc83233abdcf2124ed3f66230a1c8435896046caa4b0b5ab6013c640803cc"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329c719d31362355a96b435f4653e3b4b061fcc9eba9f91dd40804ca637d914e"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef9101f3f7b59043a34f1dccbb385ca760467590951952d6701df0da9893ca0c"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:0121803b0f424ee2109d6e1f27db45b166ebaa4b32ff47d6aa225642636cd834"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:8344127403dea42f5970adccf6c5957a71a47f522171fafaf4c6ddb41b61703a"}, - {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:443cec402ddd650bb2b885113e1dcedb22b1175c6be223b14246a714b61cd521"}, - {file = "rpds_py-0.19.0.tar.gz", hash = "sha256:4fdc9afadbeb393b4bbbad75481e0ea78e4469f2e1d713a90811700830b553a9"}, + {file = "rpds_py-0.19.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:aaf71f95b21f9dc708123335df22e5a2fef6307e3e6f9ed773b2e0938cc4d491"}, + {file = "rpds_py-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca0dda0c5715efe2ab35bb83f813f681ebcd2840d8b1b92bfc6fe3ab382fae4a"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81db2e7282cc0487f500d4db203edc57da81acde9e35f061d69ed983228ffe3b"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8dfa125b60ec00c7c9baef945bb04abf8ac772d8ebefd79dae2a5f316d7850"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:271accf41b02687cef26367c775ab220372ee0f4925591c6796e7c148c50cab5"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9bc4161bd3b970cd6a6fcda70583ad4afd10f2750609fb1f3ca9505050d4ef3"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0cf2a0dbb5987da4bd92a7ca727eadb225581dd9681365beba9accbe5308f7d"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b5e28e56143750808c1c79c70a16519e9bc0a68b623197b96292b21b62d6055c"}, + {file = "rpds_py-0.19.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c7af6f7b80f687b33a4cdb0a785a5d4de1fb027a44c9a049d8eb67d5bfe8a687"}, + {file = "rpds_py-0.19.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e429fc517a1c5e2a70d576077231538a98d59a45dfc552d1ac45a132844e6dfb"}, + {file = "rpds_py-0.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d2dbd8f4990d4788cb122f63bf000357533f34860d269c1a8e90ae362090ff3a"}, + {file = "rpds_py-0.19.1-cp310-none-win32.whl", hash = "sha256:e0f9d268b19e8f61bf42a1da48276bcd05f7ab5560311f541d22557f8227b866"}, + {file = "rpds_py-0.19.1-cp310-none-win_amd64.whl", hash = "sha256:df7c841813f6265e636fe548a49664c77af31ddfa0085515326342a751a6ba51"}, + {file = "rpds_py-0.19.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:902cf4739458852fe917104365ec0efbea7d29a15e4276c96a8d33e6ed8ec137"}, + {file = "rpds_py-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3d73022990ab0c8b172cce57c69fd9a89c24fd473a5e79cbce92df87e3d9c48"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3837c63dd6918a24de6c526277910e3766d8c2b1627c500b155f3eecad8fad65"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cdb7eb3cf3deb3dd9e7b8749323b5d970052711f9e1e9f36364163627f96da58"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26ab43b6d65d25b1a333c8d1b1c2f8399385ff683a35ab5e274ba7b8bb7dc61c"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75130df05aae7a7ac171b3b5b24714cffeabd054ad2ebc18870b3aa4526eba23"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34f751bf67cab69638564eee34023909380ba3e0d8ee7f6fe473079bf93f09b"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2671cb47e50a97f419a02cd1e0c339b31de017b033186358db92f4d8e2e17d8"}, + {file = "rpds_py-0.19.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c73254c256081704dba0a333457e2fb815364018788f9b501efe7c5e0ada401"}, + {file = "rpds_py-0.19.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4383beb4a29935b8fa28aca8fa84c956bf545cb0c46307b091b8d312a9150e6a"}, + {file = "rpds_py-0.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dbceedcf4a9329cc665452db1aaf0845b85c666e4885b92ee0cddb1dbf7e052a"}, + {file = "rpds_py-0.19.1-cp311-none-win32.whl", hash = "sha256:f0a6d4a93d2a05daec7cb885157c97bbb0be4da739d6f9dfb02e101eb40921cd"}, + {file = "rpds_py-0.19.1-cp311-none-win_amd64.whl", hash = "sha256:c149a652aeac4902ecff2dd93c3b2681c608bd5208c793c4a99404b3e1afc87c"}, + {file = "rpds_py-0.19.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:56313be667a837ff1ea3508cebb1ef6681d418fa2913a0635386cf29cff35165"}, + {file = "rpds_py-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d1d7539043b2b31307f2c6c72957a97c839a88b2629a348ebabe5aa8b626d6b"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1dc59a5e7bc7f44bd0c048681f5e05356e479c50be4f2c1a7089103f1621d5"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8f78398e67a7227aefa95f876481485403eb974b29e9dc38b307bb6eb2315ea"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef07a0a1d254eeb16455d839cef6e8c2ed127f47f014bbda64a58b5482b6c836"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8124101e92c56827bebef084ff106e8ea11c743256149a95b9fd860d3a4f331f"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08ce9c95a0b093b7aec75676b356a27879901488abc27e9d029273d280438505"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b02dd77a2de6e49078c8937aadabe933ceac04b41c5dde5eca13a69f3cf144e"}, + {file = "rpds_py-0.19.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4dd02e29c8cbed21a1875330b07246b71121a1c08e29f0ee3db5b4cfe16980c4"}, + {file = "rpds_py-0.19.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9c7042488165f7251dc7894cd533a875d2875af6d3b0e09eda9c4b334627ad1c"}, + {file = "rpds_py-0.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f809a17cc78bd331e137caa25262b507225854073fd319e987bd216bed911b7c"}, + {file = "rpds_py-0.19.1-cp312-none-win32.whl", hash = "sha256:3ddab996807c6b4227967fe1587febade4e48ac47bb0e2d3e7858bc621b1cace"}, + {file = "rpds_py-0.19.1-cp312-none-win_amd64.whl", hash = "sha256:32e0db3d6e4f45601b58e4ac75c6f24afbf99818c647cc2066f3e4b192dabb1f"}, + {file = "rpds_py-0.19.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:747251e428406b05fc86fee3904ee19550c4d2d19258cef274e2151f31ae9d38"}, + {file = "rpds_py-0.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dc733d35f861f8d78abfaf54035461e10423422999b360966bf1c443cbc42705"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbda75f245caecff8faa7e32ee94dfaa8312a3367397975527f29654cd17a6ed"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd04d8cab16cab5b0a9ffc7d10f0779cf1120ab16c3925404428f74a0a43205a"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2d66eb41ffca6cc3c91d8387509d27ba73ad28371ef90255c50cb51f8953301"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdf4890cda3b59170009d012fca3294c00140e7f2abe1910e6a730809d0f3f9b"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1fa67ef839bad3815124f5f57e48cd50ff392f4911a9f3cf449d66fa3df62a5"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b82c9514c6d74b89a370c4060bdb80d2299bc6857e462e4a215b4ef7aa7b090e"}, + {file = "rpds_py-0.19.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c7b07959866a6afb019abb9564d8a55046feb7a84506c74a6f197cbcdf8a208e"}, + {file = "rpds_py-0.19.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4f580ae79d0b861dfd912494ab9d477bea535bfb4756a2269130b6607a21802e"}, + {file = "rpds_py-0.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c6d20c8896c00775e6f62d8373aba32956aa0b850d02b5ec493f486c88e12859"}, + {file = "rpds_py-0.19.1-cp313-none-win32.whl", hash = "sha256:afedc35fe4b9e30ab240b208bb9dc8938cb4afe9187589e8d8d085e1aacb8309"}, + {file = "rpds_py-0.19.1-cp313-none-win_amd64.whl", hash = "sha256:1d4af2eb520d759f48f1073ad3caef997d1bfd910dc34e41261a595d3f038a94"}, + {file = "rpds_py-0.19.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:34bca66e2e3eabc8a19e9afe0d3e77789733c702c7c43cd008e953d5d1463fde"}, + {file = "rpds_py-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:24f8ae92c7fae7c28d0fae9b52829235df83f34847aa8160a47eb229d9666c7b"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71157f9db7f6bc6599a852852f3389343bea34315b4e6f109e5cbc97c1fb2963"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d494887d40dc4dd0d5a71e9d07324e5c09c4383d93942d391727e7a40ff810b"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3661e6d4ba63a094138032c1356d557de5b3ea6fd3cca62a195f623e381c76"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97fbb77eaeb97591efdc654b8b5f3ccc066406ccfb3175b41382f221ecc216e8"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cc4bc73e53af8e7a42c8fd7923bbe35babacfa7394ae9240b3430b5dcf16b2a"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:35af5e4d5448fa179fd7fff0bba0fba51f876cd55212f96c8bbcecc5c684ae5c"}, + {file = "rpds_py-0.19.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3511f6baf8438326e351097cecd137eb45c5f019944fe0fd0ae2fea2fd26be39"}, + {file = "rpds_py-0.19.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:57863d16187995c10fe9cf911b897ed443ac68189179541734502353af33e693"}, + {file = "rpds_py-0.19.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9e318e6786b1e750a62f90c6f7fa8b542102bdcf97c7c4de2a48b50b61bd36ec"}, + {file = "rpds_py-0.19.1-cp38-none-win32.whl", hash = "sha256:53dbc35808c6faa2ce3e48571f8f74ef70802218554884787b86a30947842a14"}, + {file = "rpds_py-0.19.1-cp38-none-win_amd64.whl", hash = "sha256:8df1c283e57c9cb4d271fdc1875f4a58a143a2d1698eb0d6b7c0d7d5f49c53a1"}, + {file = "rpds_py-0.19.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e76c902d229a3aa9d5ceb813e1cbcc69bf5bda44c80d574ff1ac1fa3136dea71"}, + {file = "rpds_py-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de1f7cd5b6b351e1afd7568bdab94934d656abe273d66cda0ceea43bbc02a0c2"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24fc5a84777cb61692d17988989690d6f34f7f95968ac81398d67c0d0994a897"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74129d5ffc4cde992d89d345f7f7d6758320e5d44a369d74d83493429dad2de5"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e360188b72f8080fefa3adfdcf3618604cc8173651c9754f189fece068d2a45"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13e6d4840897d4e4e6b2aa1443e3a8eca92b0402182aafc5f4ca1f5e24f9270a"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f09529d2332264a902688031a83c19de8fda5eb5881e44233286b9c9ec91856d"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0d4b52811dcbc1aba08fd88d475f75b4f6db0984ba12275d9bed1a04b2cae9b5"}, + {file = "rpds_py-0.19.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd635c2c4043222d80d80ca1ac4530a633102a9f2ad12252183bcf338c1b9474"}, + {file = "rpds_py-0.19.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f35b34a5184d5e0cc360b61664c1c06e866aab077b5a7c538a3e20c8fcdbf90b"}, + {file = "rpds_py-0.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d4ec0046facab83012d821b33cead742a35b54575c4edfb7ed7445f63441835f"}, + {file = "rpds_py-0.19.1-cp39-none-win32.whl", hash = "sha256:f5b8353ea1a4d7dfb59a7f45c04df66ecfd363bb5b35f33b11ea579111d4655f"}, + {file = "rpds_py-0.19.1-cp39-none-win_amd64.whl", hash = "sha256:1fb93d3486f793d54a094e2bfd9cd97031f63fcb5bc18faeb3dd4b49a1c06523"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7d5c7e32f3ee42f77d8ff1a10384b5cdcc2d37035e2e3320ded909aa192d32c3"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:89cc8921a4a5028d6dd388c399fcd2eef232e7040345af3d5b16c04b91cf3c7e"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca34e913d27401bda2a6f390d0614049f5a95b3b11cd8eff80fe4ec340a1208"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5953391af1405f968eb5701ebbb577ebc5ced8d0041406f9052638bafe52209d"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:840e18c38098221ea6201f091fc5d4de6128961d2930fbbc96806fb43f69aec1"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d8b735c4d162dc7d86a9cf3d717f14b6c73637a1f9cd57fe7e61002d9cb1972"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce757c7c90d35719b38fa3d4ca55654a76a40716ee299b0865f2de21c146801c"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9421b23c85f361a133aa7c5e8ec757668f70343f4ed8fdb5a4a14abd5437244"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3b823be829407393d84ee56dc849dbe3b31b6a326f388e171555b262e8456cc1"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:5e58b61dcbb483a442c6239c3836696b79f2cd8e7eec11e12155d3f6f2d886d1"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39d67896f7235b2c886fb1ee77b1491b77049dcef6fbf0f401e7b4cbed86bbd4"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8b32cd4ab6db50c875001ba4f5a6b30c0f42151aa1fbf9c2e7e3674893fb1dc4"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1c32e41de995f39b6b315d66c27dea3ef7f7c937c06caab4c6a79a5e09e2c415"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1a129c02b42d46758c87faeea21a9f574e1c858b9f358b6dd0bbd71d17713175"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:346557f5b1d8fd9966059b7a748fd79ac59f5752cd0e9498d6a40e3ac1c1875f"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31e450840f2f27699d014cfc8865cc747184286b26d945bcea6042bb6aa4d26e"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01227f8b3e6c8961490d869aa65c99653df80d2f0a7fde8c64ebddab2b9b02fd"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69084fd29bfeff14816666c93a466e85414fe6b7d236cfc108a9c11afa6f7301"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d2b88efe65544a7d5121b0c3b003ebba92bfede2ea3577ce548b69c5235185"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ea961a674172ed2235d990d7edf85d15d8dfa23ab8575e48306371c070cda67"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:5beffdbe766cfe4fb04f30644d822a1080b5359df7db3a63d30fa928375b2720"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:720f3108fb1bfa32e51db58b832898372eb5891e8472a8093008010911e324c5"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:c2087dbb76a87ec2c619253e021e4fb20d1a72580feeaa6892b0b3d955175a71"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ddd50f18ebc05ec29a0d9271e9dbe93997536da3546677f8ca00b76d477680c"}, + {file = "rpds_py-0.19.1.tar.gz", hash = "sha256:31dd5794837f00b46f4096aa8ccaa5972f73a938982e32ed817bb520c465e520"}, ] [[package]] @@ -1772,18 +1803,19 @@ typing-extensions = ">=4.7.1" [[package]] name = "setuptools" -version = "70.3.0" +version = "72.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, - {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, + {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, + {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, ] [package.extras] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] 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"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "shellingham" @@ -1928,4 +1960,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "17cd0c83d5aa272809743691d6b91ae3e7c3c98df8c7ce389aabc32f996b69d0" +content-hash = "bcabe584efc23c940efe1296b50b42e031f0e38c84d845b324d71070be96b361" diff --git a/pyproject.toml b/pyproject.toml index 664488cd33..7286d48a59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ django-guid = "3.5.0" djangorestframework = "3.15.2" djangorestframework-jsonapi = "7.0.2" drf-spectacular = "0.27.2" +drf-spectacular-jsonapi = "0.4.1" gunicorn = "22.0.0" psycopg2-binary = "2.9.9" python = "^3.12" diff --git a/src/backend/api/exceptions.py b/src/backend/api/exceptions.py new file mode 100644 index 0000000000..7fdc5025e6 --- /dev/null +++ b/src/backend/api/exceptions.py @@ -0,0 +1,32 @@ +from django.core.exceptions import ValidationError as django_validation_error +from rest_framework_json_api.exceptions import exception_handler +from rest_framework_json_api.serializers import ValidationError + + +class ModelValidationError(ValidationError): + def __init__( + self, + detail: str | None = None, + code: str | None = None, + pointer: str | None = None, + status_code: int = 400, + ): + super().__init__( + detail=[ + { + "detail": detail, + "status": str(status_code), + "source": {"pointer": pointer}, + "code": code, + } + ] + ) + + +def custom_exception_handler(exc, context): + if isinstance(exc, django_validation_error): + if hasattr(exc, "error_dict"): + exc = ValidationError(exc.message_dict) + else: + exc = ValidationError(detail=exc.messages[0], code=exc.code) + return exception_handler(exc, context) diff --git a/src/backend/api/filters.py b/src/backend/api/filters.py index 505008febc..dd6e48b924 100644 --- a/src/backend/api/filters.py +++ b/src/backend/api/filters.py @@ -1,6 +1,7 @@ -from django_filters.rest_framework import FilterSet +from django_filters.rest_framework import FilterSet, BooleanFilter from rest_framework_json_api.django_filters.backends import DjangoFilterBackend +from api.models import Provider from api.rls import Tenant @@ -13,24 +14,25 @@ class CustomDjangoFilterBackend(DjangoFilterBackend): return None -class BaseFilter(FilterSet): +class TenantFilter(FilterSet): class Meta: - model = None + model = Tenant fields = { + "name": ["exact", "icontains"], "inserted_at": ["exact", "gte", "lte"], "updated_at": ["exact", "gte", "lte"], } -class TenantFilter(BaseFilter): - class Meta(BaseFilter.Meta): - model = Tenant +class ProviderFilter(FilterSet): + connected = BooleanFilter() + + class Meta: + model = Provider fields = { - **BaseFilter.Meta.fields, - "name": ["exact", "icontains"], + "provider": ["exact"], + "provider_id": ["exact", "icontains"], + "alias": ["exact", "icontains"], + "inserted_at": ["exact", "gte", "lte"], + "updated_at": ["exact", "gte", "lte"], } - - -class ProviderFilter(BaseFilter): - # TODO implement proper filter - pass diff --git a/src/backend/api/fixtures/dev_providers.json b/src/backend/api/fixtures/dev_providers.json new file mode 100644 index 0000000000..6323b516f1 --- /dev/null +++ b/src/backend/api/fixtures/dev_providers.json @@ -0,0 +1,109 @@ +[ + { + "model": "api.provider", + "pk": "37b065f8-26b0-4218-a665-0b23d07b27d9", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-01T17:20:27.050Z", + "updated_at": "2024-08-01T17:20:27.050Z", + "provider": "gcp", + "provider_id": "a12322-test321", + "alias": "gcp_testing_2", + "connected": null, + "connection_last_checked_at": null, + "metadata": {} + } + }, + { + "model": "api.provider", + "pk": "8851db6b-42e5-4533-aa9e-30a32d67e875", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-01T17:19:42.453Z", + "updated_at": "2024-08-01T17:19:42.453Z", + "provider": "gcp", + "provider_id": "a12345-test123", + "alias": "gcp_testing_1", + "connected": null, + "connection_last_checked_at": null, + "metadata": {} + } + }, + { + "model": "api.provider", + "pk": "b85601a8-4b45-4194-8135-03fb980ef428", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-01T17:19:09.556Z", + "updated_at": "2024-08-01T17:19:09.556Z", + "provider": "aws", + "provider_id": "123456789020", + "alias": "aws_testing_2", + "connected": null, + "connection_last_checked_at": null, + "metadata": {} + } + }, + { + "model": "api.provider", + "pk": "baa7b895-8bac-4f47-b010-4226d132856e", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-01T17:20:16.962Z", + "updated_at": "2024-08-01T17:20:16.962Z", + "provider": "gcp", + "provider_id": "a12322-test123", + "alias": "gcp_testing_3", + "connected": null, + "connection_last_checked_at": null, + "metadata": {} + } + }, + { + "model": "api.provider", + "pk": "d7c7ea89-d9af-423b-a364-1290dcad5a01", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-01T17:18:58.132Z", + "updated_at": "2024-08-01T17:18:58.132Z", + "provider": "aws", + "provider_id": "123456789015", + "alias": "aws_testing_1", + "connected": null, + "connection_last_checked_at": null, + "metadata": {} + } + }, + { + "model": "api.provider", + "pk": "1b59e032-3eb6-4694-93a5-df84cd9b3ce2", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-06T16:03:26.176Z", + "updated_at": "2024-08-06T16:03:26.176Z", + "provider": "azure", + "provider_id": "8851db6b-42e5-4533-aa9e-30a32d67e875", + "alias": "azure_testing", + "connected": null, + "connection_last_checked_at": null, + "metadata": {}, + "scanner_args": {} + } + }, + { + "model": "api.provider", + "pk": "26e55a24-cb2c-4cef-ac87-6f91fddb2c97", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "inserted_at": "2024-08-06T16:03:07.037Z", + "updated_at": "2024-08-06T16:03:07.037Z", + "provider": "kubernetes", + "provider_id": "kubernetes-test-12345", + "alias": "k8s_testing", + "connected": null, + "connection_last_checked_at": null, + "metadata": {}, + "scanner_args": {} + } + } +] diff --git a/src/backend/api/migrations/0001_initial.py b/src/backend/api/migrations/0001_initial.py index 4110af0d26..e9cd32946d 100644 --- a/src/backend/api/migrations/0001_initial.py +++ b/src/backend/api/migrations/0001_initial.py @@ -1,8 +1,12 @@ import uuid +import django.core.validators +import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import api.rls + DB_NAME = settings.DATABASES["default"]["NAME"] DB_USER_NAME = settings.DATABASES["default"]["USER"] DB_USER_PASSWORD = settings.DATABASES["default"]["PASSWORD"] @@ -61,4 +65,80 @@ class Migration(migrations.Migration): GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE tenant TO {DB_USER_NAME}; """ ), + migrations.CreateModel( + name="Provider", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider", + models.CharField( + choices=[ + ("aws", "Aws"), + ("azure", "Azure"), + ("gcp", "Gcp"), + ("kubernetes", "Kubernetes"), + ], + default="aws", + max_length=10, + ), + ), + ( + "provider_id", + models.CharField( + max_length=63, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ( + "alias", + models.CharField( + blank=True, + null=True, + max_length=100, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ("connected", models.BooleanField(blank=True, null=True)), + ( + "connection_last_checked_at", + models.DateTimeField(blank=True, null=True), + ), + ("metadata", models.JSONField(default=dict)), + ("scanner_args", models.JSONField(default=dict)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="provider", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_provider", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + migrations.AddConstraint( + model_name="provider", + constraint=models.UniqueConstraint( + fields=("tenant_id", "provider", "provider_id"), + name="unique_provider_ids", + ), + ), ] diff --git a/src/backend/api/migrations/0002_test_test_rls_on_test.py b/src/backend/api/migrations/0002_test_test_rls_on_test.py deleted file mode 100644 index 70aa5475cd..0000000000 --- a/src/backend/api/migrations/0002_test_test_rls_on_test.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.0.6 on 2024-08-01 15:11 - -import api.rls -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Test", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100)), - ( - "tenant", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="api.tenant" - ), - ), - ], - options={ - "db_table": "test", - }, - ), - migrations.AddConstraint( - model_name="test", - constraint=api.rls.RowLevelSecurityConstraint( - "tenant_id", - name="rls_on_test", - statements=["SELECT", "INSERT", "UPDATE", "DELETE"], - ), - ), - ] diff --git a/src/backend/api/models.py b/src/backend/api/models.py index ad69cac12f..4aefca56e2 100644 --- a/src/backend/api/models.py +++ b/src/backend/api/models.py @@ -1,17 +1,96 @@ +import re +from uuid import uuid4, UUID + +from django.core.validators import MinLengthValidator from django.db import models +from django.utils.translation import gettext_lazy as _ -from api.rls import RowLevelSecurityProtectedModel, RowLevelSecurityConstraint +from api.exceptions import ModelValidationError +from api.rls import RowLevelSecurityConstraint +from api.rls import RowLevelSecurityProtectedModel -class Test(RowLevelSecurityProtectedModel): - name = models.CharField(max_length=100) +class Provider(RowLevelSecurityProtectedModel): + class ProviderChoices(models.TextChoices): + AWS = "aws", _("AWS") + AZURE = "azure", _("Azure") + GCP = "gcp", _("GCP") + KUBERNETES = "kubernetes", _("Kubernetes") - class Meta: - db_table = "test" + @staticmethod + def validate_aws_provider_id(value): + if not re.match(r"^\d{12}$", value): + raise ModelValidationError( + detail="AWS provider ID must be exactly 12 digits.", + code="aws-provider-id", + pointer="/data/attributes/provider_id", + ) + + @staticmethod + def validate_azure_provider_id(value): + try: + val = UUID(value, version=4) + if str(val) != value: + raise ValueError + except ValueError: + raise ModelValidationError( + detail="Azure provider ID must be a valid UUID.", + code="azure-provider-id", + pointer="/data/attributes/provider_id", + ) + + @staticmethod + def validate_gcp_provider_id(value): + if not re.match(r"^[a-z][a-z0-9-]{5,29}$", value): + raise ModelValidationError( + detail="GCP provider ID must be 6 to 30 characters, start with a letter, and contain only lowercase " + "letters, numbers, and hyphens.", + code="gcp-provider-id", + pointer="/data/attributes/provider_id", + ) + + @staticmethod + def validate_kubernetes_provider_id(value): + if not re.match(r"^[a-z0-9]([-a-z0-9]{1,61}[a-z0-9])?$", value): + raise ModelValidationError( + detail="K8s provider ID must be up to 63 characters, start and end with a lowercase letter or number, " + "and contain only lowercase alphanumeric characters and hyphens.", + code="kubernetes-provider-id", + pointer="/data/attributes/provider_id", + ) + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + provider = models.CharField( + max_length=10, choices=ProviderChoices.choices, default=ProviderChoices.AWS + ) + provider_id = models.CharField(max_length=63, validators=[MinLengthValidator(3)]) + alias = models.CharField( + blank=True, null=True, max_length=100, validators=[MinLengthValidator(3)] + ) + connected = models.BooleanField(null=True, blank=True) + connection_last_checked_at = models.DateTimeField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + scanner_args = models.JSONField(default=dict, blank=True) + + def clean(self): + super().clean() + getattr(self, f"validate_{self.provider}_provider_id")(self.provider_id) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + class Meta(RowLevelSecurityProtectedModel.Meta): constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "provider", "provider_id"), + name="unique_provider_ids", + ), RowLevelSecurityConstraint( field="tenant_id", name="rls_on_%(class)s", statements=["SELECT", "INSERT", "UPDATE", "DELETE"], - ) + ), ] diff --git a/src/backend/api/rls.py b/src/backend/api/rls.py index 2b33f46799..50db74a3bb 100644 --- a/src/backend/api/rls.py +++ b/src/backend/api/rls.py @@ -2,6 +2,8 @@ from typing import Any from uuid import uuid4 from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import DEFAULT_DB_ALIAS from django.db import models from django.db.backends.ddl_references import Statement, Table @@ -108,6 +110,10 @@ class RowLevelSecurityConstraint(models.BaseConstraint): path, _, kwargs = super().deconstruct() return (path, (self.target_field,), kwargs) + def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS): # noqa: F841 + if not hasattr(instance, "tenant_id"): + raise ValidationError(f"{model.__name__} does not have a tenant_id field.") + class RowLevelSecurityProtectedModel(models.Model): tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE) diff --git a/src/backend/api/serializers.py b/src/backend/api/serializers.py index 46bcffb9c3..7906f3f2f8 100644 --- a/src/backend/api/serializers.py +++ b/src/backend/api/serializers.py @@ -1,14 +1,39 @@ +from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers +from rest_framework_json_api.serializers import ValidationError -from api.models import Test +from api.models import Provider from api.rls import Tenant +# Base + + class BaseSerializerV1(serializers.ModelSerializer): def get_root_meta(self, _resource, _many): return {"version": "v1"} +class BaseWriteSerializer(BaseSerializerV1): + def validate(self, data): + if hasattr(self, "initial_data"): + initial_data = set(self.initial_data.keys()) - {"id", "type"} + unknown_keys = initial_data - set(self.fields.keys()) + if unknown_keys: + raise ValidationError(f"Invalid fields: {unknown_keys}") + return data + + +class RLSSerializer(BaseSerializerV1): + def create(self, validated_data): + tenant_id = self.context.get("tenant_id") + validated_data["tenant_id"] = tenant_id + return super().create(validated_data) + + +# Tenants + + class TenantSerializer(BaseSerializerV1): """ Serializer for the Tenant model. @@ -19,16 +44,57 @@ class TenantSerializer(BaseSerializerV1): fields = "__all__" -class TestSerializer(BaseSerializerV1): - tenant = serializers.PrimaryKeyRelatedField( - queryset=Tenant.objects.all(), write_only=True - ) +# Providers + + +class ProviderSerializer(RLSSerializer): + """ + Serializer for the Provider model. + """ + + connection = serializers.SerializerMethodField(read_only=True) class Meta: - model = Test - fields = "__all__" + model = Provider + fields = [ + "id", + "inserted_at", + "updated_at", + "provider", + "provider_id", + "alias", + "connection", + "scanner_args", + ] + extra_kwargs = { + "id": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + "connection": {"read_only": True}, + } - def to_representation(self, instance): - representation = super().to_representation(instance) - representation.pop("tenant", None) - return representation + @extend_schema_field( + { + "type": "object", + "properties": { + "connected": {"type": "boolean"}, + "last_checked_at": {"type": "string", "format": "date-time"}, + }, + } + ) + def get_connection(self, obj): + return { + "connected": obj.connected, + "last_checked_at": obj.connection_last_checked_at, + } + + +class ProviderUpdateSerializer(BaseWriteSerializer): + """ + Serializer for updating the Provider model. + Only allows "alias" and "scanner_args" fields to be updated. + """ + + class Meta: + model = Provider + fields = ["alias", "scanner_args"] diff --git a/src/backend/api/specs/v1.yaml b/src/backend/api/specs/v1.yaml index 40c328758b..1d5b205dac 100644 --- a/src/backend/api/specs/v1.yaml +++ b/src/backend/api/specs/v1.yaml @@ -7,30 +7,132 @@ info: This file is auto-generated. paths: - /api/v1/schema: + /api/v1/providers: get: - operationId: schema_retrieve - description: |- - OpenApi3 schema for this API. Format can be selected via content negotiation. - - - YAML: application/vnd.oai.openapi - - JSON: application/vnd.oai.openapi+json + operationId: providers_list + description: Retrieve a list of all providers with options for filtering by + various criteria. + summary: List all providers parameters: - in: query - name: format + name: fields[Provider] schema: - type: string - enum: - - json - - yaml + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - provider + - provider_id + - alias + - connection + - metadata + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false - in: query - name: lang + name: filter[alias] + schema: + type: string + - in: query + name: filter[alias__icontains] + schema: + type: string + - in: query + name: filter[connected] + schema: + type: boolean + - in: query + name: filter[inserted_at] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[provider] schema: type: string enum: - - en + - aws + - azure + - gcp + - kubernetes + description: |- + * `aws` - Aws + * `azure` - Azure + * `gcp` - Gcp + * `kubernetes` - Kubernetes + - in: query + name: filter[provider_id] + schema: + type: string + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - provider + - -provider + - provider_id + - -provider_id + - alias + - -alias + - connected + - -connected + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false tags: - - schema + - Provider security: - cookieAuth: [] - basicAuth: [] @@ -38,24 +140,749 @@ paths: responses: '200': content: - application/vnd.oai.openapi: + application/vnd.api+json: schema: - type: object - additionalProperties: {} - application/yaml: - schema: - type: object - additionalProperties: {} - application/vnd.oai.openapi+json: - schema: - type: object - additionalProperties: {} - application/json: - schema: - type: object - additionalProperties: {} + $ref: '#/components/schemas/PaginatedProviderList' description: '' + post: + operationId: providers_create + description: Add a new provider to the system by providing the required provider + details. + summary: Create a new provider + tags: + - Provider + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProviderRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ProviderRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/ProviderRequest' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProviderResponse' + description: '' + /api/v1/providers/{id}: + get: + operationId: providers_retrieve + description: Fetch detailed information about a specific provider by their ID. + summary: Retrieve data from a provider + parameters: + - in: query + name: fields[Provider] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - provider + - provider_id + - alias + - connection + - metadata + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this provider. + required: true + tags: + - Provider + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProviderResponse' + description: '' + patch: + operationId: providers_partial_update + description: Update certain fields of an existing provider's information without + affecting other fields. + summary: Partially update a provider + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this provider. + required: true + tags: + - Provider + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedProviderRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedProviderRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedProviderRequest' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProviderResponse' + description: '' + delete: + operationId: providers_destroy + description: Remove a provider from the system by their ID. + summary: Delete a provider + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this provider. + required: true + tags: + - Provider + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SerializerMetaclassResponse' + description: '' + /api/v1/providers/{id}/connection: + post: + operationId: providers_connection_create + description: Try to verify connection. For instance, Role & Credentials are + set correctly + summary: Check connection + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this provider. + required: true + tags: + - Provider + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SerializerMetaclassResponse' + description: '' + /api/v1/tenants: + get: + operationId: tenants_list + description: Retrieve a list of all tenants with options for filtering by various + criteria. + summary: List all tenants + parameters: + - in: query + name: fields[Tenant] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - name + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[inserted_at] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - name + - -name + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false + tags: + - Tenant + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedTenantList' + description: '' + post: + operationId: tenants_create + description: Add a new tenant to the system by providing the required tenant + details. + summary: Create a new tenant + tags: + - Tenant + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TenantRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TenantRequest' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantResponse' + description: '' + /api/v1/tenants/{id}: + get: + operationId: tenants_retrieve + description: Fetch detailed information about a specific tenant by their ID. + summary: Retrieve data from a tenant + parameters: + - in: query + name: fields[Tenant] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - name + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this tenant. + required: true + tags: + - Tenant + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantResponse' + description: '' + patch: + operationId: tenants_partial_update + description: Update certain fields of an existing tenant's information without + affecting other fields. + summary: Partially update a tenant + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this tenant. + required: true + tags: + - Tenant + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedTenantRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedTenantRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedTenantRequest' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantResponse' + description: '' + delete: + operationId: tenants_destroy + description: Remove a tenant from the system by their ID. + summary: Delete a tenant + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this tenant. + required: true + tags: + - Tenant + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body components: + schemas: + PaginatedProviderList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Provider' + required: + - data + PaginatedTenantList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Tenant' + required: + - data + PatchedProviderRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - Provider + id: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + provider: + enum: + - aws + - azure + - gcp + - kubernetes + type: string + description: |- + * `aws` - Aws + * `azure` - Azure + * `gcp` - Gcp + * `kubernetes` - Kubernetes + provider_id: + type: string + minLength: 3 + maxLength: 63 + alias: + type: string + minLength: 3 + maxLength: 100 + connection: + type: object + properties: + connected: + type: boolean + last_checked_at: + type: string + format: date-time + readOnly: true + metadata: {} + required: + - provider_id + - alias + required: + - data + PatchedTenantRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - Tenant + id: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + name: + type: string + minLength: 1 + maxLength: 100 + required: + - name + required: + - data + Provider: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/ProviderTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + provider: + enum: + - aws + - azure + - gcp + - kubernetes + type: string + description: |- + * `aws` - Aws + * `azure` - Azure + * `gcp` - Gcp + * `kubernetes` - Kubernetes + provider_id: + type: string + maxLength: 63 + minLength: 3 + alias: + type: string + maxLength: 100 + minLength: 3 + connection: + type: object + properties: + connected: + type: boolean + last_checked_at: + type: string + format: date-time + readOnly: true + metadata: {} + required: + - provider_id + - alias + ProviderRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - Provider + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + provider: + enum: + - aws + - azure + - gcp + - kubernetes + type: string + description: |- + * `aws` - Aws + * `azure` - Azure + * `gcp` - Gcp + * `kubernetes` - Kubernetes + provider_id: + type: string + minLength: 3 + maxLength: 63 + alias: + type: string + minLength: 3 + maxLength: 100 + connection: + type: object + properties: + connected: + type: boolean + last_checked_at: + type: string + format: date-time + readOnly: true + metadata: {} + required: + - provider_id + - alias + required: + - data + ProviderResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Provider' + required: + - data + ProviderTypeEnum: + type: string + enum: + - Provider + SerializerMetaclassResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Provider' + required: + - data + Tenant: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/TenantTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + name: + type: string + maxLength: 100 + required: + - name + TenantRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - Tenant + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + name: + type: string + minLength: 1 + maxLength: 100 + required: + - name + required: + - data + TenantResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Tenant' + required: + - data + TenantTypeEnum: + type: string + enum: + - Tenant securitySchemes: basicAuth: type: http diff --git a/src/backend/api/tests/test_database.py b/src/backend/api/tests/test_database.py index 4c42db40f1..396b144d57 100644 --- a/src/backend/api/tests/test_database.py +++ b/src/backend/api/tests/test_database.py @@ -4,7 +4,6 @@ from django.db.migrations.recorder import MigrationRecorder from django.db.utils import ConnectionRouter from api.db_router import MainRouter -from api.models import Test from api.rls import Tenant from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS @@ -17,7 +16,7 @@ class TestMainDatabaseRouter: yield ConnectionRouter() settings.DATABASE_ROUTERS = testing_routers - @pytest.mark.parametrize("api_model", [Tenant, Test]) + @pytest.mark.parametrize("api_model", [Tenant]) def test_router_api_models(self, api_model, router): assert router.db_for_read(api_model) == MainRouter.default_db assert router.db_for_write(api_model) == MainRouter.default_db diff --git a/src/backend/api/tests/test_views.py b/src/backend/api/tests/test_views.py index 2bd8975cdb..fcb6a53993 100644 --- a/src/backend/api/tests/test_views.py +++ b/src/backend/api/tests/test_views.py @@ -1,10 +1,15 @@ +from datetime import datetime + import pytest from django.urls import reverse from rest_framework import status +from api.models import Provider from api.rls import Tenant API_JSON_CONTENT_TYPE = "application/vnd.api+json" +# TODO Change to 401 when authentication/authorization is implemented +NO_TENANT_HTTP_STATUS = status.HTTP_403_FORBIDDEN @pytest.mark.django_db @@ -50,8 +55,7 @@ class TestTenantViewSet: assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["name"] == tenant1.name - def test_tenants_invalid_retrieve(self, client, tenants): - tenant1, _ = tenants + def test_tenants_invalid_retrieve(self, client): response = client.get(reverse("tenant-detail", kwargs={"pk": "random_id"})) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -177,3 +181,343 @@ class TestTenantViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 assert response.json()["data"][0]["attributes"]["name"] == tenant2.name + + +@pytest.mark.django_db +class TestProviderViewSet: + @pytest.fixture + def providers(self, get_tenant): + tenant = get_tenant + provider1 = Provider.objects.create( + provider="aws", + provider_id="123456789012", + alias="aws_testing_1", + tenant_id=tenant.id, + ) + provider2 = Provider.objects.create( + provider="aws", + provider_id="123456789013", + alias="aws_testing_2", + tenant_id=tenant.id, + ) + provider3 = Provider.objects.create( + provider="gcp", + provider_id="a12322-test321", + alias="gcp_testing", + tenant_id=tenant.id, + ) + provider4 = Provider.objects.create( + provider="kubernetes", + provider_id="kubernetes-test-12345", + alias="k8s_testing", + tenant_id=tenant.id, + ) + provider5 = Provider.objects.create( + provider="azure", + provider_id="37b065f8-26b0-4218-a665-0b23d07b27d9", + alias="azure_testing", + tenant_id=tenant.id, + ) + + return provider1, provider2, provider3, provider4, provider5 + + def test_providers_rls(self, client): + response = client.get(reverse("provider-list")) + assert response.status_code == NO_TENANT_HTTP_STATUS + + def test_providers_list(self, client, providers, tenant_header): + response = client.get(reverse("provider-list"), headers=tenant_header) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(providers) + + def test_providers_retrieve(self, client, providers, tenant_header): + provider1, *_ = providers + response = client.get( + reverse("provider-detail", kwargs={"pk": provider1.id}), + headers=tenant_header, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"]["attributes"]["provider"] == provider1.provider + assert ( + response.json()["data"]["attributes"]["provider_id"] + == provider1.provider_id + ) + assert response.json()["data"]["attributes"]["alias"] == provider1.alias + + def test_providers_invalid_retrieve(self, client, tenant_header): + response = client.get( + reverse("provider-detail", kwargs={"pk": "random_id"}), + headers=tenant_header, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize( + "provider_json_payload", + ( + [ + {"provider": "aws", "provider_id": "111111111111", "alias": "test"}, + {"provider": "gcp", "provider_id": "a12322-test54321", "alias": "test"}, + { + "provider": "kubernetes", + "provider_id": "kubernetes-test-123456789", + "alias": "test", + }, + { + "provider": "azure", + "provider_id": "8851db6b-42e5-4533-aa9e-30a32d67e875", + "alias": "test", + }, + ] + ), + ) + def test_providers_create_valid(self, client, tenant_header, provider_json_payload): + response = client.post( + reverse("provider-list"), + data=provider_json_payload, + format="json", + headers=tenant_header, + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.count() == 1 + assert Provider.objects.get().provider == provider_json_payload["provider"] + assert ( + Provider.objects.get().provider_id == provider_json_payload["provider_id"] + ) + assert Provider.objects.get().alias == provider_json_payload["alias"] + + @pytest.mark.parametrize( + "provider_json_payload, error_code", + ( + [ + ( + {"provider": "aws", "provider_id": "1", "alias": "test"}, + "min_length", + ), + ( + { + "provider": "aws", + "provider_id": "1111111111111", + "alias": "test", + }, + "aws-provider-id", + ), + ( + {"provider": "aws", "provider_id": "aaaaaaaaaaaa", "alias": "test"}, + "aws-provider-id", + ), + ( + {"provider": "gcp", "provider_id": "1234asdf", "alias": "test"}, + "gcp-provider-id", + ), + ( + { + "provider": "kubernetes", + "provider_id": "-1234asdf", + "alias": "test", + }, + "kubernetes-provider-id", + ), + ( + { + "provider": "azure", + "provider_id": "8851db6b-42e5-4533-aa9e-30a32d67e87", + "alias": "test", + }, + "azure-provider-id", + ), + ] + ), + ) + def test_providers_invalid_create( + self, client, tenant_header, provider_json_payload, error_code + ): + response = client.post( + reverse("provider-list"), + data=provider_json_payload, + format="json", + headers=tenant_header, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == error_code + assert ( + response.json()["errors"][0]["source"]["pointer"] + == "/data/attributes/provider_id" + ) + + def test_providers_partial_update(self, client, providers, tenant_header): + provider1, *_ = providers + new_alias = "This is the new name" + payload = { + "data": { + "type": "Provider", + "id": provider1.id, + "attributes": {"alias": new_alias}, + }, + } + response = client.patch( + reverse("provider-detail", kwargs={"pk": provider1.id}), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + headers=tenant_header, + ) + assert response.status_code == status.HTTP_200_OK + provider1.refresh_from_db() + assert provider1.alias == new_alias + + def test_providers_partial_update_invalid_content_type( + self, client, providers, tenant_header + ): + provider1, *_ = providers + response = client.patch( + reverse("provider-detail", kwargs={"pk": provider1.id}), + data={}, + headers=tenant_header, + ) + assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + + def test_providers_partial_update_invalid_content( + self, client, providers, tenant_header + ): + provider1, *_ = providers + new_name = "This is the new name" + payload = {"alias": new_name} + response = client.patch( + reverse("provider-detail", kwargs={"pk": provider1.id}), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + headers=tenant_header, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + "attribute_key, attribute_value", + [ + ("provider", "aws"), + ("provider_id", "123456789012"), + ], + ) + def test_providers_partial_update_invalid_fields( + self, client, providers, tenant_header, attribute_key, attribute_value + ): + provider1, *_ = providers + payload = { + "data": { + "type": "Provider", + "id": provider1.id, + "attributes": {attribute_key: attribute_value}, + }, + } + response = client.patch( + reverse("provider-detail", kwargs={"pk": provider1.id}), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + headers=tenant_header, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_providers_delete(self, client, providers, tenant_header): + provider1, *_ = providers + response = client.delete( + reverse("provider-detail", kwargs={"pk": provider1.id}), + headers=tenant_header, + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert "Content-Location" in response.headers + assert Provider.objects.count() == len(providers) - 1 + # TODO Assert a task is returned when they are implemented + + def test_providers_delete_invalid(self, client, tenant_header): + response = client.delete( + reverse("provider-detail", kwargs={"pk": "random_id"}), + headers=tenant_header, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_providers_connection(self, client, providers, tenant_header): + provider1, *_ = providers + assert provider1.connected is None + assert provider1.connection_last_checked_at is None + + response = client.post( + reverse("provider-connection", kwargs={"pk": provider1.id}), + headers=tenant_header, + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert "Content-Location" in response.headers + # TODO Assert a task is returned when they are implemented + + provider1.refresh_from_db() + assert provider1.connected is True + assert isinstance(provider1.connection_last_checked_at, datetime) + + def test_providers_connection_invalid_provider( + self, client, providers, tenant_header + ): + response = client.post( + reverse("provider-connection", kwargs={"pk": "random_id"}), + headers=tenant_header, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize( + "filter_name, filter_value", + ( + [ + ("provider", "aws"), + ("provider_id", "12345"), + ("alias", "test"), + ("search", "test"), + ("inserted_at", "2024-01-01 00:00:00"), + ("updated_at", "2024-01-01 00:00:00"), + ] + ), + ) + def test_providers_filters(self, client, tenant_header, filter_name, filter_value): + response = client.get( + reverse("provider-list"), + {f"filter[{filter_name}]": filter_value}, + headers=tenant_header, + ) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.parametrize( + "filter_name", + ( + [ + "provider", # Valid filter, invalid value + "invalid", + ] + ), + ) + def test_providers_filters_invalid(self, client, tenant_header, filter_name): + response = client.get( + reverse("provider-list"), + {f"filter[{filter_name}]": "whatever"}, + headers=tenant_header, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + "sort_field", + ( + [ + "provider", + "provider_id", + "alias", + "connected", + "inserted_at", + "updated_at", + ] + ), + ) + def test_providers_sort(self, client, tenant_header, sort_field): + response = client.get( + reverse("provider-list"), {"sort": sort_field}, headers=tenant_header + ) + assert response.status_code == status.HTTP_200_OK + + def test_providers_sort_invalid(self, client, tenant_header): + response = client.get( + reverse("provider-list"), {"sort": "invalid"}, headers=tenant_header + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/src/backend/api/urls/v1/urls.py b/src/backend/api/urls/v1/urls.py index 2c66e2575e..5f7e599c99 100644 --- a/src/backend/api/urls/v1/urls.py +++ b/src/backend/api/urls/v1/urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from drf_spectacular.views import SpectacularRedocView from rest_framework import routers -from api.views.v1.views import SchemaView, TenantViewSet, ProviderViewSet, TestView +from api.views.v1.views import SchemaView, TenantViewSet, ProviderViewSet router = routers.DefaultRouter(trailing_slash=False) @@ -13,5 +13,4 @@ urlpatterns = [ path("", include(router.urls)), path("schema", SchemaView.as_view(), name="schema"), path("docs", SpectacularRedocView.as_view(url_name="schema"), name="docs"), - path("test", TestView.as_view(), name="test"), ] diff --git a/src/backend/api/views/base_views.py b/src/backend/api/views/base_views.py index 9211351749..f353d1a688 100644 --- a/src/backend/api/views/base_views.py +++ b/src/backend/api/views/base_views.py @@ -1,3 +1,4 @@ +from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter from rest_framework_json_api import filters from rest_framework_json_api.views import ModelViewSet @@ -21,3 +22,18 @@ class BaseViewSet(ModelViewSet): def get_queryset(self): raise NotImplementedError + + +class BaseRLSViewSet(BaseViewSet): + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if "X-Tenant-ID" not in request.headers: + # This will return a 403 until we implement authentication/authorization + # https://www.django-rest-framework.org/api-guide/authentication/#unauthorized-and-forbidden-responses + raise NotAuthenticated("X-Tenant-ID header is required") + + def get_serializer_context(self): + context = super().get_serializer_context() + tenant_id = self.request.headers.get("X-Tenant-ID") + context["tenant_id"] = tenant_id + return context diff --git a/src/backend/api/views/v1/views.py b/src/backend/api/views/v1/views.py index 368bd25716..75ee4cf777 100644 --- a/src/backend/api/views/v1/views.py +++ b/src/backend/api/views/v1/views.py @@ -1,17 +1,23 @@ from django.conf import settings as django_settings +from django.urls import reverse +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from drf_spectacular.settings import spectacular_settings from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.views import SpectacularAPIView -from rest_framework import status, viewsets +from rest_framework import status from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 from rest_framework_json_api.views import Response + +from api.filters import ProviderFilter from api.filters import TenantFilter -from rest_framework.generics import ListCreateAPIView -from api.models import Test +from api.models import Provider from api.rls import Tenant -from api.serializers import TenantSerializer, TestSerializer +from api.serializers import ProviderSerializer, ProviderUpdateSerializer +from api.serializers import TenantSerializer +from api.views.base_views import BaseRLSViewSet from api.views.base_views import BaseViewSet CACHE_DECORATOR = cache_control( @@ -86,240 +92,74 @@ class TenantViewSet(BaseViewSet): partial_update=extend_schema( summary="Partially update a provider", description="Update certain fields of an existing provider's information without affecting other fields.", + request=ProviderUpdateSerializer, + responses={200: ProviderSerializer}, ), destroy=extend_schema( summary="Delete a provider", description="Remove a provider from the system by their ID.", + # TODO Update with task response when implemented + responses={202: ProviderSerializer}, ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") -class ProviderViewSet(viewsets.ViewSet): - content_location_header = { - "Content-Location": "https://api.prowler.com/api/v1/tasks/5234" - } - - mock_links_data = { - "first": "https://api.prowler.com/api/v1/providers?page%5Bnumber%5D=1", - "last": "https://api.prowler.com/api/v1/providers?page%5Bnumber%5D=1", - "next": None, - "prev": None, - } - - mock_provider_data = [ - { - "id": "5fd8f121-269e-4715-84cf-f92373f15dfa", - "type": "providers", - "attributes": { - "provider": "aws", - "provider_id": "1234567890", - "alias": "mock_aws_connected", - "connection": { - "connected": True, - "last_checked_at": "2024-07-17T09:55:14.191475Z", - }, - "scanner_args": { - "only_logs": True, - "excluded_checks": [ - "awslambda_function_no_secrets_in_code", - "cloudwatch_log_group_no_secrets_in_logs", - ], - "aws_retries_max_attempts": 5, - }, - "last_resource_count": 1234, - "inserted_at": "2024-07-17T09:55:14.191475Z", - "updated_at": "2024-07-17T09:55:14.191475Z", - "created_by": { - "object": "user", - "id": "eea048ab-7cb3-47eb-9e5e-dce591ade41f", - }, - }, - }, - { - "id": "16aaeb4e-d3cd-4bb6-86f8-6c39cf93821e", - "type": "providers", - "attributes": { - "provider": "aws", - "provider_id": "1234567891", - "alias": "mock_aws_not_connected", - "connection": { - "connected": False, - "last_checked_at": "2024-07-17T09:55:18.987425Z", - }, - "scanner_args": { - "only_logs": True, - "excluded_checks": [ - "awslambda_function_no_secrets_in_code", - "cloudwatch_log_group_no_secrets_in_logs", - ], - "aws_retries_max_attempts": 5, - }, - "last_resource_count": 0, - "inserted_at": "2024-07-17T09:55:18.987425Z", - "updated_at": "2024-07-17T09:55:18.987425Z", - "created_by": { - "object": "user", - "id": "a8f5e964-5964-4aaf-9176-844e2c3b0716", - }, - }, - }, - { - "id": "63f16b03-7849-4054-b40b-300e331f46f0", - "type": "providers", - "attributes": { - "provider": "gcp", - "provider_id": "1234567895", - "alias": "mock_gcp", - "connection": { - "connected": True, - "last_checked_at": "2024-07-17T09:55:18.987425Z", - }, - "scanner_args": { - "only_logs": True, - "excluded_checks": [ - "apikeys_key_exists", - "cloudsql_instance_public_ip", - ], - "excluded_services": ["kms"], - }, - "last_resource_count": 1111, - "inserted_at": "2024-07-17T09:55:18.987425Z", - "updated_at": "2024-07-17T09:55:18.987425Z", - "created_by": { - "object": "user", - "id": "eea048ab-7cb3-47eb-9e5e-dce591ade41f", - }, - }, - }, +class ProviderViewSet(BaseRLSViewSet): + queryset = Provider.objects.all() + serializer_class = ProviderSerializer + http_method_names = ["get", "post", "patch", "delete"] + filterset_class = ProviderFilter + search_fields = ["provider", "provider_id", "alias"] + ordering = ["inserted_at"] + ordering_fields = [ + "provider", + "provider_id", + "alias", + "connected", + "inserted_at", + "updated_at", ] - mock_meta_data = {"version": "v1"} - http_method_names = ["get", "post", "patch", "delete"] - resource_name = False - - def list(self, request, *args, **kwargs): - mock_data = { - "links": self.mock_links_data, - "data": self.mock_provider_data, - "meta": { - "pagination": { - "page": 1, - "pages": 1, - "count": len(self.mock_provider_data), - }, - **self.mock_meta_data, - }, - } - - return Response(mock_data, status=status.HTTP_200_OK) - - def retrieve(self, request, pk=None, *args, **kwargs): - provider = next( - (item for item in self.mock_provider_data if item["id"] == pk), None - ) - if provider is not None: - return Response({**provider, **self.mock_meta_data}) - return Response( - [{"detail": "Not found.", "status": "404", "code": "not_found"}], - status=status.HTTP_404_NOT_FOUND, - ) - - def create(self, request, *args, **kwargs): - return Response( - {"data": self.mock_provider_data[0], "meta": self.mock_meta_data}, - status.HTTP_201_CREATED, - ) + def get_queryset(self): + return Provider.objects.all() def partial_update(self, request, *args, **kwargs): - return Response( - {"data": self.mock_provider_data[0], "meta": self.mock_meta_data}, - status.HTTP_200_OK, - ) - - def destroy(self, request, *args, **kwargs): - task_mock = { - "data": { - "type": "tasks", - "id": "5234", - "attributes": { - "status": "pending", - }, - "links": {"self": "/tasks/5234"}, - } - } - return Response( - task_mock, - status=status.HTTP_202_ACCEPTED, - headers=self.content_location_header, - ) + instance = self.get_object() + serializer = ProviderUpdateSerializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + read_serializer = ProviderSerializer(instance) + return Response(data=read_serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Check connection", description="Try to verify connection. For instance, Role & Credentials are set correctly", + request=None, + # TODO Update with task response when implemented + responses={202: ProviderSerializer}, ) - @action(detail=False, methods=["post"], url_path="check_connection") - def check_connection(self, request): - connection_data_mock = { - "meta": {"version": "v1"}, - "data": { - "id": "63f16b03-7849-4054-b40b-300e331f46f0", - "type": "providers", - "attributes": { - "provider": "gcp", - "provider_id": "1234567895", - "alias": "mock_gcp", - "connection": { - "connected": True, - "last_checked_at": "2024-07-17T09:55:18.987425Z", - }, - "scanner_args": { - "only_logs": True, - "excluded_checks": [ - "apikeys_key_exists", - "cloudsql_instance_public_ip", - ], - "excluded_services": ["kms"], - }, - "last_resource_count": 1111, - "inserted_at": "2024-07-17T09:55:18.987425Z", - "updated_at": "2024-07-17T09:55:18.987425Z", - "created_by": { - "object": "user", - "id": "eea048ab-7cb3-47eb-9e5e-dce591ade41f", - }, - }, - }, - "relationships": { - "tenant": { - "links": { - "self": "http://api.prowler.com/api/v1/providers/41f1d90e-5e3b-4eb0-b565-893f277d96c1", - "related": "http://api.prowler.com/api/v1/tenants/22f9341e-4e13-44a8-996a-11f370696c54", - }, - "data": {"type": "tenants", "id": "uuid"}, - }, - "credential": { - "links": { - "self": "http://api.prowler.com/api/v1/providers/41f1d90e-5e3b-4eb0-b565-893f277d96c1", - "related": "http://api.prowler.com/api/v1/credentials/22f9341e-4e13-44a8-996a-11f370696c54", - }, - "data": {"type": "credentials", "id": "uuid"}, - }, - "schedules": { - "links": { - "self": "http://api.prowler.com/api/v1/providers/41f1d90e-5e3b-4eb0-b565-893f277d96c1", - "related": "http://api.prowler.com/api/v1/schedules/22f9341e-4e13-44a8-996a-11f370696c54", - }, - "data": { - "type": "schedules", - "id": "ee15292e-687f-41cf-8652-30a8b7cded35", - }, - }, - }, + @action(detail=True, methods=["post"], url_name="connection") + def connection(self, request, pk=None): + # TODO Connection check to third parties + # TODO Returning provider data as a placeholder for tasks. We will do something similar for the created task + provider = get_object_or_404(Provider, pk=pk) + provider.connected = True + provider.connection_last_checked_at = timezone.now() + provider.save() + serializer = ProviderSerializer(provider) + return Response( + data=serializer.data, + status=status.HTTP_202_ACCEPTED, + headers={"Content-Location": reverse("provider-detail", kwargs={"pk": pk})}, + ) + + def destroy(self, request, *args, **kwargs): + response = super().destroy(request, *args, **kwargs) + # TODO Background task to delete provider. For now, it will delete the provider from the system + # Same as /connection endpoint + response.status_code = status.HTTP_202_ACCEPTED + response.headers = { + "Content-Location": "/api/v1/tasks/5234", + **response.headers, } - - return Response(connection_data_mock, status=status.HTTP_200_OK) - - -class TestView(ListCreateAPIView): - serializer_class = TestSerializer - queryset = Test.objects.all() + return response diff --git a/src/backend/config/django/base.py b/src/backend/config/django/base.py index 5d1fffc769..094577a60d 100644 --- a/src/backend/config/django/base.py +++ b/src/backend/config/django/base.py @@ -57,10 +57,10 @@ TEMPLATES = [ ] REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular_jsonapi.schemas.openapi.JsonApiAutoSchema", "PAGE_SIZE": 10, - "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", - "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", + "EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler", + "DEFAULT_PAGINATION_CLASS": "drf_spectacular_jsonapi.schemas.pagination.JsonApiPageNumberPagination", "DEFAULT_PARSER_CLASSES": ( "rest_framework_json_api.parsers.JSONParser", "rest_framework.parsers.FormParser", @@ -84,6 +84,10 @@ REST_FRAMEWORK = { SPECTACULAR_SETTINGS = { "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, + "PREPROCESSING_HOOKS": [ + "drf_spectacular_jsonapi.hooks.fix_nested_path_parameters", + ], } WSGI_APPLICATION = "config.wsgi.application" diff --git a/src/backend/conftest.py b/src/backend/conftest.py index 4a3699b3b1..8d754f57cd 100644 --- a/src/backend/conftest.py +++ b/src/backend/conftest.py @@ -2,7 +2,24 @@ import logging import pytest +from api.rls import Tenant + @pytest.fixture(autouse=True) def disable_logging(): logging.disable(logging.CRITICAL) + + +@pytest.fixture +def get_tenant(): + tenant = Tenant.objects.create( + name="Tenant One", + inserted_at="2023-01-01T00:00:00Z", + updated_at="2023-01-02T00:00:00Z", + ) + return tenant + + +@pytest.fixture +def tenant_header(get_tenant): + return {"X-Tenant-ID": str(get_tenant.id)}