diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..d38680ce --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +terraform/* @dandds diff --git a/.secrets.baseline b/.secrets.baseline index 05921baa..75272b46 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2019-12-06T21:22:07Z", + "generated_at": "2020-01-09T11:35:03Z", "plugins_used": [ { "base64_limit": 4.5, @@ -98,7 +98,7 @@ "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_secret": false, "is_verified": false, - "line_number": 29, + "line_number": 30, "type": "Secret Keyword" } ], @@ -111,15 +111,6 @@ "type": "Secret Keyword" } ], - "ssl/certificate-authority/ca.key": [ - { - "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, - "is_verified": false, - "line_number": 1, - "type": "Private Key" - } - ], "ssl/client-certs/atat.mil.key": [ { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", @@ -170,7 +161,7 @@ "hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207", "is_secret": false, "is_verified": false, - "line_number": 656, + "line_number": 665, "type": "Hex High Entropy String" } ] diff --git a/Dockerfile b/Dockerfile index 744c9739..1785b5d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,8 +84,7 @@ COPY --from=builder /install/celery_worker.py ./celery_worker.py COPY --from=builder /install/config/ ./config/ COPY --from=builder /install/templates/ ./templates/ COPY --from=builder /install/translations.yaml . -COPY --from=builder /install/script/seed_roles.py ./script/seed_roles.py -COPY --from=builder /install/script/sync-crls ./script/sync-crls +COPY --from=builder /install/script/ ./script/ COPY --from=builder /install/static/ ./static/ COPY --from=builder /install/fixtures/ ./fixtures COPY --from=builder /install/uwsgi.ini . diff --git a/Pipfile b/Pipfile index edc8bbfc..ed87d000 100644 --- a/Pipfile +++ b/Pipfile @@ -18,7 +18,6 @@ flask-session = "*" flask-wtf = "*" pyopenssl = "*" requests = "*" -apache-libcloud = "*" lockfile = "*" werkzeug = "*" PyYAML = "*" @@ -30,6 +29,7 @@ azure-graphrbac = "*" msrestazure = "*" azure-mgmt-authorization = "*" azure-mgmt-managementgroups = "*" +azure-mgmt-resource = "*" [dev-packages] bandit = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 80f6fc96..d30bcbb2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c2b19c436646705ea3bf4df8c35c2833083f048da37fc619e66f7236153607c5" + "sha256": "c203c47b00f413fd40056ef6d2d8e51b37ad3ff5f7693db5eb170b7f8fd43234" }, "pipfile-spec": 6, "requires": { @@ -25,10 +25,10 @@ }, "alembic": { "hashes": [ - "sha256:49277bb7242192bbb9eac58fed4fe02ec6c3a2a4b4345d2171197459266482b2" + "sha256:3b0cb1948833e062f4048992fbc97ecfaaaac24aaa0d83a1202a99fb58af8c6d" ], "index": "pypi", - "version": "==1.3.1" + "version": "==1.3.2" }, "amqp": { "hashes": [ @@ -37,14 +37,6 @@ ], "version": "==2.5.2" }, - "apache-libcloud": { - "hashes": [ - "sha256:9bc5cd5c32151bb7a04a7c7de0be9b4a4b8271e348ac91dd79eaaeeae627115f", - "sha256:fcc165f2cc2db9a379c6d3a17b3beb9081bb64ba5c0bf7bbb58da864810092f0" - ], - "index": "pypi", - "version": "==2.6.1" - }, "azure-common": { "hashes": [ "sha256:53b1195b8f20943ccc0e71a17849258f7781bc6db1c72edc7d6c055f79bd54e3", @@ -76,6 +68,14 @@ "index": "pypi", "version": "==0.2.0" }, + "azure-mgmt-resource": { + "hashes": [ + "sha256:20b3394e4dc76fbd9459723cb8c0300fb18a8c32100076f023b5470426b9f104", + "sha256:eaea8b5d05495d1b74220052275d46b6bed93b59245bcaa747279a52e41c3bdf" + ], + "index": "pypi", + "version": "==7.0.0" + }, "azure-mgmt-subscription": { "hashes": [ "sha256:504b4c42ba859070c3c50637ec07ca36aca600e613fcccaa398db22822fe21f1", @@ -117,11 +117,11 @@ }, "celery": { "hashes": [ - "sha256:65f4d67fc1037edacecbf39fcf956da68b984cf2a6d89bd73a8a5a80e35e3dd7", - "sha256:8a59d80235b876881d9893751f2a87936165fc1347efee66095620b3cadf533b" + "sha256:7c544f37a84a5eadc44cab1aa8c9580dff94636bb81978cdf9bf8012d9ea7d8f", + "sha256:d3363bb5df72d74420986a435449f3c3979285941dff57d5d97ecba352a0e3e2" ], "index": "pypi", - "version": "==4.4.0rc4" + "version": "==4.4.0" }, "certifi": { "hashes": [ @@ -256,10 +256,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21", - "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742" + "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", + "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f" ], - "version": "==1.1.0" + "markers": "python_version < '3.8'", + "version": "==1.3.0" }, "isodate": { "hashes": [ @@ -284,10 +285,10 @@ }, "kombu": { "hashes": [ - "sha256:1760b54b1d15a547c9a26d3598a1c8cdaf2436386ac1f5561934bc8a3cbbbd86", - "sha256:e7465aa85a1db889116819f08c5de29520d2fa103324dcdca5e90af345f01771" + "sha256:2a9e7adff14d046c9996752b2c48b6d9185d0b992106d5160e1a179907a5d4ac", + "sha256:67b32ccb6fea030f8799f8fd50dd08e03a4b99464ebc4952d71d8747b1a52ad1" ], - "version": "==4.6.6" + "version": "==4.6.7" }, "lockfile": { "hashes": [ @@ -338,10 +339,10 @@ }, "more-itertools": { "hashes": [ - "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2", - "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45" + "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", + "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564" ], - "version": "==8.0.0" + "version": "==8.0.2" }, "msrest": { "hashes": [ @@ -514,10 +515,10 @@ }, "sqlalchemy": { "hashes": [ - "sha256:afa5541e9dea8ad0014251bc9d56171ca3d8b130c9627c6cb3681cff30be3f8a" + "sha256:bfb8f464a5000b567ac1d350b9090cf081180ec1ab4aa87e7bca12dab25320ec" ], "index": "pypi", - "version": "==1.3.11" + "version": "==1.3.12" }, "unipath": { "hashes": [ @@ -677,44 +678,46 @@ }, "colorama": { "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", + "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" ], - "version": "==0.4.1" + "version": "==0.4.3" }, "coverage": { "hashes": [ - "sha256:2358e685d0253125da42a48396038d4c7b4cd1448c00bbc4bda0cb8c43c2a870", - "sha256:25017cf384eeed2e6caf72efd3ec4124e32a8b7a4387600499104387975400c7", - "sha256:2e2de9423ff8b14303a97eafddd16c479fbcc9a0b8b0be3b7c3843a3e0cf6d69", - "sha256:324ed908e4e40a6e2451056fe502470ad4e79495cb7a03ecab94e6309c3e117e", - "sha256:34f865a0cf6255b694a46e4383a7131c61ea72c5b4c4f81d20e522fb1e440b4b", - "sha256:3a2bcc464b60a18f1f7167b95b2773ede93bf3722bfa59e0802717f652b6cc25", - "sha256:48d70865266d649b6602e2ba94820d7972ef470d3b72a8fd41a3d17321feed3a", - "sha256:50cf23523ab3a724c6905d3b60f87fa8250d9bae3995e09f49f63effa2b54f15", - "sha256:54c84a68abd8c4c5b71878b35eb85321df41f3d144c78181867d5b026ec74994", - "sha256:5b59d661ee7f3200aedd7b71882b7927ea7ed522df75e3853f316a79ad872a2e", - "sha256:5ffb39624bc573177888a21fb301ccee46838c600b27d58c3e9dae495f44d34a", - "sha256:699b3072b7f0e69ed175a88fa8b2ec7eefc4f34d490c54ed9a52feff21a15fdc", - "sha256:79ef4a2bb862110bd585174e551a783bee5c3aa461734a2ac7429193be357589", - "sha256:8210a6f93c4a8c6d460b402e20e38399529b99200c3318542faf6a520c9b6a5c", - "sha256:8d30c10cfd0a6fdf0a2d5023de00ef7b329cd6ead2310c9e53eab79c209acb70", - "sha256:97ac79ff28f2cda6ac00a803ee582b965951755f61ab43377482bfba450b619a", - "sha256:9fe4aacacff9028ed167db108bf013510654f148d83c4857fed61d2ce0588bf2", - "sha256:a5b6395d5957d638f8b1870561607e3c39b1a236ea6cff9eafe5b9bb1db913f2", - "sha256:ab32c5fad6905986a7e34e3acf01180a69bb60c2aa7331815b46e51c776a1943", - "sha256:ad67f0cfdfecbd49b9da46a7e488e6dc32a69388740b85c36a4ef4b33082cbad", - "sha256:aedad67c30326a1af324f45833a40b97180664912deb29942459ddbe9fa0ce19", - "sha256:b077cd0e70f41366ac1f9d09275258fa1906758a5d4f31cacc18b10dfcf90784", - "sha256:b8ea210810d3c14aec7561f8fe0d3eec582d1088100aaa0bb8153d53d867d20f", - "sha256:bf572722326ce6704e863447a070039a827072b7179352570859be899b9e6551", - "sha256:c0df57e189dacd2606cae6386acf127d01d85b2bf49acd9a65543b5d6c359ddc", - "sha256:d523e75f2a8a0b4a6a8be1287c0e0e3a561b8832b05ddd987d4cd7c62f3ad3bc", - "sha256:e10593c60c5f0bfd8b241bf9f27ef2191a3005b73dde8ada0424f642543a1e59", - "sha256:e9128444c83bc260aea988bf1ca6278a33ba730955bf94720468c656b61353eb", - "sha256:f7162f2e3711f3a08a8a741f92e1f63afd58d0713177979f2cf9723dd50161cf" + "sha256:0cd13a6e98c37b510a2d34c8281d5e1a226aaf9b65b7d770ef03c63169965351", + "sha256:1a4b6b6a2a3a6612e6361130c2cc3dc4378d8c221752b96167ccbad94b47f3cd", + "sha256:2ee55e6dba516ddf6f484aa83ccabbb0adf45a18892204c23486938d12258cde", + "sha256:3be5338a2eb4ef03c57f20917e1d12a1fd10e3853fed060b6d6b677cb3745898", + "sha256:44b783b02db03c4777d8cf71bae19eadc171a6f2a96777d916b2c30a1eb3d070", + "sha256:475bf7c4252af0a56e1abba9606f1e54127cdf122063095c75ab04f6f99cf45e", + "sha256:47c81ee687eafc2f1db7f03fbe99aab81330565ebc62fb3b61edfc2216a550c8", + "sha256:4a7f8e72b18f2aca288ff02255ce32cc830bc04d993efbc87abf6beddc9e56c0", + "sha256:50197163a22fd17f79086e087a787883b3ec9280a509807daf158dfc2a7ded02", + "sha256:56b13000acf891f700f5067512b804d1ec8c301d627486c678b903859d07f798", + "sha256:79388ae29c896299b3567965dbcd93255f175c17c6c7bca38614d12718c47466", + "sha256:79fd5d3d62238c4f583b75d48d53cdae759fe04d4fb18fe8b371d88ad2b6f8be", + "sha256:7fe3e2fde2bf1d7ce25ebcd2d3de3650b8d60d9a73ce6dcef36e20191291613d", + "sha256:81042a24f67b96e4287774014fa27220d8a4d91af1043389e4d73892efc89ac6", + "sha256:81326f1095c53111f8afc95da281e1414185f4a538609a77ca50bdfa39a6c207", + "sha256:8873dc0d8f42142ea9f20c27bbdc485190fff93823c6795be661703369e5877d", + "sha256:88d2cbcb0a112f47eef71eb95460b6995da18e6f8ca50c264585abc2c473154b", + "sha256:91f2491aeab9599956c45a77c5666d323efdec790bfe23fcceafcd91105d585a", + "sha256:979daa8655ae5a51e8e7a24e7d34e250ae8309fd9719490df92cbb2fe2b0422b", + "sha256:9c871b006c878a890c6e44a5b2f3c6291335324b298c904dc0402ee92ee1f0be", + "sha256:a6d092545e5af53e960465f652e00efbf5357adad177b2630d63978d85e46a72", + "sha256:b5ed7837b923d1d71c4f587ae1539ccd96bfd6be9788f507dbe94dab5febbb5d", + "sha256:ba259f68250f16d2444cbbfaddaa0bb20e1560a4fdaad50bece25c199e6af864", + "sha256:be1d89614c6b6c36d7578496dc8625123bda2ff44f224cf8b1c45b810ee7383f", + "sha256:c1b030a79749aa8d1f1486885040114ee56933b15ccfc90049ba266e4aa2139f", + "sha256:c95bb147fab76f2ecde332d972d8f4138b8f2daee6c466af4ff3b4f29bd4c19e", + "sha256:d52c1c2d7e856cecc05aa0526453cb14574f821b7f413cc279b9514750d795c1", + "sha256:d609a6d564ad3d327e9509846c2c47f170456344521462b469e5cb39e48ba31c", + "sha256:e1bad043c12fb58e8c7d92b3d7f2f49977dcb80a08a6d1e7a5114a11bf819fca", + "sha256:e5a675f6829c53c87d79117a8eb656cc4a5f8918185a32fc93ba09778e90f6db", + "sha256:fec32646b98baf4a22fdceb08703965bd16dea09051fbeb31a04b5b6e72b846c" ], - "version": "==5.0b1" + "version": "==5.0" }, "decorator": { "hashes": [ @@ -747,10 +750,10 @@ }, "faker": { "hashes": [ - "sha256:48c03580720e0b46538d528b1296e4e5b24a809dcaf33a7dddec719489a9edb8", - "sha256:6327c665c0d8721280b3036d9c9e851c60092bc1f30c8394cc433f8723e2bda5" + "sha256:202ad3b2ec16ae7c51c02904fb838831f8d2899e61bf18db1e91a5a582feab11", + "sha256:92c84a10bec81217d9cb554ee12b3838c8986ce0b5d45f72f769da22e4bb5432" ], - "version": "==2.0.4" + "version": "==3.0.0" }, "flask": { "hashes": [ @@ -791,25 +794,26 @@ }, "importlib-metadata": { "hashes": [ - "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21", - "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742" + "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", + "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f" ], - "version": "==1.1.0" + "markers": "python_version < '3.8'", + "version": "==1.3.0" }, "ipdb": { "hashes": [ - "sha256:473fdd798a099765f093231a8b1fabfa95b0b682fce12de0c74b61a4b4d8ee57" + "sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd" ], "index": "pypi", - "version": "==0.12.2" + "version": "==0.12.3" }, "ipython": { "hashes": [ - "sha256:c66c7e27239855828a764b1e8fc72c24a6f4498a2637572094a78c5551fb9d51", - "sha256:f186b01b36609e0c5d0de27c7ef8e80c990c70478f8c880863004b3489a9030e" + "sha256:190a279bd3d4fc585a611e9358a88f1048cc57fd688254a86f9461889ee152a6", + "sha256:762d79a62b6aa96b04971e920543f558dfbeedc0468b899303c080c8068d4ac2" ], "index": "pypi", - "version": "==7.10.1" + "version": "==7.10.2" }, "ipython-genutils": { "hashes": [ @@ -914,30 +918,29 @@ }, "more-itertools": { "hashes": [ - "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2", - "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45" + "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", + "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564" ], - "version": "==8.0.0" + "version": "==8.0.2" }, "mypy": { "hashes": [ - "sha256:02d9bdd3398b636723ecb6c5cfe9773025a9ab7f34612c1cde5c7f2292e2d768", - "sha256:088f758a50af31cf8b42688118077292370c90c89232c783ba7979f39ea16646", - "sha256:28e9fbc96d13397a7ddb7fad7b14f373f91b5cff538e0772e77c270468df083c", - "sha256:30e123b24931f02c5d99307406658ac8f9cd6746f0d45a3dcac2fe5fbdd60939", - "sha256:3294821b5840d51a3cd7a2bb63b40fc3f901f6a3cfb3c6046570749c4c7ef279", - "sha256:41696a7d912ce16fdc7c141d87e8db5144d4be664a0c699a2b417d393994b0c2", - "sha256:4f42675fa278f3913340bb8c3371d191319704437758d7c4a8440346c293ecb2", - "sha256:54d205ccce6ed930a8a2ccf48404896d456e8b87812e491cb907a355b1a9c640", - "sha256:6992133c95a2847d309b4b0c899d7054adc60481df6f6b52bb7dee3d5fd157f7", - "sha256:6ecbd0e8e371333027abca0922b0c2c632a5b4739a0c61ffbd0733391e39144c", - "sha256:83fa87f556e60782c0fc3df1b37b7b4a840314ba1ac27f3e1a1e10cb37c89c17", - "sha256:c87ac7233c629f305602f563db07f5221950fe34fe30af072ac838fa85395f78", - "sha256:de9ec8dba773b78c49e7bec9a35c9b6fc5235682ad1fc2105752ae7c22f4b931", - "sha256:f385a0accf353ca1bca4bbf473b9d83ed18d923fdb809d3a70a385da23e25b6a" + "sha256:0308c35fd16c96a81b8dfc4d09ec63b8fa607cfec087acf5aafb44c2c45197de", + "sha256:39f7be2f89668d21b2bbab45ce5aa15e69bf8d6f3b46f9e1cc1a88e4fcc84f3d", + "sha256:4223f576813c79a10d0fd14192c86f1b85e3bd235c93792f22ed811a20b5ee4e", + "sha256:4c8f812a2fbefa96185933fbe05aa035e9cf791cf3a23bbdb6a219c80b60e0b1", + "sha256:4ea9ee847ea5bb38ea275441f3aea7eeba1b96187a3f968ee359d33d9dcc0eda", + "sha256:573c68df69f0e399fa57866a0b72989acf0a56c4008eee59c789c2ca5ea9df03", + "sha256:588c0e38466306aa7dbe6522ceacf37dde8b13cfa5edde90be2ce382f078875f", + "sha256:6d1bd2e675823a19e6bf72149540ab9851bfe698b796aea698fb926ab2bedd02", + "sha256:aa8e3bd1540dd5c39ef580ec2146a9c99c45f7c62af890095fec9e87b5ca19fb", + "sha256:b978ba1ea90d0abe2fc720ec9a41824b7d3a1304569bd58c9038d8d61dc4dfdb", + "sha256:c85c5367c2e8247e06cc0aba84e3633e90f48e8a0677bc51b351e138b5ff80b1", + "sha256:ce69577b424058bfa177df27213869f37c1e964c3e1ebd3b3d54f1d10b234c4d", + "sha256:ec6eaf98a57624d96d9916352a5bad2d73959f6358fabf43838f7d1a4d2f8389" ], "index": "pypi", - "version": "==0.750" + "version": "==0.760" }, "mypy-extensions": { "hashes": [ @@ -948,10 +951,10 @@ }, "parso": { "hashes": [ - "sha256:63854233e1fadb5da97f2744b6b24346d2750b85965e7e399bec1620232797dc", - "sha256:666b0ee4a7a1220f65d367617f2cd3ffddff3e205f3f16a0284df30e774c2a9c" + "sha256:55cf25df1a35fd88b878715874d2c4dc1ad3f0eebd1e0266a67e1f55efccfbe1", + "sha256:5c1f7791de6bd5dbbeac8db0ef5594b36799de198b3f7f7014643b0c5536b9d3" ], - "version": "==0.5.1" + "version": "==0.5.2" }, "pathspec": { "hashes": [ @@ -1063,11 +1066,11 @@ }, "pytest-mock": { "hashes": [ - "sha256:96a0cebc66e09930be2a15b03333d90b59584d3fb011924f81c14b50ee0afbba", - "sha256:e5381be2608e49547f5e47633c5f81241ebf6206d17ce516a7a18d5a917e3859" + "sha256:67e414b3caef7bff6fc6bd83b22b5bc39147e4493f483c2679bc9d4dc485a94d", + "sha256:e24a911ec96773022ebcc7030059b57cd3480b56d4f5d19b7c370ec635e6aed5" ], "index": "pypi", - "version": "==1.12.1" + "version": "==1.13.0" }, "pytest-watch": { "hashes": [ @@ -1102,21 +1105,29 @@ }, "regex": { "hashes": [ - "sha256:15454b37c5a278f46f7aa2d9339bda450c300617ca2fca6558d05d870245edc7", - "sha256:1ad40708c255943a227e778b022c6497c129ad614bb7a2a2f916e12e8a359ee7", - "sha256:5e00f65cc507d13ab4dfa92c1232d004fa202c1d43a32a13940ab8a5afe2fb96", - "sha256:604dc563a02a74d70ae1f55208ddc9bfb6d9f470f6d1a5054c4bd5ae58744ab1", - "sha256:720e34a539a76a1fedcebe4397290604cc2bdf6f81eca44adb9fb2ea071c0c69", - "sha256:7caf47e4a9ac6ef08cabd3442cc4ca3386db141fb3c8b2a7e202d0470028e910", - "sha256:7faf534c1841c09d8fefa60ccde7b9903c9b528853ecf41628689793290ca143", - "sha256:b4e0406d822aa4993ac45072a584d57aa4931cf8288b5455bbf30c1d59dbad59", - "sha256:c31eaf28c6fe75ea329add0022efeed249e37861c19681960f99bbc7db981fb2", - "sha256:c7393597191fc2043c744db021643549061e12abe0b3ff5c429d806de7b93b66", - "sha256:d2b302f8cdd82c8f48e9de749d1d17f85ce9a0f082880b9a4859f66b07037dc6", - "sha256:e3d8dd0ec0ea280cf89026b0898971f5750a7bd92cb62c51af5a52abd020054a", - "sha256:ec032cbfed59bd5a4b8eab943c310acfaaa81394e14f44454ad5c9eba4f24a74" + "sha256:0472acc4b6319801c1bc681d838c88ba1446f9ae199e01f6e41091c701fb3d42", + "sha256:16709434c4e2332ee8ba26ae339aceb8ab0b24b8398ebd0f52ebc943f45c4fc2", + "sha256:223fb63ec8dcab20b3318e93dcec4aee89e98b062934090bf29ffc374d2000a2", + "sha256:23c3ebf05d1cd3adb26723fd598e75724e0cdb7d6a35185ac0caf061cc6edb49", + "sha256:2404a50fb48badaf214b700f08822b68d93d79200e0aefd9569d0332d21fbfcb", + "sha256:2af3a7a16fed6eff85c25da106effa36f61cbbe801d00ade349b53ce7619eb15", + "sha256:37e018d3746baf159aedfc9773c3cafacbd10d354ba15484f5cfc8ed9da5748b", + "sha256:3c9c2988d02a9238a1975c70e87c6ce94e6f36dd8e372b66f468990cfe077434", + "sha256:47298bc8b89d1c747f0f5974aa528fc0b6b17396f1694136a224d51461279d83", + "sha256:4eeb0fe936797ae00a085f99802642bfc722b3b4ea557e9e7849cb621ea10c91", + "sha256:6881be0218b47ed76db033f252bab3f912dfe7ed1fe7baa9daebf51de08546a0", + "sha256:7ac08cee5055f548eed3889e9aaef15fd00172d037949496f1f0b34acb8a7c3e", + "sha256:7c5e2efcf079c35ff266c3f3a6708834f88f9fd04a3c16b855e036b2b7b1b543", + "sha256:8355eaa64724a0fdb010a1654b77cb3e375dc08b7f592cc4a1c05ac606aa481c", + "sha256:999a885f7f5194464238ad5d74b05982acee54002f3aa775d8e0e8c5fb74c06c", + "sha256:9fd2f4813eaa3e421e82819d38e5b634d900faff7ae5a80cd89ccff407175e69", + "sha256:a2e1e53df7dd27943da2b512895125b33fb20f81862c9fed7b3bab2a1de684d1", + "sha256:ab43bc0836820b7900dfffc025b996784aec26ec87dc1df4f95a40398760223f", + "sha256:ba449b56fa419fb19bf2a2438adbd2433f27087a6fe115917eaf9cfca684d5b6", + "sha256:d3f632cefad2cf247bd845794002585e3772288bfcb0dbac59fdecd32cd38b67", + "sha256:d51311496061863caae2cfe120cf1ef37900019b86c89c2d75f0918e0b4b8bf3" ], - "version": "==2019.11.1" + "version": "==2019.12.19" }, "requests": { "hashes": [ diff --git a/README.md b/README.md index 9c31fc19..5fdbf6d3 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ To generate coverage reports for the Javascript tests: - `SECRET_KEY`: String key which will be used to sign the session cookie. Should be a long string of random bytes. https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY - `SERVER_NAME`: Hostname for ATAT. Only needs to be specified in contexts where the hostname cannot be inferred from the request, such as Celery workers. https://flask.palletsprojects.com/en/1.1.x/config/#SERVER_NAME - `SESSION_COOKIE_NAME`: String value specifying the name to use for the session cookie. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_NAME +- `SESSION_COOKIE_DOMAIN`: String value specifying the name to use for the session cookie. This should be set to the root domain so that it is valid for both the main site and the authentication subdomain. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_DOMAIN - `SESSION_TYPE`: String value specifying the cookie storage backend. https://pythonhosted.org/Flask-Session/ - `SESSION_USE_SIGNER`: Boolean value specifying if the cookie sid should be signed. - `SQLALCHEMY_ECHO`: Boolean value specifying if SQLAlchemy should log queries to stdout. diff --git a/alembic/versions/02ac8bdcf16f_update_portfolios_defense_component_.py b/alembic/versions/02ac8bdcf16f_update_portfolios_defense_component_.py new file mode 100644 index 00000000..8d202c57 --- /dev/null +++ b/alembic/versions/02ac8bdcf16f_update_portfolios_defense_component_.py @@ -0,0 +1,36 @@ +"""update portfolios defense component column type + +Revision ID: 02ac8bdcf16f +Revises: 08f2a640e9c2 +Create Date: 2019-12-26 16:10:54.366461 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '02ac8bdcf16f' # pragma: allowlist secret +down_revision = '08f2a640e9c2' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.alter_column('portfolios', 'defense_component', + type_=postgresql.ARRAY(sa.VARCHAR()), + existing_type=sa.VARCHAR(), + postgresql_using="string_to_array(defense_component, ',')::character varying[]", + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('portfolios', 'defense_component', + type_=sa.VARCHAR(), + existing_type=postgresql.ARRAY(sa.VARCHAR()), + postgresql_using="defense_component[1]::character varying", + nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/08f2a640e9c2_add_uniqueness_contraint_to_environment_.py b/alembic/versions/08f2a640e9c2_add_uniqueness_contraint_to_environment_.py new file mode 100644 index 00000000..bfc5b894 --- /dev/null +++ b/alembic/versions/08f2a640e9c2_add_uniqueness_contraint_to_environment_.py @@ -0,0 +1,26 @@ +"""add uniqueness contraint to environment within an application + +Revision ID: 08f2a640e9c2 +Revises: c487d91f1a26 +Create Date: 2019-12-16 10:43:12.331095 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '08f2a640e9c2' # pragma: allowlist secret +down_revision = 'c487d91f1a26' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('environments_name_application_id_key', 'environments', ['name', 'application_id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('environments_name_application_id_key', 'environments', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/versions/5d7198d34b91_remove_users_provisional.py b/alembic/versions/5d7198d34b91_remove_users_provisional.py new file mode 100644 index 00000000..aeb80bdc --- /dev/null +++ b/alembic/versions/5d7198d34b91_remove_users_provisional.py @@ -0,0 +1,28 @@ +"""remove users.provisional + +Revision ID: 5d7198d34b91 +Revises: 02ac8bdcf16f +Create Date: 2020-01-09 08:42:34.512191 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5d7198d34b91' # pragma: allowlist secret +down_revision = '02ac8bdcf16f' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'provisional') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('provisional', sa.BOOLEAN(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/alembic/versions/c487d91f1a26_add_application_name_and_portfolio_id_.py b/alembic/versions/c487d91f1a26_add_application_name_and_portfolio_id_.py new file mode 100644 index 00000000..b70b4fde --- /dev/null +++ b/alembic/versions/c487d91f1a26_add_application_name_and_portfolio_id_.py @@ -0,0 +1,27 @@ +"""add application name and portfolio_id unique constraint + +Revision ID: c487d91f1a26 +Revises: 3bd8552f1c57 +Create Date: 2019-12-13 14:33:23.952450 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'c487d91f1a26' # pragma: allowlist secret +down_revision = '3bd8552f1c57' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('applications_name_portfolio_id_key', 'applications', ['name', 'portfolio_id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('applications_name_portfolio_id_key', 'applications', type_='unique') + # ### end Alembic commands ### diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 21e8782b..3dbb9953 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -11,7 +11,7 @@ from atst.models import ( ApplicationRoleStatus, EnvironmentRole, ) -from atst.utils import first_or_none +from atst.utils import first_or_none, commit_or_raise_already_exists_error class Applications(BaseDomainClass): @@ -28,7 +28,7 @@ class Applications(BaseDomainClass): if environment_names: Environments.create_many(user, application, environment_names) - db.session.commit() + commit_or_raise_already_exists_error(message="application") return application @classmethod @@ -53,9 +53,9 @@ class Applications(BaseDomainClass): Environments.create_many( g.current_user, application, new_data["environment_names"] ) - db.session.add(application) - db.session.commit() + db.session.add(application) + commit_or_raise_already_exists_error(message="application") return application @classmethod diff --git a/atst/domain/auth.py b/atst/domain/auth.py index 0f99af09..73bb1db1 100644 --- a/atst/domain/auth.py +++ b/atst/domain/auth.py @@ -1,4 +1,4 @@ -from flask import g, redirect, url_for, session, request +from flask import g, redirect, url_for, session, request, current_app as app from atst.domain.users import Users @@ -59,8 +59,10 @@ def get_last_login(): def logout(): if session.get("user_id"): # pragma: no branch + dod_id = g.current_user.dod_id del session["user_id"] del session["last_login"] + app.logger.info(f"user with EDIPI {dod_id} has logged out") def _unprotected_route(request): diff --git a/atst/domain/authnid/crl/util.py b/atst/domain/authnid/crl/util.py index 7668a71f..6cc22d6a 100644 --- a/atst/domain/authnid/crl/util.py +++ b/atst/domain/authnid/crl/util.py @@ -276,7 +276,7 @@ def existing_crl_modification_time(crl): prev_time = os.path.getmtime(crl) buffered = prev_time + MODIFIED_TIME_BUFFER mod_time = prev_time if pendulum.now().timestamp() < buffered else buffered - dt = pendulum.from_timestamp(mod_time, tz="GMT") + dt = pendulum.from_timestamp(mod_time, tz="UTC") return dt.format("ddd, DD MMM YYYY HH:mm:ss zz") else: diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index ea48ba4e..36c91c95 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -6,6 +6,7 @@ from atst.models.user import User from atst.models.application import Application from atst.models.environment import Environment from atst.models.environment_role import EnvironmentRole +from .policy import AzurePolicyManager class GeneralCSPException(Exception): @@ -401,6 +402,7 @@ REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00 class AzureSDKProvider(object): def __init__(self): from azure.mgmt import subscription, authorization, managementgroups + from azure.mgmt.resource import policy import azure.graphrbac as graphrbac import azure.common.credentials as credentials from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD @@ -410,6 +412,7 @@ class AzureSDKProvider(object): self.managementgroups = managementgroups self.graphrbac = graphrbac self.credentials = credentials + self.policy = policy # may change to a JEDI cloud self.cloud = AZURE_PUBLIC_CLOUD @@ -427,6 +430,8 @@ class AzureCloudProvider(CloudProviderInterface): else: self.sdk = azure_sdk_provider + self.policy_manager = AzurePolicyManager(config["AZURE_POLICY_LOCATION"]) + def create_environment( self, auth_credentials: Dict, user: User, environment: Environment ): @@ -561,6 +566,51 @@ class AzureCloudProvider(CloudProviderInterface): # but we just don't have a valid ID pass + AZURE_MANAGEMENT_API = "https://management.azure.com" + + def _create_policy_definition( + self, credentials, subscription_id, management_group_id, properties, + ): + """ + Requires credentials that have AZURE_MANAGEMENT_API + specified as the resource. The Service Principal + specified in the credentials must have the "Resource + Policy Contributor" role assigned with a scope at least + as high as the management group specified by + management_group_id. + + Arguments: + credentials -- ServicePrincipalCredentials + subscription_id -- str, ID of the subscription (just the UUID, not the path) + management_group_id -- str, ID of the management group (just the UUID, not the path) + properties -- dictionary, the "properties" section of a valid Azure policy definition document + + Returns: + azure.mgmt.resource.policy.[api version].models.PolicyDefinition: the PolicyDefinition object provided to Azure + + Raises: + TBD + """ + # TODO: which subscription would this be? + client = self.sdk.policy.PolicyClient(credentials, subscription_id) + + definition = client.policy_definitions.models.PolicyDefinition( + policy_type=properties.get("policyType"), + mode=properties.get("mode"), + display_name=properties.get("displayName"), + description=properties.get("description"), + policy_rule=properties.get("policyRule"), + parameters=properties.get("parameters"), + ) + + name = properties.get("displayName") + + return client.policy_definitions.create_or_update_at_management_group( + policy_definition_name=name, + parameters=definition, + management_group_id=management_group_id, + ) + def _get_management_service_principal(self): # we really should be using graph.microsoft.com, but i'm getting # "expired token" errors for that diff --git a/atst/domain/csp/policy.py b/atst/domain/csp/policy.py new file mode 100644 index 00000000..cfff9aae --- /dev/null +++ b/atst/domain/csp/policy.py @@ -0,0 +1,47 @@ +from glob import glob +import json +from dataclasses import dataclass +from os.path import join as path_join + + +class AzurePolicyManager: + def __init__(self, static_policy_location): + self._static_policy_location = static_policy_location + + @property + def portfolio_definitions(self): + if getattr(self, "_portfolio_definitions", None) is None: + portfolio_files = self._glob_json("portfolios") + self._portfolio_definitions = self._load_policies(portfolio_files) + + return self._portfolio_definitions + + @property + def application_definitions(self): + pass + + @property + def environment_definitions(self): + pass + + def _glob_json(self, path): + return glob(path_join(self._static_policy_location, "portfolios", "*.json")) + + def _load_policies(self, json_policies): + return [self._load_policy(pol) for pol in json_policies] + + def _load_policy(self, policy_file): + with open(policy_file, "r") as file_: + doc = json.loads(file_.read()) + return AzurePolicy( + definition_point=doc["definitionPoint"], + definition=doc["policyDefinition"], + parameters=doc["parameters"], + ) + + +@dataclass +class AzurePolicy: + definition_point: str + definition: dict + parameters: dict diff --git a/atst/domain/csp/reports.py b/atst/domain/csp/reports.py index 76590720..3f9ccbf8 100644 --- a/atst/domain/csp/reports.py +++ b/atst/domain/csp/reports.py @@ -34,16 +34,24 @@ class MockReportingProvider: } ] """ - if portfolio.name in cls.FIXTURE_SPEND_DATA: - applications = cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"] - return sorted( - [ - cls._get_application_monthly_totals(application) - for application in applications - ], - key=lambda app: app["name"], - ) - return [] + + fixture_apps = cls.FIXTURE_SPEND_DATA.get(portfolio.name, {}).get( + "applications", [] + ) + + for application in portfolio.applications: + if application.name not in [app["name"] for app in fixture_apps]: + fixture_apps.append({"name": application.name, "environments": []}) + + return sorted( + [ + cls._get_application_monthly_totals(portfolio, fixture_app) + for fixture_app in fixture_apps + if fixture_app["name"] + in [application.name for application in portfolio.applications] + ], + key=lambda app: app["name"], + ) @classmethod def _get_environment_monthly_totals(cls, environment): @@ -64,7 +72,7 @@ class MockReportingProvider: } @classmethod - def _get_application_monthly_totals(cls, application): + def _get_application_monthly_totals(cls, portfolio, fixture_app): """ returns a dictionary that represents spending totals for an application and its environments e.g. @@ -83,19 +91,28 @@ class MockReportingProvider: ] } """ - environments = sorted( - [ - cls._get_environment_monthly_totals(env) - for env in application["environments"] - ], - key=lambda env: env["name"], - ) + application_envs = [ + env + for env in portfolio.all_environments + if env.application.name == fixture_app["name"] + ] + + environments = [ + cls._get_environment_monthly_totals(env) + for env in fixture_app["environments"] + if env["name"] in [e.name for e in application_envs] + ] + + for env in application_envs: + if env.name not in [env["name"] for env in environments]: + environments.append({"name": env.name}) + return { - "name": application["name"], - "this_month": sum(env["this_month"] for env in environments), - "last_month": sum(env["last_month"] for env in environments), - "total": sum(env["total"] for env in environments), - "environments": environments, + "name": fixture_app["name"], + "this_month": sum(env.get("this_month", 0) for env in environments), + "last_month": sum(env.get("last_month", 0) for env in environments), + "total": sum(env.get("total", 0) for env in environments), + "environments": sorted(environments, key=lambda env: env["name"]), } @classmethod diff --git a/atst/domain/environments.py b/atst/domain/environments.py index a1056623..b8a59485 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -12,6 +12,7 @@ from atst.models import ( CLIN, ) from atst.domain.environment_roles import EnvironmentRoles +from atst.utils import commit_or_raise_already_exists_error from .exceptions import NotFoundError, DisabledError @@ -21,7 +22,7 @@ class Environments(object): def create(cls, user, application, name): environment = Environment(application=application, name=name, creator=user) db.session.add(environment) - db.session.commit() + commit_or_raise_already_exists_error(message="environment") return environment @classmethod @@ -39,7 +40,8 @@ class Environments(object): if name is not None: environment.name = name db.session.add(environment) - db.session.commit() + commit_or_raise_already_exists_error(message="environment") + return environment @classmethod def get(cls, environment_id): diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py index 0e64cbfc..6b8a5c4e 100644 --- a/atst/domain/portfolios/portfolios.py +++ b/atst/domain/portfolios/portfolios.py @@ -75,10 +75,10 @@ class Portfolios(object): permission_sets = PortfolioRoles._permission_sets_for_names( member_data.get("permission_sets", []) ) - role = PortfolioRole(portfolio_id=portfolio.id, permission_sets=permission_sets) + role = PortfolioRole(portfolio=portfolio, permission_sets=permission_sets) invitation = PortfolioInvitations.create( - inviter=inviter, role=role, member_data=member_data + inviter=inviter, role=role, member_data=member_data["user_data"] ) PortfoliosQuery.add_and_commit(role) @@ -107,4 +107,7 @@ class Portfolios(object): if "name" in new_data: portfolio.name = new_data["name"] + if "description" in new_data: + portfolio.description = new_data["description"] + PortfoliosQuery.add_and_commit(portfolio) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 835590c9..9ecf41e9 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -1,11 +1,10 @@ import datetime -from sqlalchemy.exc import IntegrityError from atst.database import db from atst.models.clin import CLIN from atst.models.task_order import TaskOrder, SORT_ORDERING from . import BaseDomainClass -from .exceptions import AlreadyExistsError +from atst.utils import commit_or_raise_already_exists_error class TaskOrders(BaseDomainClass): @@ -16,15 +15,8 @@ class TaskOrders(BaseDomainClass): def create(cls, portfolio_id, number, clins, pdf): task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf) db.session.add(task_order) - - try: - db.session.commit() - except IntegrityError: - db.session.rollback() - raise AlreadyExistsError("task_order") - + commit_or_raise_already_exists_error(message="task_order") TaskOrders.create_clins(task_order.id, clins) - return task_order @classmethod @@ -42,12 +34,7 @@ class TaskOrders(BaseDomainClass): task_order.number = number db.session.add(task_order) - try: - db.session.commit() - except IntegrityError: - db.session.rollback() - raise AlreadyExistsError("task_order") - + commit_or_raise_already_exists_error(message="task_order") return task_order @classmethod diff --git a/atst/domain/users.py b/atst/domain/users.py index 1db198cf..5e09ce22 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -117,12 +117,3 @@ class Users(object): user.last_session_id = session_id db.session.add(user) db.session.commit() - - @classmethod - def finalize(cls, user): - user.provisional = False - - db.session.add(user) - db.session.commit() - - return user diff --git a/atst/forms/portfolio.py b/atst/forms/portfolio.py index 7f281787..069659bb 100644 --- a/atst/forms/portfolio.py +++ b/atst/forms/portfolio.py @@ -23,20 +23,10 @@ class PortfolioForm(BaseForm): ) ], ) - - -class PortfolioCreationForm(BaseForm): - name = StringField( - translate("forms.portfolio.name.label"), - validators=[ - Length( - min=4, - max=100, - message=translate("forms.portfolio.name.length_validation_message"), - ) - ], - ) description = TextAreaField(translate("forms.portfolio.description.label"),) + + +class PortfolioCreationForm(PortfolioForm): defense_component = SelectMultipleField( choices=SERVICE_BRANCHES, widget=ListWidget(prefix_label=False), diff --git a/atst/forms/portfolio_member.py b/atst/forms/portfolio_member.py index 918d1112..9fff59c1 100644 --- a/atst/forms/portfolio_member.py +++ b/atst/forms/portfolio_member.py @@ -1,76 +1,59 @@ from wtforms.validators import Required -from wtforms.fields import StringField, FormField, FieldList, HiddenField +from wtforms.fields import BooleanField, FormField -from atst.domain.permission_sets import PermissionSets from .forms import BaseForm from .member import NewForm as BaseNewMemberForm +from atst.domain.permission_sets import PermissionSets from atst.forms.fields import SelectField from atst.utils.localization import translate class PermissionsForm(BaseForm): - member_name = StringField() - member_id = HiddenField() - perms_app_mgmt = SelectField( - translate("forms.new_member.app_mgmt"), - choices=[ - ( - PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, - translate("common.view"), - ), - ( - PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, - translate("common.edit"), - ), - ], + perms_app_mgmt = BooleanField( + translate("forms.new_member.app_mgmt.label"), + default=False, + description=translate("forms.new_member.app_mgmt.description"), ) - perms_funding = SelectField( - translate("forms.new_member.funding"), - choices=[ - (PermissionSets.VIEW_PORTFOLIO_FUNDING, translate("common.view")), - (PermissionSets.EDIT_PORTFOLIO_FUNDING, translate("common.edit")), - ], + perms_funding = BooleanField( + translate("forms.new_member.funding.label"), + default=False, + description=translate("forms.new_member.funding.description"), ) - perms_reporting = SelectField( - translate("forms.new_member.reporting"), - choices=[ - (PermissionSets.VIEW_PORTFOLIO_REPORTS, translate("common.view")), - (PermissionSets.EDIT_PORTFOLIO_REPORTS, translate("common.edit")), - ], + perms_reporting = BooleanField( + translate("forms.new_member.reporting.label"), + default=False, + description=translate("forms.new_member.reporting.description"), ) - perms_portfolio_mgmt = SelectField( - translate("forms.new_member.portfolio_mgmt"), - choices=[ - (PermissionSets.VIEW_PORTFOLIO_ADMIN, translate("common.view")), - (PermissionSets.EDIT_PORTFOLIO_ADMIN, translate("common.edit")), - ], + perms_portfolio_mgmt = BooleanField( + translate("forms.new_member.portfolio_mgmt.label"), + default=False, + description=translate("forms.new_member.portfolio_mgmt.description"), ) @property def data(self): _data = super().data - _data["permission_sets"] = [] - for field in _data: - if "perms" in field: - _data["permission_sets"].append(_data[field]) + _data.pop("csrf_token", None) + perm_sets = [] + if _data["perms_app_mgmt"]: + perm_sets.append(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT) + + if _data["perms_funding"]: + perm_sets.append(PermissionSets.EDIT_PORTFOLIO_FUNDING) + + if _data["perms_reporting"]: + perm_sets.append(PermissionSets.EDIT_PORTFOLIO_REPORTS) + + if _data["perms_portfolio_mgmt"]: + perm_sets.append(PermissionSets.EDIT_PORTFOLIO_ADMIN) + + _data["permission_sets"] = perm_sets return _data -class MembersPermissionsForm(BaseForm): - members_permissions = FieldList(FormField(PermissionsForm)) - - -class NewForm(BaseForm): +class NewForm(PermissionsForm): user_data = FormField(BaseNewMemberForm) - permission_sets = FormField(PermissionsForm) - - @property - def update_data(self): - return { - "permission_sets": self.data.get("permission_sets").get("permission_sets"), - **self.data.get("user_data"), - } class AssignPPOCForm(PermissionsForm): diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index a5e02e8b..1c324736 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -151,3 +151,6 @@ class SignatureForm(BaseForm): translate("task_orders.sign.digital_signature_description"), validators=[Required()], ) + confirm = BooleanField( + translate("task_orders.sign.confirmation_description"), validators=[Required()], + ) diff --git a/atst/models/application.py b/atst/models/application.py index b6cff6bd..a7bdadba 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -1,4 +1,4 @@ -from sqlalchemy import and_, Column, ForeignKey, String +from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint from sqlalchemy.orm import relationship, synonym from atst.models.base import Base @@ -34,6 +34,11 @@ class Application( ), ) members = synonym("roles") + __table_args__ = ( + UniqueConstraint( + "name", "portfolio_id", name="applications_name_portfolio_id_key" + ), + ) @property def users(self): diff --git a/atst/models/environment.py b/atst/models/environment.py index 5fc642e5..115f3ed7 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, String, TIMESTAMP +from sqlalchemy import Column, ForeignKey, String, TIMESTAMP, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import JSONB from enum import Enum @@ -38,6 +38,12 @@ class Environment( primaryjoin="and_(EnvironmentRole.environment_id == Environment.id, EnvironmentRole.deleted == False)", ) + __table_args__ = ( + UniqueConstraint( + "name", "application_id", name="environments_name_application_id_key" + ), + ) + class ProvisioningStatus(Enum): PENDING = "pending" COMPLETED = "completed" diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 8cfc35d6..0e48745b 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,5 +1,6 @@ from sqlalchemy import Column, String from sqlalchemy.orm import relationship +from sqlalchemy.types import ARRAY from itertools import chain from atst.models.base import Base @@ -20,7 +21,7 @@ class Portfolio( name = Column(String, nullable=False) description = Column(String) defense_component = Column( - String, nullable=False + ARRAY(String), nullable=False ) # Department of Defense Component applications = relationship( diff --git a/atst/models/portfolio_role.py b/atst/models/portfolio_role.py index 53204e82..6d34dd97 100644 --- a/atst/models/portfolio_role.py +++ b/atst/models/portfolio_role.py @@ -12,17 +12,6 @@ from atst.utils import first_or_none from atst.models.mixins.auditable import record_permission_sets_updates -MEMBER_STATUSES = { - "active": "Active", - "revoked": "Invite revoked", - "expired": "Invite expired", - "error": "Error on invite", - "pending": "Pending", - "unknown": "Unknown errors", - "disabled": "Disabled", -} - - class Status(Enum): ACTIVE = "active" DISABLED = "disabled" @@ -90,23 +79,23 @@ class PortfolioRole( @property def display_status(self): if self.status == Status.ACTIVE: - return MEMBER_STATUSES["active"] + return "active" elif self.status == Status.DISABLED: - return MEMBER_STATUSES["disabled"] + return "disabled" elif self.latest_invitation: if self.latest_invitation.is_revoked: - return MEMBER_STATUSES["revoked"] + return "invite_revoked" elif self.latest_invitation.is_rejected_wrong_user: - return MEMBER_STATUSES["error"] + return "invite_error" elif ( self.latest_invitation.is_rejected_expired or self.latest_invitation.is_expired ): - return MEMBER_STATUSES["expired"] + return "invite_expired" else: - return MEMBER_STATUSES["pending"] + return "invite_pending" else: - return MEMBER_STATUSES["unknown"] + return "unknown" def has_permission_set(self, perm_set_name): return first_or_none( diff --git a/atst/models/task_order.py b/atst/models/task_order.py index eba34147..789a7e3f 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -9,7 +9,7 @@ from atst.models.base import Base import atst.models.types as types import atst.models.mixins as mixins from atst.models.attachment import Attachment -from atst.utils.clock import Clock +from pendulum import today class Status(Enum): @@ -83,26 +83,10 @@ class TaskOrder(Base, mixins.TimestampsMixin): def is_active(self): return self.status == Status.ACTIVE - @property - def is_upcoming(self): - return self.status == Status.UPCOMING - @property def is_expired(self): return self.status == Status.EXPIRED - @property - def is_unsigned(self): - return self.status == Status.UNSIGNED - - @property - def has_begun(self): - return self.start_date is not None and Clock.today() >= self.start_date - - @property - def has_ended(self): - return self.start_date is not None and Clock.today() >= self.end_date - @property def clins_are_completed(self): return all([len(self.clins), (clin.is_completed for clin in self.clins)]) @@ -117,17 +101,17 @@ class TaskOrder(Base, mixins.TimestampsMixin): @property def status(self): - today = Clock.today() + todays_date = today(tz="UTC").date() if not self.is_completed and not self.is_signed: return Status.DRAFT elif self.is_completed and not self.is_signed: return Status.UNSIGNED - elif today < self.start_date: + elif todays_date < self.start_date: return Status.UPCOMING - elif today >= self.end_date: + elif todays_date > self.end_date: return Status.EXPIRED - elif self.start_date <= today < self.end_date: + elif self.start_date <= todays_date <= self.end_date: return Status.ACTIVE @property @@ -141,39 +125,25 @@ class TaskOrder(Base, mixins.TimestampsMixin): @property def days_to_expiration(self): if self.end_date: - return (self.end_date - Clock.today()).days + return (self.end_date - today(tz="UTC").date()).days @property def total_obligated_funds(self): - total = 0 - for clin in self.clins: - if clin.obligated_amount is not None: - total += clin.obligated_amount - return total + return sum( + (clin.obligated_amount for clin in self.clins if clin.obligated_amount) + ) @property def total_contract_amount(self): - total = 0 - for clin in self.clins: - if clin.total_amount is not None: - total += clin.total_amount - return total - - @property - # TODO delete when we delete task_order_review flow - def budget(self): - return 100000 - - @property - def balance(self): - # TODO: fix task order -- reimplement using CLINs - # Faked for display purposes - return 50 + return sum((clin.total_amount for clin in self.clins if clin.total_amount)) @property def invoiced_funds(self): # TODO: implement this using reporting data from the CSP - return self.total_obligated_funds * Decimal(0.75) + if self.is_active: + return self.total_obligated_funds * Decimal(0.75) + else: + return 0 @property def display_status(self): diff --git a/atst/models/user.py b/atst/models/user.py index 29b377d6..a45760d0 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, ForeignKey, Column, Date, Boolean, Table, TIMESTAMP +from sqlalchemy import String, ForeignKey, Column, Date, Table, TIMESTAMP from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.event import listen @@ -67,8 +67,6 @@ class User( last_login = Column(TIMESTAMP(timezone=True), nullable=True) last_session_id = Column(UUID(as_uuid=True), nullable=True) - provisional = Column(Boolean) - cloud_id = Column(String) REQUIRED_FIELDS = [ diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 9665ef9b..4e366a08 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -19,6 +19,7 @@ from werkzeug.exceptions import NotFound from atst.domain.users import Users from atst.domain.authnid import AuthenticationContext from atst.domain.auth import logout as _logout +from atst.domain.exceptions import UnauthenticatedError from atst.utils.flash import formatted_flash as flash @@ -64,11 +65,15 @@ def catch_all(path): raise NotFound() +def _client_s_dn(): + return request.environ.get("HTTP_X_SSL_CLIENT_S_DN") + + def _make_authentication_context(): return AuthenticationContext( crl_cache=app.crl_cache, auth_status=request.environ.get("HTTP_X_SSL_CLIENT_VERIFY"), - sdn=request.environ.get("HTTP_X_SSL_CLIENT_S_DN"), + sdn=_client_s_dn(), cert=request.environ.get("HTTP_X_SSL_CLIENT_CERT"), ) @@ -89,19 +94,24 @@ def current_user_setup(user): session["user_id"] = user.id session["last_login"] = user.last_login app.session_limiter.on_login(user) + app.logger.info(f"authentication succeeded for user with EDIPI {user.dod_id}") Users.update_last_login(user) @bp.route("/login-redirect") def login_redirect(): - auth_context = _make_authentication_context() - auth_context.authenticate() - user = auth_context.get_user() + try: + auth_context = _make_authentication_context() + auth_context.authenticate() - if user.provisional: - Users.finalize(user) + user = auth_context.get_user() + current_user_setup(user) + except UnauthenticatedError as err: + app.logger.info( + f"authentication failed for subject distinguished name {_client_s_dn()}" + ) + raise err - current_user_setup(user) return redirect(redirect_after_login_url()) diff --git a/atst/routes/applications/new.py b/atst/routes/applications/new.py index a1a1a54c..9d673b04 100644 --- a/atst/routes/applications/new.py +++ b/atst/routes/applications/new.py @@ -1,8 +1,7 @@ -from flask import redirect, render_template, request as http_request, url_for, g +from flask import redirect, render_template, request as http_request, url_for from .blueprint import applications_bp from atst.domain.applications import Applications -from atst.domain.portfolios import Portfolios from atst.forms.application import NameAndDescriptionForm, EnvironmentsForm from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.models.permissions import Permissions @@ -12,6 +11,7 @@ from atst.routes.applications.settings import ( get_new_member_form, handle_create_member, handle_update_member, + handle_update_application, ) @@ -64,17 +64,9 @@ def create_or_update_new_application_step_1(portfolio_id=None, application_id=No form = get_new_application_form( {**http_request.form}, NameAndDescriptionForm, application_id ) + application = handle_update_application(form, application_id, portfolio_id) - if form.validate(): - application = None - if application_id: - application = Applications.get(application_id) - application = Applications.update(application, form.data) - flash("application_updated", application_name=application.name) - else: - portfolio = Portfolios.get_for_update(portfolio_id) - application = Applications.create(g.current_user, portfolio, **form.data) - flash("application_created", application_name=application.name) + if application: return redirect( url_for( "applications.update_new_application_step_2", diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index f2d252a9..92226e89 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -1,4 +1,10 @@ -from flask import redirect, render_template, request as http_request, url_for, g +from flask import ( + redirect, + render_template, + request as http_request, + url_for, + g, +) from .blueprint import applications_bp from atst.domain.exceptions import AlreadyExistsError @@ -10,6 +16,7 @@ from atst.domain.csp.cloud import GeneralCSPException from atst.domain.common import Paginator from atst.domain.environment_roles import EnvironmentRoles from atst.domain.invitations import ApplicationInvitations +from atst.domain.portfolios import Portfolios from atst.forms.application_member import NewForm as NewMemberForm, UpdateMemberForm from atst.forms.application import NameAndDescriptionForm, EditEnvironmentForm from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS @@ -245,16 +252,59 @@ def handle_update_member(application_id, application_role_id, form_data): # TODO: flash error message +def handle_update_environment(form, application=None, environment=None): + if form.validate(): + try: + if environment: + environment = Environments.update( + environment=environment, name=form.name.data + ) + flash("application_environments_updated") + else: + environment = Environments.create( + g.current_user, application=application, name=form.name.data + ) + flash("environment_added", environment_name=form.name.data) + + return environment + + except AlreadyExistsError: + flash("application_environments_name_error", name=form.name.data) + return False + + else: + return False + + +def handle_update_application(form, application_id=None, portfolio_id=None): + if form.validate(): + application = None + + try: + if application_id: + application = Applications.get(application_id) + application = Applications.update(application, form.data) + flash("application_updated", application_name=application.name) + else: + portfolio = Portfolios.get_for_update(portfolio_id) + application = Applications.create( + g.current_user, portfolio, **form.data + ) + flash("application_created", application_name=application.name) + + return application + + except AlreadyExistsError: + flash("application_name_error", name=form.data["name"]) + return False + + @applications_bp.route("/applications//settings") @user_can(Permissions.VIEW_APPLICATION, message="view application edit form") def settings(application_id): application = Applications.get(application_id) - return render_settings_page( - application=application, - active_toggler=http_request.args.get("active_toggler"), - active_toggler_section=http_request.args.get("active_toggler_section"), - ) + return render_settings_page(application=application,) @applications_bp.route("/environments//edit", methods=["POST"]) @@ -264,31 +314,21 @@ def update_environment(environment_id): application = environment.application env_form = EditEnvironmentForm(obj=environment, formdata=http_request.form) + updated_environment = handle_update_environment( + form=env_form, application=application, environment=environment + ) - if env_form.validate(): - Environments.update(environment=environment, name=env_form.name.data) - - flash("application_environments_updated") - + if updated_environment: return redirect( url_for( "applications.settings", application_id=application.id, fragment="application-environments", _anchor="application-environments", - active_toggler=environment.id, - active_toggler_section="edit", ) ) else: - return ( - render_settings_page( - application=application, - active_toggler=environment.id, - active_toggler_section="edit", - ), - 400, - ) + return (render_settings_page(application=application, show_flash=True), 400) @applications_bp.route( @@ -298,14 +338,9 @@ def update_environment(environment_id): def new_environment(application_id): application = Applications.get(application_id) env_form = EditEnvironmentForm(formdata=http_request.form) + environment = handle_update_environment(form=env_form, application=application) - if env_form.validate(): - Environments.create( - g.current_user, application=application, name=env_form.name.data - ) - - flash("environment_added", environment_name=env_form.data["name"]) - + if environment: return redirect( url_for( "applications.settings", @@ -315,7 +350,7 @@ def new_environment(application_id): ) ) else: - return (render_settings_page(application=application), 400) + return (render_settings_page(application=application, show_flash=True), 400) @applications_bp.route("/applications//edit", methods=["POST"]) @@ -323,10 +358,9 @@ def new_environment(application_id): def update(application_id): application = Applications.get(application_id) form = NameAndDescriptionForm(http_request.form) - if form.validate(): - application_data = form.data - Applications.update(application, application_data) + updated_application = handle_update_application(form, application_id) + if updated_application: return redirect( url_for( "applications.portfolio_applications", @@ -334,22 +368,10 @@ def update(application_id): ) ) else: - return render_settings_page(application=application, application_form=form) - - -@applications_bp.route("/applications//delete", methods=["POST"]) -@user_can(Permissions.DELETE_APPLICATION, message="delete application") -def delete(application_id): - application = Applications.get(application_id) - Applications.delete(application) - - flash("application_deleted", application_name=application.name) - - return redirect( - url_for( - "applications.portfolio_applications", portfolio_id=application.portfolio_id + return ( + render_settings_page(application=application, show_flash=True), + 400, ) - ) @applications_bp.route("/environments//delete", methods=["POST"]) diff --git a/atst/routes/portfolios/admin.py b/atst/routes/portfolios/admin.py index bc959150..699bdfab 100644 --- a/atst/routes/portfolios/admin.py +++ b/atst/routes/portfolios/admin.py @@ -17,63 +17,51 @@ from atst.utils.flash import formatted_flash as flash from atst.domain.exceptions import UnauthorizedError -def permission_str(member, edit_perm_set, view_perm_set): - if member.has_permission_set(edit_perm_set): - return edit_perm_set - else: - return view_perm_set - - -def serialize_member_form_data(member): - return { - "member_name": member.full_name, - "member_id": member.id, - "perms_app_mgmt": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, - PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, +def filter_perm_sets_data(member): + perm_sets_data = { + "perms_portfolio_mgmt": bool( + member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN) ), - "perms_funding": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_FUNDING, - PermissionSets.VIEW_PORTFOLIO_FUNDING, + "perms_app_mgmt": bool( + member.has_permission_set( + PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT + ) ), - "perms_reporting": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_REPORTS, - PermissionSets.VIEW_PORTFOLIO_REPORTS, + "perms_funding": bool( + member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_FUNDING) ), - "perms_portfolio_mgmt": permission_str( - member, - PermissionSets.EDIT_PORTFOLIO_ADMIN, - PermissionSets.VIEW_PORTFOLIO_ADMIN, + "perms_reporting": bool( + member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_REPORTS) ), } + return perm_sets_data -def get_members_data(portfolio): - members = sorted( - [serialize_member_form_data(member) for member in portfolio.members], - key=lambda member: member["member_name"], - ) - for member in members: - if member["member_id"] == portfolio.owner_role.id: - ppoc = member - members.remove(member) - members.insert(0, ppoc) - return members + +def filter_members_data(members_list, portfolio): + members_data = [] + for member in members_list: + members_data.append( + { + "role_id": member.id, + "user_name": member.user_name, + "permission_sets": filter_perm_sets_data(member), + "status": member.display_status, + "ppoc": PermissionSets.PORTFOLIO_POC in member.permission_sets, + # add in stuff here for forms + } + ) + + return sorted(members_data, key=lambda member: member["user_name"]) def render_admin_page(portfolio, form=None): pagination_opts = Paginator.get_pagination_opts(http_request) audit_events = AuditLog.get_portfolio_events(portfolio, pagination_opts) - members_data = get_members_data(portfolio) - portfolio_form = PortfolioForm(data={"name": portfolio.name}) - member_perms_form = member_forms.MembersPermissionsForm( - data={"members_permissions": members_data} - ) - + portfolio_form = PortfolioForm(obj=portfolio) + member_list = portfolio.members assign_ppoc_form = member_forms.AssignPPOCForm() + for pf_role in portfolio.roles: if pf_role.user != portfolio.owner and pf_role.is_active: assign_ppoc_form.role_id.choices += [(pf_role.id, pf_role.full_name)] @@ -87,13 +75,12 @@ def render_admin_page(portfolio, form=None): "portfolios/admin.html", form=form, portfolio_form=portfolio_form, - member_perms_form=member_perms_form, - member_form=member_forms.NewForm(), + members=filter_members_data(member_list, portfolio), + new_manager_form=member_forms.NewForm(), assign_ppoc_form=assign_ppoc_form, portfolio=portfolio, audit_events=audit_events, user=g.current_user, - ppoc_id=members_data[0].get("member_id"), current_member_id=current_member_id, applications_count=len(portfolio.applications), ) @@ -106,34 +93,6 @@ def admin(portfolio_id): return render_admin_page(portfolio) -@portfolios_bp.route("/portfolios//admin", methods=["POST"]) -@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="view portfolio admin page") -def edit_members(portfolio_id): - portfolio = Portfolios.get_for_update(portfolio_id) - member_perms_form = member_forms.MembersPermissionsForm(http_request.form) - - if member_perms_form.validate(): - for subform in member_perms_form.members_permissions: - member_id = subform.member_id.data - member = PortfolioRoles.get_by_id(member_id) - if member is not portfolio.owner_role: - new_perm_set = subform.data["permission_sets"] - PortfolioRoles.update(member, new_perm_set) - - flash("update_portfolio_members", portfolio=portfolio) - - return redirect( - url_for( - "portfolios.admin", - portfolio_id=portfolio_id, - fragment="portfolio-members", - _anchor="portfolio-members", - ) - ) - else: - return render_admin_page(portfolio) - - @portfolios_bp.route("/portfolios//update_ppoc", methods=["POST"]) @user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc") def update_ppoc(portfolio_id): diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index 0447be57..f9e7d5cf 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -56,13 +56,3 @@ def reports(portfolio_id): monthly_spending=Reports.monthly_spending(portfolio), retrieved=datetime.now(), # mocked datetime of reporting data retrival ) - - -@portfolios_bp.route("/portfolios//destroy", methods=["POST"]) -@user_can(Permissions.ARCHIVE_PORTFOLIO, message="archive portfolio") -def delete_portfolio(portfolio_id): - Portfolios.delete(portfolio=g.portfolio) - - flash("portfolio_deleted", portfolio_name=g.portfolio.name) - - return redirect(url_for("atst.home")) diff --git a/atst/routes/portfolios/invitations.py b/atst/routes/portfolios/invitations.py index 8f2e4701..09a22d1f 100644 --- a/atst/routes/portfolios/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -79,7 +79,7 @@ def invite_member(portfolio_id): if form.validate(): try: - invite = Portfolios.invite(portfolio, g.current_user, form.update_data) + invite = Portfolios.invite(portfolio, g.current_user, form.data) send_portfolio_invitation( invitee_email=invite.email, inviter_name=g.current_user.full_name, diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py index e02780ca..04f1baaa 100644 --- a/atst/routes/task_orders/index.py +++ b/atst/routes/task_orders/index.py @@ -8,16 +8,16 @@ from atst.forms.task_order import SignatureForm from atst.models import Permissions -@task_orders_bp.route("/task_orders//review") -@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="review task order details") -def review_task_order(task_order_id): +@task_orders_bp.route("/task_orders/") +@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="view task order details") +def view_task_order(task_order_id): task_order = TaskOrders.get(task_order_id) if task_order.is_draft: return redirect(url_for("task_orders.edit", task_order_id=task_order.id)) else: signature_form = SignatureForm() return render_template( - "task_orders/review.html", + "task_orders/view.html", task_order=task_order, signature_form=signature_form, ) diff --git a/atst/utils/__init__.py b/atst/utils/__init__.py index 01988e10..d3f284cc 100644 --- a/atst/utils/__init__.py +++ b/atst/utils/__init__.py @@ -1,5 +1,10 @@ import re +from sqlalchemy.exc import IntegrityError + +from atst.database import db +from atst.domain.exceptions import AlreadyExistsError + def first_or_none(predicate, lst): return next((x for x in lst if predicate(x)), None) @@ -23,3 +28,11 @@ def camel_to_snake(camel_cased): def pick(keys, dct): _keys = set(keys) return {k: v for (k, v) in dct.items() if k in _keys} + + +def commit_or_raise_already_exists_error(message): + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + raise AlreadyExistsError(message) diff --git a/atst/utils/clock.py b/atst/utils/clock.py deleted file mode 100644 index 1ac4455e..00000000 --- a/atst/utils/clock.py +++ /dev/null @@ -1,11 +0,0 @@ -import pendulum - - -class Clock(object): - @classmethod - def today(cls, tz="UTC"): - return pendulum.today(tz=tz).date() - - @classmethod - def now(cls, tz="UTC"): - return pendulum.now(tz=tz) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index fabba4d2..da2c9253 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -1,204 +1,171 @@ -from flask import flash, render_template_string +from flask import flash from atst.utils.localization import translate + MESSAGES = { - "portfolio_deleted": { - "title_template": "Portfolio has been deleted", - "message_template": "Portfolio '{{portfolio_name}}' has been deleted", - "category": "success", - }, "application_created": { - "title_template": translate("flash.application.created.title"), - "message_template": """ - {{ "flash.application.created.message" | translate({"application_name": application_name}) }} - """, + "title": "flash.application.created.title", + "message": "flash.application.created.message", "category": "success", }, "application_updated": { - "title_template": translate("flash.success"), - "message_template": """ - {{ "flash.application.updated" | translate({"application_name": application_name}) }} - """, + "title": "flash.success", + "message": "flash.application.updated", "category": "success", }, - "application_deleted": { - "title_template": translate("flash.success"), - "message_template": """ - {{ "flash.application.deleted" | translate({"application_name": application_name}) }} - {{ "common.undo" | translate }} - """, - "category": "success", + "application_environments_name_error": { + "title": None, + "message": "flash.application.env_name_error.message", + "category": "error", }, "application_environments_updated": { - "title_template": "Application environments updated", - "message_template": "Application environments have been updated", + "title": "flash.environment.updated.title", + "message": "flash.environment.updated.message", "category": "success", }, "application_invite_error": { - "title_template": "Application invitation error", - "message_template": "There was an error processing the invitation for {{ user_name }} from {{ application_name }}", + "title": "flash.application_invite.error.title", + "message": "flash.application_invite.error.message", "category": "error", }, "application_invite_resent": { - "title_template": "Application invitation resent", - "message_template": "You have successfully resent the invite for {{ user_name }} from {{ application_name }}", + "title": "flash.application_invite.resent.title", + "message": "flash.application_invite.resent.message", "category": "success", }, "application_invite_revoked": { - "title_template": "Application invitation revoked", - "message_template": "You have successfully revoked the invite for {{ user_name }} from {{ application_name }}", + "title": "flash.application_invite.revoked.title", + "message": "flash.application_invite.revoked.message", "category": "success", }, "application_member_removed": { - "title_template": "Team member removed from application", - "message_template": "You have successfully deleted {{ user_name }} from {{ application_name }}", + "title": "flash.application_member.removed.title", + "message": "flash.application_member.removed.message", "category": "success", }, "application_member_update_error": { - "title_template": "{{ user_name }} could not be updated", - "message_template": "An unexpected problem occurred with your request, please try again. If the problem persists, contact an administrator.", + "title": "flash.application_member.update_error.title", + "message": "flash.application_member.update_error.message", "category": "error", }, "application_member_updated": { - "title_template": "Team member updated", - "message_template": "You have successfully updated the permissions for {{ user_name }}", + "title": "flash.application_member.updated.title", + "message": "flash.application_member.updated.message", "category": "success", }, + "application_name_error": { + "title": None, + "message": "flash.application.name_error.message", + "category": "error", + }, "ccpo_user_added": { - "title_template": translate("flash.success"), - "message_template": "You have successfully given {{ user_name }} CCPO permissions.", + "title": "flash.success", + "message": "flash.ccpo_user.added.message", "category": "success", }, "ccpo_user_not_found": { - "title_template": translate("ccpo.form.user_not_found_title"), - "message_template": translate("ccpo.form.user_not_found_text"), + "title": "ccpo.form.user_not_found_title", + "message": "ccpo.form.user_not_found_text", "category": "info", }, "ccpo_user_removed": { - "title_template": translate("flash.success"), - "message_template": "You have successfully removed {{ user_name }}'s CCPO permissions.", + "title": "flash.success", + "message": "flash.ccpo_user.removed.message", "category": "success", }, "environment_added": { - "title_template": translate("flash.success"), - "message_template": """ - {{ "flash.environment_added" | translate({ "env_name": environment_name }) }} - """, + "title": "flash.success", + "message": "flash.environment_added", "category": "success", }, "environment_deleted": { - "title_template": "{{ environment_name }} deleted", - "message_template": 'The environment "{{ environment_name }}" has been deleted', + "title": "flash.environment.deleted.title", + "message": "flash.environment.deleted.message", "category": "success", }, "form_errors": { - "title_template": "There were some errors", - "message_template": "

Please see below.

", + "title": "flash.form.errors.title", + "message": "flash.form.errors.message", "category": "error", }, "insufficient_funds": { - "title_template": "Insufficient Funds", - "message_template": "", + "title": "flash.task_order.insufficient_funds.title", + "message": "", "category": "warning", }, "logged_out": { - "title_template": translate("flash.logged_out"), - "message_template": """ - You've been logged out. - """, + "title": "flash.logged_out.title", + "message": "flash.logged_out.message", "category": "info", }, "login_next": { - "title_template": translate("flash.login_required_title"), - "message_template": translate("flash.login_required_message"), + "title": "flash.login_required_title", + "message": "flash.login_required_message", "category": "warning", }, "new_application_member": { - "title_template": """{{ "flash.new_application_member.title" | translate({ "user_name": user_name }) }}""", - "message_template": """ -

{{ "flash.new_application_member.message" | translate({ "user_name": user_name }) }}

- """, + "title": "flash.new_application_member.title", + "message": "flash.new_application_member.message", "category": "success", }, "new_portfolio_member": { - "title_template": translate("flash.success"), - "message_template": """ -

{{ "flash.new_portfolio_member" | translate({ "user_name": user_name }) }}

- """, + "title": "flash.success", + "message": "flash.new_portfolio_member", "category": "success", }, "portfolio_member_removed": { - "title_template": translate("flash.deleted_member"), - "message_template": """ - {{ "flash.delete_member_success" | translate({ "member_name": member_name }) }} - """, + "title": "flash.deleted_member", + "message": "flash.delete_member_success", "category": "success", }, "primary_point_of_contact_changed": { - "title_template": translate("flash.new_ppoc_title"), - "message_template": """{{ "flash.new_ppoc_message" | translate({ "ppoc_name": ppoc_name }) }}""", + "title": "flash.new_ppoc_title", + "message": "flash.new_ppoc_message", "category": "success", }, "resend_portfolio_invitation": { - "title_template": "Invitation resent", - "message_template": """ -

Successfully sent a new invitation to {{ user_name }}.

- """, + "title": "flash.portfolio_invite.resent.title", + "message": "flash.portfolio_invite.resent.message", "category": "success", }, "revoked_portfolio_access": { - "title_template": "Removed portfolio access", - "message_template": """ -

Portfolio access successfully removed from {{ member_name }}.

- """, + "title": "flash.portfolio_member.revoked.title", + "message": "flash.portfolio_member.revoked.message", "category": "success", }, "session_expired": { - "title_template": "Session Expired", - "message_template": """ - Your session expired due to inactivity. Please log in again to continue. - """, + "title": "flash.session_expired.title", + "message": "flash.session_expired.message", "category": "error", }, "task_order_draft": { - "title_template": translate("task_orders.form.draft_alert_title"), - "message_template": translate("task_orders.form.draft_alert_message"), + "title": "task_orders.form.draft_alert_title", + "message": "task_orders.form.draft_alert_message", "category": "warning", }, "task_order_number_error": { - "title_template": "", - "message_template": """{{ 'flash.task_order_number_error.message' | translate({ 'to_number': to_number }) }}""", + "title": None, + "message": "flash.task_order_number_error.message", "category": "error", }, "task_order_submitted": { - "title_template": "Your Task Order has been uploaded successfully.", - "message_template": """ - Your task order form for {{ task_order.portfolio_name }} has been submitted. - """, - "category": "success", - }, - "update_portfolio_members": { - "title_template": "Success!", - "message_template": """ -

You have successfully updated access permissions for members of {{ portfolio.name }}.

- """, + "title": "flash.task_order.submitted.title", + "message": "flash.task_order.submitted.message", "category": "success", }, "updated_application_team_settings": { - "title_template": translate("flash.success"), - "message_template": """ -

{{ "flash.updated_application_team_settings" | translate({"application_name": application_name}) }}

- """, + "title": "flash.success", + "message": "flash.updated_application_team_settings", "category": "success", }, "user_must_complete_profile": { - "title_template": "You must complete your profile", - "message_template": "

Before continuing, you must complete your profile

", + "title": "flash.user.complete_profile.title", + "message": "flash.user.complete_profile.message", "category": "info", }, "user_updated": { - "title_template": "User information updated.", - "message_template": "", + "title": "flash.user.updated.title", + "message": None, "category": "success", }, } @@ -206,9 +173,11 @@ MESSAGES = { def formatted_flash(message_name, **message_args): config = MESSAGES[message_name] - title = render_template_string(config["title_template"], **message_args) - message = render_template_string(config["message_template"], **message_args) - actions = None - if "actions" in config: - actions = render_template_string(config["actions"], **message_args) + + title = translate(config["title"], message_args) if config["title"] else None + message = translate(config["message"], message_args) if config["message"] else None + actions = ( + translate(config["actions"], message_args) if config.get("actions") else None + ) + flash({"title": title, "message": message, "actions": actions}, config["category"]) diff --git a/atst/utils/logging.py b/atst/utils/logging.py index 94e7aab6..f0b16dff 100644 --- a/atst/utils/logging.py +++ b/atst/utils/logging.py @@ -2,16 +2,22 @@ import datetime import json import logging -from flask import g, request, has_request_context +from flask import g, request, has_request_context, session class RequestContextFilter(logging.Filter): def filter(self, record): if has_request_context(): if getattr(g, "current_user", None): - record.user_id = str(g.current_user.id) record.dod_edipi = g.current_user.dod_id + user_id = session.get("user_id") + if user_id: + record.user_id = str(user_id) + record.logged_in = True + else: + record.logged_in = False + if request.environ.get("HTTP_X_REQUEST_ID"): record.request_id = request.environ.get("HTTP_X_REQUEST_ID") @@ -30,6 +36,7 @@ class JsonFormatter(logging.Formatter): ("request_id", lambda r: r.__dict__.get("request_id")), ("user_id", lambda r: r.__dict__.get("user_id")), ("dod_edipi", lambda r: r.__dict__.get("dod_edipi")), + ("logged_in", lambda r: r.__dict__.get("logged_in")), ("severity", lambda r: r.levelname), ("tags", lambda r: r.__dict__.get("tags")), ("audit_event", lambda r: r.__dict__.get("audit_event")), @@ -44,7 +51,7 @@ class JsonFormatter(logging.Formatter): for field, func in self._DEFAULT_RECORD_FIELDS: result = func(record) - if result: + if result is not None: message_dict[field] = result if record.args: diff --git a/config/base.ini b/config/base.ini index 2cc8fd93..9233ef21 100644 --- a/config/base.ini +++ b/config/base.ini @@ -3,6 +3,7 @@ ASSETS_URL AZURE_ACCOUNT_NAME AZURE_STORAGE_KEY AZURE_TO_BUCKET_NAME +AZURE_POLICY_LOCATION=policies BLOB_STORAGE_URL=http://localhost:8000/ CAC_URL = http://localhost:8000/login-redirect CA_CHAIN = ssl/server-certs/ca-chain.pem @@ -39,6 +40,7 @@ REDIS_USER SECRET_KEY = change_me_into_something_secret SERVER_NAME SESSION_COOKIE_NAME=atat +SESSION_COOKIE_DOMAIN SESSION_TYPE = redis SESSION_USE_SIGNER = True SQLALCHEMY_ECHO = False diff --git a/deploy/azure/atst-envvars-configmap.yml b/deploy/azure/atst-envvars-configmap.yml index d2b0ba45..6f412a3d 100644 --- a/deploy/azure/atst-envvars-configmap.yml +++ b/deploy/azure/atst-envvars-configmap.yml @@ -30,6 +30,7 @@ data: PGUSER: atat_master@atat-db REDIS_HOST: atat.redis.cache.windows.net:6380 REDIS_TLS: "true" + SESSION_COOKIE_DOMAIN: atat.code.mil STATIC_URL: https://atat-cdn.azureedge.net/static/ TZ: UTC UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini diff --git a/deploy/azure/atst-nginx-configmap.yml b/deploy/azure/atst-nginx-configmap.yml index 5f51c7d6..c79164f5 100644 --- a/deploy/azure/atst-nginx-configmap.yml +++ b/deploy/azure/atst-nginx-configmap.yml @@ -45,7 +45,7 @@ data: include /etc/nginx/snippets/ssl.conf; location /login-redirect { - return 301 https://auth-azure.atat.code.mil$request_uri; + return 301 https://${AUTH_DOMAIN}$request_uri; } location /login-dev { try_files $uri @appbasicauth; @@ -82,7 +82,7 @@ data: include /etc/nginx/snippets/ssl.conf; location / { - return 301 https://azure.atat.code.mil$request_uri; + return 301 https://${MAIN_DOMAIN}$request_uri; } location /login-redirect { try_files $uri @app; diff --git a/deploy/overlays/cloudzero-dev/envvars.yml b/deploy/overlays/cloudzero-dev/envvars.yml new file mode 100644 index 00000000..179811ed --- /dev/null +++ b/deploy/overlays/cloudzero-dev/envvars.yml @@ -0,0 +1,22 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: atst-worker-envvars +data: + CELERY_DEFAULT_QUEUE: celery-staging + SERVER_NAME: staging.atat.code.mil + FLASK_ENV: staging +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: atst-envvars +data: + ASSETS_URL: https://atat-cdn-staging.azureedge.net/ + CDN_ORIGIN: https://staging.atat.code.mil + CELERY_DEFAULT_QUEUE: celery-staging + FLASK_ENV: staging + STATIC_URL: https://atat-cdn-staging.azureedge.net/static/ + PGHOST: cloudzero-dev-sql.postgres.database.azure.com + REDIS_HOST: cloudzero-dev-redis.redis.cache.windows.net:6380 diff --git a/deploy/overlays/cloudzero-dev/flex_vol.yml b/deploy/overlays/cloudzero-dev/flex_vol.yml new file mode 100644 index 00000000..1da24f7a --- /dev/null +++ b/deploy/overlays/cloudzero-dev/flex_vol.yml @@ -0,0 +1,62 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst +spec: + template: + spec: + volumes: + - name: nginx-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "dhparam4096;cert;cert" + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY" +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-worker +spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY" +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-beat +spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY" +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: crls +spec: + jobTemplate: + spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY" diff --git a/deploy/overlays/cloudzero-dev/json_ports.yml b/deploy/overlays/cloudzero-dev/json_ports.yml new file mode 100644 index 00000000..0e8de3b3 --- /dev/null +++ b/deploy/overlays/cloudzero-dev/json_ports.yml @@ -0,0 +1,12 @@ +- op: replace + path: /spec/template/spec/containers/1/ports/0/containerPort + value: 9342 +- op: replace + path: /spec/template/spec/containers/1/ports/1/containerPort + value: 9442 +- op: replace + path: /spec/template/spec/containers/1/ports/2/containerPort + value: 9343 +- op: replace + path: /spec/template/spec/containers/1/ports/3/containerPort + value: 9443 diff --git a/deploy/overlays/cloudzero-dev/kustomization.yaml b/deploy/overlays/cloudzero-dev/kustomization.yaml new file mode 100644 index 00000000..ee6f3a0c --- /dev/null +++ b/deploy/overlays/cloudzero-dev/kustomization.yaml @@ -0,0 +1,18 @@ +namespace: staging +bases: + - ../../azure/ +resources: + - namespace.yml + - reset-cron-job.yml +patchesStrategicMerge: + - replica_count.yml + - ports.yml + - envvars.yml + - flex_vol.yml +patchesJson6902: + - target: + group: extensions + version: v1beta1 + kind: Deployment + name: atst + path: json_ports.yml diff --git a/deploy/overlays/cloudzero-dev/namespace.yml b/deploy/overlays/cloudzero-dev/namespace.yml new file mode 100644 index 00000000..ee38adfb --- /dev/null +++ b/deploy/overlays/cloudzero-dev/namespace.yml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: staging diff --git a/deploy/overlays/cloudzero-dev/ports.yml b/deploy/overlays/cloudzero-dev/ports.yml new file mode 100644 index 00000000..8f4ff72c --- /dev/null +++ b/deploy/overlays/cloudzero-dev/ports.yml @@ -0,0 +1,28 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: atst-main +spec: + loadBalancerIP: "" + ports: + - port: 80 + targetPort: 9342 + name: http + - port: 443 + targetPort: 9442 + name: https +--- +apiVersion: v1 +kind: Service +metadata: + name: atst-auth +spec: + loadBalancerIP: "" + ports: + - port: 80 + targetPort: 9343 + name: http + - port: 443 + targetPort: 9443 + name: https diff --git a/deploy/overlays/cloudzero-dev/replica_count.yml b/deploy/overlays/cloudzero-dev/replica_count.yml new file mode 100644 index 00000000..272286f7 --- /dev/null +++ b/deploy/overlays/cloudzero-dev/replica_count.yml @@ -0,0 +1,14 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst +spec: + replicas: 2 +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-worker +spec: + replicas: 1 diff --git a/deploy/overlays/cloudzero-dev/reset-cron-job.yml b/deploy/overlays/cloudzero-dev/reset-cron-job.yml new file mode 100644 index 00000000..b4792e5d --- /dev/null +++ b/deploy/overlays/cloudzero-dev/reset-cron-job.yml @@ -0,0 +1,46 @@ +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: reset-db + namespace: atat +spec: + schedule: "0 4 * * *" + concurrencyPolicy: Replace + successfulJobsHistoryLimit: 1 + jobTemplate: + spec: + template: + metadata: + labels: + app: atst + role: reset-db + aadpodidbinding: atat-kv-id-binding + spec: + restartPolicy: OnFailure + containers: + - name: reset + image: $CONTAINER_IMAGE + command: [ + "/bin/sh", "-c" + ] + args: [ + "/opt/atat/atst/.venv/bin/python", + "/opt/atat/atst/script/reset_database.py" + ] + envFrom: + - configMapRef: + name: atst-worker-envvars + volumeMounts: + - name: flask-secret + mountPath: "/config" + volumes: + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID diff --git a/deploy/overlays/staging/kustomization.yaml b/deploy/overlays/staging/kustomization.yaml index 38251002..ee6f3a0c 100644 --- a/deploy/overlays/staging/kustomization.yaml +++ b/deploy/overlays/staging/kustomization.yaml @@ -3,6 +3,7 @@ bases: - ../../azure/ resources: - namespace.yml + - reset-cron-job.yml patchesStrategicMerge: - replica_count.yml - ports.yml diff --git a/deploy/overlays/staging/reset-cron-job.yml b/deploy/overlays/staging/reset-cron-job.yml new file mode 100644 index 00000000..b4792e5d --- /dev/null +++ b/deploy/overlays/staging/reset-cron-job.yml @@ -0,0 +1,46 @@ +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: reset-db + namespace: atat +spec: + schedule: "0 4 * * *" + concurrencyPolicy: Replace + successfulJobsHistoryLimit: 1 + jobTemplate: + spec: + template: + metadata: + labels: + app: atst + role: reset-db + aadpodidbinding: atat-kv-id-binding + spec: + restartPolicy: OnFailure + containers: + - name: reset + image: $CONTAINER_IMAGE + command: [ + "/bin/sh", "-c" + ] + args: [ + "/opt/atat/atst/.venv/bin/python", + "/opt/atat/atst/script/reset_database.py" + ] + envFrom: + - configMapRef: + name: atst-worker-envvars + volumeMounts: + - name: flask-secret + mountPath: "/config" + volumes: + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID diff --git a/js/components/clin_fields.js b/js/components/clin_fields.js index 327bedf0..59d6c8c9 100644 --- a/js/components/clin_fields.js +++ b/js/components/clin_fields.js @@ -1,4 +1,5 @@ import { emitFieldChange } from '../lib/emitters' +import escape from '../lib/escape' import optionsinput from './options_input' import textinput from './text_input' import clindollaramount from './clin_dollar_amount' @@ -99,7 +100,7 @@ export default { computed: { clinTitle: function() { if (!!this.clinNumber) { - return `CLIN ${this.clinNumber}` + return escape(`CLIN ${this.clinNumber}`) } else { return `CLIN` } diff --git a/js/lib/__tests__/escape.test.js b/js/lib/__tests__/escape.test.js new file mode 100644 index 00000000..9dc2d5fe --- /dev/null +++ b/js/lib/__tests__/escape.test.js @@ -0,0 +1,21 @@ +import escape from '../escape' +describe('escape', () => { + const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + } + it('should escape each character', () => { + for (let [char, escapedChar] of Object.entries(htmlEscapes)) { + expect(escape(char)).toBe(escapedChar) + } + }) + it('should escape multiple characters', () => { + expect(escape('& and < and > and " and \' and /')).toBe( + '& and < and > and " and ' and /' + ) + }) +}) diff --git a/js/lib/escape.js b/js/lib/escape.js new file mode 100644 index 00000000..b72103c4 --- /dev/null +++ b/js/lib/escape.js @@ -0,0 +1,20 @@ +// https://stackoverflow.com/a/6020820 + +// List of HTML entities for escaping. +const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', +} + +const htmlEscaper = /[&<>"'\/]/g + +// Escape a string for HTML interpolation. +const escape = string => { + return ('' + string).replace(htmlEscaper, match => htmlEscapes[match]) +} + +export default escape diff --git a/policies/portfolios/allowed-resource-types.json b/policies/portfolios/allowed-resource-types.json new file mode 100644 index 00000000..1358a9f4 --- /dev/null +++ b/policies/portfolios/allowed-resource-types.json @@ -0,0 +1,40 @@ +{ + "definitionPoint": "portfolio", + "policyDefinition": { + "properties": { + "displayName": "Allowed resource types", + "policyType": "Custom", + "mode": "Indexed", + "description": "This policy enables you to specify the resource types that your organization can deploy.", + "parameters": { + "listOfResourceTypesAllowed": { + "type": "Array", + "metadata": { + "description": "The list of resource types that can be deployed.", + "displayName": "Allowed resource types", + "strongType": "resourceTypes" + } + } + }, + "policyRule": { + "if": { + "not": { + "field": "type", + "in": "[parameters('listOfResourceTypesAllowed')]" + } + }, + "then": { + "effect": "deny" + } + } + }, + "type": "Microsoft.Authorization/policyDefinitions" + }, + "parameters": { + "listOfResourceTypesAllowed": { + "value": [ + "Microsoft.Cache" + ] + } + } +} diff --git a/policies/portfolios/region-restriction.json b/policies/portfolios/region-restriction.json new file mode 100644 index 00000000..a828c40e --- /dev/null +++ b/policies/portfolios/region-restriction.json @@ -0,0 +1,51 @@ +{ + "definitionPoint": "portfolio", + "policyDefinition": { + "properties": { + "displayName": "Custom - Region Restriction", + "policyType": "Custom", + "mode": "Indexed", + "parameters": { + "listOfAllowedLocations": { + "type": "Array", + "metadata": { + "displayName": "Allowed locations", + "description": "The list of locations that can be specified when deploying resources.", + "strongType": "location" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "location", + "notIn": "[parameters('listOfAllowedLocations')]" + }, + { + "field": "location", + "notEquals": "global" + }, + { + "field": "type", + "notEquals": "Microsoft.AzureActiveDirectory/b2cDirectories" + } + ] + }, + "then": { + "effect": "Deny" + } + } + }, + "type": "Microsoft.Authorization/policyDefinitions" + }, + "parameters": { + "listOfAllowedLocations": { + "value": [ + "eastus", + "southcentralus", + "westus" + ] + } + } +} diff --git a/script/cibuild b/script/cibuild index 55bc7857..f0011051 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,4 +1,5 @@ #!/bin/bash +set -e # script/cibuild: Run CI related checks and tests diff --git a/script/include/run_test b/script/include/run_test index 2da77654..7b696dfe 100644 --- a/script/include/run_test +++ b/script/include/run_test @@ -26,28 +26,16 @@ fi ## Main if [ "${RUN_PYTHON_TESTS}" = "true" ]; then - python_test_status=0 - set +e output_divider "Lint Python files" run_python_lint "${PYTHON_FILES}" - ((python_test_status+=$?)) output_divider "Perform static analysis on Python files" run_python_static_analysis "${PYTHON_FILES}" - ((python_test_status+=$?)) output_divider "Perform type checking on Python files" run_python_typecheck - ((python_test_status+=$?)) output_divider "Run Python unit test suite" run_python_unit_tests "${PYTHON_FILES}" - ((python_test_status+=$?)) - if [ "${python_test_status}" != "0" ]; then - warning "Failed to pass one or more Python checks" - exit ${python_test_status} - fi - - set -e fi if [ "${RUN_JS_TESTS}" = "true" ]; then diff --git a/script/integration_tests b/script/integration_tests index e84b103a..cb0d813a 100755 --- a/script/integration_tests +++ b/script/integration_tests @@ -72,15 +72,18 @@ $CONTAINER_IMAGE \ # Use curl to wait for application container to become available docker pull curlimages/curl:latest +echo "Waiting for application container to become available" docker run --network atat \ curlimages/curl:latest \ - curl --connect-timeout 3 \ + curl \ + --silent \ + --connect-timeout 3 \ --max-time 5 \ --retry $CONTAINER_TIMEOUT \ --retry-connrefused \ --retry-delay 1 \ --retry-max-time $CONTAINER_TIMEOUT \ - test-atat:8000 + test-atat:8000 >/dev/null # Run Ghost Inspector tests docker pull ghostinspector/test-runner-standalone:latest diff --git a/script/seed_sample.py b/script/seed_sample.py index 9f64f049..72c16c6c 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -34,7 +34,7 @@ from atst.routes.dev import _DEV_USERS as DEV_USERS from atst.utils import pick from tests.factories import ( - random_service_branch, + random_defense_component, TaskOrderFactory, CLINFactory, AttachmentFactory, @@ -159,7 +159,7 @@ def get_users(): def add_members_to_portfolio(portfolio): for user_data in PORTFOLIO_USERS: - invite = Portfolios.invite(portfolio, portfolio.owner, user_data) + invite = Portfolios.invite(portfolio, portfolio.owner, {"user_data": user_data}) profile = { k: user_data[k] for k in user_data if k not in ["dod_id", "permission_sets"] } @@ -308,7 +308,7 @@ def create_demo_portfolio(name, data): portfolio = Portfolios.create( user=portfolio_owner, - portfolio_attrs={"name": name, "defense_component": random_service_branch()}, + portfolio_attrs={"name": name, "defense_component": random_defense_component()}, ) add_task_orders_to_portfolio(portfolio) @@ -336,7 +336,7 @@ def seed_db(): user=amanda, portfolio_attrs={ "name": "TIE Interceptor", - "defense_component": random_service_branch(), + "defense_component": random_defense_component(), }, ) add_task_orders_to_portfolio(tie_interceptor) @@ -347,7 +347,7 @@ def seed_db(): user=amanda, portfolio_attrs={ "name": "TIE Fighter", - "defense_component": random_service_branch(), + "defense_component": random_defense_component(), }, ) add_task_orders_to_portfolio(tie_fighter) @@ -363,7 +363,7 @@ def seed_db(): user=user, portfolio_attrs={ "name": ship, - "defense_component": random_service_branch(), + "defense_component": random_defense_component(), }, ) add_task_orders_to_portfolio(portfolio) diff --git a/ssl/certificate-authority/ca.crt b/ssl/certificate-authority/ca.crt deleted file mode 100644 index 60ed3ff2..00000000 --- a/ssl/certificate-authority/ca.crt +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEJDCCAwygAwIBAgIJAK4JGo3BBGhVMA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNV -BAYTAlVTMRUwEwYDVQQIEwxQZW5uc3lsdmFuaWExFTATBgNVBAcTDFBoaWxhZGVs -cGhpYTEMMAoGA1UEChMDRG9EMQwwCgYDVQQLEwNERFMxEDAOBgNVBAMTB0FUQVQg -Q0EwHhcNMTgwNjAxMTk0NjIyWhcNMzgwNTI3MTk0NjIyWjBpMQswCQYDVQQGEwJV -UzEVMBMGA1UECBMMUGVubnN5bHZhbmlhMRUwEwYDVQQHEwxQaGlsYWRlbHBoaWEx -DDAKBgNVBAoTA0RvRDEMMAoGA1UECxMDRERTMRAwDgYDVQQDEwdBVEFUIENBMIIB -IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzYU7UbstArnnVliaC/TB6Vir -kVWMnAEYMUZA1BKP8DZaNEKbzFH2+mMw7O0BY7Ph9x0hEZ1kXLr6U93xcKyUWNPo -13i5EwUUCSh2MdPfS8ZZt8DUIIKC7XzFnKyKSKQmr0Mt9dC44rryPKTBvmI60rQ8 -VZkFEgvs8FCP0M4Ar6/gtJ24ZLEtilu5dQBSlru4nPGXg07r2C2JgEZWshtMBtbH -LkOM2gtp/pkYCCG0zqeU+0s3H8IqDq0uYkONOfVeCumbg1/AtjgrZu7aOVPKyibk -aI6sTTooXE5aSZkfkx0z6+fKM2nPSe30HgiBODtb7G+44ln08d0isjpQ67OvGQID -AQABo4HOMIHLMB0GA1UdDgQWBBSl7CUAWPbx8XqotKKKAufPh0wn4DCBmwYDVR0j -BIGTMIGQgBSl7CUAWPbx8XqotKKKAufPh0wn4KFtpGswaTELMAkGA1UEBhMCVVMx -FTATBgNVBAgTDFBlbm5zeWx2YW5pYTEVMBMGA1UEBxMMUGhpbGFkZWxwaGlhMQww -CgYDVQQKEwNEb0QxDDAKBgNVBAsTA0REUzEQMA4GA1UEAxMHQVRBVCBDQYIJAK4J -Go3BBGhVMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABguwdFk42YP -8U6Du5HQ6Is1jfc1KEOowdh0d2MCH8q0KNktqiu6kWzjH1gRjRwc07bAkAWqXPB6 -6gkRGYe/FRgi2Rn+Uo5UC5ahI4cXkE8OitCIEP3Br9fUw+vj/3Iiov0QZ6Hv81Kl -ZTZhLiZbjAg5maL/vufnUp+n15qzm67APh3/2hcgO93UlE9o9vXohWy1lHs8u12o -hPLxghSmGc9eKalEWEs61OrohpOtCHUEd1isq76WhaiXSwSUrBxgy89Z517A7ffC -BjzLo5AVo6a9ou+ONVeZk8qw6YR6X9J7axy8YuTWt+Z82WFvOF0ubkqjm72d001M -7R9zCOQ3O+g= ------END CERTIFICATE----- diff --git a/ssl/certificate-authority/ca.key b/ssl/certificate-authority/ca.key deleted file mode 100644 index 529761e9..00000000 --- a/ssl/certificate-authority/ca.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAzYU7UbstArnnVliaC/TB6VirkVWMnAEYMUZA1BKP8DZaNEKb -zFH2+mMw7O0BY7Ph9x0hEZ1kXLr6U93xcKyUWNPo13i5EwUUCSh2MdPfS8ZZt8DU -IIKC7XzFnKyKSKQmr0Mt9dC44rryPKTBvmI60rQ8VZkFEgvs8FCP0M4Ar6/gtJ24 -ZLEtilu5dQBSlru4nPGXg07r2C2JgEZWshtMBtbHLkOM2gtp/pkYCCG0zqeU+0s3 -H8IqDq0uYkONOfVeCumbg1/AtjgrZu7aOVPKyibkaI6sTTooXE5aSZkfkx0z6+fK -M2nPSe30HgiBODtb7G+44ln08d0isjpQ67OvGQIDAQABAoIBAHR4EInc3UEyQVu5 -knM8Hbgzu+b86FZweFlUSuDkNBYZdz0ukkRUHvb+x3c9SRBLnL8CDv+AhqPWgo6M -tIr6Aofkb4vMqnWQ5y3ZdEIApAa5PZbY/F4AGFql3wdO8H8CJ7ojBCTOSDiVYTnk -1Lcjy9okshyAP1Ne1sPJo/bdB56HtXs+wqok1NntIQwiXjjD9xUuc1EZk0J4M97L -vBUjUGNX942UjtRiey5zwhRp3bTPasTduHcA01NaIbOVYlRFwc2W+cflz0l6ml2p -14TNEEvIMMMCNKnlPrpGI23n0psAvE4nbuxZQGVYAFvXrWn+Gyvz0Yag2EoMUCEs -ziLED9ECgYEA6IByu+xqIuIAhj/PwIIxV4+lkuV4TXIlfAFLR4JuokOVfbRsmu2e -9EfeOUD9LfQ4KsG5mu4Abpja0k/VKRKRGRjV6Oe2C6VK942HFP6Kpn0hgIuomZkD -eVv8naDezZjAvVace38zjRWB2GXTpapwBAgf/YflPPsDZ8bi/weqZCMCgYEA4kqx -Ka489Rr7+cSXpMeS5lLufhlaE5OVQc5HVFREDAI5vXU8BM2sLiHTC/BHjis2JvLm -aRJ0UsxUoIUURl2KjTbx3zns4HDVkzBrSpoDXWxBjAo0oEg7JVc+6+qEqbDHHS1L -/UJ6mlUegsE42MkFWG3YJQuHxyLZqPXIwNAyhZMCgYEA5cxnGnSt5rJoAEi7xzMn -H7s71Hf3stw6TlldFV3GiZyw+aDFo09vR1RtQTuJwczbYu88yvOn+6gax7neHo1a -WmrgqiWzGcmS0iDRPZ/kXG/bGBlxV/cTpvSTNx0UejMbdUhQvANaaXyzbLYgPWK6 -+lEphUW2/tG+aOj73UOvVu8CgYA5L8sJz4CUKJeZDTeNauoSzs56i4mZ/OfxU2Hv -S8ROjJlu6ZubUya6Gc4t7DEJGp56xVO5JfLDoeOZFUiEZ8tF2KbTVN4p8hnnMotK -tRU4nM0LyOB3yQk5bIz4LbIM+CG5m+LiQ9Sb//rP7GijUFnLeSbwZbOQfZwn+MUd -BQBfhQKBgQDmuX8tJdPkjE133IhQhZHbHHt6AEQA3aXkFdvPvbYD9VbGTZ8wnpFO -VJrDDWnIKAgO2FerIX9oq+H9a5fggYtTMeAX1cOA6b9SnLmFjt0utxrQKxf7p5I+ -n+EsmcAWfb+KRQwoB0L/mE9Ool14AeJ15kHyNIrCrMPv0J4zoC0Jdg== ------END RSA PRIVATE KEY----- diff --git a/ssl/certificate-authority/ca.srl b/ssl/certificate-authority/ca.srl deleted file mode 100644 index a23185d3..00000000 --- a/ssl/certificate-authority/ca.srl +++ /dev/null @@ -1 +0,0 @@ -F4D74F1607DD3C83 diff --git a/ssl/make-certs.sh b/ssl/make-certs.sh deleted file mode 100755 index 8f1afd19..00000000 --- a/ssl/make-certs.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Generate the root (GIVE IT A PASSWORD IF YOU'RE NOT AUTOMATING SIGNING!): -echo 'MAKING CA' -openssl genrsa -out certificate-authority/ca.key 2048 -openssl req -new -x509 -days 7300 -key certificate-authority/ca.key -sha256 -extensions v3_ca -out certificate-authority/ca.crt - -# Generate the domain key: -openssl genrsa -out server-certs/dev.cac.atat.codes.key 2048 - -echo 'MAKING CSR' -# Generate the certificate signing request -openssl req -nodes -sha256 -new -key server-certs/dev.cac.atat.codes.key -out server-certs/dev.cac.atat.codes.csr -reqexts SAN -config <(cat req.cnf <(printf "[SAN]\nsubjectAltName=DNS.1:dev.cac.atat.codes,DNS.2:cac.atat.codes,DNS.3:backend")) - -# Sign the request with your root key -openssl x509 -sha256 -req -in server-certs/dev.cac.atat.codes.csr -CA certificate-authority/ca.crt -CAkey certificate-authority/ca.key -CAcreateserial -out server-certs/dev.cac.atat.codes.crt -days 7300 -extfile <(cat req.cnf <(printf "[SAN]\nsubjectAltName=DNS.1:dev.cac.atat.codes,DNS.2:cac.atat.codes,DNS.3:backend")) -extensions SAN - -# Check your homework: -openssl verify -CAfile certificate-authority/ca.crt server-certs/dev.cac.atat.codes.crt diff --git a/styles/atat.scss b/styles/atat.scss index 4c8aa263..0134dd89 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -38,6 +38,7 @@ @import "components/dod_login_notice.scss"; @import "components/sticky_cta.scss"; @import "components/error_page.scss"; +@import "components/member_form.scss"; @import "sections/login"; @import "sections/home"; diff --git a/styles/components/_footer.scss b/styles/components/_footer.scss index 2b7fc552..bb248e4d 100644 --- a/styles/components/_footer.scss +++ b/styles/components/_footer.scss @@ -3,8 +3,7 @@ background-color: $color-white; border-top: 1px solid $color-gray-lightest; display: flex; - flex-direction: row; - justify-content: space-between; + flex-direction: row-reverse; align-items: center; padding: $gap * 1.5; position: fixed; @@ -15,19 +14,7 @@ color: $color-gray-dark; font-size: 1.5rem; - &__info { - flex-grow: 1; + &__login { padding-left: 0.8rem; - - &__link { - margin: (-$gap * 2) (-$gap); - font-weight: normal; - - .icon--footer { - @include icon-size(16); - - margin: 0rem 0.8rem 0rem 0rem; - } - } } } diff --git a/styles/components/_forms.scss b/styles/components/_forms.scss index a6e0709a..f11af8f8 100644 --- a/styles/components/_forms.scss +++ b/styles/components/_forms.scss @@ -1,7 +1,8 @@ // Form Grid .form-row { margin: ($gap * 4) 0; - &--separated { + + &--bordered { border-bottom: $color-gray-lighter 1px solid; } @@ -202,3 +203,7 @@ display: inline; } } + +.form-container__half { + max-width: 46rem; +} diff --git a/styles/components/_global_navigation.scss b/styles/components/_global_navigation.scss index 63bf0d41..4b90c7dd 100644 --- a/styles/components/_global_navigation.scss +++ b/styles/components/_global_navigation.scss @@ -4,18 +4,4 @@ height: auto; box-shadow: $box-shadow; margin-bottom: -$footer-height * 2.5; - - .sidenav__link { - padding-right: $gap * 2; - - @include media($large-screen) { - padding-right: $gap * 2; - } - } - - &__context--portfolio { - .sidenav__link { - padding-right: $gap; - } - } } diff --git a/styles/components/_member_form.scss b/styles/components/_member_form.scss new file mode 100644 index 00000000..5cbeaf87 --- /dev/null +++ b/styles/components/_member_form.scss @@ -0,0 +1,61 @@ +.member-form { + text-align: left; + + input[type="checkbox"] + label::before { + margin-left: 0; + } + + .input__inline-fields { + text-align: left; + + .usa-input__choices label { + font-weight: $font-bold; + } + } + + .input__inline-fields { + padding: $gap * 2; + border: 1px solid $color-gray-lighter; + + &.checked { + border: 1px solid $color-blue; + } + + label { + font-weight: $font-bold; + } + + p.usa-input__help { + margin-bottom: 0; + padding-left: 3rem; + } + } + + .user-info { + .usa-input { + width: 45rem; + + input, + label, + .usa-input__message { + max-width: unset; + } + + label .icon-validation { + left: unset; + right: -$gap * 4; + } + + &--validation--phoneExt { + width: 18rem; + } + } + } +} + +#modal--add-app-mem, +#modal--add-portfolio-manager { + .modal__body { + min-width: 75rem; + } +} diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 0da98f17..fffc468f 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -5,13 +5,6 @@ } margin-left: 2 * $gap; - - .line { - box-sizing: border-box; - height: 2px; - width: 100%; - border: 1px solid $color-gray-lightest; - } } .portfolio-header { @@ -40,36 +33,6 @@ } } - &__budget { - font-size: $small-font-size; - align-items: center; - - .icon-tooltip { - margin-left: -$gap / 2; - } - - button { - margin: 0; - padding: 0; - } - - &--dollars { - font-size: $h2-font-size; - font-weight: bold; - } - - &--amount { - white-space: nowrap; - } - - &--cents { - font-size: 2rem; - margin-top: 0.75rem; - margin-left: -0.7rem; - font-weight: bold; - } - } - .links { justify-content: center; font-size: $small-font-size; @@ -109,22 +72,6 @@ } } } - - .column-left { - width: 12.5rem; - float: left; - } - - .column-right { - margin-left: -0.4rem; - } - - .unfunded { - color: $color-red; - .icon { - @include icon-color($color-red); - } - } } @mixin subheading { @@ -138,6 +85,10 @@ .portfolio-content { margin: (4 * $gap) $gap 0 $gap; + .panel { + padding-bottom: 2rem; + } + a.add-new-button { display: inherit; margin-left: auto; @@ -157,44 +108,6 @@ } } - input.usa-button.usa-button-primary { - width: 9rem; - height: 4rem; - } - - select { - padding-left: 1.2rem; - } - - .members-table-ppoc { - select::-ms-expand { - display: none; - color: $color-gray; - } - - select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - display: block; - width: 100%; - float: right; - margin: 5px 0px; - padding: 0px 24px; - background-image: none; - -ms-word-break: normal; - word-break: normal; - padding-right: 3rem; - padding-left: 1.2rem; - color: $color-gray; - } - - select:hover { - box-shadow: none; - color: $color-gray; - } - } - a.modal-link.icon-link { float: right; @@ -607,3 +520,28 @@ margin-right: $gap * 3; } } + +.summary-item { + border-right: 1px solid $color-gray-light; + margin-right: $gap * 3; + padding-right: $gap * 3; + &:last-child { + border-right: none; + margin-right: 0; + padding-right: 0; + } + &__header { + margin: 0; + &-icon { + margin: 0; + padding: 0; + } + } + &__value { + font-size: $lead-font-size; + &--large { + font-size: 3.4rem; + font-weight: $font-bold; + } + } +} diff --git a/styles/components/_sticky_cta.scss b/styles/components/_sticky_cta.scss index ce05144f..a62dc326 100644 --- a/styles/components/_sticky_cta.scss +++ b/styles/components/_sticky_cta.scss @@ -63,4 +63,8 @@ font-size: $small-font-size; font-weight: $font-bold; } + + &--link { + font-weight: $font-bold; + } } diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 9e180685..1f5db0ff 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,88 +12,44 @@ flex-direction: row; align-items: stretch; justify-content: space-between; + } - .topbar__link { - color: $color-white; - display: inline-flex; - align-items: center; - height: $topbar-height; - padding: 0 ($gap * 2); + &__link { + color: $color-white !important; + display: inline-flex; + align-items: center; + height: $topbar-height; + padding: 0 ($gap * 2); + text-decoration: none; + + &-label { + @include h5; + text-decoration: underline; + padding-left: $gap; text-decoration: none; - - &-label { - @include h5; - text-decoration: underline; - padding-left: $gap; - text-decoration: none; - } - - &-icon { - margin-left: $gap; - - @include icon-color($color-white); - } - - &--home { - padding-left: $gap / 2; - } - - &--shield { - width: $icon-bar-width; - justify-content: center; - padding: 0; - - .topbar__link-icon { - margin: 0; - } - } - - &:hover { - background-color: $color-primary-darker; - color: $color-white; - } } - .topbar__context { - display: flex; - flex-grow: 1; - flex-direction: row; - align-items: stretch; - justify-content: flex-end; + &-icon { + margin-left: $gap; - .topbar__portfolio-menu { - margin-right: auto; - position: relative; - } + @include icon-color($color-white); + } + + &--home { + padding-left: $gap / 2; + } + + &:hover { + background-color: $color-primary-darker; + color: $color-white; } } - &.topbar--public { - background-color: $color-primary; - - .topbar__navigation { - justify-content: flex-end; - -ms-flex-pack: justify; - } - - .topbar__link { - color: $color-white; - - &-icon { - @include icon-style-inverted; - } - - &--home { - padding-left: $gap; - } - - &:first-child { - margin-right: auto; - } - - &:hover { - background-color: $color-primary-darker; - } - } + &__context { + display: flex; + flex-grow: 1; + flex-direction: row; + align-items: stretch; + justify-content: flex-end; } } diff --git a/styles/core/_util.scss b/styles/core/_util.scss index ff6e8e3f..5203da45 100644 --- a/styles/core/_util.scss +++ b/styles/core/_util.scss @@ -88,5 +88,9 @@ p { hr { border: 0; border-bottom: 1px solid $color-gray-light; - margin: ($gap * 3) ($site-margins * -4); + margin: ($gap * 3) 0; + + &.full-width { + margin: ($gap * 3) ($site-margins * -4); + } } diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index fe81b498..44fc53c8 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -17,6 +17,7 @@ $usa-banner-height: 2.8rem; $sidenav-expanded-width: 25rem; $sidenav-collapsed-width: 10rem; $max-panel-width: 80rem; +$home-pg-icon-width: 6rem; /* * USWDS Variables diff --git a/styles/elements/_action_group.scss b/styles/elements/_action_group.scss index 1f79f7d2..fe375f67 100644 --- a/styles/elements/_action_group.scss +++ b/styles/elements/_action_group.scss @@ -12,6 +12,7 @@ .usa-button, a { margin: 0 0 0 $gap; + cursor: pointer; @include media($medium-screen) { margin: 0 0 0 ($gap * 2); @@ -46,7 +47,7 @@ background: white; right: 0; padding-right: $gap * 4; - border-top: 1px solid $color-gray-light; + border-top: 1px solid $color-gray-lighter; width: 100%; z-index: 1; } diff --git a/styles/elements/_icons.scss b/styles/elements/_icons.scss index c7a43414..b2980227 100644 --- a/styles/elements/_icons.scss +++ b/styles/elements/_icons.scss @@ -94,4 +94,19 @@ &--primary { @include icon-color($color-primary); } + + &--home-pg-badge { + @include icon-size(27); + @include icon-color($color-white); + + background-color: $color-primary; + height: $home-pg-icon-width; + width: $home-pg-icon-width; + border-radius: 100%; + display: inline-flex; + + svg { + margin: auto; + } + } } diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index 47cc4f3c..a5040e41 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -165,6 +165,15 @@ margin-top: 0; margin-bottom: 0; } + + label { + margin-left: 3rem; + + &:before { + position: absolute; + left: -3rem; + } + } } select { diff --git a/styles/elements/_sidenav.scss b/styles/elements/_sidenav.scss index 0a6b201c..23a4f05f 100644 --- a/styles/elements/_sidenav.scss +++ b/styles/elements/_sidenav.scss @@ -1,10 +1,4 @@ -@mixin sidenav__header { - padding: $gap ($gap * 2); - font-weight: bold; -} - .sidenav-container { - box-shadow: $box-shadow; overflow: hidden; position: relative; top: $topbar-height + $usa-banner-height; @@ -14,187 +8,11 @@ @extend .sidenav-container; width: $sidenav-collapsed-width; } +} - &__fixed { - position: fixed; - } - - .sidenav { - width: $sidenav-expanded-width; - - @include media($large-screen) { - margin: 0px; - } - - &__title { - @include sidenav__header; - - font-size: $h3-font-size; - text-transform: uppercase; - width: 50%; - color: $color-gray-dark; - opacity: 0.54; - } - - &__toggle { - @include sidenav__header; - - font-size: $small-font-size; - line-height: 2.8rem; - float: right; - color: $color-blue-darker; - - .toggle-arrows { - vertical-align: middle; - } - } - - ul { - &.sidenav__list--padded { - margin-top: 4 * $gap; - margin-bottom: $footer-height; - padding-bottom: $gap; - position: fixed; - overflow-y: scroll; - top: $topbar-height + $usa-banner-height + 4rem; - bottom: 0; - left: 0; - width: $sidenav-expanded-width; - background-color: $color-white; - } - - list-style: none; - padding: 0; - - li { - margin: 0; - display: block; - } - } - - &__divider--small { - display: block; - width: 4 * $gap; - border: 1px solid #d6d7d9; - margin-left: 2 * $gap; - margin-bottom: $gap; - } - - &__text { - margin: 2 * $gap; - color: $color-gray; - font-style: italic; - } - - &__link { - display: block; - padding: $gap ($gap * 2); - color: $color-black; - text-decoration: underline; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &-icon { - margin-left: -($gap * 0.5); - } - - &--disabled { - color: $color-shadow; - pointer-events: none; - } - - &--add { - color: $color-blue; - font-size: $small-font-size; - - .icon { - @include icon-color($color-blue); - @include icon-size(14); - } - } - - &--active { - @include h4; - - color: $color-primary; - background-color: $color-aqua-lightest; - box-shadow: inset ($gap / 2) 0 0 0 $color-primary; - - .sidenav__link-icon { - @include icon-style-active; - } - - position: relative; - - &_indicator .icon { - @include icon-color($color-primary); - - position: absolute; - right: 0; - } - - + ul { - background-color: $color-primary; - - .sidenav__link { - color: $color-white; - background-color: $color-primary; - - &:hover { - background-color: $color-blue-darker; - } - - &--active { - @include h5; - - color: $color-white; - background-color: $color-primary; - box-shadow: none; - } - - .icon { - @include icon-color($color-white); - } - } - } - } - - + ul { - li { - .sidenav__link { - @include h5; - - padding: $gap * 0.75; - padding-left: 4.5rem; - border: 0; - font-weight: normal; - - .sidenav__link-icon { - @include icon-size(12); - - flex-shrink: 0; - margin-right: 1.5rem; - margin-left: -3rem; - } - - .sidenav__link-label { - padding-left: 0; - } - } - } - } - - &:hover { - color: $color-primary; - background-color: $color-aqua-lightest; - - .sidenav__link-icon { - @include icon-style-active; - } - } - } - } +.sidenav { + width: $sidenav-expanded-width; + position: fixed; &--minimized { @extend .sidenav; @@ -202,4 +20,110 @@ width: $sidenav-collapsed-width; margin: 0px; } + + @include media($large-screen) { + margin: 0px; + } + + &__header { + padding: $gap ($gap * 2); + font-weight: bold; + border-bottom: 1px solid $color-gray-lighter; + + &--minimized { + @extend .sidenav__header; + + padding: $gap; + width: $sidenav-collapsed-width; + } + } + + &__title { + font-size: $h6-font-size; + text-transform: uppercase; + width: 50%; + color: $color-gray-dark; + opacity: 0.54; + white-space: nowrap; + padding: $gap; + display: inline-flex; + align-items: center; + } + + &__toggle { + font-size: $small-font-size; + color: $color-blue; + text-decoration: none; + padding: $gap; + display: inline-flex; + align-items: center; + } + + &__toggle-arrows { + vertical-align: middle; + @include icon-size(20); + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + } + + &__list { + margin-top: 3 * $gap; + margin-bottom: $footer-height; + padding-bottom: ($gap * 2); + position: fixed; + overflow-y: scroll; + top: $topbar-height + $usa-banner-height + 4rem; + bottom: 0; + left: 0; + width: $sidenav-expanded-width; + background-color: $color-white; + list-style: none; + padding: 0; + + &--no-header { + top: $topbar-height + $usa-banner-height; + } + } + + &__text { + margin: 2 * $gap; + color: $color-gray; + font-style: italic; + } + + &__item { + margin: 0; + display: block; + color: $color-black-light !important; + } + + &__link { + display: block; + padding: $gap ($gap * 2); + white-space: nowrap; + overflow: hidden; + color: $color-black-light !important; + text-decoration: none; + text-overflow: ellipsis; + + &--active { + @include h4; + + background-color: $color-aqua-lightest !important; + color: $color-primary-darker !important; + box-shadow: inset ($gap / 2) 0 0 0 $color-primary-darker; + position: relative; + } + + &:hover { + color: $color-primary !important; + background-color: $color-aqua-lightest; + } + } } diff --git a/styles/elements/_tables.scss b/styles/elements/_tables.scss index f313756c..2a742929 100644 --- a/styles/elements/_tables.scss +++ b/styles/elements/_tables.scss @@ -21,24 +21,8 @@ table.atat-table { text-align: right; } - &--align-center { - text-align: center; - } - - &--shrink { - width: 1%; - } - - &--expand { - width: 100%; - } - - &--hide-small { - display: none; - - @include media($medium-screen) { - display: table-cell; - } + &--third { + width: 33%; } } } @@ -53,7 +37,6 @@ table.atat-table { padding: $gap * 2; border: 1px solid $color-gray-lighter; display: table-cell; - white-space: nowrap; vertical-align: top; &:first-child { @@ -84,28 +67,6 @@ table.atat-table { @include panel-margin; - &__header { - @include panel-base; - @include panel-theme-default; - - border-top: none; - border-bottom: 0; - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - padding: $gap * 2; - - &__title { - @include h4; - - font-size: $lead-font-size; - justify-content: space-between; - flex: 2; - } - } - table { margin-bottom: 0; } diff --git a/styles/sections/_application_edit.scss b/styles/sections/_application_edit.scss index 8ff79b7b..8282cb64 100644 --- a/styles/sections/_application_edit.scss +++ b/styles/sections/_application_edit.scss @@ -23,66 +23,6 @@ } } -#modal--add-app-mem, -.form-content--app-mem { - text-align: left; - - .modal__body { - min-width: 75rem; - } - - input[type="checkbox"] + label::before { - margin-left: 0; - } - - .input__inline-fields { - text-align: left; - - .usa-input__choices label { - font-weight: $font-bold; - } - } - - .input__inline-fields { - padding: $gap * 2; - border: 1px solid $color-gray-lighter; - - &.checked { - border: 1px solid $color-blue; - } - - label { - font-weight: $font-bold; - } - - p.usa-input__help { - margin-bottom: 0; - padding-left: 3rem; - } - } - - .application-member__user-info { - .usa-input { - width: 45rem; - - input, - label, - .usa-input__message { - max-width: unset; - } - - label .icon-validation { - left: unset; - right: -$gap * 4; - } - - &--validation--phoneExt { - width: 18rem; - } - } - } -} - .environment-roles { padding: 0 ($gap * 3) ($gap * 3); diff --git a/styles/sections/_home.scss b/styles/sections/_home.scss index 1ca33efc..b0d715d2 100644 --- a/styles/sections/_home.scss +++ b/styles/sections/_home.scss @@ -1,49 +1,25 @@ .home { + margin: $gap * 3; .sticky-cta { margin: -1.6rem -1.6rem 0 -1.6rem; } -} -.about-cloud { - margin: 4rem auto; - max-width: 900px; -} + &__content { + margin: 4rem; + max-width: 900px; -.your-project { - margin-top: 3rem; - padding: 3rem; - background-color: $color-gray-lightest; + &--descriptions { + .col { + margin-left: $home-pg-icon-width; + padding: ($gap * 2) ($gap * 4); + position: relative; - h2 { - margin-top: 0; - } - - .links { - justify-content: flex-start; - - .icon-link { - padding: $gap ($gap * 4); - - &:first-child { - padding-left: 0; - } - - &:last-child { - padding-right: 0; - } - - &:hover { - background-color: transparent; - color: $color-gray-dark; - - .svg * { - fill: $color-gray-dark; + .icon--home-pg-badge { + position: absolute; + left: -$home-pg-icon-width; + top: $gap * 3; } } - - &.active:hover { - color: $color-blue; - } } } } @@ -112,8 +88,3 @@ } } } - -#jedi-heirarchy { - max-width: 65rem; - margin-top: $gap * 8; -} diff --git a/styles/sections/_reports.scss b/styles/sections/_reports.scss index ee43ffa2..699daac6 100644 --- a/styles/sections/_reports.scss +++ b/styles/sections/_reports.scss @@ -118,27 +118,6 @@ } } - .reporting-summary-item { - border-right: 1px solid $color-gray-light; - margin-right: $gap * 3; - padding-right: $gap * 3; - &:last-child { - border-right: none; - margin-right: 0; - padding-right: 0; - } - &__header { - margin: 0; - &-icon { - margin: 0; - padding: 0; - } - } - &__value { - font-size: $lead-font-size; - } - } - .reporting-expended-funding { &__header { margin: 0; diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index 228bf126..79f391e0 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -20,10 +20,7 @@ } &__header { - .h2, - p { - margin-bottom: $gap * 0.5; - } + margin-bottom: $gap * 6; } .col { @@ -39,22 +36,6 @@ margin-top: $gap * 2; } - .task-order__review { - .h2 { - margin-bottom: $gap * 4; - } - - .task-order__number { - text-align: right; - } - - .totals-box { - flex-grow: unset; - display: table; - min-width: 350px; - } - } - .card { padding: ($gap * 4) ($gap * 5) 0; @@ -155,6 +136,10 @@ } } } + + &__confirmation { + margin-left: $gap * 8; + } } .task-order__modal-cancel { @@ -170,3 +155,16 @@ padding-bottom: $gap * 2.5; } } + +table.clin-summary { + tbody, + thead { + tr { + &:last-child { + td { + border-bottom: none; + } + } + } + } +} diff --git a/templates/applications/fragments/member_form_fields.html b/templates/applications/fragments/member_form_fields.html index 20ecc210..707653fa 100644 --- a/templates/applications/fragments/member_form_fields.html +++ b/templates/applications/fragments/member_form_fields.html @@ -100,7 +100,7 @@ {{ CheckboxInput(form.perms_env_mgmt, classes="input__inline-fields", key=env_mgmt, id=env_mgmt, optional=True) }} {{ CheckboxInput(form.perms_del_env, classes="input__inline-fields", key=del_env, id=del_env, optional=True) }} -
+

{{ "portfolios.applications.members.form.env_access.title" | translate }}

@@ -118,7 +118,7 @@ {% endmacro %} {% macro InfoFields(member_form) %} -

-
+
{{ TextInput(form.description, paragraph=True, optional=True) }} diff --git a/templates/applications/new/step_2.html b/templates/applications/new/step_2.html index bb622f8f..462c0f46 100644 --- a/templates/applications/new/step_2.html +++ b/templates/applications/new/step_2.html @@ -19,7 +19,7 @@

{{ 'portfolios.applications.new.step_2_description' | translate }}

-
+
{{ 'portfolios.applications.environments_heading' | translate }}
@@ -58,9 +58,9 @@ {{ Icon("plus") }}
-
+
- + {% block next_button %} {{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }} diff --git a/templates/applications/new/step_3.html b/templates/applications/new/step_3.html index 06dd4e0d..a4e1aa53 100644 --- a/templates/applications/new/step_3.html +++ b/templates/applications/new/step_3.html @@ -15,7 +15,7 @@

{{ ('portfolios.applications.new.step_3_description' | translate) }}

-
+
{{ MemberManagementTemplate( application, diff --git a/templates/applications/settings.html b/templates/applications/settings.html index c8e41fcd..e10c5a50 100644 --- a/templates/applications/settings.html +++ b/templates/applications/settings.html @@ -13,6 +13,9 @@ {% block application_content %} + {% if show_flash -%} + {% include "fragments/flash.html" %} + {%- endif %}

{{ 'portfolios.applications.settings.name_description' | translate }}

{% if user_can(permissions.EDIT_APPLICATION) %} @@ -59,59 +62,8 @@ environments_obj, new_env_form) }} - {% if user_can(permissions.DELETE_APPLICATION) %} - {% set env_count = application.environments | length %} - {% if env_count == 1 %} - {% set pluralized_env = "environment" %} - {% else %} - {% set pluralized_env = "environments" %} - {% endif %} - -

- {{ "portfolios.applications.delete.subheading" | translate }} -

-
-
- {{ "portfolios.applications.delete.text" | translate({"application_name": application.name}) | safe }} -
-
-
- -
-
-
- - {% call Modal(name="delete-application") %} -

{{ "portfolios.applications.delete.header" | translate }}

-
- {{ - Alert( - title=("components.modal.destructive_title" | translate), - message=("portfolios.applications.delete.alert.message" | translate), - level="warning" - ) - }} - - {{ - DeleteConfirmation( - modal_id="delete_application", - delete_text=('portfolios.applications.delete.button' | translate), - delete_action= url_for('applications.delete', application_id=application.id), - form=application_form - ) - }} - {% endcall %} - {% endif %} - -
- {% if user_can(permissions.VIEW_APPLICATION_ACTIVITY_LOG) and config.get("USE_AUDIT_LOG", False) %} +
{% include "fragments/audit_events_log.html" %} {{ Pagination(audit_events, url=url_for('applications.settings', application_id=application.id)) }} {% endif %} diff --git a/templates/base.html b/templates/base.html index fabcb92c..f4964add 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,9 +21,7 @@ {% include 'navigation/topbar.html' %}
- {% if portfolios %} - {% include 'navigation/global_sidenav.html' %} - {% endif %} + {% include 'navigation/global_sidenav.html' %}
{% block sidenav %}{% endblock %} diff --git a/templates/components/label.html b/templates/components/label.html index ddf9dce7..27f1c1b1 100644 --- a/templates/components/label.html +++ b/templates/components/label.html @@ -9,11 +9,15 @@ "text": "changes pending", "color": "default", }, + "ppoc": {"text": "primary point of contact"} } %} - {% if type -%} + {% if type in label_info.keys() -%} - {{ Icon(label_info[type]["icon"]) }} {{ label_info[type]["text"] }} + {% if label_info[type]["icon"] %} + {{ Icon(label_info[type]["icon"]) }} + {% endif %} + {{ label_info[type]["text"] }} {%- endif %} {%- endmacro %} diff --git a/templates/components/member_form.html b/templates/components/member_form.html new file mode 100644 index 00000000..f68c5eed --- /dev/null +++ b/templates/components/member_form.html @@ -0,0 +1,65 @@ + +{% macro MemberForm(title=None, next_button=None, previous=True, modal=modal) %} +
+
+ {% if title %}

{{ title }}

{% endif %} + + {{ caller() }} +
+
+ {{ next_button }} + {% if previous %} + + {% endif %} + {{ "common.cancel" | translate }} +
+{% endmacro %} + + + +{% macro BasicStep( + title=None, + form=form, + next_button_text=next_button_text, + previous=True, + modal=modal +) %} + {% set next_button %} + + {% endset %} + + {% call MemberForm(title=title, next_button=next_button, previous=previous, modal=modal) %} + {{ form }} + {% endcall %} +{% endmacro %} + +{% macro SubmitStep( + name=name, + title=None, + form=form, + submit_text=submit_text, + previous=True, + modal=modal +) %} + {% set next_button %} + + {% endset %} + + {% call MemberForm(title=title, next_button=next_button, previous=previous, modal=modal) %} + {{ form }} + {% endcall %} +{% endmacro %} diff --git a/templates/components/sidenav_item.html b/templates/components/sidenav_item.html index 8e3c5135..82fec932 100644 --- a/templates/components/sidenav_item.html +++ b/templates/components/sidenav_item.html @@ -1,35 +1,9 @@ -{% from "components/icon.html" import Icon %} - -{% macro SidenavItem(label, href, active=False, icon=None, subnav=None) -%} -
  • - - {% if icon %} - {{ Icon(icon, classes="sidenav__link-icon") }} - {% endif %} - - - {{label}} - - {% if active %} - - {{ Icon("caret_right") }} +{% macro SidenavItem(label, href, active=False) -%} +
  • + + + {{label}} - {% endif %} - - - {% if subnav and active %} - - {% endif %} +
  • {%- endmacro %} diff --git a/templates/components/tooltip.html b/templates/components/tooltip.html index 08a6f92f..052299f6 100644 --- a/templates/components/tooltip.html +++ b/templates/components/tooltip.html @@ -3,7 +3,7 @@ {% macro Tooltip(message,title='Help', classes="") %} {%- endmacro %} diff --git a/templates/footer.html b/templates/footer.html index 4e07c0a7..be67bf8b 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -1,14 +1,8 @@ {% from "components/icon.html" import Icon %}