diff --git a/.gitignore b/.gitignore index 6e7ccdfe..083c3d92 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ mainsite/settings/prod.py mainsite/settings/tutor.py peeringdb_database/ venv +locale diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4d1682a8..00000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -dist: bionic -language: python -services: - - docker -branches: - except: - - gh-pages -matrix: - fast_finish: true -before_install: - - sudo apt-get -qq update - - sudo apt-get install fping - - sudo apt-get install traceroute - - sudo apt-get install librrd-dev -install: - - touch ./Ctl/dev/.env - - bash ./Ctl/dev/compose.sh build peeringdb - - bash ./Ctl/dev/compose.sh up -d database -script: - - bash ./Ctl/dev/run.sh run_tests diff --git a/Dockerfile b/Dockerfile index ea97e0cd..a8708361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ +# RUN true is used here to separate problematic COPY statements, +# per this issue: https://github.com/moby/moby/issues/37965 + FROM python:3.9-alpine as base ARG virtual_env=/srv/www.peeringdb.com/venv @@ -15,7 +18,14 @@ RUN apk --update --no-cache add \ linux-headers \ make \ mariadb-dev \ - libffi-dev + libffi-dev \ + curl + + +# Install Rust to install Poetry +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + # create venv RUN pip install -U pip pipenv @@ -53,7 +63,9 @@ COPY in.whoisd . COPY Ctl/VERSION etc COPY docs/ docs COPY mainsite/ mainsite +RUN true COPY $ADD_SETTINGS_FILE mainsite/settings/ +RUN true COPY peeringdb_server/ peeringdb_server COPY fixtures/ fixtures COPY .coveragerc .coveragerc @@ -64,6 +76,7 @@ COPY Ctl/docker/entrypoint.sh / # inetd for whois COPY --from=builder /usr/sbin/inetd /usr/sbin/ +RUN true COPY Ctl/docker/inetd.conf /etc/ RUN chown -R pdb:pdb api-cache locale media var/log coverage @@ -74,6 +87,7 @@ FROM final as tester WORKDIR /srv/www.peeringdb.com # copy from builder in case we're testing new deps COPY --from=builder /srv/www.peeringdb.com/Pipfile* ./ +RUN true COPY tests/ tests RUN chown -R pdb:pdb tests/ COPY Ctl/docker/entrypoint.sh . @@ -92,6 +106,7 @@ CMD ["runserver", "$RUNSERVER_BIND"] FROM final COPY Ctl/docker/entrypoint.sh . +RUN true COPY Ctl/docker/django-uwsgi.ini etc/ ENV UWSGI_SOCKET="127.0.0.1:7002" diff --git a/Pipfile b/Pipfile index f48ba40d..ac605522 100644 --- a/Pipfile +++ b/Pipfile @@ -23,7 +23,7 @@ django = ">=2.2, <2.3" django-inet = ">=0.5.0, <0.6" django-handleref = ">=0.6.0, <0.7" django-namespace-perms = ">=0.6.0, <0.7" -django-peeringdb = "==2.5.0" +django-peeringdb = "==2.6.0" djangorestframework = ">=3.12,<3.13" mysqlclient = ">=1.3.9" peeringdb = ">=1.1.0, <2" @@ -46,6 +46,7 @@ django-oauth-toolkit = ">=1.0.0" django-phonenumber-field = ">=0.6" django-ratelimit = ">=3" django-rest-swagger = ">=2.1.2" +djangorestframework-api-key = ">=2.0.0" django-tables2 = ">=1.0.4" django-vanilla-views = ">=1.0.2" googlemaps = ">=2.5.1" @@ -62,4 +63,4 @@ tld = ">=0.7.6" coreapi = ">=2.3.1" django-two-factor-auth = ">=1.11,<2" grainy = ">=1.7,<2" -django-grainy = ">=1.9.1,<2" +django-grainy = ">=1.9.1,<2" \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index ad2dbea4..c6de1cb2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "432c4c9fefb9c6df1de39c00e7f1fb527e99f0d33c220069cb03ebaf19a5756d" + "sha256": "27ae0facd1bbc229de9eb6512d1f029041fef7a1ed82d42c1261e9e56d7f4b8f" }, "pipfile-spec": 6, "requires": { @@ -42,44 +42,45 @@ }, "cffi": { "hashes": [ - "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", - "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", - "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", - "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", - "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", - "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", - "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", - "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", - "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", - "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", - "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", - "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", - "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", - "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", - "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", - "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", - "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", - "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", - "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", - "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", - "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", - "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", - "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", - "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", - "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", - "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", - "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", - "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", - "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", - "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", - "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", - "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", - "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", - "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", - "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", - "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" + "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", + "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", + "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", + "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", + "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", + "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", + "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", + "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", + "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", + "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", + "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", + "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", + "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", + "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", + "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", + "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", + "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", + "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", + "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", + "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", + "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", + "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", + "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", + "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", + "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", + "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", + "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", + "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", + "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", + "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", + "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", + "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", + "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", + "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", + "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", + "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", + "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" ], - "version": "==1.14.4" + "version": "==1.14.5" }, "cfu": { "hashes": [ @@ -120,38 +121,36 @@ }, "cryptography": { "hashes": [ - "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d", - "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7", - "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901", - "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c", - "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244", - "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6", - "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5", - "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e", - "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c", - "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0", - "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812", - "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a", - "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030", - "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302" + "sha256:066bc53f052dfeda2f2d7c195cf16fb3e5ff13e1b6b7415b468514b40b381a5b", + "sha256:0923ba600d00718d63a3976f23cab19aef10c1765038945628cd9be047ad0336", + "sha256:2d32223e5b0ee02943f32b19245b61a62db83a882f0e76cc564e1cec60d48f87", + "sha256:4169a27b818de4a1860720108b55a2801f32b6ae79e7f99c00d79f2a2822eeb7", + "sha256:57ad77d32917bc55299b16d3b996ffa42a1c73c6cfa829b14043c561288d2799", + "sha256:5ecf2bcb34d17415e89b546dbb44e73080f747e504273e4d4987630493cded1b", + "sha256:600cf9bfe75e96d965509a4c0b2b183f74a4fa6f5331dcb40fb7b77b7c2484df", + "sha256:66b57a9ca4b3221d51b237094b0303843b914b7d5afd4349970bb26518e350b0", + "sha256:93cfe5b7ff006de13e1e89830810ecbd014791b042cbe5eec253be11ac2b28f3", + "sha256:9e98b452132963678e3ac6c73f7010fe53adf72209a32854d55690acac3f6724", + "sha256:df186fcbf86dc1ce56305becb8434e4b6b7504bc724b71ad7a3239e0c9d14ef2", + "sha256:fec7fb46b10da10d9e1d078d1ff8ed9e05ae14f431fdbd11145edd0550b9a964" ], - "version": "==3.3.1" + "version": "==3.4.6" }, "defusedxml": { "hashes": [ - "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", - "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.6.0" + "version": "==0.7.1" }, "django": { "hashes": [ - "sha256:0eaca08f236bf502a9773e53623f766cc3ceee6453cc41e6de1c8b80f07d2364", - "sha256:c9c994f5e0a032cbd45089798b52e4080f4dea7241c58e3e0636c54146480bb4" + "sha256:30c235dec87e05667597e339f194c9fed6c855bda637266ceee891bf9093da43", + "sha256:e319a7164d6d30cb177b3fd74d02c52f1185c37304057bb76d74047889c605d9" ], "index": "pypi", - "version": "==2.2.18" + "version": "==2.2.19" }, "django-allauth": { "hashes": [ @@ -162,10 +161,10 @@ }, "django-autocomplete-light": { "hashes": [ - "sha256:4e84a6d95d272b0d7221614332e2bd54ffff15ec06e78947279398f6507ce225" + "sha256:25f0ea71b59a8f1f97a8a564e33e429570b0ea77c5eac81f7beb283073b4ba90" ], "index": "pypi", - "version": "==3.8.1" + "version": "==3.8.2" }, "django-bootstrap3": { "hashes": [ @@ -201,11 +200,11 @@ }, "django-crispy-forms": { "hashes": [ - "sha256:21cf717b621f93cdf01bac0a419b520fe3b17bffd67e140b6c16558d9b75ab80", - "sha256:a2aa34ee3fccafdebb33c016cbd60246b37df85dae717637c6419b929fa24b25" + "sha256:2ea206f35a9554597b89315ea52737d32d2fd9f5304e79a469a08887aa0824b0", + "sha256:5c600dea66e2f435f774fe2c4cacfb5b5eb9914dd6da0728edd174b95f6956fd" ], "index": "pypi", - "version": "==1.11.0" + "version": "==1.11.1" }, "django-debug-toolbar": { "hashes": [ @@ -217,11 +216,11 @@ }, "django-extensions": { "hashes": [ - "sha256:7cd002495ff0a0e5eb6cdd6be759600905b4e4079232ea27618fc46bdd853651", - "sha256:c7f88625a53f631745d4f2bef9ec4dcb999ed59476393bdbbe99db8596778846" + "sha256:674ad4c3b1587a884881824f40212d51829e662e52f85b012cd83d83fe1271d9", + "sha256:9507f8761ee760748938fd8af766d0608fb2738cf368adfa1b2451f61c15ae35" ], "index": "pypi", - "version": "==3.1.0" + "version": "==3.1.1" }, "django-formtools": { "hashes": [ @@ -276,11 +275,11 @@ }, "django-oauth-toolkit": { "hashes": [ - "sha256:48a45d9ec23b50646b14b4b93988bd0d35ad5cf933edfcb833a8527f86329f28", - "sha256:a67ab96089b96540e34dc8d1ee6e6305a80ee01a36f7688108cd94b77406bdd3" + "sha256:87cc482cfb0f9ef8fa3730857cdadcc2a25fcfd9b587804dd8f2ad7f0d457e2d", + "sha256:b8fbf226dbafeb429563de03623cc771dda18c5dbd5a1c9850fde969c245c74f" ], "index": "pypi", - "version": "==1.3.3" + "version": "==1.4.0" }, "django-otp": { "hashes": [ @@ -291,10 +290,10 @@ }, "django-peeringdb": { "hashes": [ - "sha256:bd0764fcf6951a0472c7697af902538d794813bff929dde5788cfc480391be80" + "sha256:fbbf81fdf269671e0df74515ad6a05e478e43862868eb4570987659b5fe4a712" ], "index": "pypi", - "version": "==2.5.0" + "version": "==2.6.0" }, "django-phonenumber-field": { "hashes": [ @@ -374,6 +373,14 @@ "index": "pypi", "version": "==3.12.2" }, + "djangorestframework-api-key": { + "hashes": [ + "sha256:61cdb75f16dc4425e0c8587c71f1d890963422c51b4192eec259c6446d7de976", + "sha256:631d1898510f6adfd4585539daf5f91630d3a92f1f4b1faa029bd45ccc379736" + ], + "index": "pypi", + "version": "==2.0.0" + }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" @@ -390,10 +397,10 @@ }, "grainy": { "hashes": [ - "sha256:24c5fc72c897aeb7e416935f930ca949290f5a39fff41a8f2149ca0d51de31e8" + "sha256:5d0e8efec0f7fde61b65e098efb409c88997db31d2bed8a2508620e524ad59ef" ], "index": "pypi", - "version": "==1.7.1" + "version": "==1.7.2" }, "idna": { "hashes": [ @@ -403,14 +410,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, - "importlib-metadata": { - "hashes": [ - "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", - "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" - ], - "markers": "python_version < '3.8'", - "version": "==1.7.0" - }, "itypes": { "hashes": [ "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", @@ -428,11 +427,11 @@ }, "markdown": { "hashes": [ - "sha256:5d9f2b5ca24bc4c7a390d22323ca4bad200368612b5aaa7796babf971d2b2f18", - "sha256:c109c15b7dc20a9ac454c9e6025927d44460b85bd039da028d85e2b6d0bcc328" + "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49", + "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c" ], "index": "pypi", - "version": "==3.3.3" + "version": "==3.3.4" }, "markupsafe": { "hashes": [ @@ -548,49 +547,50 @@ }, "phonenumbers": { "hashes": [ - "sha256:c14eee6fa24f37ca1ead7ba3b8e5b84763f97c74ade728fa157de6d95c7469c0", - "sha256:f5d57c9fc8f7162ba562325d69d65b4f76e750951c5945c57876e94d824392ec" + "sha256:0f597b602e64af90c06b14c8223e94fdb0ed20f203e1c9785a8bbe4de00c45e8", + "sha256:dadc72b81effefa499f2ee7f77fcad601fb725c024f444c9ea60500e4d79aa4e" ], "index": "pypi", - "version": "==8.12.17" + "version": "==8.12.19" }, "pillow": { "hashes": [ - "sha256:165c88bc9d8dba670110c689e3cc5c71dbe4bfb984ffa7cbebf1fac9554071d6", - "sha256:1d208e670abfeb41b6143537a681299ef86e92d2a3dac299d3cd6830d5c7bded", - "sha256:22d070ca2e60c99929ef274cfced04294d2368193e935c5d6febfd8b601bf865", - "sha256:2353834b2c49b95e1313fb34edf18fca4d57446675d05298bb694bca4b194174", - "sha256:39725acf2d2e9c17356e6835dccebe7a697db55f25a09207e38b835d5e1bc032", - "sha256:3de6b2ee4f78c6b3d89d184ade5d8fa68af0848f9b6b6da2b9ab7943ec46971a", - "sha256:47c0d93ee9c8b181f353dbead6530b26980fe4f5485aa18be8f1fd3c3cbc685e", - "sha256:5e2fe3bb2363b862671eba632537cd3a823847db4d98be95690b7e382f3d6378", - "sha256:604815c55fd92e735f9738f65dabf4edc3e79f88541c221d292faec1904a4b17", - "sha256:6c5275bd82711cd3dcd0af8ce0bb99113ae8911fc2952805f1d012de7d600a4c", - "sha256:731ca5aabe9085160cf68b2dbef95fc1991015bc0a3a6ea46a371ab88f3d0913", - "sha256:7612520e5e1a371d77e1d1ca3a3ee6227eef00d0a9cddb4ef7ecb0b7396eddf7", - "sha256:7916cbc94f1c6b1301ac04510d0881b9e9feb20ae34094d3615a8a7c3db0dcc0", - "sha256:81c3fa9a75d9f1afafdb916d5995633f319db09bd773cb56b8e39f1e98d90820", - "sha256:887668e792b7edbfb1d3c9d8b5d8c859269a0f0eba4dda562adb95500f60dbba", - "sha256:93a473b53cc6e0b3ce6bf51b1b95b7b1e7e6084be3a07e40f79b42e83503fbf2", - "sha256:96d4dc103d1a0fa6d47c6c55a47de5f5dafd5ef0114fa10c85a1fd8e0216284b", - "sha256:a3d3e086474ef12ef13d42e5f9b7bbf09d39cf6bd4940f982263d6954b13f6a9", - "sha256:b02a0b9f332086657852b1f7cb380f6a42403a6d9c42a4c34a561aa4530d5234", - "sha256:b09e10ec453de97f9a23a5aa5e30b334195e8d2ddd1ce76cc32e52ba63c8b31d", - "sha256:b6f00ad5ebe846cc91763b1d0c6d30a8042e02b2316e27b05de04fa6ec831ec5", - "sha256:bba80df38cfc17f490ec651c73bb37cd896bc2400cfba27d078c2135223c1206", - "sha256:c3d911614b008e8a576b8e5303e3db29224b455d3d66d1b2848ba6ca83f9ece9", - "sha256:ca20739e303254287138234485579b28cb0d524401f83d5129b5ff9d606cb0a8", - "sha256:cb192176b477d49b0a327b2a5a4979552b7a58cd42037034316b8018ac3ebb59", - "sha256:cdbbe7dff4a677fb555a54f9bc0450f2a21a93c5ba2b44e09e54fcb72d2bd13d", - "sha256:cf6e33d92b1526190a1de904df21663c46a456758c0424e4f947ae9aa6088bf7", - "sha256:d355502dce85ade85a2511b40b4c61a128902f246504f7de29bbeec1ae27933a", - "sha256:d673c4990acd016229a5c1c4ee8a9e6d8f481b27ade5fc3d95938697fa443ce0", - "sha256:dc577f4cfdda354db3ae37a572428a90ffdbe4e51eda7849bf442fb803f09c9b", - "sha256:dd9eef866c70d2cbbea1ae58134eaffda0d4bfea403025f4db6859724b18ab3d", - "sha256:f50e7a98b0453f39000619d845be8b06e611e56ee6e8186f7f60c3b1e2f0feae" + "sha256:15306d71a1e96d7e271fd2a0737038b5a92ca2978d2e38b6ced7966583e3d5af", + "sha256:1940fc4d361f9cc7e558d6f56ff38d7351b53052fd7911f4b60cd7bc091ea3b1", + "sha256:1f93f2fe211f1ef75e6f589327f4d4f8545d5c8e826231b042b483d8383e8a7c", + "sha256:30d33a1a6400132e6f521640dd3f64578ac9bfb79a619416d7e8802b4ce1dd55", + "sha256:328240f7dddf77783e72d5ed79899a6b48bc6681f8d1f6001f55933cb4905060", + "sha256:46c2bcf8e1e75d154e78417b3e3c64e96def738c2a25435e74909e127a8cba5e", + "sha256:5762ebb4436f46b566fc6351d67a9b5386b5e5de4e58fdaa18a1c83e0e20f1a8", + "sha256:5a2d957eb4aba9d48170b8fe6538ec1fbc2119ffe6373782c03d8acad3323f2e", + "sha256:5cf03b9534aca63b192856aa601c68d0764810857786ea5da652581f3a44c2b0", + "sha256:5daba2b40782c1c5157a788ec4454067c6616f5a0c1b70e26ac326a880c2d328", + "sha256:63cd413ac52ee3f67057223d363f4f82ce966e64906aea046daf46695e3c8238", + "sha256:6efac40344d8f668b6c4533ae02a48d52fd852ef0654cc6f19f6ac146399c733", + "sha256:71b01ee69e7df527439d7752a2ce8fb89e19a32df484a308eca3e81f673d3a03", + "sha256:71f31ee4df3d5e0b366dd362007740106d3210fb6a56ec4b581a5324ba254f06", + "sha256:72027ebf682abc9bafd93b43edc44279f641e8996fb2945104471419113cfc71", + "sha256:74cd9aa648ed6dd25e572453eb09b08817a1e3d9f8d1bd4d8403d99e42ea790b", + "sha256:81b3716cc9744ffdf76b39afb6247eae754186838cedad0b0ac63b2571253fe6", + "sha256:8565355a29655b28fdc2c666fd9a3890fe5edc6639d128814fafecfae2d70910", + "sha256:87f42c976f91ca2fc21a3293e25bd3cd895918597db1b95b93cbd949f7d019ce", + "sha256:89e4c757a91b8c55d97c91fa09c69b3677c227b942fa749e9a66eef602f59c28", + "sha256:8c4e32218c764bc27fe49b7328195579581aa419920edcc321c4cb877c65258d", + "sha256:903293320efe2466c1ab3509a33d6b866dc850cfd0c5d9cc92632014cec185fb", + "sha256:90882c6f084ef68b71bba190209a734bf90abb82ab5e8f64444c71d5974008c6", + "sha256:98afcac3205d31ab6a10c5006b0cf040d0026a68ec051edd3517b776c1d78b09", + "sha256:a01da2c266d9868c4f91a9c6faf47a251f23b9a862dce81d2ff583135206f5be", + "sha256:aeab4cd016e11e7aa5cfc49dcff8e51561fa64818a0be86efa82c7038e9369d0", + "sha256:b07c660e014852d98a00a91adfbe25033898a9d90a8f39beb2437d22a203fc44", + "sha256:bead24c0ae3f1f6afcb915a057943ccf65fc755d11a1410a909c1fefb6c06ad1", + "sha256:d1d6bca39bb6dd94fba23cdb3eeaea5e30c7717c5343004d900e2a63b132c341", + "sha256:e2cd8ac157c1e5ae88b6dd790648ee5d2777e76f1e5c7d184eaddb2938594f34", + "sha256:e5739ae63636a52b706a0facec77b2b58e485637e1638202556156e424a02dc2", + "sha256:f36c3ff63d6fc509ce599a2f5b0d0732189eed653420e7294c039d342c6e204a", + "sha256:f91b50ad88048d795c0ad004abbe1390aa1882073b1dca10bfd55d0b8cf18ec5" ], "markers": "python_version >= '3.6'", - "version": "==8.1.0" + "version": "==8.1.2" }, "pycparser": { "hashes": [ @@ -647,15 +647,23 @@ "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc" + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", + "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", + "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", + "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==5.4.1" @@ -779,11 +787,11 @@ }, "unidecode": { "hashes": [ - "sha256:4c9d15d2f73eb0d2649a151c566901f80a030da1ccb0a2043352e1dbf647586b", - "sha256:a039f89014245e0cad8858976293e23501accc9ff5a7bdbc739a14a2b7b85cdc" + "sha256:12435ef2fc4cdfd9cf1035a1db7e98b6b047fe591892e81f34e94959591fad00", + "sha256:8d73a97d387a956922344f6b74243c2c6771594659778744b2dbdaad8f6b727d" ], "index": "pypi", - "version": "==1.1.2" + "version": "==1.2.0" }, "uritemplate": { "hashes": [ @@ -814,14 +822,6 @@ "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" ], "version": "==0.5.1" - }, - "zipp": { - "hashes": [ - "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108", - "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb" - ], - "markers": "python_version >= '3.6'", - "version": "==3.4.0" } }, "develop": { @@ -858,58 +858,61 @@ }, "coverage": { "hashes": [ - "sha256:03ed2a641e412e42cc35c244508cf186015c217f0e4d496bf6d7078ebe837ae7", - "sha256:04b14e45d6a8e159c9767ae57ecb34563ad93440fc1b26516a89ceb5b33c1ad5", - "sha256:0cdde51bfcf6b6bd862ee9be324521ec619b20590787d1655d005c3fb175005f", - "sha256:0f48fc7dc82ee14aeaedb986e175a429d24129b7eada1b7e94a864e4f0644dde", - "sha256:107d327071061fd4f4a2587d14c389a27e4e5c93c7cba5f1f59987181903902f", - "sha256:1375bb8b88cb050a2d4e0da901001347a44302aeadb8ceb4b6e5aa373b8ea68f", - "sha256:14a9f1887591684fb59fdba8feef7123a0da2424b0652e1b58dd5b9a7bb1188c", - "sha256:16baa799ec09cc0dcb43a10680573269d407c159325972dd7114ee7649e56c66", - "sha256:1b811662ecf72eb2d08872731636aee6559cae21862c36f74703be727b45df90", - "sha256:1ccae21a076d3d5f471700f6d30eb486da1626c380b23c70ae32ab823e453337", - "sha256:2f2cf7a42d4b7654c9a67b9d091ec24374f7c58794858bff632a2039cb15984d", - "sha256:322549b880b2d746a7672bf6ff9ed3f895e9c9f108b714e7360292aa5c5d7cf4", - "sha256:32ab83016c24c5cf3db2943286b85b0a172dae08c58d0f53875235219b676409", - "sha256:3fe50f1cac369b02d34ad904dfe0771acc483f82a1b54c5e93632916ba847b37", - "sha256:4a780807e80479f281d47ee4af2eb2df3e4ccf4723484f77da0bb49d027e40a1", - "sha256:4a8eb7785bd23565b542b01fb39115a975fefb4a82f23d407503eee2c0106247", - "sha256:5bee3970617b3d74759b2d2df2f6a327d372f9732f9ccbf03fa591b5f7581e39", - "sha256:60a3307a84ec60578accd35d7f0c71a3a971430ed7eca6567399d2b50ef37b8c", - "sha256:6625e52b6f346a283c3d563d1fd8bae8956daafc64bb5bbd2b8f8a07608e3994", - "sha256:66a5aae8233d766a877c5ef293ec5ab9520929c2578fd2069308a98b7374ea8c", - "sha256:68fb816a5dd901c6aff352ce49e2a0ffadacdf9b6fae282a69e7a16a02dad5fb", - "sha256:6b588b5cf51dc0fd1c9e19f622457cc74b7d26fe295432e434525f1c0fae02bc", - "sha256:6c4d7165a4e8f41eca6b990c12ee7f44fef3932fac48ca32cecb3a1b2223c21f", - "sha256:6d2e262e5e8da6fa56e774fb8e2643417351427604c2b177f8e8c5f75fc928ca", - "sha256:6d9c88b787638a451f41f97446a1c9fd416e669b4d9717ae4615bd29de1ac135", - "sha256:755c56beeacac6a24c8e1074f89f34f4373abce8b662470d3aa719ae304931f3", - "sha256:7e40d3f8eb472c1509b12ac2a7e24158ec352fc8567b77ab02c0db053927e339", - "sha256:812eaf4939ef2284d29653bcfee9665f11f013724f07258928f849a2306ea9f9", - "sha256:84df004223fd0550d0ea7a37882e5c889f3c6d45535c639ce9802293b39cd5c9", - "sha256:859f0add98707b182b4867359e12bde806b82483fb12a9ae868a77880fc3b7af", - "sha256:87c4b38288f71acd2106f5d94f575bc2136ea2887fdb5dfe18003c881fa6b370", - "sha256:89fc12c6371bf963809abc46cced4a01ca4f99cba17be5e7d416ed7ef1245d19", - "sha256:9564ac7eb1652c3701ac691ca72934dd3009997c81266807aef924012df2f4b3", - "sha256:9754a5c265f991317de2bac0c70a746efc2b695cf4d49f5d2cddeac36544fb44", - "sha256:a565f48c4aae72d1d3d3f8e8fb7218f5609c964e9c6f68604608e5958b9c60c3", - "sha256:a636160680c6e526b84f85d304e2f0bb4e94f8284dd765a1911de9a40450b10a", - "sha256:a839e25f07e428a87d17d857d9935dd743130e77ff46524abb992b962eb2076c", - "sha256:b62046592b44263fa7570f1117d372ae3f310222af1fc1407416f037fb3af21b", - "sha256:b7f7421841f8db443855d2854e25914a79a1ff48ae92f70d0a5c2f8907ab98c9", - "sha256:ba7ca81b6d60a9f7a0b4b4e175dcc38e8fef4992673d9d6e6879fd6de00dd9b8", - "sha256:bb32ca14b4d04e172c541c69eec5f385f9a075b38fb22d765d8b0ce3af3a0c22", - "sha256:c0ff1c1b4d13e2240821ef23c1efb1f009207cb3f56e16986f713c2b0e7cd37f", - "sha256:c669b440ce46ae3abe9b2d44a913b5fd86bb19eb14a8701e88e3918902ecd345", - "sha256:c67734cff78383a1f23ceba3b3239c7deefc62ac2b05fa6a47bcd565771e5880", - "sha256:c6809ebcbf6c1049002b9ac09c127ae43929042ec1f1dbd8bb1615f7cd9f70a0", - "sha256:cd601187476c6bed26a0398353212684c427e10a903aeafa6da40c63309d438b", - "sha256:ebfa374067af240d079ef97b8064478f3bf71038b78b017eb6ec93ede1b6bcec", - "sha256:fbb17c0d0822684b7d6c09915677a32319f16ff1115df5ec05bdcaaee40b35f3", - "sha256:fff1f3a586246110f34dc762098b5afd2de88de507559e63553d7da643053786" + "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", + "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6", + "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45", + "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a", + "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03", + "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529", + "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a", + "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a", + "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2", + "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6", + "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759", + "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53", + "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a", + "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4", + "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff", + "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502", + "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793", + "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb", + "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905", + "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821", + "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b", + "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81", + "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0", + "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b", + "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3", + "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184", + "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701", + "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a", + "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82", + "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638", + "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5", + "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083", + "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6", + "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90", + "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465", + "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a", + "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3", + "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e", + "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066", + "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf", + "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b", + "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae", + "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669", + "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873", + "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b", + "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6", + "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb", + "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160", + "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c", + "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079", + "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", + "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==5.4" + "version": "==5.5" }, "decorator": { "hashes": [ @@ -947,14 +950,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, - "importlib-metadata": { - "hashes": [ - "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83", - "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070" - ], - "markers": "python_version < '3.8'", - "version": "==1.7.0" - }, "iniconfig": { "hashes": [ "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", @@ -1050,11 +1045,11 @@ }, "pyupgrade": { "hashes": [ - "sha256:b23f80e3c337ebb45e9ed846926f22fab1d990de319bc9e72dccbac33115d412", - "sha256:fde759f8a697f76d8100c3530cf49affac06fccdfb8882069c43b117588af00c" + "sha256:601427033f280d50b5b102fed1013b96f91244777772114aeb7e191762cd6050", + "sha256:b26a00db6e2d745fe5a949e1fd02c5286c3999edaf804f746c69d559c8f8b365" ], "index": "pypi", - "version": "==2.9.0" + "version": "==2.10.0" }, "requests": { "hashes": [ @@ -1097,11 +1092,11 @@ }, "tox": { "hashes": [ - "sha256:76df3db6eee929bb62bdbacca5bb6bc840669d98e86a015b7a57b7df0a6eaf8b", - "sha256:854e6e4a71c614b488f81cb88df3b92edcb1a9ec43d4102e6289e9669bbf7f18" + "sha256:05a4dbd5e4d3d8269b72b55600f0b0303e2eb47ad5c6fe76d3576f4c58d93661", + "sha256:e007673f3595cede9b17a7c4962389e4305d4a3682a6c5a4159a1453b4f326aa" ], "index": "pypi", - "version": "==3.21.3" + "version": "==3.23.0" }, "twentyc.rpc": { "hashes": [ @@ -1109,15 +1104,6 @@ ], "version": "==0.4.0" }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "markers": "python_version < '3.8'", - "version": "==3.7.4.3" - }, "urllib3": { "hashes": [ "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", @@ -1133,14 +1119,6 @@ ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4.2" - }, - "zipp": { - "hashes": [ - "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108", - "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb" - ], - "markers": "python_version >= '3.6'", - "version": "==3.4.0" } } } diff --git a/docs/api_keys.md b/docs/api_keys.md new file mode 100644 index 00000000..b805262a --- /dev/null +++ b/docs/api_keys.md @@ -0,0 +1,131 @@ +# API Keys + +PeeringDB offers API keys for authenticating API requests. There are two main forms of API keys: + +## Organization-level API Keys + +These API keys are created and revoked from the organization admin panel. Each key gets its own custom permissions, which can be modified from the "org api key permissions" panel. + +Each key must have an email attached to it; this is because keys may be allowed to create and modify data in PeeringDB, and we need a contact to reach out to in case of questions. + +!["api key creation"](img/org-key-added.png) + +!["manage organization api key permissions"](img/org-key-permissions.png) + +## User-level API Keys + +These API key are tied to a individual user account and can be created from the user profile page. There are only two permission levels: a normal key will mirror the same permissions of the user, while a readonly key will have readonly permissions to all the same namespaces as the user. + +!["form to add user api key"](img/user-key-add.png) + +## Copy/Write down keys immediately + +**One thing to note** is that the full api key string is only ever exposed to the user or organization at its moment of creation. If this string is lost, then the user or organization should revoke that key and create and permission a new one. + + +## Commandline example using Python and Requests +API keys allow developers to interact with their PeeringDB account programmatically, rather than through the website. Here is an example script in Python. It uses the module Requests to GET data about a particular Facility, and then sends a PUT request to modify that data. + +This example assumes we have an environment variable set with our API Key. To do that from the commandline, we can run: + +```sh +export API_KEY="[created api key string]" +``` + +Then the Python script would look like the following. First we get the API key from the environment: + +```py +import os + +import requests + +API_KEY = os.environ.get("API_KEY") +``` + +We set the url for the Facility we want to interact with. Note the `/api` in the URL, which signals we are making calls to the REST API. + +```py +URL = "https://www.peeringdb.com/api/fac/10003" +``` + +We set the headers to include our API key as authorization. Printing the `headers` variable should allow us to see the API key. + +```py +headers = {"AUTHORIZATION": "Api-Key " + API_KEY} +print(headers) +``` + +First we make a GET request, to simply get data about example Facility number 10003 + +```py +response = requests.get(URL, headers=headers) +data = response.json()["data"][0] +print(data) +``` + +Printing this data allows us to see what fields we would like to change. Let's say we decide to change the name of this facility. We overwrite the value for key "name" + +```py +data["name"] = "Newly decided name" +``` + +Then we use a PUT request to send that modified data back to PeeringDB. +Note that this time, we must provide data to the API, using the keyword argument "data" + +```py +put_response = requests.put(URL, headers=headers, data=data) +``` + +We can print the status code to see if our request was successful. + +```py +print(put_response.status_code) +``` +This will return a code 200 to signal success. + +Additionally the content of the request should include data for the now modified Facility + +```py +print(put_response.json()) +``` + +Would return a dictionary of the values of the now modified Facility. + +## Commandline example using Curl + +API keys provide a cleaner way to authenticate api requests. PeeringDB recommends the commandline user creates a API_KEY variable like so + +```sh +export API_KEY="[created api key string]" +``` +then requests can be made with Curl like in the following examples: + +### GET +The following request would return JSON data coresponding to the [ChiX](https://www.peeringdb.com/ix/239) Internet Exchange. + +```sh +curl -H "Authorization: Api-Key $API_KEY" -H "Content-Type: application/json" -X GET https://peeringdb.com/api/ix/239 +``` + +### POST + +The following request would create a new Network under the organization [United IX](https://www.peeringdb.com/org/10843). + +```sh +curl -H "Authorization: Api-Key $API_KEY" -H "Content-Type: application/json" -X POST --data "{\""org_id"\":\"10843\", \""name"\":\"Brand New Network\", \""asn"\":\"63311\"}" https://peeringdb.com/api/net +``` + +### PUT + +The following request would update the data about a particular Network, [ChIX Route Servers](https://www.peeringdb.com/net/7889), in particular changing the name to "Edited Name". + +```sh +curl -H "Authorization: Api-Key $API_KEY" -H "Content-Type: application/json" -X PUT --data "{\""org_id"\":\"10843\", \""name"\":\"Edited Name\", \""asn"\":\"33713\"}" https://peeringdb.com/api/net/7889 +``` + +### DELETE +The following request would delete the [ChiX](https://www.peeringdb.com/ix/239) Internet Exchange. The API key holder would need delete privileges to that particular Exchange. + +```sh +curl -H "Authorization: Api-Key $API_KEY" -H "Content-Type: application/json" -X DELETE https://peeringdb.com/api/ix/239 +``` diff --git a/docs/img/org-key-add.png b/docs/img/org-key-add.png new file mode 100755 index 00000000..eb122165 Binary files /dev/null and b/docs/img/org-key-add.png differ diff --git a/docs/img/org-key-added.png b/docs/img/org-key-added.png new file mode 100755 index 00000000..8dbbbd65 Binary files /dev/null and b/docs/img/org-key-added.png differ diff --git a/docs/img/org-key-permissions.png b/docs/img/org-key-permissions.png new file mode 100755 index 00000000..8128db3d Binary files /dev/null and b/docs/img/org-key-permissions.png differ diff --git a/docs/img/user-key-add.png b/docs/img/user-key-add.png new file mode 100755 index 00000000..d340209a Binary files /dev/null and b/docs/img/user-key-add.png differ diff --git a/mainsite/settings/__init__.py b/mainsite/settings/__init__.py index bcf1fc9a..8ec2c84a 100644 --- a/mainsite/settings/__init__.py +++ b/mainsite/settings/__init__.py @@ -592,7 +592,7 @@ AUTHENTICATION_BACKENDS += ("django_grainy.backends.GrainyBackend",) ## Django Rest Framework -INSTALLED_APPS += ("rest_framework", "rest_framework_swagger") +INSTALLED_APPS += ("rest_framework", "rest_framework_swagger", "rest_framework_api_key") REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( diff --git a/peeringdb_server/admin.py b/peeringdb_server/admin.py index de08176b..d0a097b6 100644 --- a/peeringdb_server/admin.py +++ b/peeringdb_server/admin.py @@ -18,6 +18,7 @@ from django.contrib.admin.actions import delete_selected from django.contrib.admin.views.main import ChangeList from django.contrib.auth.admin import UserAdmin from django.db.utils import OperationalError +from django.forms import DecimalField from django.http import HttpResponseForbidden from django import forms as baseForms from django.utils import html @@ -38,7 +39,6 @@ from django_grainy.admin import ( GrainyGroupAdmin, ) - import reversion from reversion.admin import VersionAdmin @@ -78,14 +78,21 @@ from peeringdb_server.models import ( IXFImportEmail, EnvironmentSetting, ProtectedAction, + OrganizationAPIKey, + UserAPIKey, ) + from peeringdb_server.mail import mail_users_entity_merge from peeringdb_server.inet import RdapLookup, RdapException, rdap_pretty_error_message +from rest_framework_api_key.admin import APIKeyModelAdmin +from rest_framework_api_key.models import APIKey +from peeringdb_server.util import round_decimal delete_selected.short_description = "HARD DELETE - Proceed with caution" from django.utils.translation import ugettext_lazy as _ + # these app labels control permissions for the views # currently exposed in admin @@ -384,6 +391,9 @@ class SoftDeleteAdmin( def grainy_namespace(self, obj): return obj.grainy_namespace + def grainy_namespace(self, obj): + return obj.grainy_namespace + class ModelAdminWithVQCtrl: """ @@ -575,6 +585,7 @@ class InternetExchangeAdminForm(StatusForm): class InternetExchangeAdmin(ModelAdminWithVQCtrl, SoftDeleteAdmin): list_display = ( "name", + "aka", "name_long", "city", "country", @@ -854,17 +865,30 @@ class PartnershipAdmin(admin.ModelAdmin): return _("Active") +class RoundingDecimalFormField(DecimalField): + def to_python(self, value): + value = super(RoundingDecimalFormField, self).to_python(value) + return round_decimal(value, self.decimal_places) + + +class OrganizationAdminForm(StatusForm): + latitude = RoundingDecimalFormField(max_digits=9, decimal_places=6) + longitude = RoundingDecimalFormField(max_digits=9, decimal_places=6) + + class OrganizationAdmin(ModelAdminWithVQCtrl, SoftDeleteAdmin): list_display = ("handle", "name", "status", "created", "updated") ordering = ("-created",) search_fields = ("name",) list_filter = (StatusFilter,) readonly_fields = ("id", "grainy_namespace") - form = StatusForm + form = OrganizationAdminForm fields = [ "status", "name", + "aka", + "name_long", "address1", "address2", "city", @@ -976,6 +1000,9 @@ class OrganizationMergeLog(ModelAdminWithUrlActions): class FacilityAdminForm(StatusForm): + latitude = RoundingDecimalFormField(max_digits=9, decimal_places=6) + longitude = RoundingDecimalFormField(max_digits=9, decimal_places=6) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) fk_handleref_filter(self, "org") @@ -1002,6 +1029,8 @@ class FacilityAdmin(ModelAdminWithVQCtrl, SoftDeleteAdmin): fields = [ "status", "name", + "aka", + "name_long", "address1", "address2", "city", @@ -1045,7 +1074,7 @@ class NetworkAdminForm(StatusForm): class NetworkAdmin(ModelAdminWithVQCtrl, SoftDeleteAdmin): - list_display = ("name", "asn", "aka", "status", "created", "updated") + list_display = ("name", "asn", "aka", "name_long", "status", "created", "updated") ordering = ("-created",) list_filter = (StatusFilter,) search_fields = ("name", "asn") @@ -1945,6 +1974,16 @@ class EnvironmentSettingAdmin(admin.ModelAdmin): return obj.set_value(form.cleaned_data["value"]) +class OrganizationAPIKeyAdmin(APIKeyModelAdmin): + list_display = ["org", "prefix", "name", "created", "revoked"] + search_fields = ("prefix", "org__name") + + +class UserAPIKeyAdmin(APIKeyModelAdmin): + list_display = ["user", "prefix", "name", "readonly", "created", "revoked"] + search_fields = ("prefix", "user__username", "user__email") + + # Commented out via issue #860 # admin.site.register(EnvironmentSetting, EnvironmentSettingAdmin) admin.site.register(IXFMemberData, IXFMemberDataAdmin) @@ -1969,3 +2008,6 @@ admin.site.register(CommandLineTool, CommandLineToolAdmin) admin.site.register(UserOrgAffiliationRequest, UserOrgAffiliationRequestAdmin) admin.site.register(DeskProTicket, DeskProTicketAdmin) admin.site.register(IXFImportEmail, IXFImportEmailAdmin) +admin.site.unregister(APIKey) +admin.site.register(OrganizationAPIKey, OrganizationAPIKeyAdmin) +admin.site.register(UserAPIKey, UserAPIKeyAdmin) diff --git a/peeringdb_server/api_key_views.py b/peeringdb_server/api_key_views.py new file mode 100644 index 00000000..e0218385 --- /dev/null +++ b/peeringdb_server/api_key_views.py @@ -0,0 +1,344 @@ +""" +Views for organization api key management +""" +from django.contrib.auth.decorators import login_required +from django.views.decorators.csrf import csrf_protect +from django.http import JsonResponse +from django.template import loader +from django.conf import settings +from peeringdb_server.forms import OrgAdminUserPermissionForm, OrganizationAPIKeyForm + +from grainy.const import PERM_READ +from django_grainy.models import UserPermission + +from django_handleref.models import HandleRefModel + +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import override + +from peeringdb_server.org_admin_views import ( + load_entity_permissions, + org_admin_required, +) + + +from peeringdb_server.models import ( + OrganizationAPIKey, + OrganizationAPIPermission, + UserAPIKey, + User, +) + + +def save_key_permissions(org, key, perms): + """ + Save key permissions for the specified org and key + + perms should be a dict of permissioning ids and permission levels + """ + + # wipe all the key's perms for the targeted org + + key.grainy_permissions.filter(namespace__startswith=org.grainy_namespace).delete() + + # collect permissioning namespaces from the provided permissioning ids + + grainy_perms = {} + + for id, permissions in list(perms.items()): + + if not permissions & PERM_READ: + permissions = permissions | PERM_READ + + if id == "org.%d" % org.id: + grainy_perms[org.grainy_namespace] = permissions + grainy_perms[ + f"{org.grainy_namespace}.network.*.poc_set.private" + ] = permissions + elif id == "net": + grainy_perms[f"{org.grainy_namespace}.network"] = permissions + grainy_perms[ + f"{org.grainy_namespace}.network.*.poc_set.private" + ] = permissions + elif id == "ix": + grainy_perms[f"{org.grainy_namespace}.internetexchange"] = permissions + elif id == "fac": + grainy_perms[f"{org.grainy_namespace}.facility"] = permissions + elif id.find(".") > -1: + id = id.split(".") + if id[0] == "net": + grainy_perms[f"{org.grainy_namespace}.network.{id[1]}"] = permissions + grainy_perms[ + f"{org.grainy_namespace}.network.{id[1]}.poc_set.private" + ] = permissions + elif id[0] == "ix": + grainy_perms[ + f"{org.grainy_namespace}.internetexchange.{id[1]}" + ] = permissions + elif id[0] == "fac": + grainy_perms[f"{org.grainy_namespace}.facility.{id[1]}"] = permissions + + # save + for ns, p in list(grainy_perms.items()): + OrganizationAPIPermission.objects.create( + namespace=ns, permission=p, org_api_key=key + ) + + return grainy_perms + + +def load_all_key_permissions(org): + """ + Returns dict of all users with all their permissions for + the given org + """ + + rv = {} + for key in org.api_keys.filter(revoked=False): + kperms, perms = load_entity_permissions(org, key) + rv[key.prefix] = { + "prefix": key.prefix, + "perms": perms, + "name": key.name, + } + return rv + + +@login_required +@org_admin_required +def manage_key_add(request, **kwargs): + """ + Create a new Organization API key + + Requires a name for the key. + """ + + api_key_form = OrganizationAPIKeyForm(request.POST) + + if api_key_form.is_valid(): + name = api_key_form.cleaned_data.get("name") + org_id = api_key_form.cleaned_data.get("org_id") + email = api_key_form.cleaned_data.get("email") + + api_key, key = OrganizationAPIKey.objects.create_key( + org_id=org_id, name=name, email=email + ) + + return JsonResponse( + { + "status": "ok", + "name": api_key.name, + "email": api_key.email, + "prefix": api_key.prefix, + "org_id": api_key.org_id, + "key": key, + } + ) + + else: + return JsonResponse(api_key_form.errors, status=400) + + +@login_required +@org_admin_required +def manage_key_update(request, **kwargs): + """ + Updated existing Organization API key + """ + + prefix = request.POST.get("prefix") + org = kwargs.get("org") + + api_key_form = OrganizationAPIKeyForm(request.POST) + + if api_key_form.is_valid(): + name = api_key_form.cleaned_data.get("name") + email = api_key_form.cleaned_data.get("email") + + # attempt to retrieve api for key prefix + org combination + + try: + api_key = OrganizationAPIKey.objects.get(prefix=prefix, org=org) + except OrganizationAPIKey.DoesNotExist: + return JsonResponse({"non_field_errors": [_("Key not found")]}, status=404) + + # update name and email fields of key + + api_key.name = name + api_key.email = email + api_key.save() + + return JsonResponse( + { + "status": "ok", + "name": api_key.name, + "email": api_key.email, + "prefix": api_key.prefix, + } + ) + + else: + return JsonResponse(api_key_form.errors, status=400) + + +@login_required +@org_admin_required +def manage_key_revoke(request, **kwargs): + """ + Revoke an existing API key. + """ + + org = kwargs.get("org") + prefix = request.POST.get("prefix") + + try: + api_key = OrganizationAPIKey.objects.get(org=org, prefix=prefix) + except OrganizationAPIKey.DoesNotExist: + return JsonResponse({"non_field_errors": [_("Key not found")]}, status=404) + + api_key.revoked = True + api_key.save() + + return JsonResponse( + { + "status": "ok", + } + ) + + +@login_required +@org_admin_required +def key_permissions(request, **kwargs): + """ + Returns JsonResponse with list of key permissions for the targeted + org an entities under it + + Permisions are returned as a dict of permissioning ids and permission + levels. + + Permissioning ids serve as a wrapper for actual permissioning namespaces + so we can expose them to the organization admins for changes without allowing + them to set permissioning namespaces directly. + """ + + org = kwargs.get("org") + perms_rv = {} + for key in org.api_keys.filter(revoked=False).all(): + kperms, perms = load_entity_permissions(org, key) + perms_rv[key.prefix] = perms + + return JsonResponse({"status": "ok", "key_permissions": perms_rv}) + + +@login_required +@csrf_protect +@org_admin_required +def key_permission_update(request, **kwargs): + """ + Update/Add a user's permission + + perms = permission level + entity = permission id + """ + + org = kwargs.get("org") + prefix = request.POST.get("key_prefix") + key = OrganizationAPIKey.objects.get(prefix=prefix) + kperms, perms = load_entity_permissions(org, key) + form = OrgAdminUserPermissionForm(request.POST) + if not form.is_valid(): + return JsonResponse(form.errors, status=400) + + level = form.cleaned_data.get("perms") + entity = form.cleaned_data.get("entity") + perms[entity] = level + save_key_permissions(org, key, perms) + + return JsonResponse({"status": "ok"}) + + +@login_required +@csrf_protect +@org_admin_required +def key_permission_remove(request, **kwargs): + """ + Remove a keys permission + + entity = permission id + """ + + org = kwargs.get("org") + prefix = request.POST.get("key_prefix") + key = OrganizationAPIKey.objects.get(prefix=prefix) + + entity = request.POST.get("entity") + kperms, perms = load_entity_permissions(org, key) + if entity in perms: + del perms[entity] + save_key_permissions(org, key, perms) + + return JsonResponse({"status": "ok"}) + + +""" +USER API KEY MANAGEMENT +""" + + +def convert_to_bool(data): + if data is None: + return False + + return data.lower() == "true" + + +@login_required +def add_user_key(request, **kwargs): + """ + Create a new User API key + + Requires a name and a readonly boolean. + """ + + user = request.user + name = request.POST.get("name") + readonly = convert_to_bool(request.POST.get("readonly")) + + api_key, key = UserAPIKey.objects.create_key( + name=name, + user=user, + readonly=readonly, + ) + + return JsonResponse( + { + "status": "ok", + "name": api_key.name, + "prefix": api_key.prefix, + "readonly": api_key.readonly, + "key": key, + } + ) + + +@login_required +def remove_user_key(request, **kwargs): + """ + Revoke user api key + """ + + user = request.user + prefix = request.POST.get("prefix") + + try: + api_key = UserAPIKey.objects.get(user=user, prefix=prefix) + except UserAPIKey.DoesNotExist: + return JsonResponse({"non_field_errors": [_("Key not found")]}, status=404) + api_key.revoked = True + api_key.save() + + return JsonResponse( + { + "status": "ok", + } + ) diff --git a/peeringdb_server/deskpro.py b/peeringdb_server/deskpro.py index e8b85435..e824a5cd 100644 --- a/peeringdb_server/deskpro.py +++ b/peeringdb_server/deskpro.py @@ -2,36 +2,50 @@ DeskPro API Client """ -import uuid -import re -import requests import datetime +import re +import uuid -from django.template import loader -from django.conf import settings import django.urls +import requests +from django.conf import settings +from django.template import loader -from peeringdb_server.models import DeskProTicket from peeringdb_server.inet import RdapNotFoundError +from peeringdb_server.models import is_suggested, DeskProTicket +from peeringdb_server.permissions import get_user_from_request, get_org_key_from_request + +from django.utils.translation import override def ticket_queue(subject, body, user): """ queue a deskpro ticket for creation """ - ticket = DeskProTicket.objects.create( + DeskProTicket.objects.create( subject=f"{settings.EMAIL_SUBJECT_PREFIX}{subject}", body=body, user=user, ) +def ticket_queue_email_only(subject, body, email): + """ queue a deskpro ticket for creation """ + + DeskProTicket.objects.create( + subject=f"{settings.EMAIL_SUBJECT_PREFIX}{subject}", + body=body, + email=email, + user=None, + ) + + class APIError(IOError): def __init__(self, msg, data): super().__init__(msg) self.data = data -def ticket_queue_asnauto_skipvq(user, org, net, rir_data): +def ticket_queue_asnauto_skipvq(request, org, net, rir_data): """ queue deskro ticket creation for asn automation action: skip vq """ @@ -46,13 +60,28 @@ def ticket_queue_asnauto_skipvq(user, org, net, rir_data): else: org_name = org.name - ticket_queue( - f"[ASNAUTO] Network '{net_name}' approved for existing Org '{org_name}'", - loader.get_template("email/notify-pdb-admin-asnauto-skipvq.txt").render( - {"user": user, "org": org, "net": net, "rir_data": rir_data} - ), - user, - ) + user = get_user_from_request(request) + if user: + ticket_queue( + f"[ASNAUTO] Network '{net_name}' approved for existing Org '{org_name}'", + loader.get_template("email/notify-pdb-admin-asnauto-skipvq.txt").render( + {"user": user, "org": org, "net": net, "rir_data": rir_data} + ), + user, + ) + return + + org_key = get_org_key_from_request(request) + if org_key: + ticket_queue_email_only( + f"[ASNAUTO] Network '{net_name}' approved for existing Org '{org_name}'", + loader.get_template( + "email/notify-pdb-admin-asnauto-skipvq-org-key.txt" + ).render( + {"org_key": org_key, "org": org, "net": net, "rir_data": rir_data} + ), + org_key.email, + ) def ticket_queue_asnauto_affil(user, org, net, rir_data): @@ -107,7 +136,53 @@ def ticket_queue_asnauto_create( ) -def ticket_queue_rdap_error(user, asn, error): +def ticket_queue_vqi_notify(instance, rdap): + item = instance.item + user = instance.user + org_key = instance.org_key + + with override("en"): + entity_type_name = str(instance.content_type) + + title = f"{entity_type_name} - {item}" + + if is_suggested(item): + title = f"[SUGGEST] {title}" + + if user: + ticket_queue( + title, + loader.get_template("email/notify-pdb-admin-vq.txt").render( + { + "entity_type_name": entity_type_name, + "suggested": is_suggested(item), + "item": item, + "user": user, + "rdap": rdap, + "edit_url": "%s%s" % (settings.BASE_URL, instance.item_admin_url), + } + ), + user, + ) + + elif org_key: + ticket_queue_email_only( + title, + loader.get_template("email/notify-pdb-admin-vq-org-key.txt").render( + { + "entity_type_name": entity_type_name, + "suggested": is_suggested(item), + "item": item, + "org_key": org_key, + "rdap": rdap, + "edit_url": "%s%s" % (settings.BASE_URL, instance.item_admin_url), + } + ), + org_key.email, + ) + + +def ticket_queue_rdap_error(request, asn, error): if isinstance(error, RdapNotFoundError): return error_message = f"{error}" @@ -115,14 +190,29 @@ def ticket_queue_rdap_error(user, asn, error): if re.match("(.+) returned 400", error_message): return - subject = f"[RDAP_ERR] {user.username} - AS{asn}" - ticket_queue( - subject, - loader.get_template("email/notify-pdb-admin-rdap-error.txt").render( - {"user": user, "asn": asn, "error_details": error_message} - ), - user, - ) + user = get_user_from_request(request) + + if user: + subject = f"[RDAP_ERR] {user.username} - AS{asn}" + ticket_queue( + subject, + loader.get_template("email/notify-pdb-admin-rdap-error.txt").render( + {"user": user, "asn": asn, "error_details": error_message} + ), + user, + ) + return + + org_key = get_org_key_from_request(request) + if org_key: + subject = f"[RDAP_ERR] {org_key.email} - AS{asn}" + ticket_queue_email_only( + subject, + loader.get_template("email/notify-pdb-admin-rdap-error-org-key.txt").render( + {"org_key": org_key, "asn": asn, "error_details": error_message} + ), + org_key.email, + ) class APIClient: @@ -162,23 +252,60 @@ class APIClient: ) return self.parse_response(response) - def require_person(self, user): - person = self.get("people", {"primary_email": user.email}) + def require_person(self, email, user=None): + + """ + Gets or creates a deskpro person using the deskpro API + + At the minimum this needs to be passed an email + address. + + If a peeringdb user instance is also specified, it will + be used to fill in name information. + + Arguments: + + - email(`str`) + - user(`User`) + """ + + person = self.get("people", {"primary_email": email}) + if not person: - person = self.create( - "people", - { - "primary_email": user.email, - "first_name": user.first_name, - "last_name": user.last_name, - "name": user.full_name, - }, - ) + + payload = {"primary_email": email} + + if user: + payload.update( + first_name=user.first_name, + last_name=user.last_name, + name=user.full_name, + ) + else: + payload.update(name=email) + + person = self.create("people", payload) return person def create_ticket(self, ticket): - person = self.require_person(ticket.user) + + """ + Creates a deskpro ticket using the deskpro API + + Arguments: + + - ticket (`DeskProTicket`) + """ + + if ticket.user: + person = self.require_person(ticket.user.email, user=ticket.user) + elif ticket.email: + person = self.require_person(ticket.email) + else: + raise ValueError( + "Either user or email need to be specified on the DeskProTicket instance" + ) if not ticket.deskpro_id: @@ -263,7 +390,7 @@ class FailingMockAPIClient(MockAPIClient): ) -def ticket_queue_deletion_prevented(user, instance): +def ticket_queue_deletion_prevented(request, instance): """ queue deskpro ticket to notify about the prevented deletion of an object #696 @@ -297,19 +424,43 @@ def ticket_queue_deletion_prevented(user, instance): model_name = instance.__class__.__name__.lower() - # create ticket + # Create ticket if a request was made by user or UserAPIKey + user = get_user_from_request(request) + if user: + ticket_queue( + subject, + loader.get_template("email/notify-pdb-admin-deletion-prevented.txt").render( + { + "user": user, + "instance": instance, + "admin_url": settings.BASE_URL + + django.urls.reverse( + f"admin:peeringdb_server_{model_name}_change", + args=(instance.id,), + ), + } + ), + user, + ) + return - ticket_queue( - subject, - loader.get_template("email/notify-pdb-admin-deletion-prevented.txt").render( - { - "user": user, - "instance": instance, - "admin_url": settings.BASE_URL - + django.urls.reverse( - f"admin:peeringdb_server_{model_name}_change", args=(instance.id,) - ), - } - ), - user, - ) + # Create ticket if request was made by OrgAPIKey + org_key = get_org_key_from_request(request) + if org_key: + ticket_queue_email_only( + subject, + loader.get_template( + "email/notify-pdb-admin-deletion-prevented-org-key.txt" + ).render( + { + "org_key": org_key, + "instance": instance, + "admin_url": settings.BASE_URL + + django.urls.reverse( + f"admin:peeringdb_server_{model_name}_change", + args=(instance.id,), + ), + } + ), + org_key.email, + ) diff --git a/peeringdb_server/forms.py b/peeringdb_server/forms.py index a239c4ae..e8bf964d 100644 --- a/peeringdb_server/forms.py +++ b/peeringdb_server/forms.py @@ -12,11 +12,17 @@ from django.conf import settings as dj_settings from captcha.fields import CaptchaField from captcha.models import CaptchaStore -from peeringdb_server.models import User, Organization +from peeringdb_server.models import User, Organization, OrganizationAPIKey from peeringdb_server.inet import get_client_ip from peeringdb_server.util import PERM_CRUD +class OrganizationAPIKeyForm(forms.Form): + name = forms.CharField() + email = forms.EmailField() + org_id = forms.IntegerField() + + class OrgAdminUserPermissionForm(forms.Form): entity = forms.CharField() diff --git a/peeringdb_server/management/commands/pdb_api_test.py b/peeringdb_server/management/commands/pdb_api_test.py index f73e3f75..9d4f7473 100644 --- a/peeringdb_server/management/commands/pdb_api_test.py +++ b/peeringdb_server/management/commands/pdb_api_test.py @@ -978,7 +978,6 @@ class TestJSON(unittest.TestCase): def test_user_001_GET_as_set(self): data = self.db_guest.all("as_set") networks = Network.objects.filter(status="ok") - print(data) for net in networks: self.assertEqual(data[0].get(f"{net.asn}"), net.irr_as_set) @@ -3426,50 +3425,93 @@ class TestJSON(unittest.TestCase): self.db_guest, "fac", data, test_success=False, test_failures={"perms": {}} ) - def test_z_misc_001_suggest_ix(self): - # test exchange suggestions - - data = self.make_data_ix( - org_id=settings.SUGGEST_ENTITY_ORG, suggest=True, prefix=self.get_prefix4() - ) - - r_data = self.assert_create( - self.db_user, "ix", data, ignore=["prefix", "suggest"] - ) - - self.assertEqual(r_data["org_id"], settings.SUGGEST_ENTITY_ORG) - self.assertEqual(r_data["status"], "pending") - - ix = InternetExchange.objects.get(id=r_data["id"]) - self.assertEqual(ix.org_id, settings.SUGGEST_ENTITY_ORG) - - data = self.make_data_ix( - org_id=settings.SUGGEST_ENTITY_ORG, suggest=True, prefix=self.get_prefix4() - ) - - r_data = self.assert_create( - self.db_guest, + def test_z_misc_001_disable_suggest_ix(self): + """ + Issue 827: We are removing the ability for non-admin users to "suggest" an IX + Therefore we change this test so that a "suggest" field being set on the API + request is disregarded, and permission is denied if a user who cannot create an + IX tries to POST. + """ + org = SHARED["org_rw_ok"] + data = self.make_data_ix(org_id=org.id, suggest=True, prefix=self.get_prefix4()) + # Assert that this throws a permission error (previously would "suggest"/create) + self.assert_create( + self.db_user, "ix", data, ignore=["prefix", "suggest"], test_success=False, test_failures={"perms": {}}, ) + # Assert that this doesn't create a "pending" IX in the + # suggested entity org + suggest_entity_org = Organization.objects.get(id=settings.SUGGEST_ENTITY_ORG) + assert suggest_entity_org.ix_set(manager="handleref").count() == 0 + + def test_z_misc_001_suggest_kwarg_on_ix_does_nothing(self): + """ + Issue 827: We are removing the ability for non-admin users to "suggest" an IX + If a user tries to "suggest" an IX, this keyword should simply be ignored. Admins + should be able to still create a "pending" IX even if "suggest" is provided. + """ + org = SHARED["org_rw_ok"] + data = self.make_data_ix(org_id=org.id, suggest=True, prefix=self.get_prefix4()) + # Assert that this creates a "pending" IX + ix = self.assert_create( + self.db_org_admin, + "ix", + data, + ignore=["prefix", "suggest"], + test_success=True, + ) + # Assert that this doesn't create a "pending" IX in the + # suggested entity org + suggest_entity_org = Organization.objects.get(id=settings.SUGGEST_ENTITY_ORG) + assert suggest_entity_org.ix_set(manager="handleref").count() == 0 + + # Assert that this does create a "pending" IX in the + # provided org + org.refresh_from_db() + assert ix["status"] == "pending" + assert InternetExchange.objects.get(name=data["name"]) + + def test_z_misc_001_cannot_post_ix_to_suggest_entity_org(self): + """ + Issue 827: We are removing the ability for non-admin users to "suggest" an IX + As part of that, we need to remove the ability to POST an IX with an ORG that is + the special "suggested entity org" even if the POST explicitly tries + to create an IX with that ORG. + """ + # Explicity designate org is the SUGGESTED ENTITY ORG + data = self.make_data_ix( + org_id=settings.SUGGEST_ENTITY_ORG, suggest=True, prefix=self.get_prefix4() + ) + self.assert_create( + self.db_user, + "ix", + data, + ignore=["prefix", "suggest"], + test_success=False, + test_failures={"invalid": {"org_id": settings.SUGGEST_ENTITY_ORG}}, + ) + # Assert that this doesn't create a "pending" IX in the + # suggested entity org + suggest_entity_org = Organization.objects.get(id=settings.SUGGEST_ENTITY_ORG) + assert suggest_entity_org.ix_set(manager="handleref").count() == 0 def test_z_misc_001_suggest_outside_of_post(self): - # The `suggest` keyword should only be allowed for - # `POST` events + # The `suggest` keyword should only do something for + # `POST` events, on `PUT` events it should be silently + # ignored + + for reftag in ["fac", "net"]: - for reftag in ["ix", "fac", "net"]: ent = SHARED[f"{reftag}_rw_ok"] org_id = ent.org_id - self.assert_update( - self.db_org_admin, - reftag, - ent.id, - {"notes": "bla"}, - test_failures={"invalid": {"suggest": True}}, - ) + db = self.db_org_admin + orig = self.assert_get_handleref(db, reftag, ent.id) + orig.update(notes="test", suggest=True) + db.update(reftag, **orig) ent.refresh_from_db() self.assertEqual(ent.org_id, org_id) diff --git a/peeringdb_server/migrations/0064_api_keys.py b/peeringdb_server/migrations/0064_api_keys.py new file mode 100644 index 00000000..0e43127c --- /dev/null +++ b/peeringdb_server/migrations/0064_api_keys.py @@ -0,0 +1,174 @@ +# Generated by Django 2.2.17 on 2021-01-04 11:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_grainy.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("peeringdb_server", "0063_populate_last_update_fields"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationAPIKey", + fields=[ + ( + "id", + models.CharField( + editable=False, + max_length=100, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("prefix", models.CharField(editable=False, max_length=8, unique=True)), + ("hashed_key", models.CharField(editable=False, max_length=100)), + ("created", models.DateTimeField(auto_now_add=True, db_index=True)), + ( + "name", + models.CharField( + default=None, + help_text="A free-form name for the API key. Need not be unique. 50 characters max.", + max_length=50, + ), + ), + ( + "revoked", + models.BooleanField( + blank=True, + default=False, + help_text="If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)", + ), + ), + ( + "expiry_date", + models.DateTimeField( + blank=True, + help_text="Once API key expires, clients cannot use it anymore.", + null=True, + verbose_name="Expires", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to="peeringdb_server.Organization", + ), + ), + ], + options={ + "verbose_name": "Organization API key", + "verbose_name_plural": "Organization API keys", + "db_table": "peeringdb_org_api_key", + "ordering": ("-created",), + "abstract": False, + }, + ), + migrations.CreateModel( + name="UserAPIKey", + fields=[ + ( + "id", + models.CharField( + editable=False, + max_length=100, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("prefix", models.CharField(editable=False, max_length=8, unique=True)), + ("hashed_key", models.CharField(editable=False, max_length=100)), + ("created", models.DateTimeField(auto_now_add=True, db_index=True)), + ( + "name", + models.CharField( + default=None, + help_text="A free-form name for the API key. Need not be unique. 50 characters max.", + max_length=50, + ), + ), + ( + "revoked", + models.BooleanField( + blank=True, + default=False, + help_text="If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)", + ), + ), + ( + "expiry_date", + models.DateTimeField( + blank=True, + help_text="Once API key expires, clients cannot use it anymore.", + null=True, + verbose_name="Expires", + ), + ), + ( + "readonly", + models.BooleanField( + default=False, + help_text="Determines if API Key inherits the User Permissions or is readonly.", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "User API key", + "verbose_name_plural": "User API keys", + "db_table": "peeringdb_user_api_key", + "ordering": ("-created",), + "abstract": False, + }, + ), + migrations.CreateModel( + name="OrganizationAPIPermission", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "namespace", + models.CharField( + help_text="Permission namespace (A '.' delimited list of keys", + max_length=255, + ), + ), + ("permission", django_grainy.fields.PermissionField(default=1)), + ( + "org_api_key", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="grainy_permissions", + to="peeringdb_server.OrganizationAPIKey", + ), + ), + ], + options={ + "verbose_name": "Organization API key Permission", + "verbose_name_plural": "Organization API key Permission", + "base_manager_name": "objects", + }, + ), + ] diff --git a/peeringdb_server/migrations/0065_alter_vqi_for_api_key.py b/peeringdb_server/migrations/0065_alter_vqi_for_api_key.py new file mode 100644 index 00000000..ab68b85a --- /dev/null +++ b/peeringdb_server/migrations/0065_alter_vqi_for_api_key.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.17 on 2021-01-07 00:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("peeringdb_server", "0064_api_keys"), + ] + + operations = [ + migrations.AddField( + model_name="verificationqueueitem", + name="org_key", + field=models.ForeignKey( + blank=True, + help_text="The item that this queue is attached to was created by this organization api key", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="vqitems", + to="peeringdb_server.OrganizationAPIKey", + ), + ), + ] diff --git a/peeringdb_server/migrations/0066_add_dpt_email.py b/peeringdb_server/migrations/0066_add_dpt_email.py new file mode 100644 index 00000000..c4377ecc --- /dev/null +++ b/peeringdb_server/migrations/0066_add_dpt_email.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.18 on 2021-02-24 19:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("peeringdb_server", "0065_alter_vqi_for_api_key"), + ] + + operations = [ + migrations.AddField( + model_name="deskproticket", + name="email", + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name="deskproticket", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/peeringdb_server/migrations/0067_add_email_to_org_key.py b/peeringdb_server/migrations/0067_add_email_to_org_key.py new file mode 100644 index 00000000..7fc1c6c5 --- /dev/null +++ b/peeringdb_server/migrations/0067_add_email_to_org_key.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.18 on 2021-02-25 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("peeringdb_server", "0066_add_dpt_email"), + ] + + operations = [ + migrations.AddField( + model_name="organizationapikey", + name="email", + field=models.EmailField( + default="test@localhost.com", + max_length=254, + verbose_name="email address", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="deskproticket", + name="email", + field=models.EmailField( + blank=True, max_length=254, null=True, verbose_name="email address" + ), + ), + ] diff --git a/peeringdb_server/migrations/0068_add_namelong_aka.py b/peeringdb_server/migrations/0068_add_namelong_aka.py new file mode 100644 index 00000000..a506f89b --- /dev/null +++ b/peeringdb_server/migrations/0068_add_namelong_aka.py @@ -0,0 +1,62 @@ +# Generated by Django 2.2.18 on 2021-03-05 20:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("peeringdb_server", "0067_add_email_to_org_key"), + ] + + operations = [ + migrations.AddField( + model_name="facility", + name="aka", + field=models.CharField( + blank=True, max_length=255, verbose_name="Also Known As" + ), + ), + migrations.AddField( + model_name="facility", + name="name_long", + field=models.CharField( + blank=True, max_length=255, verbose_name="Long Name" + ), + ), + migrations.AddField( + model_name="internetexchange", + name="aka", + field=models.CharField( + blank=True, max_length=255, verbose_name="Also Known As" + ), + ), + migrations.AddField( + model_name="network", + name="name_long", + field=models.CharField( + blank=True, max_length=255, verbose_name="Long Name" + ), + ), + migrations.AddField( + model_name="organization", + name="aka", + field=models.CharField( + blank=True, max_length=255, verbose_name="Also Known As" + ), + ), + migrations.AddField( + model_name="organization", + name="name_long", + field=models.CharField( + blank=True, max_length=255, verbose_name="Long Name" + ), + ), + migrations.AlterField( + model_name="internetexchange", + name="name_long", + field=models.CharField( + blank=True, max_length=255, verbose_name="Long Name" + ), + ), + ] diff --git a/peeringdb_server/models.py b/peeringdb_server/models.py index dc8e37f6..abfbd319 100644 --- a/peeringdb_server/models.py +++ b/peeringdb_server/models.py @@ -36,12 +36,14 @@ from django_inet.models import ASNField from django_grainy.decorators import grainy_model import django_grainy.decorators +from django_grainy.models import Permission, PermissionManager from allauth.account.models import EmailAddress, EmailConfirmation from allauth.socialaccount.models import SocialAccount from passlib.hash import sha256_crypt +from rest_framework_api_key.models import AbstractAPIKey -from peeringdb_server.util import check_permissions +from django_grainy.util import check_permissions from peeringdb_server.inet import RdapLookup, RdapNotFoundError from peeringdb_server.validators import ( validate_address_space, @@ -95,9 +97,9 @@ def make_relation_filter(field, filt, value, prefix=None): return filt -def validate_PUT_ownership(user, instance, data, fields): +def validate_PUT_ownership(permission_holder, instance, data, fields): """ - Helper function that checks if a user has write perms to + Helper function that checks if a user or api key has write perms to the instance provided as well as write perms to any child instances specified by fields as they exist on the model and in data @@ -123,7 +125,7 @@ def validate_PUT_ownership(user, instance, data, fields): if any fail the permission check False is returned. """ - if not check_permissions(user, instance, "u"): + if not check_permissions(permission_holder, instance, "u"): return False for fld in fields: @@ -142,7 +144,7 @@ def validate_PUT_ownership(user, instance, data, fields): if a.id != s_id: try: other = a.__class__.objects.get(id=s_id) - if not check_permissions(user, other, "u"): + if not check_permissions(permission_holder, other, "u"): return False except ValueError: # if id is not intable return False @@ -337,20 +339,19 @@ class GeocodeBaseMixin(models.Model): return f"{street_number} {route}".strip() - def reverse_geocode(self, gmaps): - if (self.latitude is None) or (self.longitude is None): + def reverse_geocode(self, gmaps, latlang): + + if latlang is None: raise ValidationError( _("Latitude and longitude must be defined for reverse geocode lookup") ) - - latlang = f"{self.latitude},{self.longitude}" try: response = gmaps.reverse_geocode(latlang) except ( googlemaps.exceptions.HTTPError, googlemaps.exceptions.ApiError, googlemaps.exceptions.TransportError, - ) as exc: + ): raise ValidationError(_("Error in reverse geocode: Google Maps API error")) except googlemaps.exceptions.Timeout: raise ValidationError( @@ -378,39 +379,55 @@ class GeocodeBaseMixin(models.Model): return data def normalize_api_response(self): - # The forward geocode sets the lat,long + suggested_address = {} + gmaps = googlemaps.Client(settings.GOOGLE_GEOLOC_API_KEY, timeout=5) + # The forward geocode sets the lat,long forward_result = self.geocode(gmaps, save=True) # Set information from forward geocode loc = forward_result[0].get("geometry").get("location") self.latitude = loc.get("lat") self.longitude = loc.get("lng") + if (self.latitude is None) or (self.longitude is None): + raise ValidationError( + _("Latitude and longitude must be defined for reverse geocode lookup") + ) + + self.geocode_status = True + self.geocode_date = datetime.datetime.now(datetime.timezone.utc) + self.save() + + suggested_address["latitude"] = self.latitude + suggested_address["longitude"] = self.longitude address1 = self.get_address1_from_geocode(forward_result) if address1 is None: raise ValidationError(_("Error in forward geocode: No address returned")) - self.address1 = address1 - self.address2 = "" + suggested_address["address1"] = address1 + suggested_address["address2"] = "" # The reverse result normalizes some administrative info # (city, state, zip) and translates them into English - reverse_result = self.reverse_geocode(gmaps) + latlang = f"{self.latitude},{self.longitude}" + reverse_result = self.reverse_geocode(gmaps, latlang) data = self.parse_reverse_geocode(reverse_result) if data.get("locality"): - self.city = data["locality"]["long_name"] + suggested_address["city"] = data["locality"]["long_name"] if data.get("administrative_area_level_1"): - self.state = data["administrative_area_level_1"]["long_name"] + suggested_address["state"] = data["administrative_area_level_1"][ + "long_name" + ] if data.get("postal_code"): - self.zipcode = data["postal_code"]["long_name"] + suggested_address["zipcode"] = data["postal_code"]["long_name"] # Set status to True to indicate we've normalized the data - self.geocode_status = True - self.geocode_date = datetime.datetime.now(datetime.timezone.utc) - self.save() - return self + suggested_address["geocode_status"] = True + suggested_address["geocode_date"] = datetime.datetime.now(datetime.timezone.utc) + + return suggested_address class UserOrgAffiliationRequest(models.Model): @@ -585,6 +602,17 @@ class VerificationQueueItem(models.Model): blank=True, help_text=_("The item that this queue is attached to was created by this user"), ) + org_key = models.ForeignKey( + "peeringdb_server.OrganizationAPIKey", + on_delete=models.CASCADE, + related_name="vqitems", + null=True, + blank=True, + help_text=_( + "The item that this queue is attached to was created by this organization api key" + ), + ) + created = CreatedDateTimeField() notified = models.BooleanField(default=False) @@ -664,7 +692,10 @@ class VerificationQueueItem(models.Model): class DeskProTicket(models.Model): subject = models.CharField(max_length=255) body = models.TextField() - user = models.ForeignKey("peeringdb_server.User", on_delete=models.CASCADE) + user = models.ForeignKey( + "peeringdb_server.User", on_delete=models.CASCADE, null=True, blank=True + ) + email = models.EmailField(_("email address"), null=True, blank=True) created = models.DateTimeField(auto_now_add=True) published = models.DateTimeField(null=True, blank=True) @@ -973,6 +1004,42 @@ def default_time_e(): return now.replace(hour=23, minute=59, second=59, tzinfo=UTC()) +class OrganizationAPIKey(AbstractAPIKey): + """ + An API Key managed by an organization. + """ + + org = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="api_keys", + ) + email = models.EmailField( + _("email address"), max_length=254, null=False, blank=False + ) + + class Meta(AbstractAPIKey.Meta): + verbose_name = "Organization API key" + verbose_name_plural = "Organization API keys" + db_table = "peeringdb_org_api_key" + + +class OrganizationAPIPermission(Permission): + """ + Describes permission for a OrganizationAPIKey + """ + + class Meta: + verbose_name = _("Organization API key Permission") + verbose_name_plural = _("Organization API key Permission") + base_manager_name = "objects" + + org_api_key = models.ForeignKey( + OrganizationAPIKey, related_name="grainy_permissions", on_delete=models.CASCADE + ) + objects = PermissionManager() + + class Sponsorship(models.Model): """ Allows an organization to be marked for sponsorship @@ -1824,14 +1891,14 @@ class IXLan(pdb_models.IXLanBase): db_table = "peeringdb_ixlan" @classmethod - def api_cache_permissions_applicator(cls, row, ns, user): + def api_cache_permissions_applicator(cls, row, ns, permission_holder): """ Applies permissions to a row in an api-cache result set for ixlan. This will strip `ixf_ixp_member_list_url` fields for - users that don't have read permissions for them according + users / api keys that don't have read permissions for them according to `ixf_ixp_member_list_url_visible` Argument(s): @@ -1839,15 +1906,15 @@ class IXLan(pdb_models.IXLanBase): - row (dict): ixlan row from api-cache result - ns (str): ixlan namespace as determined during api-cache result rendering - - user (User) + - permission_holder (User or API Key) """ visible = row.get("ixf_ixp_member_list_url_visible").lower() - if not user and visible == "public": + if not permission_holder and visible == "public": return namespace = f"{ns}.ixf_ixp_member_list_url.{visible}" - if not check_permissions(user, namespace, "r", explicit=True): + if not check_permissions(permission_holder, namespace, "r", explicit=True): try: del row["ixf_ixp_member_list_url"] except KeyError: @@ -3167,7 +3234,7 @@ class IXFMemberData(pdb_models.NetworkIXLanBase): if this is True """ - if user: + if user and user.is_authenticated: reversion.set_user(user) if comment: @@ -4518,6 +4585,31 @@ class User(AbstractBaseUser, PermissionsMixin): return False +class UserAPIKey(AbstractAPIKey): + """ + An API Key managed by a user. Can be readonly or can take on the + permissions of the User. + """ + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="api_keys", + ) + + readonly = models.BooleanField( + default=False, + help_text=_( + "Determines if API Key inherits the User Permissions or is readonly." + ), + ) + + class Meta(AbstractAPIKey.Meta): + verbose_name = "User API key" + verbose_name_plural = "User API keys" + db_table = "peeringdb_user_api_key" + + def password_reset_token(): token = str(uuid.uuid4()) diff --git a/peeringdb_server/org_admin_views.py b/peeringdb_server/org_admin_views.py index 2c0b33b5..ad109c0d 100644 --- a/peeringdb_server/org_admin_views.py +++ b/peeringdb_server/org_admin_views.py @@ -91,7 +91,7 @@ def load_all_user_permissions(org): rv = {} for user in org.usergroup.user_set.all(): - uperms, perms = load_user_permissions(org, user) + uperms, perms = load_entity_permissions(org, user) rv[user.id] = { "id": user.id, "perms": perms, @@ -101,40 +101,44 @@ def load_all_user_permissions(org): def load_user_permissions(org, user): + return load_entity_permissions(org, user) + + +def load_entity_permissions(org, entity): """ - Returns user's permissions for the specified org + Returns entity's permissions for the specified org """ - # load all of the user's permissions related to this org - uperms = { + # load all of the entity's permissions related to this org + entity_perms = { p.namespace: p.permission - for p in user.grainy_permissions.filter( + for p in entity.grainy_permissions.filter( namespace__startswith=org.grainy_namespace ) } perms = {} - extract_permission_id(uperms, perms, org, org) + extract_permission_id(entity_perms, perms, org, org) - # extract user's permissioning ids from grainy_namespaces targeting + # extract entity's permissioning ids from grainy_namespaces targeting # organization's entities for model in [Network, InternetExchange, Facility]: - extract_permission_id(uperms, perms, model, org) + extract_permission_id(entity_perms, perms, model, org) - # extract user's permissioning ids from grainy_namespaces targeting - # organization's entities by their id (eg user has perms only + # extract entity's permissioning ids from grainy_namespaces targeting + # organization's entities by their id (eg entity has perms only # to THAT specific network) for net in org.net_set_active: - extract_permission_id(uperms, perms, net, org) + extract_permission_id(entity_perms, perms, net, org) for net in org.ix_set_active: - extract_permission_id(uperms, perms, net, org) + extract_permission_id(entity_perms, perms, net, org) for net in org.fac_set_active: - extract_permission_id(uperms, perms, net, org) + extract_permission_id(entity_perms, perms, net, org) - return uperms, perms + return entity_perms, perms def permission_ids(org): diff --git a/peeringdb_server/permissions.py b/peeringdb_server/permissions.py new file mode 100644 index 00000000..b311afc4 --- /dev/null +++ b/peeringdb_server/permissions.py @@ -0,0 +1,252 @@ +# from django_grainy.rest import ModelViewSetPermissions, PermissionDenied +from rest_framework_api_key.permissions import KeyParser +from rest_framework.permissions import BasePermission + +from django_grainy.helpers import request_method_to_flag + +from peeringdb_server.models import OrganizationAPIKey, UserAPIKey, Group, User +from django.contrib.auth.models import AnonymousUser + +import grainy.const as grainy_constant +from grainy.core import NamespaceKeyApplicator +from django.conf import settings + +# from django_grainy.const import * +from django_grainy.util import Permissions + + +def validate_rdap_user_or_key(request, rdap): + user = get_user_from_request(request) + if user: + return user.validate_rdap_relationship(rdap) + + org_key = get_org_key_from_request(request) + if org_key: + return validate_rdap_org_key(org_key, rdap) + + return False + + +def validate_rdap_org_key(org_key, rdap): + for email in rdap.emails: + if email.lower() == org_key.email.lower(): + return True + return False + + +def get_key_from_request(request): + """Use the default KeyParser from drf-api-keys to pull the key out of the request""" + return KeyParser().get(request) + + +def get_permission_holder_from_request(request): + """Returns either an API Key instance or User instance + depending on how the request is Authenticated. + """ + key = get_key_from_request(request) + if key is not None: + try: + api_key = OrganizationAPIKey.objects.get_from_key(key) + return api_key + + except OrganizationAPIKey.DoesNotExist: + pass + + try: + api_key = UserAPIKey.objects.get_from_key(key) + return api_key + + except UserAPIKey.DoesNotExist: + pass + + if hasattr(request, "user"): + return request.user + + return AnonymousUser() + + +def get_user_from_request(request): + """ + Returns a user from the request if the request + was made with either a User or UserAPIKey. + + If request was made with OrgKey, returns None. + """ + perm_holder = get_permission_holder_from_request(request) + if isinstance(perm_holder, User): + return perm_holder + elif isinstance(perm_holder, UserAPIKey): + return perm_holder.user + elif isinstance(perm_holder, OrganizationAPIKey): + return None + + return None + + +def get_org_key_from_request(request): + """ + Returns a org key from the request if the request + was made with an OrgKey. + + Otherwise returns None. + """ + perm_holder = get_permission_holder_from_request(request) + + if type(perm_holder) == OrganizationAPIKey: + return perm_holder + + return None + + +def get_user_key_from_request(request): + """ + Returns a user api key from the request if the request + was made with an User API Key. + + Otherwise returns None. + """ + perm_holder = get_permission_holder_from_request(request) + + if type(perm_holder) == UserAPIKey: + return perm_holder + + return None + + +def check_permissions_from_request(request, target, flag, **kwargs): + """Call the check_permissions util but takes a request as + input, not a permission-holding object + """ + perm_obj = get_permission_holder_from_request(request) + return check_permissions(perm_obj, target, flag, **kwargs) + + +def check_permissions(obj, target, permissions, **kwargs): + """Users the provided permission holding object to initialize + the Permissions Util, which then checks permissions. + """ + if not hasattr(obj, "_permissions_util"): + obj._permissions_util = init_permissions_helper(obj) + + return obj._permissions_util.check(target, permissions, **kwargs) + + +def init_permissions_helper(obj): + """Initializes the Permission Util based on + if the provided object is a UserAPIKey, OrgAPIKey, + or a different object. + """ + if isinstance(obj, UserAPIKey): + return return_user_api_key_perms(obj) + if isinstance(obj, OrganizationAPIKey): + return return_org_api_key_perms(obj) + else: + return Permissions(obj) + + +def return_user_api_key_perms(key): + """ + Initializes the Permissions Util with the + permissions of the user linked to the User API + key. + + If the UserAPIKey is marked readonly, it downgrades + all permissions to readonly. + """ + user = key.user + permissions = Permissions(user) + + if key.readonly is True: + readonly_perms = { + ns: grainy_constant.PERM_READ for ns in permissions.pset.namespaces + } + permissions.pset.update(readonly_perms) + + return permissions + + +def return_org_api_key_perms(key): + """ + Load Permissions util with OrgAPIKey perms + and then add in that organization's user group perms + and general user group permissions + """ + permissions = Permissions(key) + # #Add user group perms + org_usergroup = key.org.usergroup + permissions.pset.update( + org_usergroup.grainy_permissions.permission_set().permissions, override=False + ) + + # # Add general user group perms + general_usergroup = Group.objects.get(id=settings.USER_GROUP_ID) + permissions.pset.update( + general_usergroup.grainy_permissions.permission_set().permissions, + override=False, + ) + return permissions + + +class ModelViewSetPermissions(BasePermission): + """ + Use as a permission class on a ModelRestViewSet + to automatically wire up the following views + to the correct permissions based on the handled object + - retrieve + - list + - create + - destroy + - update + - partial update + """ + + def has_permission(self, request, view): + if hasattr(view, "Grainy"): + perm_obj = get_permission_holder_from_request(request) + flag = request_method_to_flag(request.method) + return check_permissions(perm_obj, view, flag) + + # view has not been grainy decorated + return True + + def has_object_permission(self, request, view, obj): + perm_obj = get_permission_holder_from_request(request) + flag = request_method_to_flag(request.method) + return check_permissions(perm_obj, obj, flag) + + +class APIPermissionsApplicator(NamespaceKeyApplicator): + @property + def is_generating_api_cache(self): + try: + return getattr(settings, "GENERATING_API_CACHE", False) + except IndexError: + return False + + def __init__(self, request): + super().__init__(None) + perm_obj = get_permission_holder_from_request(request) + self.permissions = init_permissions_helper(perm_obj) + self.pset = self.permissions + self.set_peeringdb_handlers() + + if self.is_generating_api_cache: + self.drop_namespace_key = False + + def set_peeringdb_handlers(self): + self.handler( + "peeringdb.organization.*.network.*.poc_set.private", explicit=True + ) + self.handler("peeringdb.organization.*.network.*.poc_set.users", explicit=True) + self.handler( + "peeringdb.organization.*.internetexchange.*", fn=self.handle_ixlan + ) + + def handle_ixlan(self, namespace, data): + if "ixf_ixp_member_list_url" in data: + visible = data["ixf_ixp_member_list_url_visible"].lower() + _namespace = f"{namespace}.ixf_ixp_member_list_url.{visible}" + + perms = self.permissions.check(_namespace, 0x01, explicit=True) + if not perms: + del data["ixf_ixp_member_list_url"] diff --git a/peeringdb_server/rest.py b/peeringdb_server/rest.py index d67e1688..06a337b6 100644 --- a/peeringdb_server/rest.py +++ b/peeringdb_server/rest.py @@ -21,6 +21,7 @@ from django.utils import timezone from django.db.models import DateTimeField from django.utils.translation import ugettext_lazy as _ from django_grainy.rest import ModelViewSetPermissions, PermissionDenied + import reversion from peeringdb_server.models import Network, UTC, ProtectedAction @@ -28,7 +29,13 @@ from peeringdb_server.serializers import ParentStatusException from peeringdb_server.api_cache import CacheRedirect, APICacheLoader from peeringdb_server.api_schema import BaseSchema from peeringdb_server.deskpro import ticket_queue_deletion_prevented -from peeringdb_server.util import check_permissions, APIPermissionsApplicator +from peeringdb_server.permissions import ( + ModelViewSetPermissions, + check_permissions_from_request, + APIPermissionsApplicator, + get_org_key_from_request, + get_user_key_from_request, +) class DataException(ValueError): @@ -37,7 +44,7 @@ class DataException(ValueError): class DataMissingException(DataException): - """ + """ "" Will be raised when the json data sent with a POST, PUT or PATCH request is missing """ @@ -490,7 +497,7 @@ class ModelViewSet(viewsets.ModelViewSet): print("done in %.5f seconds, %d queries" % (d, len(connection.queries))) - applicator = APIPermissionsApplicator(request.user) + applicator = APIPermissionsApplicator(request) if not applicator.is_generating_api_cache: r.data = applicator.apply(r.data) @@ -508,7 +515,7 @@ class ModelViewSet(viewsets.ModelViewSet): d = time.time() - t print("done in %.5f seconds, %d queries" % (d, len(connection.queries))) - applicator = APIPermissionsApplicator(request.user) + applicator = APIPermissionsApplicator(request) if not applicator.is_generating_api_cache: r.data = applicator.apply(r.data) @@ -543,9 +550,18 @@ class ModelViewSet(viewsets.ModelViewSet): """ try: self.require_data(request) + + org_key = get_org_key_from_request(request) + user_key = get_user_key_from_request(request) + with reversion.create_revision(): - if request.user: + if request.user and request.user.is_authenticated: reversion.set_user(request.user) + if org_key: + reversion.set_comment(f"API-key: {org_key.prefix}") + if user_key: + reversion.set_comment(f"API-key: {user_key.prefix}") + r = super().create(request, *args, **kwargs) if "_grainy" in r.data: del r.data["_grainy"] @@ -566,9 +582,17 @@ class ModelViewSet(viewsets.ModelViewSet): """ try: self.require_data(request) + + org_key = get_org_key_from_request(request) + user_key = get_user_key_from_request(request) + with reversion.create_revision(): - if request.user: + if request.user and request.user.is_authenticated: reversion.set_user(request.user) + if org_key: + reversion.set_comment(f"API-key: {org_key.prefix}") + if user_key: + reversion.set_comment(f"API-key: {user_key.prefix}") r = super().update(request, *args, **kwargs) if "_grainy" in r.data: @@ -609,10 +633,16 @@ class ModelViewSet(viewsets.ModelViewSet): except self.model.DoesNotExist: return Response(status=status.HTTP_204_NO_CONTENT) - if check_permissions(request.user, obj, "d"): + user_key = get_user_key_from_request(request) + org_key = get_org_key_from_request(request) + if check_permissions_from_request(request, obj, "d"): with reversion.create_revision(): - if request.user: + if request.user and request.user.is_authenticated: reversion.set_user(request.user) + if org_key: + reversion.set_comment(f"API-key: {org_key.prefix}") + if user_key: + reversion.set_comment(f"API-key: {user_key.prefix}") obj.delete() return Response(status=status.HTTP_204_NO_CONTENT) else: @@ -622,7 +652,7 @@ class ModelViewSet(viewsets.ModelViewSet): "Please contact {} to help with the deletion of this object" ).format(settings.DEFAULT_FROM_EMAIL) - ticket_queue_deletion_prevented(request.user, exc.protected_object) + ticket_queue_deletion_prevented(request, exc.protected_object) return Response( status=status.HTTP_403_FORBIDDEN, data={"detail": exc_message} diff --git a/peeringdb_server/serializers.py b/peeringdb_server/serializers.py index 7166e654..bb6f4261 100644 --- a/peeringdb_server/serializers.py +++ b/peeringdb_server/serializers.py @@ -1,30 +1,35 @@ import ipaddress import re -import reversion -from django_inet.rest import IPAddressField, IPPrefixField -from django.core.validators import URLValidator -from django.db.models.query import QuerySet -from django.db.models import Prefetch, Q, Sum, IntegerField, Case, When -from django.db import models, transaction, IntegrityError -from django.db.models.fields.related import ( - ReverseManyToOneDescriptor, - ForwardManyToOneDescriptor, -) -from django.http import JsonResponse +import reversion +from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError -from rest_framework import serializers, validators -from rest_framework.exceptions import ValidationError as RestValidationError +from django.core.validators import URLValidator +from django.db import IntegrityError, models, transaction +from django.db.models import Case, IntegerField, Prefetch, Q, Sum, When +from django.db.models.fields.related import ( + ForwardManyToOneDescriptor, + ReverseManyToOneDescriptor, +) +from django.db.models.query import QuerySet +from django.http import JsonResponse +from django.utils.translation import ugettext_lazy as _ +from django_grainy.rest import PermissionDenied # from drf_toolbox import serializers from django_handleref.rest.serializers import HandleRefSerializer -from django.conf import settings -from django.contrib.contenttypes.models import ContentType +from django_inet.rest import IPAddressField, IPPrefixField from django_peeringdb.models.abstract import AddressModel +from rest_framework import serializers, validators +from rest_framework.exceptions import ValidationError as RestValidationError -from django_grainy.rest import PermissionDenied - -from peeringdb_server.util import check_permissions, Permissions +from peeringdb_server.permissions import ( + check_permissions_from_request, + get_org_key_from_request, + validate_rdap_user_or_key, + get_user_from_request, +) from peeringdb_server.inet import ( RdapLookup, RdapNotFoundError, @@ -50,6 +55,7 @@ from peeringdb_server.models import ( NetworkFacility, NetworkIXLan, Organization, + OrganizationAPIKey, ) from peeringdb_server.validators import ( validate_address_space, @@ -61,8 +67,6 @@ from peeringdb_server.validators import ( validate_zipcode, ) -from django.utils.translation import ugettext_lazy as _ - # exclude certain query filters that would otherwise # be exposed to the api for filtering operations @@ -165,6 +169,52 @@ class GeocodeSerializerMixin(object): return False + def _add_meta_information(self, metadata): + """ + Adds a dictionary of metadata to the "meta" field of the API + request, so that it ends up in the API response. + """ + if "request" in self.context: + request = self.context["request"] + if not hasattr(request, "meta_response"): + request.meta_response = {} + request.meta_response.update(metadata) + return True + + return False + + def handle_geo_error(self, exc, instance): + """ + Issue #939 In the event that there is an error in geovalidating + the address(including address not found), we return a warning in + the "meta" field of the response and null the latitude and + longitude on the instance. + """ + self._add_meta_information( + { + "geovalidation_warning": self.GEO_ERROR_MESSAGE, + } + ) + print(exc.message) + instance.latitude = None + instance.longitude = None + instance.save() + + def needs_address_suggestion(self, suggested_address, instance): + """ + Issue #940: If the geovalidated address meaningfully differs + from the address the user provided, we return True to signal + a address suggestion should be provided to the user. + """ + + for key in ["address1", "city", "state", "zipcode"]: + suggested_val = suggested_address.get(key, None) + instance_val = getattr(instance, key, None) + if instance_val != suggested_val: + return True + + return False + def update(self, instance, validated_data): """ When updating a geo-enabled object, @@ -184,15 +234,21 @@ class GeocodeSerializerMixin(object): if need_geosync: print("Normalizing geofields") try: - instance.normalize_api_response() + suggested_address = instance.normalize_api_response() + print(suggested_address) + + if self.needs_address_suggestion(suggested_address, instance): + self._add_meta_information( + { + "suggested_address": suggested_address, + } + ) # Reraise the model validation error # as a serializer validation error except ValidationError as exc: - print(exc.message) - raise serializers.ValidationError( - {"non_field_errors": [self.GEO_ERROR_MESSAGE]} - ) + self.handle_geo_error(exc, instance) + return instance def create(self, validated_data): @@ -207,15 +263,19 @@ class GeocodeSerializerMixin(object): if self._geosync_information_present(instance, validated_data): try: - instance.normalize_api_response() + suggested_address = instance.normalize_api_response() + + if self.needs_address_suggestion(suggested_address, instance): + self._add_meta_information( + { + "suggested_address": suggested_address, + } + ) # Reraise the model validation error # as a serializer validation error except ValidationError as exc: - print(exc.message) - raise serializers.ValidationError( - {"non_field_errors": [self.GEO_ERROR_MESSAGE]} - ) + self.handle_geo_error(exc, instance) return instance @@ -409,7 +469,7 @@ class AsnRdapValidator: emails = rdap.emails self.request.rdap_result = rdap except RdapException as exc: - self.request.rdap_error = (self.request.user, asn, exc) + self.request.rdap_error = (asn, exc) raise RestValidationError({self.field: rdap_pretty_error_message(exc)}) def set_context(self, serializer): @@ -500,6 +560,8 @@ class AddressSerializer(serializers.ModelSerializer): "zipcode", "floor", "suite", + "latitude", + "longitude", ] @@ -940,7 +1002,8 @@ class ModelSerializer(serializers.ModelSerializer): namespace = self.Meta.model.Grainy.namespace_instance("*", **grainy_kwargs) request = self.context.get("request") - if request and not check_permissions(request.user, namespace, "u"): + + if request and not check_permissions_from_request(request, namespace, "u"): raise PermissionDenied( f"User does not have write permissions to '{namespace}'" ) @@ -961,8 +1024,8 @@ class ModelSerializer(serializers.ModelSerializer): del validated_data["suggest"] self.validate_create(validated_data) - grainy_kwargs = {"id": "*"} + grainy_kwargs.update(**validated_data) request = self.context.get("request") @@ -972,7 +1035,7 @@ class ModelSerializer(serializers.ModelSerializer): else: namespace = self.Meta.model.Grainy.namespace_instance("*", **grainy_kwargs) - if request and not check_permissions(request.user, namespace, "c"): + if request and not check_permissions_from_request(request, namespace, "c"): raise PermissionDenied( f"User does not have write permissions to '{namespace}'" ) @@ -1125,14 +1188,25 @@ class ModelSerializer(serializers.ModelSerializer): instance.status = "ok" instance.save() - if instance.status == "pending": - if self._context["request"]: - vq = VerificationQueueItem.objects.filter( - content_type=ContentType.objects.get_for_model(type(instance)), - object_id=instance.id, - ).first() - if vq: - vq.user = self._context["request"].user + request = self._context["request"] + + if instance.status == "pending" and request: + vq = VerificationQueueItem.objects.filter( + content_type=ContentType.objects.get_for_model(type(instance)), + object_id=instance.id, + ).first() + if vq: + # This will save the user field if user credentials + # or if a user api key are used + user = get_user_from_request(request) + org_key = get_org_key_from_request(request) + if user: + vq.user = user + vq.save() + + # This will save the org api key if provided + elif org_key: + vq.org_key = org_key vq.save() def finalize_create(self, request): @@ -1228,9 +1302,6 @@ class FacilitySerializer(GeocodeSerializerMixin, ModelSerializer): net_count = serializers.SerializerMethodField() - latitude = serializers.FloatField(read_only=True) - longitude = serializers.FloatField(read_only=True) - suggest = serializers.BooleanField(required=False, write_only=True) website = serializers.URLField() @@ -1241,7 +1312,8 @@ class FacilitySerializer(GeocodeSerializerMixin, ModelSerializer): tech_phone = serializers.CharField(required=False, allow_blank=True, default="") sales_phone = serializers.CharField(required=False, allow_blank=True, default="") - validators = [FieldMethodValidator("suggest", ["POST"])] + latitude = serializers.FloatField(read_only=True) + longitude = serializers.FloatField(read_only=True) def validate_create(self, data): # we don't want users to be able to create facilities if the parent @@ -1260,14 +1332,14 @@ class FacilitySerializer(GeocodeSerializerMixin, ModelSerializer): "org_name", "org", "name", + "aka", + "name_long", "website", "clli", "rencode", "npanxx", "notes", "net_count", - "latitude", - "longitude", "suggest", "sales_email", "sales_phone", @@ -1329,7 +1401,7 @@ class FacilitySerializer(GeocodeSerializerMixin, ModelSerializer): # whichever org is specified in `SUGGEST_ENTITY_ORG` # # this happens here so it is done before the validators run - if "suggest" in data: + if "suggest" in data and (not self.instance or not self.instance.id): data["org_id"] = settings.SUGGEST_ENTITY_ORG return super().to_internal_value(data) @@ -1877,7 +1949,9 @@ class NetworkSerializer(ModelSerializer): ) suggest = serializers.BooleanField(required=False, write_only=True) - validators = [AsnRdapValidator(), FieldMethodValidator("suggest", ["POST"])] + validators = [ + AsnRdapValidator(), + ] # irr_as_set = serializers.CharField(validators=[validate_irr_as_set]) @@ -1890,6 +1964,7 @@ class NetworkSerializer(ModelSerializer): "org", "name", "aka", + "name_long", "website", "asn", "looking_glass", @@ -2000,7 +2075,7 @@ class NetworkSerializer(ModelSerializer): # whichever org is specified in `SUGGEST_ENTITY_ORG` # # this happens here so it is done before the validators run - if "suggest" in data: + if "suggest" in data and (not self.instance or not self.instance.id): data["org_id"] = settings.SUGGEST_ENTITY_ORG # if an asn exists already but is currently deleted, fail @@ -2046,11 +2121,12 @@ class NetworkSerializer(ModelSerializer): rdap = RdapLookup().get_asn(asn) # add network to existing org - if rdap and user.validate_rdap_relationship(rdap): + if rdap and validate_rdap_user_or_key(request, rdap): + # user email exists in RiR data, skip verification queue validated_data["status"] = "ok" net = super().create(validated_data) - ticket_queue_asnauto_skipvq(user, validated_data["org"], net, rdap) + ticket_queue_asnauto_skipvq(request, validated_data["org"], net, rdap) return net elif self.Meta.model in QUEUE_ENABLED: @@ -2074,8 +2150,9 @@ class NetworkSerializer(ModelSerializer): def finalize_create(self, request): rdap_error = getattr(request, "rdap_error", None) + if rdap_error: - ticket_queue_rdap_error(*rdap_error) + ticket_queue_rdap_error(request, *rdap_error) def validate_irr_as_set(self, value): if value: @@ -2311,7 +2388,7 @@ class InternetExchangeSerializer(ModelSerializer): net_count = serializers.SerializerMethodField() - suggest = serializers.BooleanField(required=False, write_only=True) + # suggest = serializers.BooleanField(required=False, write_only=True) ixf_net_count = serializers.IntegerField(read_only=True) ixf_last_import = serializers.DateTimeField(read_only=True) @@ -2339,7 +2416,6 @@ class InternetExchangeSerializer(ModelSerializer): ) validators = [ - FieldMethodValidator("suggest", ["POST"]), RequiredForMethodValidator("prefix", ["POST"]), SoftRequiredValidator( ["policy_email", "tech_email"], @@ -2354,6 +2430,7 @@ class InternetExchangeSerializer(ModelSerializer): "org_id", "org", "name", + "aka", "name_long", "city", "country", @@ -2371,7 +2448,7 @@ class InternetExchangeSerializer(ModelSerializer): "policy_phone", "fac_set", "ixlan_set", - "suggest", + # "suggest", "prefix", "net_count", "ixf_net_count", @@ -2435,16 +2512,19 @@ class InternetExchangeSerializer(ModelSerializer): # organization status is pending or deleted if data.get("org") and data.get("org").status != "ok": raise ParentStatusException(data.get("org"), self.Meta.model.handleref.tag) - return super().validate_create(data) - def to_internal_value(self, data): - # if `suggest` keyword is provided, hard-set the org to - # whichever org is specified in `SUGGEST_ENTITY_ORG` - # - # this happens here so it is done before the validators run - if "suggest" in data: - data["org_id"] = settings.SUGGEST_ENTITY_ORG - return super().to_internal_value(data) + # we don't want users to be able to create an internet exchange with an + # org that is the "suggested entity org" + if data.get("org") and (data.get("org").id == settings.SUGGEST_ENTITY_ORG): + raise serializers.ValidationError( + { + "org": _( + "User cannot create an internet exchange with" + "its org set as the SUGGEST_ENTITY organization" + ) + } + ) + return super().validate_create(data) def to_representation(self, data): # When an ix is created we want to add the ixlan_id and ixpfx_id @@ -2542,6 +2622,9 @@ class OrganizationSerializer(GeocodeSerializerMixin, ModelSerializer): source="ix_set_active_prefetched", ) + latitude = serializers.FloatField(read_only=True) + longitude = serializers.FloatField(read_only=True) + class Meta: # (AddressSerializer.Meta): model = Organization depth = 1 @@ -2549,13 +2632,13 @@ class OrganizationSerializer(GeocodeSerializerMixin, ModelSerializer): [ "id", "name", + "aka", + "name_long", "website", "notes", "net_set", "fac_set", "ix_set", - "latitude", - "longitude", ] + AddressSerializer.Meta.fields + HandleRefSerializer.Meta.fields diff --git a/peeringdb_server/signals.py b/peeringdb_server/signals.py index 8a39b245..44c2a36f 100644 --- a/peeringdb_server/signals.py +++ b/peeringdb_server/signals.py @@ -20,6 +20,7 @@ from peeringdb_server.deskpro import ( ticket_queue, ticket_queue_asnauto_affil, ticket_queue_asnauto_create, + ticket_queue_vqi_notify, ) from peeringdb_server.models import ( @@ -429,12 +430,11 @@ if getattr(settings, "DISABLE_VERIFICATION_QUEUE", False) is False: if instance.notified: return - # we don't sent notifications unless requesting user has been identified - if not instance.user_id: + # no contact point exists + if not instance.user_id and not instance.org_key: return item = instance.item - user = instance.user if type(item) in QUEUE_NOTIFY and not getattr( settings, "DISABLE_VERIFICATION_QUEUE_EMAILS", False @@ -445,30 +445,7 @@ if getattr(settings, "DISABLE_VERIFICATION_QUEUE", False) is False: else: rdap = None - with override("en"): - entity_type_name = str(instance.content_type) - - title = f"{entity_type_name} - {item}" - - if is_suggested(item): - title = f"[SUGGEST] {title}" - - ticket_queue( - title, - loader.get_template("email/notify-pdb-admin-vq.txt").render( - { - "entity_type_name": entity_type_name, - "suggested": is_suggested(item), - "item": item, - "user": user, - "rdap": rdap, - "edit_url": "%s%s" - % (settings.BASE_URL, instance.item_admin_url), - } - ), - instance.user, - ) - + ticket_queue_vqi_notify(instance, rdap) instance.notified = True instance.save() diff --git a/peeringdb_server/static/20c/twentyc.edit.js b/peeringdb_server/static/20c/twentyc.edit.js index 565f6f44..392ff58f 100644 --- a/peeringdb_server/static/20c/twentyc.edit.js +++ b/peeringdb_server/static/20c/twentyc.edit.js @@ -496,6 +496,7 @@ twentyc.editable.module.register( this.action.signal_success(container, rowId); container.trigger("listing:row-add", [rowId, row, data, this]); this.components.list.scrollTop(function() { return this.scrollHeight; }); + return row; }, remove : function(rowId, row, trigger, container) { diff --git a/peeringdb_server/static/peeringdb.js b/peeringdb_server/static/peeringdb.js index 7666a1a4..c278c130 100644 --- a/peeringdb_server/static/peeringdb.js +++ b/peeringdb_server/static/peeringdb.js @@ -154,7 +154,88 @@ PeeringDB = { return value }, + // if an api response includes a "geovalidation warning" + // field in its metadata, display that warning + add_geo_warning : function(meta, endpoint) { + $('.geovalidation-warning').each(function(){ + let popin = $(this); + let warning = meta.geovalidation_warning; + if (endpoint == popin.data("edit-geotag")){ + popin.text(warning); + popin.removeClass("hidden").show(); + + } + }) + }, + + // if an api response includes a "geo" + add_suggested_address : function(request, endpoint) { + + let popin = $('.suggested-address').filter(function() { + return $(this).data("edit-geotag") == endpoint + }); + if (popin === null){ + return + } + + // Fill in text to each field + let suggested_address = request.meta.suggested_address; + let address_fields = popin.find('div, span').filter(function(){ + return $(this).data("edit-field") + }) + address_fields.each(function(){ + let elem = $(this); + let field = elem.data("edit-field"); + let value = suggested_address[field]; + if (value){ + if (field === "city" || field === "state"){ + value += ","; + } + elem.text(value); + } + + }) + + // Initialize the Submit button + let button = popin.find("a.btn.suggestion-accept"); + PeeringDB.init_accept_suggestion(button, request, endpoint); + + // Show the popin + popin.removeClass("hidden").show(); + }, + + // initializes the "Accept suggestion" button + + init_accept_suggestion : function(button, response, endpoint){ + + let payload = response.data[0]; + // Overwrite returned instance with the suggested data + Object.assign(payload, response.meta.suggested_address); + + // No need to have latitude or longitude + // in the payload since it will get + // geocoded again + + delete payload.latitude; + delete payload.longitude; + + + // Set up PUT request on click + button.click(function(event){ + $("#view").editable("loading-shim", "show"); + PeeringDB.API.request( + "PUT", + endpoint, + payload.id, + payload, + function(){ + PeeringDB.refresh() + } + ) + }); + + }, // searches the page for all editable forms that // have data-check-incomplete attribute set and // displays a notification if any of the fields @@ -302,16 +383,20 @@ PeeringDB.ViewTools = { }, update_geocode: function(data){ - const geo_field = $("#geocode"); - if (data.latitude && data.longitude) { + const geo_field = $("#geocode_active"); + if (data.latitude && data.longitude){ let link = `https://maps.google.com/?q=${data.latitude},${data.longitude}` let contents = `${data.latitude}, ${data.longitude}` geo_field.empty().append(contents); + $("#geocode_inactive").addClass("hidden").hide(); + } else if (data.latitude === null && data.longitude === null) { + $("#geocode_active").empty(); + $("#geocode_inactive").removeClass("hidden").show(); } - } } + PeeringDB.ViewActions = { init : function() { @@ -426,7 +511,7 @@ PeeringDB.IXFProposals = twentyc.cls.define( $.ajax({ method: "POST", url: path - }).done(PeeringDB.refresh); + }).done(PeerignDB.refresh); }, /** @@ -1209,6 +1294,125 @@ PeeringDB.API = { } } +/** + * editable key management endpoint + */ + +twentyc.editable.action.register( + "revoke-org-key", + { + execute: function(trigger, container) { + + } + } +) + +twentyc.editable.module.register( + "key_perm_listing", + { + loading_shim : true, + PERM_UPDATE : 0x02, + PERM_CREATE : 0x04, + PERM_DELETE : 0x08, + + init : function() { + this.listing_init(); + this.container.on("listing:row-add", function(e, rowId, row, data, me) { + row.editable("payload", { + key_prefix : data.key_prefix, + org_id : data.org_id + }) + }); + }, + + org_id : function() { + return this.container.data("edit-id"); + }, + key_prefix : function() { + return this.container.data("edit-key-prefix"); + }, + prepare_data : function(extra) { + var perms = 0; + if(this.target.data.perm_u) + perms |= this.PERM_UPDATE; + if(this.target.data.perm_c) + perms |= this.PERM_CREATE; + if(this.target.data.perm_d) + perms |= this.PERM_DELETE; + this.target.data.perms = perms; + if(extra) + $.extend(this.target.data, extra); + }, + + add : function(rowId, trigger, container, data) { + + var i, labels = twentyc.data.get("key_permissions"); + + for(i=0; i').addClass("alert alert-success marg-top-15"). + append($('
').text(message)). + append($('
').addClass("center marg-top-15").text(key)) + + this.container.find("#api-key-popin-frame").empty().append(panel) + }, + + remove : function(id, row, trigger, container) { + var b = PeeringDB.confirm(gettext("Remove") + " " +row.data("edit-label"), "remove"); /// + var me = this; + $(this.target).on("success", function(ev, data) { + if(b) + me.listing_remove(id, row, trigger, container); + }); + if(b) { + this.target.data = { prefix : id, org_id : this.org_id() }; + this.target.execute("delete"); + } else { + $(this.target).trigger("success", [gettext("Canceled")]); /// + } + }, + + execute_add : function(trigger, container) { + this.components.add.editable("export", this.target.data); + var data = this.target.data; + this.target.execute("add", this.components.add, function(response) { + if(response.readonly) + response.name = response.name + " (read-only)"; + var row = this.add(data.entity, trigger, container, response); + console.log(data) + console.log(row) + this.api_key_popin(response.key) + }.bind(this)); + }, + + add : function(rowId, trigger, container, data) { + var row = this.listing_add(data.prefix, trigger, container, data); + row.attr("data-edit-label", data.prefix + " - " + data.name) + row.data("edit-label", data.prefix + " - " + data.name) + var update_key_form = row.find(".update-key") + update_key_form.find(".popin, .loading-shim").detach() + update_key_form.editable() + return row + }, + + execute_update : function(trigger, container) { + var row = this.row(trigger); + row.editable("export", this.target.data); + var data = this.target.data; + var id = data.prefix = row.data("edit-id") + console.log(this.target, row) + this.target.execute("update", trigger, function(response) { + }.bind(this)); + }, + + execute_revoke : function(trigger, container) { + + var row = this.row(trigger); + var b = PeeringDB.confirm(gettext("Revoke key") + " " +row.data("edit-label"), "remove"); + + if(!b) { + container.editable("loading-shim", "hide") + return + } + + this.components.add.editable("export", this.target.data); + var data = this.target.data; + var id = data.prefix = row.data("edit-id") + this.target.execute("revoke", trigger, function(response) { + this.listing_remove(id, row, trigger, container); + }.bind(this)); + } + + }, + "listing" +); + + twentyc.editable.module.register( "uoar_listing", { @@ -1566,7 +1865,13 @@ twentyc.editable.target.register( else me.trigger("success", {}); } - ).fail(function(r) { + ).done(function(r) { + if (r.meta && r.meta.geovalidation_warning){ + PeeringDB.add_geo_warning(r.meta, endpoint); + } else if (r.meta && r.meta.suggested_address){ + PeeringDB.add_suggested_address(r, endpoint); + } + }).fail(function(r) { if(r.status == 400) { var k,i,info=[gettext("The server rejected your data")]; /// for(k in r.responseJSON) { diff --git a/peeringdb_server/static/site.css b/peeringdb_server/static/site.css index 6565d127..bef16249 100644 --- a/peeringdb_server/static/site.css +++ b/peeringdb_server/static/site.css @@ -145,6 +145,10 @@ a.btn { top: 0px; } +a.btn.suggestion-accept { + margin-top: 16px; +} + h5 { font-weight: bold; } diff --git a/peeringdb_server/templates/email/notify-pdb-admin-asnauto-skipvq-org-key.txt b/peeringdb_server/templates/email/notify-pdb-admin-asnauto-skipvq-org-key.txt new file mode 100644 index 00000000..ad9cbee4 --- /dev/null +++ b/peeringdb_server/templates/email/notify-pdb-admin-asnauto-skipvq-org-key.txt @@ -0,0 +1,9 @@ +{% load i18n %} +{% language 'en' %} + +{% blocktrans with prefix=org_key.prefix email=org_key.email n_name=net.name n_asn=net.asn n_id=net.id o_name=org.name o_id=org.id n_url=net.view_url o_url=org.view_url trimmed %} +Organization API Key (prefix:{{ prefix }}) with email '{{ email }}' created Network {{ n_name }} AS{{ n_asn }} ({{n_id}}) under organization {{ o_name }} ({{ o_id }}). +{% endblocktrans %} + +{% trans "As the key's email address was successfully matched against RiR entry data this network has skipped the verification queue." %} +{% endlanguage %} diff --git a/peeringdb_server/templates/email/notify-pdb-admin-deletion-prevented-org-key.txt b/peeringdb_server/templates/email/notify-pdb-admin-deletion-prevented-org-key.txt new file mode 100644 index 00000000..1a38b11f --- /dev/null +++ b/peeringdb_server/templates/email/notify-pdb-admin-deletion-prevented-org-key.txt @@ -0,0 +1,9 @@ +Organization APIKey (prefix: {{ org_key.prefix }}, email: {{ org_key.email }}) tried to delete the following object: + +{{ instance.HandleRef.tag }}-{{ instance.id }}: {{ instance }} + +The deletion was prevented because of the following reason(s): + +{{ instance.not_deletable_reason }} + +{{ admin_url }} diff --git a/peeringdb_server/templates/email/notify-pdb-admin-rdap-error-org-key.txt b/peeringdb_server/templates/email/notify-pdb-admin-rdap-error-org-key.txt new file mode 100644 index 00000000..0e32cb5d --- /dev/null +++ b/peeringdb_server/templates/email/notify-pdb-admin-rdap-error-org-key.txt @@ -0,0 +1,5 @@ +Organization API Key (prefix: {{org_key.prefix}}) has run into an RDAP Lookup Error when trying to create a network under the org {{ org_key.org.name }}. + +ASN: {{ asn }} +Error Details: {{ error_details }} +Org APIKey Email: {{org_key.email}} diff --git a/peeringdb_server/templates/email/notify-pdb-admin-vq-org-key.txt b/peeringdb_server/templates/email/notify-pdb-admin-vq-org-key.txt new file mode 100644 index 00000000..78b9f9a3 --- /dev/null +++ b/peeringdb_server/templates/email/notify-pdb-admin-vq-org-key.txt @@ -0,0 +1,27 @@ +{% load i18n %} +{% language 'en' %} +{% blocktrans trimmed%} +A new '{{ entity_type_name }}' has been created and requires your approval. +{% endblocktrans %} + +{% if suggested %} +{% blocktrans trimmed %} + This {{ entity_type_name }} has been submitted as a suggestion and will need to be assigned the appropriate organization after review +{% endblocktrans %} +{% endif %} + +{% if rdap %} + {% trans "We retrieved the RiR entry for the specified ASN" %} {{ item.asn }} + {% if not suggested %} + {% trans "but found no match with the requesting Organization API Key email. Here are the email addresses we gathered" %}: + {% endif %} + {% for email in rdap.emails %} - {{ email }} {% endfor %} +{% endif %} + +{% trans "Name" %}: {{ item }} +{% trans "Requested by Organization API Key" %}: +- {% trans "Organization" %}: {{ org_key.org.name }} +- {% trans "API key prefix" %}: {{ org_key.prefix }} +- {% trans "Email" %}: {{ org_key.email }} + +{% trans "You can go to" %} {{ edit_url }} {% trans "to view and approve or deny this entry" %}{% endlanguage %} diff --git a/peeringdb_server/templates/site/org_manage_key_permissions.html b/peeringdb_server/templates/site/org_manage_key_permissions.html new file mode 100644 index 00000000..750531a1 --- /dev/null +++ b/peeringdb_server/templates/site/org_manage_key_permissions.html @@ -0,0 +1,116 @@ +{% load util %} +{% load i18n %} + +
+ {%blocktrans trimmed %} + Here you can grant permissions to your organization API keys. In case your recently added api key does not show up in this listing, please reload the page. + {%endblocktrans%} +
+ + +
+ + {% for kperms in key_perms.values %} +
+ +
+
+
{{ kperms.name}} - {{ kperms.prefix }}
+
+
+
{% trans "Create" %}
+
+
+
{% trans "Update" %}
+
+
+
{% trans "Delete" %}
+
+
+
+
+ +
+ + {% for entity, perm in kperms.perms.items %} + +
+
+
{{ kperms.prefix }}
+
{{ instance.id }}
+
{{ entity }}
+
+
+ × +
{{ instance|org_permission_id_xl:entity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {% endfor %} + +
+ +
+ +
+
{{ instance.id }}
+
{{ kperms.prefix }}
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + +
+
+ + {% endfor %} + + + + +
+ diff --git a/peeringdb_server/templates/site/org_manage_keys.html b/peeringdb_server/templates/site/org_manage_keys.html new file mode 100644 index 00000000..c6aa54cd --- /dev/null +++ b/peeringdb_server/templates/site/org_manage_keys.html @@ -0,0 +1,111 @@ +{% load util %} +{% load i18n %} +
+ {%blocktrans trimmed %} + Here you can create new API keys for your organization or revoke existing keys. + {%endblocktrans%} +
+
+
+ +
+
+
+
+
{% trans "Prefix" %}
+
+
+
{% trans "Description" %}
+
+
+
{% trans "Email" %}
+
+
+
+ +
+ {% for key in instance.api_keys.all %} + {% if not key.revoked %} +
+ + + +
+ +
+
+
{{ instance.id }}
+
{{ key.prefix }}
+
+ + {% trans "Edit" %} + + + {% trans "Cancel" %} + {% trans "Save" %} + +
+ +
+
+ {{ key.prefix }} +
+
+ + +
+
{{ key.name }}
+
+ +
+
{{ key.email }}
+
+ +
+ + +
+ {% endif %} + {% endfor %} +
+ +
+ + +
+
+
{{ instance.id }}
+
+
+
{% trans "Add Key" %}
+
+
+
+
+
+
+
+
+
+ +
+ +
diff --git a/peeringdb_server/templates/site/profile-api-keys.html b/peeringdb_server/templates/site/profile-api-keys.html new file mode 100644 index 00000000..e7a9c78a --- /dev/null +++ b/peeringdb_server/templates/site/profile-api-keys.html @@ -0,0 +1,86 @@ +{% load i18n %} +
+

{% trans "API Keys" %}

+ +
+ {% blocktrans trimmed %} + API Keys allow you to authenticate a client without providing your username and password. + {% endblocktrans %} +
+ + +
+
+
+
{% trans "Prefix" %}
+
+
+
{% trans "Description" %}
+
+
+
+ +
+ {% for key in user.api_keys.all %} + {% if not key.revoked %} +
+
{{ key.prefix }}
+
+ {{ key.name }} + {% if key.readonly %}({% trans "read-only" %}){% endif %} +
+ +
+ {% endif %} + {% endfor %} +
+ + + {% if request.user.is_superuser %} +
+ {% blocktrans trimmed %} + You are creating an API Key for a superuser account. Such API keys cannot be made + read only and will always have full access. Proceed with caution. + {% endblocktrans %} +
+ {% endif %} + + +
+ +
+
+
{% trans "Add Key" %}
+
+
+
+ {% if not request.user.is_superuser %} +
{% trans "Read only" %} + {% endif %} +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
diff --git a/peeringdb_server/templates/site/verify.html b/peeringdb_server/templates/site/verify.html index 81303613..2882da24 100644 --- a/peeringdb_server/templates/site/verify.html +++ b/peeringdb_server/templates/site/verify.html @@ -34,6 +34,9 @@ {% include "site/profile-change-password.html" %} {% endif %} + + {% include "site/profile-api-keys.html" %} +
@@ -41,6 +44,7 @@