Merge branch 'staging' into 170505212-uwsgi-logs

This commit is contained in:
dandds 2020-01-16 16:58:52 -05:00 committed by GitHub
commit 2254e0dd01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
162 changed files with 2502 additions and 3687 deletions

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null
},
"generated_at": "2019-12-18T22:26:52Z",
"generated_at": "2020-01-09T16:55:07Z",
"plugins_used": [
{
"base64_limit": 4.5,
@ -25,15 +25,6 @@
}
],
"results": {
"Pipfile.lock": [
{
"hashed_secret": "526c14b5cd73155d72784fec39907f9b7d5ddcdd",
"is_secret": false,
"is_verified": false,
"line_number": 4,
"type": "Hex High Entropy String"
}
],
"README.md": [
{
"hashed_secret": "d141ce86b0584abb29ee7c24af9afb1e3d871f04",
@ -111,15 +102,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",

View File

@ -8,7 +8,9 @@ webassets = "*"
Unipath = "*"
pendulum = "*"
redis = "*"
sqlalchemy = ">=1.3.0"
sqlalchemy = ">=1.3.12"
sqlalchemy-json = "*"
pydantic = "*"
alembic = "*"
"psycopg2-binary" = "*"
flask = "*"
@ -30,6 +32,7 @@ msrestazure = "*"
azure-mgmt-authorization = "*"
azure-mgmt-managementgroups = "*"
azure-mgmt-resource = "*"
transitions = "*"
[dev-packages]
bandit = "*"

380
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "c203c47b00f413fd40056ef6d2d8e51b37ad3ff5f7693db5eb170b7f8fd43234"
"sha256": "63b8f9d203f306a6f0ff20514b024909aa7e64917e1befcc9ea79931b5b4bd34"
},
"pipfile-spec": 6,
"requires": {
@ -39,10 +39,10 @@
},
"azure-common": {
"hashes": [
"sha256:53b1195b8f20943ccc0e71a17849258f7781bc6db1c72edc7d6c055f79bd54e3",
"sha256:99ef36e74b6395329aada288764ce80504da16ecc8206cb9a72f55fb02e8b484"
"sha256:184ad6a05a3089dfdc1ce07c1cbfa489bbc45b5f6f56e848cac0851e6443da21",
"sha256:3d64e9ab995300f42abd5bc0ef02f02bab661321e394d4dbacb4382ea1fb2f72"
],
"version": "==1.1.23"
"version": "==1.1.24"
},
"azure-graphrbac": {
"hashes": [
@ -218,10 +218,11 @@
},
"flask-assets": {
"hashes": [
"sha256:6031527b89fb3509d1581d932affa5a79dd348cfffb58d0aef99a43461d47847"
"sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2",
"sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b"
],
"index": "pypi",
"version": "==0.12"
"version": "==2.0"
},
"flask-session": {
"hashes": [
@ -256,11 +257,11 @@
},
"importlib-metadata": {
"hashes": [
"sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45",
"sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"
"sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359",
"sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8"
],
"markers": "python_version < '3.8'",
"version": "==1.3.0"
"version": "==1.4.0"
},
"isodate": {
"hashes": [
@ -339,10 +340,10 @@
},
"more-itertools": {
"hashes": [
"sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d",
"sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"
"sha256:1a2a32c72400d365000412fe08eb4a24ebee89997c18d3d147544f70f5403b39",
"sha256:c468adec578380b6281a114cb8a5db34eb1116277da92d7c46f904f0b52d3288"
],
"version": "==8.0.2"
"version": "==8.1.0"
},
"msrest": {
"hashes": [
@ -422,6 +423,26 @@
],
"version": "==2.19"
},
"pydantic": {
"hashes": [
"sha256:176885123dfdd8f7ab6e7ba1b66d4197de75ba830bb44d921af88b3d977b8aa5",
"sha256:2b32a5f14558c36e39aeefda0c550bfc0f47fc32b4ce16d80dc4df2b33838ed8",
"sha256:2eab7d548b0e530bf65bee7855ad8164c2f6a889975d5e9c4eefd1e7c98245dc",
"sha256:479ca8dc7cc41418751bf10302ee0a1b1f8eedb2de6c4f4c0f3cf8372b204f9a",
"sha256:59235324dd7dc5363a654cd14271ea8631f1a43de5d4fc29c782318fcc498002",
"sha256:87673d1de790c8d5282153cab0b09271be77c49aabcedf3ac5ab1a1fd4dcbac0",
"sha256:8a8e089aec18c26561e09ee6daf15a3cc06df05bdc67de60a8684535ef54562f",
"sha256:b60f2b3b0e0dd74f1800a57d1bbd597839d16faf267e45fa4a5407b15d311085",
"sha256:c0da48978382c83f9488c6bbe4350e065ea5c83e85ca5cfb8fa14ac11de3c296",
"sha256:cbe284bd5ad67333d49ecc0dc27fa52c25b4c2fe72802a5c060b5f922db58bef",
"sha256:d03df07b7611004140b0fef91548878c2b5f48c520a8cb76d11d20e9887a495e",
"sha256:d4bb6a75abc2f04f6993124f1ed4221724c9dc3bd9df5cb54132e0b68775d375",
"sha256:dacb79144bb3fdb57cf9435e1bd16c35586bc44256215cfaa33bf21565d926ae",
"sha256:dd9359db7644317898816f6142f378aa48848dcc5cf14a481236235fde11a148"
],
"index": "pypi",
"version": "==1.3"
},
"pyjwt": {
"hashes": [
"sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
@ -468,20 +489,20 @@
},
"pyyaml": {
"hashes": [
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
"sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
"sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
"sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
"sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
"sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
"sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
"sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
"sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
"sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
"sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
"sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
"sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
"sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
"sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
"sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
"sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
"sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
"sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
"sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
"sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
"sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
],
"index": "pypi",
"version": "==5.2"
"version": "==5.3"
},
"redis": {
"hashes": [
@ -520,6 +541,27 @@
"index": "pypi",
"version": "==1.3.12"
},
"sqlalchemy-json": {
"hashes": [
"sha256:d17952e771eecd9023c0f683d2a6aaa27ce1a6dbf57b0fe2bf4d5aef4c5dad1c"
],
"index": "pypi",
"version": "==0.2.3"
},
"sqlalchemy-utils": {
"hashes": [
"sha256:4e637c88bf3ac5f99b7d72342092a1f636bea1287b2e3e17d441b0413771f86e"
],
"version": "==0.36.1"
},
"transitions": {
"hashes": [
"sha256:011afaefa1244177cad3d960d836c0c4a201403252371bd4c555cf8c17ce7d3c",
"sha256:5566c9d32e438ee9eb1f046e3ac1a0b2689f32807b47859210162084d4c84ab7"
],
"index": "pypi",
"version": "==0.7.2"
},
"unipath": {
"hashes": [
"sha256:09839adcc72e8a24d4f76d63656f30b5a1f721fc40c9bcd79d8c67bdd8b47dae",
@ -544,10 +586,11 @@
},
"webassets": {
"hashes": [
"sha256:e7d9c8887343123fd5b32309b33167428cb1318cdda97ece12d0907fd69d38db"
"sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd",
"sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"
],
"index": "pypi",
"version": "==0.12.1"
"version": "==2.0"
},
"werkzeug": {
"hashes": [
@ -566,10 +609,10 @@
},
"zipp": {
"hashes": [
"sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
"sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
],
"version": "==0.6.0"
"version": "==1.0.0"
}
},
"develop": {
@ -633,12 +676,12 @@
},
"beautifulsoup4": {
"hashes": [
"sha256:5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169",
"sha256:6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931",
"sha256:dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57"
"sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a",
"sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887",
"sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae"
],
"index": "pypi",
"version": "==4.8.1"
"version": "==4.8.2"
},
"black": {
"hashes": [
@ -685,39 +728,39 @@
},
"coverage": {
"hashes": [
"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"
"sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3",
"sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c",
"sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0",
"sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477",
"sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a",
"sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf",
"sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691",
"sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73",
"sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987",
"sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894",
"sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e",
"sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef",
"sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf",
"sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68",
"sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8",
"sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954",
"sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2",
"sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40",
"sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc",
"sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc",
"sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e",
"sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d",
"sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f",
"sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc",
"sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301",
"sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea",
"sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb",
"sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af",
"sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52",
"sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37",
"sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0"
],
"version": "==5.0"
"version": "==5.0.3"
},
"decorator": {
"hashes": [
@ -750,10 +793,10 @@
},
"faker": {
"hashes": [
"sha256:202ad3b2ec16ae7c51c02904fb838831f8d2899e61bf18db1e91a5a582feab11",
"sha256:92c84a10bec81217d9cb554ee12b3838c8986ce0b5d45f72f769da22e4bb5432"
"sha256:047d4d1791bfb3756264da670d99df13d799bb36e7d88774b1585a82d05dbaec",
"sha256:1b1a58961683b30c574520d0c739c4443e0ef6a185c04382e8cc888273dbebed"
],
"version": "==3.0.0"
"version": "==4.0.0"
},
"flask": {
"hashes": [
@ -794,11 +837,11 @@
},
"importlib-metadata": {
"hashes": [
"sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45",
"sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"
"sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359",
"sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8"
],
"markers": "python_version < '3.8'",
"version": "==1.3.0"
"version": "==1.4.0"
},
"ipdb": {
"hashes": [
@ -809,11 +852,11 @@
},
"ipython": {
"hashes": [
"sha256:190a279bd3d4fc585a611e9358a88f1048cc57fd688254a86f9461889ee152a6",
"sha256:762d79a62b6aa96b04971e920543f558dfbeedc0468b899303c080c8068d4ac2"
"sha256:0f4bcf18293fb666df8511feec0403bdb7e061a5842ea6e88a3177b0ceb34ead",
"sha256:387686dd7fc9caf29d2fddcf3116c4b07a11d9025701d220c589a430b0171d8a"
],
"index": "pypi",
"version": "==7.10.2"
"version": "==7.11.1"
},
"ipython-genutils": {
"hashes": [
@ -838,10 +881,10 @@
},
"jedi": {
"hashes": [
"sha256:786b6c3d80e2f06fd77162a07fed81b8baa22dde5d62896a790a331d6ac21a27",
"sha256:ba859c74fa3c966a22f2aeebe1b74ee27e2a462f56d3f5f7ca4a59af61bfe42e"
"sha256:1349c1e8c107095a55386628bb3b2a79422f3a2cab8381e34ce19909e0cf5064",
"sha256:e909527104a903606dd63bea6e8e888833f0ef087057829b89a18364a856f807"
],
"version": "==0.15.1"
"version": "==0.15.2"
},
"jinja2": {
"hashes": [
@ -918,29 +961,30 @@
},
"more-itertools": {
"hashes": [
"sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d",
"sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"
"sha256:1a2a32c72400d365000412fe08eb4a24ebee89997c18d3d147544f70f5403b39",
"sha256:c468adec578380b6281a114cb8a5db34eb1116277da92d7c46f904f0b52d3288"
],
"version": "==8.0.2"
"version": "==8.1.0"
},
"mypy": {
"hashes": [
"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"
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
],
"index": "pypi",
"version": "==0.760"
"version": "==0.761"
},
"mypy-extensions": {
"hashes": [
@ -958,9 +1002,10 @@
},
"pathspec": {
"hashes": [
"sha256:e285ccc8b0785beadd4c18e5708b12bb8fcf529a1e61215b3feff1d1e559ea5c"
"sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424",
"sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"
],
"version": "==0.6.0"
"version": "==0.7.0"
},
"pathtools": {
"hashes": [
@ -1013,10 +1058,10 @@
},
"py": {
"hashes": [
"sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
"sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
],
"version": "==1.8.0"
"version": "==1.8.1"
},
"pygments": {
"hashes": [
@ -1066,11 +1111,11 @@
},
"pytest-mock": {
"hashes": [
"sha256:67e414b3caef7bff6fc6bd83b22b5bc39147e4493f483c2679bc9d4dc485a94d",
"sha256:e24a911ec96773022ebcc7030059b57cd3480b56d4f5d19b7c370ec635e6aed5"
"sha256:b35eb281e93aafed138db25c8772b95d3756108b601947f89af503f8c629413f",
"sha256:cb67402d87d5f53c579263d37971a164743dc33c159dfb4fb4a86f37c5552307"
],
"index": "pypi",
"version": "==1.13.0"
"version": "==2.0.0"
},
"pytest-watch": {
"hashes": [
@ -1088,46 +1133,46 @@
},
"pyyaml": {
"hashes": [
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
"sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
"sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
"sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
"sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
"sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
"sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
"sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
"sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
"sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
"sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
"sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
"sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
"sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
"sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
"sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
"sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
"sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
"sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
"sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
"sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
"sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
],
"index": "pypi",
"version": "==5.2"
"version": "==5.3"
},
"regex": {
"hashes": [
"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"
"sha256:07b39bf943d3d2fe63d46281d8504f8df0ff3fe4c57e13d1656737950e53e525",
"sha256:0932941cdfb3afcbc26cc3bcf7c3f3d73d5a9b9c56955d432dbf8bbc147d4c5b",
"sha256:0e182d2f097ea8549a249040922fa2b92ae28be4be4895933e369a525ba36576",
"sha256:10671601ee06cf4dc1bc0b4805309040bb34c9af423c12c379c83d7895622bb5",
"sha256:23e2c2c0ff50f44877f64780b815b8fd2e003cda9ce817a7fd00dea5600c84a0",
"sha256:26ff99c980f53b3191d8931b199b29d6787c059f2e029b2b0c694343b1708c35",
"sha256:27429b8d74ba683484a06b260b7bb00f312e7c757792628ea251afdbf1434003",
"sha256:3e77409b678b21a056415da3a56abfd7c3ad03da71f3051bbcdb68cf44d3c34d",
"sha256:4e8f02d3d72ca94efc8396f8036c0d3bcc812aefc28ec70f35bb888c74a25161",
"sha256:4eae742636aec40cf7ab98171ab9400393360b97e8f9da67b1867a9ee0889b26",
"sha256:6a6ae17bf8f2d82d1e8858a47757ce389b880083c4ff2498dba17c56e6c103b9",
"sha256:6a6ba91b94427cd49cd27764679024b14a96874e0dc638ae6bdd4b1a3ce97be1",
"sha256:7bcd322935377abcc79bfe5b63c44abd0b29387f267791d566bbb566edfdd146",
"sha256:98b8ed7bb2155e2cbb8b76f627b2fd12cf4b22ab6e14873e8641f266e0fb6d8f",
"sha256:bd25bb7980917e4e70ccccd7e3b5740614f1c408a642c245019cff9d7d1b6149",
"sha256:d0f424328f9822b0323b3b6f2e4b9c90960b24743d220763c7f07071e0778351",
"sha256:d58e4606da2a41659c84baeb3cfa2e4c87a74cec89a1e7c56bee4b956f9d7461",
"sha256:e3cd21cc2840ca67de0bbe4071f79f031c81418deb544ceda93ad75ca1ee9f7b",
"sha256:e6c02171d62ed6972ca8631f6f34fa3281d51db8b326ee397b9c83093a6b7242",
"sha256:e7c7661f7276507bce416eaae22040fd91ca471b5b33c13f8ff21137ed6f248c",
"sha256:ecc6de77df3ef68fee966bb8cb4e067e84d4d1f397d0ef6fce46913663540d77"
],
"version": "==2019.12.19"
"version": "==2020.1.8"
},
"requests": {
"hashes": [
@ -1139,12 +1184,12 @@
},
"rope": {
"hashes": [
"sha256:6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969",
"sha256:c5c5a6a87f7b1a2095fb311135e2a3d1f194f5ecb96900fdd0a9100881f48aaf",
"sha256:f0dcf719b63200d492b85535ebe5ea9b29e0d0b8aebeb87fe03fc1a65924fdaf"
"sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203",
"sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad",
"sha256:d2830142c2e046f5fc26a022fe680675b6f48f81c7fc1f03a950706e746e9dfe"
],
"index": "pypi",
"version": "==0.14.0"
"version": "==0.16.0"
},
"selenium": {
"hashes": [
@ -1205,29 +1250,30 @@
},
"typed-ast": {
"hashes": [
"sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161",
"sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
"sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
"sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
"sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
"sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47",
"sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
"sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
"sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
"sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
"sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2",
"sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e",
"sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
"sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
"sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
"sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
"sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
"sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
"sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66",
"sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
],
"markers": "implementation_name == 'cpython' and python_version < '3.8'",
"version": "==1.4.0"
"version": "==1.4.1"
},
"typing-extensions": {
"hashes": [
@ -1252,10 +1298,10 @@
},
"wcwidth": {
"hashes": [
"sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
"sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
"sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603",
"sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"
],
"version": "==0.1.7"
"version": "==0.1.8"
},
"werkzeug": {
"hashes": [
@ -1273,10 +1319,10 @@
},
"zipp": {
"hashes": [
"sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
"sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
],
"version": "==0.6.0"
"version": "==1.0.0"
}
}
}

70
PortfolioProvision.md Normal file
View File

@ -0,0 +1,70 @@
Each CSP will have a set of "stages" that are required to be completed before the provisioning process can be considered complete.
Azure Stages:
tenant,
billing profile,
admin subscription
etc.
`atst.models.mixins.state_machines` module contains:
python Enum classes that define the stages for a CSP
class AzureStages(Enum):
TENANT = "tenant"
BILLING_PROFILE = "billing profile"
ADMIN_SUBSCRIPTION = "admin subscription"
there are two types of python dataclass subclasses defined in `atst.models.portoflio_state_machine` module.
one holds the data that is submitted to the CSP
@dataclass
class TenantCSPPayload():
user_id: str
password: str
etc.
the other holds the results of the call to the CSP
@dataclass
class TenantCSPResult():
user_id: str
tenant_id: str
user_object_id: str
etc.
A Finite State Machine `atst.models.portoflio_state_machine.PortfolioStateMachine` is created for each provisioning process and tied to an instance of Portfolio class.
Aach time the FSM is created/accessed it will generate a list of States and Transitions between the states.
There is a set of "system" states such as UNSTARTED, STARTING, STARTED, COMPLETED, FAILED etc
There is a set of CSP specific states generated for each "stage" in the FSM.
TENANT_IN_PROGRESS
TENANT_IN_COMPLETED
TENANT_IN_FAILED
BILLING_PROFILE_IN_PROGRESS
BILLING_PROFILE_IN_COMPLETED
BILLING_PROFILE_IN_FAILED
etc.
There is a set of callbacks defined that are triggered as the process transitions between stages.
callback `PortfolioStateMachine.after_in_progress_callback`
The CSP api call is made as the process transitions into IN_PROGESS state for each state.
callback `PortfolioStateMachine.is_csp_data_valid`
validates the collected data.
A transition into the next state can be triggered using PortfolioStateMachine.trigger_next_transition`

View File

@ -362,50 +362,3 @@ fi
Also note that if the line number of a previously whitelisted secret changes, the whitelist file, `.secrets.baseline`, will be updated and needs to be committed.
## Local Kubernetes Setup
A modified version of the Kubernetes cluster can be deployed locally for
testing and development purposes.
It is strongly recommended that you backup your local K8s config (usually
`~/.kube/config`) before launching Minikube for the first time.
Before beginning:
- install the [Docker CLI](https://docs.docker.com/v17.12/install/)
- install [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/)
(this will also require installing a Hypervisor, such as VirtualBox)
### Setup
Run
```
script/minikube_setup
```
Once the script exits successfully, run
```
minikube service list
```
### Access the site
One of the two URLs given for the `atat-auth` service will load an HTTP version
of the application.
For HTTP basic auth, the username and password are both `minikube`.
### Differences from the main config
As of the time of writing, this setup does not include the following:
- SSL/TLS or the complete DoD PKI
- the cronjob for syncing CRLs and the peristent storage
- production configuration
In order for the application to run, the K8s config for Minikube includes an
additional deployment resource called `datastores`. This includes Postgres
and Redis containers. It also includes hard-coded versions of the K8s secrets
used in the regular clusters.

View File

@ -0,0 +1,113 @@
"""portfolio state machine table.
Revision ID: 59973fa17ded
Revises: 828d8c188dce
Create Date: 2020-01-08 10:37:32.924245
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlalchemy_json
# revision identifiers, used by Alembic.
revision = "59973fa17ded" # pragma: allowlist secret
down_revision = "828d8c188dce" # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"portfolio_job_failures",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("task_id", sa.String(), nullable=False),
sa.Column("portfolio_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(["portfolio_id"], ["portfolios.id"],),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"portfolio_state_machines",
sa.Column(
"time_created",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"time_updated",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False
),
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
server_default=sa.text("uuid_generate_v4()"),
nullable=False,
),
sa.Column("portfolio_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"state",
sa.Enum(
"UNSTARTED",
"STARTING",
"STARTED",
"COMPLETED",
"FAILED",
"TENANT_CREATED",
"TENANT_IN_PROGRESS",
"TENANT_FAILED",
"BILLING_PROFILE_CREATED",
"BILLING_PROFILE_IN_PROGRESS",
"BILLING_PROFILE_FAILED",
"ADMIN_SUBSCRIPTION_CREATED",
"ADMIN_SUBSCRIPTION_IN_PROGRESS",
"ADMIN_SUBSCRIPTION_FAILED",
name="fsmstates",
native_enum=False,
),
nullable=False,
),
sa.ForeignKeyConstraint(["portfolio_id"], ["portfolios.id"],),
sa.PrimaryKeyConstraint("id"),
)
op.add_column("portfolios", sa.Column("app_migration", sa.String(), nullable=True))
op.add_column(
"portfolios", sa.Column("complexity", sa.ARRAY(sa.String()), nullable=True)
)
op.add_column(
"portfolios", sa.Column("complexity_other", sa.String(), nullable=True)
)
op.add_column(
"portfolios",
sa.Column("csp_data", sqlalchemy_json.NestedMutableJson(), nullable=True),
)
op.add_column(
"portfolios", sa.Column("dev_team", sa.ARRAY(sa.String()), nullable=True)
)
op.add_column("portfolios", sa.Column("dev_team_other", sa.String(), nullable=True))
op.add_column("portfolios", sa.Column("native_apps", sa.String(), nullable=True))
op.add_column(
"portfolios", sa.Column("team_experience", sa.String(), nullable=True)
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("portfolios", "team_experience")
op.drop_column("portfolios", "native_apps")
op.drop_column("portfolios", "dev_team_other")
op.drop_column("portfolios", "dev_team")
op.drop_column("portfolios", "csp_data")
op.drop_column("portfolios", "complexity_other")
op.drop_column("portfolios", "complexity")
op.drop_column("portfolios", "app_migration")
op.drop_table("portfolio_state_machines")
op.drop_table("portfolio_job_failures")
# ### end Alembic commands ###

View File

@ -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 ###

View File

@ -0,0 +1,58 @@
"""update environment_roles enum list
Revision ID: 828d8c188dce
Revises: 5d7198d34b91
Create Date: 2020-01-08 16:08:03.879881
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '828d8c188dce' # pragma: allowlist secret
down_revision = '5d7198d34b91' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute(
"""
UPDATE environment_roles
SET role = NULL
"""
)
op.alter_column(
"environment_roles",
"role",
type_=sa.Enum(
"ADMIN",
"BILLING_READ",
"CONTRIBUTOR",
name="role",
native_enum=False,
),
existing_type=sa.VARCHAR(),
nullable=True,
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"environment_roles",
"status",
type_=sa.VARCHAR(),
existing_type=sa.Enum(
"ADMIN",
"BILLING_READ",
"CONTRIBUTOR",
name="status",
native_enum=False,
),
)
# ### end Alembic commands ###

View File

@ -159,6 +159,7 @@ def map_config(config):
"ENV": config["default"]["ENVIRONMENT"],
"BROKER_URL": config["default"]["REDIS_URI"],
"DEBUG": config["default"].getboolean("DEBUG"),
"DEBUG_MAILER": config["default"].getboolean("DEBUG_MAILER"),
"SQLALCHEMY_ECHO": config["default"].getboolean("SQLALCHEMY_ECHO"),
"PORT": int(config["default"]["PORT"]),
"SQLALCHEMY_DATABASE_URI": config["default"]["DATABASE_URI"],
@ -289,7 +290,7 @@ def make_crl_validator(app):
def make_mailer(app):
if app.config["DEBUG"]:
if app.config["DEBUG"] or app.config["DEBUG_MAILER"]:
mailer_connection = mailer.RedisConnection(app.redis)
else:
mailer_connection = mailer.SMTPConnection(

View File

@ -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):

View File

@ -1,3 +1,5 @@
import importlib
from .cloud import MockCloudProvider
from .file_uploads import AzureUploader, MockUploader
from .reports import MockReportingProvider
@ -29,3 +31,22 @@ def make_csp_provider(app, csp=None):
app.csp = MockCSP(app, test_mode=True)
else:
app.csp = MockCSP(app)
def _stage_to_classname(stage):
return "".join(
map(lambda word: word.capitalize(), stage.replace("_", " ").split(" "))
)
def get_stage_csp_class(stage, class_type):
"""
given a stage name and class_type return the class
class_type is either 'payload' or 'result'
"""
cls_name = "".join([_stage_to_classname(stage), "CSP", class_type.capitalize()])
try:
return getattr(importlib.import_module("atst.domain.csp.cloud"), cls_name)
except AttributeError:
print("could not import CSP Result class <%s>" % cls_name)

View File

@ -1,12 +1,12 @@
from typing import Dict
import re
from typing import Dict
from uuid import uuid4
from pydantic import BaseModel
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):
@ -142,6 +142,97 @@ class BaselineProvisionException(GeneralCSPException):
)
class BaseCSPPayload(BaseModel):
# {"username": "mock-cloud", "pass": "shh"}
creds: Dict
class TenantCSPPayload(BaseCSPPayload):
user_id: str
password: str
domain_name: str
first_name: str
last_name: str
country_code: str
password_recovery_email_address: str
class TenantCSPResult(BaseModel):
user_id: str
tenant_id: str
user_object_id: str
class BillingProfileAddress(BaseModel):
address: Dict
"""
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"""
class BillingProfileCLINBudget(BaseModel):
clinBudget: Dict
"""
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
"""
class BillingProfileCSPPayload(
BaseCSPPayload, BillingProfileAddress, BillingProfileCLINBudget
):
displayName: str
poNumber: str
invoiceEmailOptIn: str
"""
{
"displayName": "string",
"poNumber": "string",
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"invoiceEmailOptIn": true,
Note: These last 2 are also the body for adding/updating new TOs/clins
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
class CloudProviderInterface:
def root_creds(self) -> Dict:
raise NotImplementedError()
@ -325,6 +416,68 @@ class MockCloudProvider(CloudProviderInterface):
return {"id": self._id(), "credentials": self._auth_credentials}
def create_tenant(self, payload):
"""
payload is an instance of TenantCSPPayload data class
"""
self._authorize(payload.creds)
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
# return tenant id, tenant owner id and tenant owner object id from:
response = {"tenantId": "string", "userId": "string", "objectId": "string"}
return {
"tenant_id": response["tenantId"],
"user_id": response["userId"],
"user_object_id": response["objectId"],
}
def create_billing_profile(self, creds, tenant_admin_details, billing_owner_id):
# call billing profile creation endpoint, specifying owner
# Payload:
"""
{
"displayName": "string",
"poNumber": "string",
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"invoiceEmailOptIn": true,
Note: These last 2 are also the body for adding/updating new TOs/clins
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
# response will be mostly the same as the body, but we only really care about the id
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
response = {"id": "string"}
return {"billing_profile_id": response["id"]}
def create_or_update_user(self, auth_credentials, user_info, csp_role_id):
self._authorize(auth_credentials)
@ -401,18 +554,15 @@ 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
from azure.mgmt import subscription, authorization
import azure.graphrbac as graphrbac
import azure.common.credentials as credentials
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
self.subscription = subscription
self.authorization = authorization
self.managementgroups = managementgroups
self.graphrbac = graphrbac
self.credentials = credentials
self.policy = policy
# may change to a JEDI cloud
self.cloud = AZURE_PUBLIC_CLOUD
@ -430,28 +580,45 @@ 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
):
# since this operation would only occur within a tenant, should we source the tenant
# via lookup from environment once we've created the portfolio csp data schema
# something like this:
# environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None)
# though we'd probably source the whole credentials for these calls from the portfolio csp
# data, as it would have to be where we store the creds for the at-at user within the portfolio tenant
# credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds())
credentials = self._get_credential_obj(self._root_creds)
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
management_group_id = "?" # management group id chained from environment
parent_id = "?" # from environment.application
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
management_group = self._create_management_group(
credentials, management_group_id, display_name, parent_id,
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
billing_profile_id = "?" # something chained from environment?
sku_id = AZURE_SKU_ID
# we want to set AT-AT as an owner here
# we could potentially associate subscriptions with "management groups" per DOD component
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
display_name,
billing_profile_id,
sku_id,
# owner=<AdPrincipal: for AT-AT user>
)
return management_group
# These 2 seem like something that might be worthwhile to allow tiebacks to
# TOs filed for the environment
billing_account_name = "?"
invoice_section_name = "?"
# We may also want to create billing sections in the enrollment account
sub_creation_operation = sub_client.subscription_factory.create_subscription(
billing_account_name, invoice_section_name, body
)
# the resulting object from this process is a link to the new subscription
# not a subscription model, so we'll have to unpack the ID
new_sub = sub_creation_operation.result()
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
if subscription_id:
return subscription_id
else:
# troublesome error, subscription should exist at this point
# but we just don't have a valid ID
pass
def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
@ -490,126 +657,135 @@ class AzureCloudProvider(CloudProviderInterface):
"role_name": role_assignment_id,
}
def _create_application(self, auth_credentials: Dict, application: Application):
management_group_name = str(uuid4()) # can be anything, not just uuid
display_name = application.name # Does this need to be unique?
credentials = self._get_credential_obj(auth_credentials)
parent_id = "?" # application.portfolio.csp_details.management_group_id
def create_tenant(self, payload):
# auth as SP that is allowed to create tenant? (tenant creation sp creds)
# create tenant with owner details (populated from portfolio point of contact, pw is generated)
return self._create_management_group(
credentials, management_group_name, display_name, parent_id,
# return tenant id, tenant owner id and tenant owner object id from:
response = {"tenantId": "string", "userId": "string", "objectId": "string"}
return self._ok(
{
"tenant_id": response["tenantId"],
"user_id": response["userId"],
"user_object_id": response["objectId"],
}
)
def _create_management_group(
self, credentials, management_group_id, display_name, parent_id=None,
):
mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials)
create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo(
id=parent_id
)
create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails(
parent=create_parent_grp_info
)
mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest(
name=management_group_id,
display_name=display_name,
details=create_mgmt_grp_details,
)
create_request = mgmgt_group_client.management_groups.create_or_update(
management_group_id, mgmt_grp_create
)
def create_billing_owner(self, creds, tenant_admin_details):
# authenticate as tenant_admin
# create billing owner identity
# result is a synchronous wait, might need to do a poll instead to handle first mgmt group create
# since we were told it could take 10+ minutes to complete, unless this handles that polling internally
return create_request.result()
# TODO: Lookup response format
# Managed service identity?
response = {"id": "string"}
return self._ok({"billing_owner_id": response["id"]})
def _create_subscription(
self,
credentials,
display_name,
billing_profile_id,
sku_id,
management_group_id,
billing_account_name,
invoice_section_name,
):
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
billing_profile_id = "?" # where do we source this?
sku_id = AZURE_SKU_ID
# These 2 seem like something that might be worthwhile to allow tiebacks to
# TOs filed for the environment
billing_account_name = "?" # from TO?
invoice_section_name = "?" # from TO?
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
display_name=display_name,
billing_profile_id=billing_profile_id,
sku_id=sku_id,
management_group_id=management_group_id,
)
# We may also want to create billing sections in the enrollment account
sub_creation_operation = sub_client.subscription_factory.create_subscription(
billing_account_name, invoice_section_name, body
)
# the resulting object from this process is a link to the new subscription
# not a subscription model, so we'll have to unpack the ID
new_sub = sub_creation_operation.result()
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
if subscription_id:
return subscription_id
else:
# troublesome error, subscription should exist at this point
# 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,
):
def assign_billing_owner(self, creds, billing_owner_id, tenant_id):
# TODO: Do we source role definition ID from config, api or self-defined?
# TODO: If from api,
"""
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
{
"principalId": "string",
"principalTenantId": "string",
"billingRoleDefinitionId": "string"
}
"""
# 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"),
return self.ok()
def create_billing_profile(self, creds, tenant_admin_details, billing_owner_id):
# call billing profile creation endpoint, specifying owner
# Payload:
"""
{
"displayName": "string",
"poNumber": "string",
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"invoiceEmailOptIn": true,
Note: These last 2 are also the body for adding/updating new TOs/clins
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
# response will be mostly the same as the body, but we only really care about the id
response = {"id": "string"}
return self._ok({"billing_profile_id": response["id"]})
def report_clin(self, creds, clin_id, clin_amount, clin_start, clin_end, clin_to):
# should consumer be responsible for reporting each clin or
# should this take a list and manage the sequential reporting?
""" Payload
{
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
# we don't need any of the returned info for this
return self._ok()
def create_remote_admin(self, creds, tenant_details):
# create app/service principal within tenant, with name constructed from tenant details
# assign principal global admin
# needs to call out to CLI with tenant owner username/password, prototyping for that underway
# return identifier and creds to consumer for storage
response = {"clientId": "string", "secretKey": "string", "tenantId": "string"}
return self._ok(
{
"client_id": response["clientId"],
"secret_key": response["secret_key"],
"tenant_id": response["tenantId"],
}
)
name = properties.get("displayName")
def force_tenant_admin_pw_update(self, creds, tenant_owner_id):
# use creds to update to force password recovery?
# not sure what the endpoint/method for this is, yet
return client.policy_definitions.create_or_update_at_management_group(
policy_definition_name=name,
parameters=definition,
management_group_id=management_group_id,
)
return self._ok()
def create_billing_alerts(self, TBD):
# TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations
# TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment
# TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates
# TODO: determine what the keys in the Notifications dict are supposed to be
# we may need to rotate budget objects when new TOs/CLINs are reported?
# we likely only want the budget ID, can be updated or replaced?
response = {"id": "id"}
return self._ok({"budget_id": response["id"]})
def _get_management_service_principal(self):
# we really should be using graph.microsoft.com, but i'm getting
@ -663,6 +839,7 @@ class AzureCloudProvider(CloudProviderInterface):
return sub_id_match.group(1)
def _get_credential_obj(self, creds, resource=None):
return self.sdk.credentials.ServicePrincipalCredentials(
client_id=creds.get("client_id"),
secret=creds.get("secret_key"),
@ -671,6 +848,27 @@ class AzureCloudProvider(CloudProviderInterface):
cloud_environment=self.sdk.cloud,
)
def _make_tenant_admin_cred_obj(self, username, password):
return self.sdk.credentials.UserPassCredentials(username, password)
def _ok(self, body=None):
return self._make_response("ok", body)
def _error(self, body=None):
return self._make_response("error", body)
def _make_response(self, status, body=dict()):
"""Create body for responses from API
Arguments:
status {string} -- "ok" or "error"
body {dict} -- dict containing details of response or error, if applicable
Returns:
dict -- status of call with body containing details
"""
return {"status": status, "body": body}
@property
def _root_creds(self):
return {

View File

@ -2,4 +2,5 @@ from .portfolios import (
Portfolios,
PortfolioError,
PortfolioDeletionApplicationsExistError,
PortfolioStateMachines,
)

View File

@ -1,11 +1,23 @@
from sqlalchemy import or_
from typing import List
from uuid import UUID
from atst.database import db
from atst.domain.permission_sets import PermissionSets
from atst.domain.authz import Authorization
from atst.domain.portfolio_roles import PortfolioRoles
from atst.domain.invitations import PortfolioInvitations
from atst.models import Permissions, PortfolioRole, PortfolioRoleStatus
from .query import PortfoliosQuery
from atst.domain.invitations import PortfolioInvitations
from atst.models import (
Portfolio,
PortfolioStateMachine,
FSMStates,
Permissions,
PortfolioRole,
PortfolioRoleStatus,
)
from .query import PortfoliosQuery, PortfolioStateMachinesQuery
from .scopes import ScopedPortfolio
@ -17,7 +29,22 @@ class PortfolioDeletionApplicationsExistError(Exception):
pass
class PortfolioStateMachines(object):
@classmethod
def create(cls, portfolio, **sm_attrs):
sm_attrs.update({"portfolio": portfolio})
sm = PortfolioStateMachinesQuery.create(**sm_attrs)
return sm
class Portfolios(object):
@classmethod
def get_or_create_state_machine(cls, portfolio):
"""
get or create Portfolio State Machine for a Portfolio
"""
return portfolio.state_machine or PortfolioStateMachines.create(portfolio)
@classmethod
def create(cls, user, portfolio_attrs):
portfolio = PortfoliosQuery.create(**portfolio_attrs)
@ -75,10 +102,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)
@ -111,3 +138,37 @@ class Portfolios(object):
portfolio.description = new_data["description"]
PortfoliosQuery.add_and_commit(portfolio)
@classmethod
def base_provision_query(cls):
return db.session.query(Portfolio.id)
@classmethod
def get_portfolios_pending_provisioning(cls) -> List[UUID]:
"""
Any portfolio with a corresponding State Machine that is either:
not started yet,
failed in creating a tenant
failed
"""
results = (
cls.base_provision_query()
.join(PortfolioStateMachine)
.filter(
or_(
PortfolioStateMachine.state == FSMStates.UNSTARTED,
PortfolioStateMachine.state == FSMStates.FAILED,
PortfolioStateMachine.state == FSMStates.TENANT_FAILED,
)
)
)
return [id_ for id_, in results]
# db.session.query(PortfolioStateMachine).\
# filter(
# or_(
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# )
# ).all()

View File

@ -8,6 +8,13 @@ from atst.models.application_role import (
Status as ApplicationRoleStatus,
)
from atst.models.application import Application
from atst.models.portfolio_state_machine import PortfolioStateMachine
# from atst.models.application import Application
class PortfolioStateMachinesQuery(Query):
model = PortfolioStateMachine
class PortfoliosQuery(Query):

View File

@ -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

View File

@ -14,7 +14,7 @@ SERVICE_BRANCHES = [
]
ENV_ROLE_NO_ACCESS = "No Access"
ENV_ROLES = [(role.value, role.value) for role in CSPRole] + [
ENV_ROLES = [(role.name, role.value) for role in CSPRole] + [
(ENV_ROLE_NO_ACCESS, ENV_ROLE_NO_ACCESS)
]

View File

@ -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):

View File

@ -7,7 +7,7 @@ from wtforms.fields import (
HiddenField,
)
from wtforms.fields.html5 import DateField
from wtforms.validators import Required, Length, NumberRange, ValidationError
from wtforms.validators import Required, Length, NumberRange, ValidationError, Regexp
from flask_wtf import FlaskForm
from numbers import Number
@ -15,6 +15,7 @@ from .data import JEDI_CLIN_TYPES
from .fields import SelectField
from .forms import BaseForm, remove_empty_string
from atst.utils.localization import translate
from .validators import REGEX_ALPHA_NUMERIC
from flask import current_app as app
MAX_CLIN_AMOUNT = 1000000000
@ -116,7 +117,10 @@ class AttachmentForm(BaseForm):
filename = HiddenField(
id="attachment_filename",
validators=[
Length(max=100, message=translate("forms.attachment.filename.length_error"))
Length(
max=100, message=translate("forms.attachment.filename.length_error")
),
Regexp(regex=REGEX_ALPHA_NUMERIC),
],
)
object_name = HiddenField(
@ -124,7 +128,8 @@ class AttachmentForm(BaseForm):
validators=[
Length(
max=40, message=translate("forms.attachment.object_name.length_error")
)
),
Regexp(regex=REGEX_ALPHA_NUMERIC),
],
)
accept = ".pdf,application/pdf"

View File

@ -8,6 +8,9 @@ import pendulum
from atst.utils.localization import translate
REGEX_ALPHA_NUMERIC = "^[A-Za-z0-9\-_ \.]*$"
def DateRange(lower_bound=None, upper_bound=None, message=None):
def _date_range(form, field):
if field.data is None:

View File

@ -7,14 +7,27 @@ from atst.models import (
EnvironmentJobFailure,
EnvironmentRoleJobFailure,
EnvironmentRole,
PortfolioJobFailure,
)
from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException
from atst.domain.environments import Environments
from atst.domain.portfolios import Portfolios
from atst.domain.environment_roles import EnvironmentRoles
from atst.models.utils import claim_for_update
from atst.utils.localization import translate
class RecordPortfolioFailure(celery.Task):
def on_failure(self, exc, task_id, args, kwargs, einfo):
if "portfolio_id" in kwargs:
failure = PortfolioJobFailure(
portfolio_id=kwargs["portfolio_id"], task_id=task_id
)
db.session.add(failure)
db.session.commit()
class RecordEnvironmentFailure(celery.Task):
def on_failure(self, exc, task_id, args, kwargs, einfo):
if "environment_id" in kwargs:
@ -125,6 +138,17 @@ def do_work(fn, task, csp, **kwargs):
raise task.retry(exc=e)
def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None):
portfolio = Portfolios.get_for_update(portfolio_id)
fsm = Portfolios.get_or_create_state_machine(portfolio)
fsm.trigger_next_transition()
@celery.task(bind=True, base=RecordPortfolioFailure)
def provision_portfolio(self, portfolio_id=None):
do_work(do_provision_portfolio, self, app.csp.cloud, portfolio_id=portfolio_id)
@celery.task(bind=True, base=RecordEnvironmentFailure)
def create_environment(self, environment_id=None):
do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id)
@ -144,6 +168,15 @@ def provision_user(self, environment_role_id=None):
)
@celery.task(bind=True)
def dispatch_provision_portfolio(self):
"""
Iterate over portfolios with a corresponding State Machine that have not completed.
"""
for portfolio_id in Portfolios.get_portfolios_pending_provisioning():
provision_portfolio.delay(portfolio_id=portfolio_id)
@celery.task(bind=True)
def dispatch_create_environment(self):
for environment_id in Environments.get_environments_pending_creation(

View File

@ -7,11 +7,16 @@ from .audit_event import AuditEvent
from .clin import CLIN, JEDICLINType
from .environment import Environment
from .environment_role import EnvironmentRole, CSPRole
from .job_failure import EnvironmentJobFailure, EnvironmentRoleJobFailure
from .job_failure import (
EnvironmentJobFailure,
EnvironmentRoleJobFailure,
PortfolioJobFailure,
)
from .notification_recipient import NotificationRecipient
from .permissions import Permissions
from .permission_set import PermissionSet
from .portfolio import Portfolio
from .portfolio_state_machine import PortfolioStateMachine, FSMStates
from .portfolio_invitation import PortfolioInvitation
from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from .task_order import TaskOrder

View File

@ -9,10 +9,9 @@ import atst.models.types as types
class CSPRole(Enum):
BASIC_ACCESS = "Basic Access"
NETWORK_ADMIN = "Network Admin"
BUSINESS_READ = "Business Read-only"
TECHNICAL_READ = "Technical Read-only"
ADMIN = "Admin"
BILLING_READ = "Billing Read-only"
CONTRIBUTOR = "Contributor"
class EnvironmentRole(
@ -26,7 +25,7 @@ class EnvironmentRole(
)
environment = relationship("Environment")
role = Column(String())
role = Column(SQLAEnum(CSPRole, native_enum=False), nullable=True)
application_role_id = Column(
UUID(as_uuid=True), ForeignKey("application_roles.id"), nullable=False

View File

@ -14,3 +14,9 @@ class EnvironmentRoleJobFailure(Base, mixins.JobFailureMixin):
__tablename__ = "environment_role_job_failures"
environment_role_id = Column(ForeignKey("environment_roles.id"), nullable=False)
class PortfolioJobFailure(Base, mixins.JobFailureMixin):
__tablename__ = "portfolio_job_failures"
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)

View File

@ -4,3 +4,4 @@ from .permissions import PermissionsMixin
from .deletable import DeletableMixin
from .invites import InvitesMixin
from .job_failure import JobFailureMixin
from .state_machines import FSMMixin

View File

@ -0,0 +1,137 @@
from enum import Enum
class StageStates(Enum):
CREATED = "created"
IN_PROGRESS = "in progress"
FAILED = "failed"
class AzureStages(Enum):
TENANT = "tenant"
BILLING_PROFILE = "billing profile"
ADMIN_SUBSCRIPTION = "admin subscription"
def _build_csp_states(csp_stages):
states = {
"UNSTARTED": "unstarted",
"STARTING": "starting",
"STARTED": "started",
"COMPLETED": "completed",
"FAILED": "failed",
}
for csp_stage in csp_stages:
for state in StageStates:
states[csp_stage.name + "_" + state.name] = (
csp_stage.value + " " + state.value
)
return states
FSMStates = Enum("FSMStates", _build_csp_states(AzureStages))
def _build_transitions(csp_stages):
transitions = []
states = []
compose_state = lambda csp_stage, state: getattr(
FSMStates, "_".join([csp_stage.name, state.name])
)
for stage_i, csp_stage in enumerate(csp_stages):
for state in StageStates:
states.append(
dict(
name=compose_state(csp_stage, state),
tags=[csp_stage.name, state.name],
)
)
if state == StageStates.CREATED:
if stage_i > 0:
src = compose_state(
list(csp_stages)[stage_i - 1], StageStates.CREATED
)
else:
src = FSMStates.STARTED
transitions.append(
dict(
trigger="create_" + csp_stage.name.lower(),
source=src,
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
after="after_in_progress_callback",
)
)
if state == StageStates.IN_PROGRESS:
transitions.append(
dict(
trigger="finish_" + csp_stage.name.lower(),
source=compose_state(csp_stage, state),
dest=compose_state(csp_stage, StageStates.CREATED),
conditions=["is_csp_data_valid"],
)
)
if state == StageStates.FAILED:
transitions.append(
dict(
trigger="fail_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.IN_PROGRESS),
dest=compose_state(csp_stage, StageStates.FAILED),
)
)
return states, transitions
class FSMMixin:
system_states = [
{"name": FSMStates.UNSTARTED.name, "tags": ["system"]},
{"name": FSMStates.STARTING.name, "tags": ["system"]},
{"name": FSMStates.STARTED.name, "tags": ["system"]},
{"name": FSMStates.FAILED.name, "tags": ["system"]},
{"name": FSMStates.COMPLETED.name, "tags": ["system"]},
]
system_transitions = [
{"trigger": "init", "source": FSMStates.UNSTARTED, "dest": FSMStates.STARTING},
{"trigger": "start", "source": FSMStates.STARTING, "dest": FSMStates.STARTED},
{"trigger": "reset", "source": "*", "dest": FSMStates.UNSTARTED},
{"trigger": "fail", "source": "*", "dest": FSMStates.FAILED,},
]
def prepare_init(self, event):
pass
def before_init(self, event):
pass
def after_init(self, event):
pass
def prepare_start(self, event):
pass
def before_start(self, event):
pass
def after_start(self, event):
pass
def prepare_reset(self, event):
pass
def before_reset(self, event):
pass
def after_reset(self, event):
pass
def fail_stage(self, stage):
fail_trigger = "fail" + stage
if fail_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(fail_trigger)
def finish_stage(self, stage):
finish_trigger = "finish_" + stage
if finish_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(finish_trigger)

View File

@ -11,6 +11,8 @@ from atst.domain.permission_sets import PermissionSets
from atst.utils import first_or_none
from atst.database import db
from sqlalchemy_json import NestedMutableJson
class Portfolio(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
@ -19,16 +21,31 @@ class Portfolio(
id = types.Id()
name = Column(String, nullable=False)
description = Column(String)
defense_component = Column(
ARRAY(String), nullable=False
String, nullable=False
) # Department of Defense Component
app_migration = Column(String) # App Migration
complexity = Column(ARRAY(String)) # Application Complexity
complexity_other = Column(String)
description = Column(String)
dev_team = Column(ARRAY(String)) # Development Team
dev_team_other = Column(String)
native_apps = Column(String) # Native Apps
team_experience = Column(String) # Team Experience
csp_data = Column(NestedMutableJson, nullable=True)
applications = relationship(
"Application",
back_populates="portfolio",
primaryjoin="and_(Application.portfolio_id == Portfolio.id, Application.deleted == False)",
)
state_machine = relationship(
"PortfolioStateMachine", uselist=False, back_populates="portfolio"
)
roles = relationship("PortfolioRole")
task_orders = relationship("TaskOrder")
@ -77,7 +94,7 @@ class Portfolio(
"""
Return the earliest period of performance start date and latest period
of performance end date for all active task orders in a portfolio.
@return: (datetime.date or None, datetime.date or None)
@return: (datetime.date or None, datetime.date or None)
"""
start_dates = (
task_order.start_date

View File

@ -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(

View File

@ -0,0 +1,181 @@
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum
from sqlalchemy.orm import relationship, reconstructor
from sqlalchemy.dialects.postgresql import UUID
from pydantic import ValidationError as PydanticValidationError
from transitions import Machine
from transitions.extensions.states import add_state_features, Tags
from flask import current_app as app
from atst.domain.csp.cloud import ConnectionException, UnknownServerException
from atst.domain.csp import MockCSP, AzureCSP, get_stage_csp_class
from atst.database import db
from atst.models.types import Id
from atst.models.base import Base
import atst.models.mixins as mixins
from atst.models.mixins.state_machines import FSMStates, AzureStages, _build_transitions
@add_state_features(Tags)
class StateMachineWithTags(Machine):
pass
class PortfolioStateMachine(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.FSMMixin,
):
__tablename__ = "portfolio_state_machines"
id = Id()
portfolio_id = Column(UUID(as_uuid=True), ForeignKey("portfolios.id"),)
portfolio = relationship("Portfolio", back_populates="state_machine")
state = Column(
SQLAEnum(FSMStates, native_enum=False, create_constraint=False),
default=FSMStates.UNSTARTED,
nullable=False,
)
def __init__(self, portfolio, csp=None, **kwargs):
self.portfolio = portfolio
self.attach_machine()
def after_state_change(self, event):
db.session.add(self)
db.session.commit()
@reconstructor
def attach_machine(self):
"""
This is called as a result of a sqlalchemy query.
Attach a machine depending on the current state.
"""
self.machine = StateMachineWithTags(
model=self,
send_event=True,
initial=self.current_state if self.state else FSMStates.UNSTARTED,
auto_transitions=False,
after_state_change="after_state_change",
)
states, transitions = _build_transitions(AzureStages)
self.machine.add_states(self.system_states + states)
self.machine.add_transitions(self.system_transitions + transitions)
@property
def current_state(self):
if isinstance(self.state, str):
return getattr(FSMStates, self.state)
return self.state
def trigger_next_transition(self):
state_obj = self.machine.get_state(self.state)
if state_obj.is_system:
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
# call the first trigger availabe for these two system states
trigger_name = self.machine.get_triggers(self.current_state.name)[0]
self.trigger(trigger_name)
elif self.current_state == FSMStates.STARTED:
# get the first trigger that starts with 'create_'
create_trigger = list(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(FSMStates.STARTED.name),
)
)[0]
self.trigger(create_trigger)
elif state_obj.is_IN_PROGRESS:
pass
# elif state_obj.is_TENANT:
# pass
# elif state_obj.is_BILLING_PROFILE:
# pass
# @with_payload
def after_in_progress_callback(self, event):
stage = self.current_state.name.split("_IN_PROGRESS")[0].lower()
if stage == "tenant":
payload = dict( # nosec
creds={"username": "mock-cloud", "pass": "shh"},
user_id="123",
password="123",
domain_name="123",
first_name="john",
last_name="doe",
country_code="US",
password_recovery_email_address="password@email.com",
)
elif stage == "billing_profile":
payload = dict(creds={"username": "mock-cloud", "pass": "shh"},)
payload_data_cls = get_stage_csp_class(stage, "payload")
if not payload_data_cls:
self.fail_stage(stage)
try:
payload_data = payload_data_cls(**payload)
except PydanticValidationError as exc:
print(exc.json())
self.fail_stage(stage)
csp = event.kwargs.get("csp")
if csp is not None:
self.csp = AzureCSP(app).cloud
else:
self.csp = MockCSP(app).cloud
for attempt in range(5):
try:
response = getattr(self.csp, "create_" + stage)(payload_data)
except (ConnectionException, UnknownServerException) as exc:
print("caught exception. retry", attempt)
continue
else:
break
else:
# failed all attempts
self.fail_stage(stage)
if self.portfolio.csp_data is None:
self.portfolio.csp_data = {}
self.portfolio.csp_data[stage + "_data"] = response
db.session.add(self.portfolio)
db.session.commit()
self.finish_stage(stage)
def is_csp_data_valid(self, event):
# check portfolio csp details json field for fields
if self.portfolio.csp_data is None or not isinstance(
self.portfolio.csp_data, dict
):
return False
stage = self.current_state.name.split("_IN_PROGRESS")[0].lower()
stage_data = self.portfolio.csp_data.get(stage + "_data")
cls = get_stage_csp_class(stage, "result")
if not cls:
return False
try:
cls(**stage_data)
except PydanticValidationError as exc:
print(exc.json())
return False
return True
# print('failed condition', self.portfolio.csp_data)
@property
def application_id(self):
return None

View File

@ -140,7 +140,10 @@ class TaskOrder(Base, mixins.TimestampsMixin):
@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):

View File

@ -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 = [

View File

@ -7,6 +7,10 @@ celery = Celery(__name__)
def update_celery(celery, app):
celery.conf.update(app.config)
celery.conf.CELERYBEAT_SCHEDULE = {
"beat-dispatch_provision_portfolio": {
"task": "atst.jobs.dispatch_provision_portfolio",
"schedule": 60,
},
"beat-dispatch_create_environment": {
"task": "atst.jobs.dispatch_create_environment",
"schedule": 60,

View File

@ -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())

View File

@ -78,7 +78,7 @@ def filter_env_roles_data(roles):
{
"environment_id": str(role.environment.id),
"environment_name": role.environment.name,
"role": role.role,
"role": (role.role.value if role.role else "None"),
}
for role in roles
],
@ -99,8 +99,9 @@ def filter_env_roles_form_data(member, environments):
if len(env_roles_set) == 1:
(env_role,) = env_roles_set
env_data["role"] = env_role.role
env_data["disabled"] = env_role.disabled
if env_role.role:
env_data["role"] = env_role.role.name
env_roles_form_data.append(env_data)

View File

@ -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(obj=portfolio)
member_perms_form = member_forms.MembersPermissionsForm(
data={"members_permissions": members_data}
)
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/<portfolio_id>/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/<portfolio_id>/update_ppoc", methods=["POST"])
@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc")
def update_ppoc(portfolio_id):

View File

@ -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,

View File

@ -1,214 +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}) }}
""",
"category": "success",
},
"application_deleted": {
"title_template": translate("flash.success"),
"message_template": """
{{ "flash.application.deleted" | translate({"application_name": application_name}) }}
<a href="#">{{ "common.undo" | translate }}</a>
""",
"title": "flash.success",
"message": "flash.application.updated",
"category": "success",
},
"application_environments_name_error": {
"title_template": "",
"message_template": """{{ 'flash.application.env_name_error.message' | translate({ 'name': name }) }}""",
"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_template": "",
"message_template": """{{ 'flash.application.name_error.message' | translate({ 'name': name }) }}""",
"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": "<p>Please see below.</p>",
"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": """
<p>{{ "flash.new_application_member.message" | translate({ "user_name": user_name }) }}</p>
""",
"title": "flash.new_application_member.title",
"message": "flash.new_application_member.message",
"category": "success",
},
"new_portfolio_member": {
"title_template": translate("flash.success"),
"message_template": """
<p>{{ "flash.new_portfolio_member" | translate({ "user_name": user_name }) }}</p>
""",
"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": """
<p>Successfully sent a new invitation to {{ user_name }}.</p>
""",
"title": "flash.portfolio_invite.resent.title",
"message": "flash.portfolio_invite.resent.message",
"category": "success",
},
"revoked_portfolio_access": {
"title_template": "Removed portfolio access",
"message_template": """
<p>Portfolio access successfully removed from {{ member_name }}.</p>
""",
"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": """
<p>You have successfully updated access permissions for members of {{ portfolio.name }}.</p>
""",
"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": """
<p>{{ "flash.updated_application_team_settings" | translate({"application_name": application_name}) }}</p>
""",
"title": "flash.success",
"message": "flash.updated_application_team_settings",
"category": "success",
},
"user_must_complete_profile": {
"title_template": "You must complete your profile",
"message_template": "<p>Before continuing, you must complete your profile</p>",
"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",
},
}
@ -216,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"])

View File

@ -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:

View File

@ -15,6 +15,7 @@ CRL_FAIL_OPEN = false
CRL_STORAGE_CONTAINER = crls
CSP=mock
DEBUG = true
DEBUG_MAILER = false
DISABLE_CRL_CHECK = false
ENVIRONMENT = dev
LIMIT_CONCURRENT_SESSIONS = false

View File

@ -0,0 +1,40 @@
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
labels:
app: atst
name: atst
namespace: atat
spec:
minReplicas: 2
maxReplicas: 10
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: atst
metrics:
- type: Resource
resource:
name: cpu
targetAverageUtilization: 60
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
labels:
app: atst
name: atst-worker
namespace: atat
spec:
minReplicas: 1
maxReplicas: 10
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: atst-worker
metrics:
- type: Resource
resource:
name: cpu
targetAverageUtilization: 60

View File

@ -15,7 +15,6 @@ spec:
selector:
matchLabels:
role: web
replicas: 4
strategy:
type: RollingUpdate
template:
@ -49,6 +48,13 @@ spec:
subPath: uwsgi.ini
- name: flask-secret
mountPath: "/config"
resources:
requests:
memory: 200Mi
cpu: 400m
limits:
memory: 200Mi
cpu: 400m
- name: nginx
image: nginx:alpine
ports:
@ -77,6 +83,13 @@ spec:
mountPath: "/etc/nginx/snippets/"
- name: nginx-secret
mountPath: "/etc/ssl/"
resources:
requests:
memory: 20Mi
cpu: 10m
limits:
memory: 20Mi
cpu: 10m
volumes:
- name: nginx-client-ca-bundle
configMap:
@ -155,7 +168,6 @@ spec:
selector:
matchLabels:
role: worker
replicas: 2
strategy:
type: RollingUpdate
template:
@ -190,6 +202,13 @@ spec:
subPath: pgsslrootcert.crt
- name: flask-secret
mountPath: "/config"
resources:
requests:
memory: 280Mi
cpu: 400m
limits:
memory: 280Mi
cpu: 400m
volumes:
- name: pgsslrootcert
configMap:
@ -255,6 +274,13 @@ spec:
subPath: pgsslrootcert.crt
- name: flask-secret
mountPath: "/config"
resources:
requests:
memory: 80Mi
cpu: 10m
limits:
memory: 80Mi
cpu: 10m
volumes:
- name: pgsslrootcert
configMap:

View File

@ -12,3 +12,4 @@ resources:
- acme-challenges.yml
- aadpodidentity.yml
- nginx-snippets.yml
- autoscaling.yml

View File

@ -1,35 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-config
namespace: atat
data:
uwsgi-config: |-
[uwsgi]
callable = app
module = app
socket = /var/run/uwsgi/uwsgi.socket
plugin = python3
plugin = logfile
virtualenv = /opt/atat/atst/.venv
chmod-socket = 666
; logger config
; application logs: log without modifying
logger = secondlogger stdio
log-route = secondlogger atst
log-encoder = format:secondlogger ${msg}
; default uWSGI messages (start, stop, etc.)
logger = default stdio
log-route = default ^((?!atst).)*$
log-encoder = json:default {"timestamp":"${strftime:%%FT%%T}","source":"uwsgi","severity":"DEBUG","message":"${msg}"}
log-encoder = nl
; uWSGI request logs
logger-req = stdio
log-format = request_id=%(var.HTTP_X_REQUEST_ID), pid=%(pid), remote_add=%(addr), request=%(method) %(uri), status=%(status), body_bytes_sent=%(rsize), referer=%(referer), user_agent=%(uagent), http_x_forwarded_for=%(var.HTTP_X_FORWARDED_FOR)
log-req-encoder = json {"timestamp":"${strftime:%%FT%%T}","source":"req","severity":"INFO","message":"${msg}"}
log-req-encoder = nl

View File

@ -1,15 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-envvars
namespace: atat
data:
TZ: UTC
FLASK_ENV: dev
OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini
UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini
CRL_STORAGE_PROVIDER: CLOUDFILES
LOG_JSON: "true"
REDIS_URI: "redis://redis-svc:6379"
PGHOST: postgres-svc

View File

@ -1,73 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-nginx
namespace: atat
data:
nginx-config: |-
server {
listen 8342;
server_name aws.atat.code.mil;
return 301 https://$host$request_uri;
}
server {
listen 8343;
server_name auth-aws.atat.code.mil;
return 301 https://$host$request_uri;
}
server {
server_name aws.atat.code.mil;
# access_log /var/log/nginx/access.log json;
listen 8442;
location /login-redirect {
return 301 https://auth-aws.atat.code.mil$request_uri;
}
location /login-dev {
try_files $uri @appbasicauth;
}
location / {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
uwsgi_param HTTP_X_REQUEST_ID $request_id;
}
location @appbasicauth {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
auth_basic "Developer Access";
auth_basic_user_file /etc/nginx/.htpasswd;
uwsgi_param HTTP_X_REQUEST_ID $request_id;
}
}
server {
# access_log /var/log/nginx/access.log json;
server_name auth-aws.atat.code.mil;
listen 8443;
listen [::]:8443 ipv6only=on;
# Request and validate client certificate
ssl_verify_client on;
ssl_verify_depth 10;
ssl_client_certificate /etc/ssl/client-ca-bundle.pem;
# Guard against HTTPS -> HTTP downgrade
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always";
location / {
return 301 https://aws.atat.code.mil$request_uri;
}
location /login-redirect {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
uwsgi_param HTTP_X_SSL_CLIENT_VERIFY $ssl_client_verify;
uwsgi_param HTTP_X_SSL_CLIENT_CERT $ssl_client_raw_cert;
uwsgi_param HTTP_X_SSL_CLIENT_S_DN $ssl_client_s_dn;
uwsgi_param HTTP_X_SSL_CLIENT_S_DN_LEGACY $ssl_client_s_dn_legacy;
uwsgi_param HTTP_X_SSL_CLIENT_I_DN $ssl_client_i_dn;
uwsgi_param HTTP_X_SSL_CLIENT_I_DN_LEGACY $ssl_client_i_dn_legacy;
uwsgi_param HTTP_X_REQUEST_ID $request_id;
}
}

View File

@ -1,12 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-worker-envvars
namespace: atat
data:
TZ: UTC
DISABLE_CRL_CHECK: "True"
CRL_STORAGE_PROVIDER: CLOUDFILES
REDIS_URI: "redis://redis-svc:6379"
PGHOST: postgres-svc

View File

@ -1,61 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: db-cache
name: datastores
namespace: atat
spec:
selector:
matchLabels:
app: db-cache
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: db-cache
spec:
securityContext:
fsGroup: 101
containers:
- name: postgres
image: postgres:11-alpine
imagePullPolicy: Never
ports:
- containerPort: 5432
- name: redis
image: redis:5.0-alpine
imagePullPolicy: Never
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: postgres-svc
namespace: atat
spec:
ports:
- name: db-port
protocol: "TCP"
port: 5432
targetPort: 5432
selector:
app: db-cache
---
apiVersion: v1
kind: Service
metadata:
name: redis-svc
namespace: atat
spec:
ports:
- name: cache-port
protocol: "TCP"
port: 6379
targetPort: 6379
selector:
app: db-cache

View File

@ -1,232 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: atst
name: atst
namespace: atat
spec:
selector:
matchLabels:
role: web
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
role: web
spec:
securityContext:
fsGroup: 101
containers:
- name: atst
image: atat:latest
imagePullPolicy: Never
envFrom:
- configMapRef:
name: atst-envvars
volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: nginx-client-ca-bundle
mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem"
subPath: client-ca-bundle.pem
- name: uwsgi-socket-dir
mountPath: "/var/run/uwsgi"
- name: nginx
image: nginx:alpine
imagePullPolicy: Never
ports:
- containerPort: 8342
name: main-upgrade
- containerPort: 8442
name: main
- containerPort: 8343
name: auth-upgrade
- containerPort: 8443
name: auth
volumeMounts:
- name: nginx-config
mountPath: "/etc/nginx/conf.d/atst.conf"
subPath: atst.conf
- name: uwsgi-socket-dir
mountPath: "/var/run/uwsgi"
- name: nginx-htpasswd
mountPath: "/etc/nginx/.htpasswd"
subPath: .htpasswd
- name: nginx-client-ca-bundle
mountPath: "/etc/ssl/"
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: nginx-client-ca-bundle
configMap:
name: nginx-client-ca-bundle
defaultMode: 0666
- name: nginx-config
configMap:
name: atst-nginx
items:
- key: nginx-config
path: atst.conf
- name: uwsgi-socket-dir
emptyDir:
medium: Memory
- name: nginx-htpasswd
secret:
secretName: atst-nginx-htpasswd
items:
- key: htpasswd
path: .htpasswd
mode: 0640
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: atst
name: atst-worker
namespace: atat
spec:
selector:
matchLabels:
role: worker
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
role: worker
spec:
securityContext:
fsGroup: 101
containers:
- name: atst-worker
image: atat:latest
imagePullPolicy: Never
args: [
"/opt/atat/atst/.venv/bin/python",
"/opt/atat/atst/.venv/bin/celery",
"-A",
"celery_worker.celery",
"worker",
"--loglevel=info"
]
envFrom:
- configMapRef:
name: atst-envvars
- configMapRef:
name: atst-worker-envvars
volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: atst
name: atst-beat
namespace: atat
spec:
selector:
matchLabels:
role: beat
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
role: beat
spec:
securityContext:
fsGroup: 101
containers:
- name: atst-beat
image: atat:latest
imagePullPolicy: Never
args: [
"/opt/atat/atst/.venv/bin/python",
"/opt/atat/atst/.venv/bin/celery",
"-A",
"celery_worker.celery",
"beat",
"--loglevel=info"
]
envFrom:
- configMapRef:
name: atst-envvars
- configMapRef:
name: atst-worker-envvars
volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
---
apiVersion: v1
kind: Service
metadata:
labels:
app: atst
name: atst-main
namespace: atat
spec:
ports:
- port: 80
targetPort: 8342
name: http-main
- port: 443
targetPort: 8442
name: https-main
selector:
role: web
type: LoadBalancer
---
apiVersion: v1
kind: Service
metadata:
labels:
app: atst
name: atst-auth
namespace: atat
spec:
ports:
- port: 80
targetPort: 8343
name: http-auth
- port: 443
targetPort: 8443
name: https-auth
selector:
role: web
type: LoadBalancer

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,6 @@ resources:
- namespace.yml
- reset-cron-job.yml
patchesStrategicMerge:
- replica_count.yml
- ports.yml
- envvars.yml
- flex_vol.yml

View File

@ -1,14 +0,0 @@
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst
spec:
replicas: 2
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst-worker
spec:
replicas: 1

View File

@ -5,7 +5,6 @@ resources:
- namespace.yml
- reset-cron-job.yml
patchesStrategicMerge:
- replica_count.yml
- ports.yml
- envvars.yml
- flex_vol.yml

View File

@ -1,14 +0,0 @@
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst
spec:
replicas: 2
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst-worker
spec:
replicas: 1

0
docs/ATATArchitecture.md Normal file
View File

32
docs/EdgeControls.md Normal file
View File

@ -0,0 +1,32 @@
# Edge Control
This document describes the expected connections and listening services.
## Transient Connections
| Service | Direction | Ports | Protocol | Encrypted? | Ciphers |
| --------|-----------|-------|----------|------------|--------------|
| Azure Container Registry | Egress | 443 | HTTP | Yes | MSFT Managed |
| DOD CRL Service | Egress | 443 | HTTP | Yes | DOD Managed |
| Azure Storage | Egress | 443 | HTTP | Yes | MSFT Managed|
| Redis | Egress | 6380 | HTTP | Yes | MSFT Managed|
| Postgres | Egress | 5432 | HTTP | Yes | MSFT Managed|
# Listening Ports / Services
| Service/App | Port | Protocol| Encrypted? | Accessible |
|-------------|---------|---------|------------|--------|
| ATAT App | 80, 443 | HTTP | Both | Load Balancer Only
| ATAT Auth | 80, 443 | HTTP | Both | Load Balancer Only
# Host List
## Dev
| Service| Host |
|--------|------|
| Redis | cloudzero-dev-redis.redis.cache.windows.net |
| Postgres| cloudzero-dev-sql.postgres.database.azure.com |
| Docker Container Registry | cloudzerodevregistry.azurecr.io |
## Production
| Service | Host |
|---------|------|
| Redis | |
| Postgres| |
| Docker Container Registry | |

View File

@ -6,4 +6,6 @@ app = make_app(make_config())
ctx = app.app_context()
ctx.push()
print("\nWelcome to atst. This shell has all models in scope, and a SQLAlchemy session called db.")
print(
"\nWelcome to atst. This shell has all models in scope, and a SQLAlchemy session called db."
)

View File

@ -70,7 +70,7 @@ describe('UploadInput Test', () => {
})
const component = wrapper.find(uploadinput)
const event = { target: { value: '', files: [{ name: '' }] } }
const event = { target: { value: '', files: [{ name: 'sample.pdf' }] } }
component.setMethods({
getUploader: async () => new MockUploader('token', 'objectName'),

View File

@ -1,5 +1,4 @@
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'
@ -100,7 +99,7 @@ export default {
computed: {
clinTitle: function() {
if (!!this.clinNumber) {
return escape(`CLIN ${this.clinNumber}`)
return `CLIN ${this.clinNumber}`
} else {
return `CLIN`
}

View File

@ -1,5 +1,6 @@
import { buildUploader } from '../lib/upload'
import { emitFieldChange } from '../lib/emitters'
import inputValidations from '../lib/input_validations'
export default {
name: 'uploadinput',
@ -28,6 +29,7 @@ export default {
changed: false,
uploadError: false,
sizeError: false,
filenameError: false,
downloadLink: '',
}
},
@ -50,6 +52,10 @@ export default {
this.sizeError = true
return
}
if (!this.validateFileName(file.name)) {
this.filenameError = true
return
}
const uploader = await this.getUploader()
const response = await uploader.upload(file)
@ -71,6 +77,10 @@ export default {
this.uploadError = true
}
},
validateFileName: function(name) {
const regex = inputValidations.restrictedFileName.match
return regex.test(name)
},
removeAttachment: function(e) {
e.preventDefault()
this.attachment = null
@ -118,7 +128,8 @@ export default {
return (
(!this.changed && this.initialErrors) ||
this.uploadError ||
this.sizeError
this.sizeError ||
this.filenameError
)
},
valid: function() {

View File

@ -104,4 +104,11 @@ export default {
unmask: ['(', ')', '-', ' '],
validationError: 'Please enter a 10-digit phone number',
},
restrictedFileName: {
mask: false,
match: /^[A-Za-z0-9\-_ \.]+$/,
unmask: [],
validationError:
'File names can only contain the characters A-Z, 0-9, space, hyphen, underscore, and period.',
},
}

View File

@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "")
DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true"
# Alpha numerics for random entity names
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" # pragma: allowlist secret
NEW_PORTFOLIO_CHANCE = 10
NEW_APPLICATION_CHANCE = 10
@ -29,10 +29,6 @@ def logout(l):
l.client.get("/logout")
def get_index(l):
l.client.get("/")
def get_csrf_token(response):
d = pq(response.text)
return d("#csrf_token").val()
@ -52,14 +48,9 @@ def extract_id(path):
def get_portfolios(l):
response = l.client.get("/portfolios")
response = l.client.get("/home")
d = pq(response.text)
portfolio_links = [
p.attr("href")
for p in d(
".global-panel-container .atat-table tbody tr td:first-child a"
).items()
]
portfolio_links = [p.attr("href") for p in d(".sidenav__link").items()]
force_new_portfolio = randrange(0, 100) < NEW_PORTFOLIO_CHANCE
if len(portfolio_links) == 0 or force_new_portfolio:
portfolio_links += [create_portfolio(l)]
@ -73,7 +64,7 @@ def get_portfolio(l):
d = pq(response.text)
application_links = [
p.attr("href")
for p in d(".application-list .accordion__actions a:first-child").items()
for p in d(".portfolio-applications .accordion__header-text a").items()
]
if len(application_links) > 0:
portfolio_id = extract_id(portfolio_link)
@ -161,18 +152,14 @@ class UserBehavior(TaskSequence):
login(self)
@seq_task(1)
def home(l):
get_index(l)
@seq_task(2)
def portfolios(l):
get_portfolios(l)
@seq_task(3)
@seq_task(2)
def pick_a_portfolio(l):
get_portfolio(l)
@seq_task(4)
@seq_task(3)
def pick_an_app(l):
get_app(l)
@ -189,4 +176,3 @@ class WebsiteUser(HttpLocust):
if __name__ == "__main__":
# if run as the main file, will spin up a single locust
WebsiteUser().run()

View File

@ -1,33 +0,0 @@
#!/bin/bash
# script/minikube_setup: Set up local AT-AT cluster on Minikube
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
output_divider "Start Minikube"
minikube start
output_divider "Use Minikube Docker environment"
eval $(minikube docker-env)
output_divider "Build AT-AT Docker image for Minikube registry"
docker build . -t atat:latest
output_divider "Pull images for AT-AT cluster"
docker pull redis:5.0-alpine
docker pull postgres:11-alpine
docker pull nginx:alpine
output_divider "Apply AT-AT Kubernetes config to Minikube cluster"
kubectl --context=minikube create namespace atat
kubectl --context=minikube apply -f deploy/minikube/
output_divider "Create database and apply migrations"
# wait for the datastore deployment to become available
kubectl --context=minikube -n atat wait --for=condition=Available deployment/datastores
# postgres isn't necessarily running as soon as the pod is available, so wait a few
sleep 3
DB_POD=$(kubectl --context=minikube -n atat get pods -l app=db-cache -o custom-columns=NAME:.metadata.name --no-headers | sed -n 1p)
ATST_POD=$(kubectl --context=minikube -n atat get pods -l app=atst -o custom-columns=NAME:.metadata.name --no-headers | sed -n 1p)
kubectl --context=minikube -n atat exec -it $DB_POD -c postgres -- createdb -U postgres atat
kubectl --context=minikube -n atat exec -it $ATST_POD -c atst -- .venv/bin/python .venv/bin/alembic upgrade head

View File

@ -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"]
}

View File

@ -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-----

View File

@ -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-----

View File

@ -1 +0,0 @@
F4D74F1607DD3C83

View File

@ -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

View File

@ -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";

View File

@ -4,6 +4,20 @@
background-color: $color-gray-lightest;
margin-top: $gap * 5;
&--white {
background-color: $color-white;
}
&--centered {
text-align: center;
}
&__message {
display: inline-block;
font-weight: bold;
margin-top: 3rem;
}
hr {
margin-left: -$gap * 3;
margin-right: -$gap * 3;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -33,7 +33,7 @@ $title-font-size: 5.2rem;
$h1-font-size: 4rem;
$h2-font-size: 3rem;
$h3-font-size: 2.3rem;
$h4-font-size: 1.7rem;
$h4-font-size: 1.9rem;
$h5-font-size: 1.5rem;
$h6-font-size: 1.3rem;
$base-line-height: 1.5;
@ -44,6 +44,7 @@ $font-sans: "Source Sans Pro", sans-serif;
$font-serif: "Merriweather", serif;
$font-normal: 400;
$font-semibold: 600;
$font-bold: 700;
// Color

View File

@ -12,6 +12,7 @@
.usa-button,
a {
margin: 0 0 0 $gap;
cursor: pointer;
@include media($medium-screen) {
margin: 0 0 0 ($gap * 2);

View File

@ -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%;
}
}
}
@ -83,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;
}

View File

@ -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);

View File

@ -1,5 +1,6 @@
.task-order {
margin-top: $gap * 4;
margin-bottom: $footer-height;
width: 900px;
&__amount {

View File

@ -118,7 +118,7 @@
{% endmacro %}
{% macro InfoFields(member_form) %}
<div class="application-member__user-info">
<div class="user-info">
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.email, validation='email', optional=False) }}

View File

@ -1,12 +1,10 @@
{% from "components/alert.html" import Alert %}
{% from "components/icon.html" import Icon %}
{% from "components/label.html" import Label %}
{% import "applications/fragments/new_member_modal_content.html" as member_steps %}
{% import "components/member_form.html" as member_form %}
{% import "applications/fragments/member_form_fields.html" as member_fields %}
{% from "components/modal.html" import Modal %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% from "components/save_button.html" import SaveButton %}
{% from "components/toggle_list.html" import ToggleButton, ToggleSection %}
{% macro MemberManagementTemplate(
application,
@ -25,7 +23,7 @@
<div class="panel">
{% if not application.members %}
<div class='empty-state panel__content'>
<div class='empty-state empty-state--centered empty-state--white panel__content'>
<p class='empty-state__message'>
{{ ("portfolios.applications.members.blank_slate" | translate) }}
</p>
@ -179,8 +177,19 @@
form=new_member_form,
form_action=url_for(action_new, application_id=application.id),
steps=[
member_steps.MemberStepOne(new_member_form),
member_steps.MemberStepTwo(new_member_form, application)
member_form.BasicStep(
title="portfolios.applications.members.form.add_member"|translate,
form=member_fields.InfoFields(new_member_form.user_data),
next_button_text="portfolios.applications.members.form.next_button"|translate,
previous=False,
modal=new_member_modal_name,
),
member_form.SubmitStep(
name=new_member_modal_name,
form=member_fields.PermsFields(form=new_member_form, new=True),
submit_text="portfolios.applications.members.form.add_member"|translate,
modal=new_member_modal_name,
)
],
) }}
{% endif %}

View File

@ -1,50 +0,0 @@
{% from "components/icon.html" import Icon %}
{% import "applications/fragments/member_form_fields.html" as member_fields %}
{% macro MemberFormTemplate(title=None, next_button=None, previous=True) %}
<hr class="full-width">
{% if title %} <h1>{{ title }}</h1> {% endif %}
{{ caller() }}
<div class='action-group'>
{{ next_button }}
{% if previous %}
<input
type='button'
v-on:click="previous()"
class='action-group__action usa-button usa-button-secondary'
value='{{ "common.previous" | translate }}'>
{% endif %}
<a class='action-group__action' v-on:click="closeModal('{{ new_port_mem }}')">{{ "common.cancel" | translate }}</a>
</div>
{% endmacro %}
{% macro MemberStepOne(member_form) %}
{% set next_button %}
<input
type='button'
v-on:click="next()"
v-bind:disabled="!canSave"
class='action-group__action usa-button'
value='{{ "portfolios.applications.members.form.next_button" | translate }}'>
{% endset %}
{% call MemberFormTemplate(title="portfolios.applications.members.form.add_member"|translate, next_button=next_button, previous=False) %}
{{ member_fields.InfoFields(member_form.user_data) }}
{% endcall %}
{% endmacro %}
{% macro MemberStepTwo(member_form, application) %}
{% set next_button %}
<input
type="submit"
class='action-group__action usa-button'
form="add-app-mem"
v-bind:disabled="!canSave"
value='{{ "portfolios.applications.members.form.add_member" | translate}}'>
{% endset %}
{% call MemberFormTemplate(next_button=next_button) %}
{{ member_fields.PermsFields(form=member_form, new=True) }}
{% endcall %}
{% endmacro %}

View File

@ -25,7 +25,7 @@
<div class='usa-alert-body'>
{% if vue_template %}
<h3 class='usa-alert-heading' v-html='title'></h3>
<h3 class='usa-alert-heading' v-text='title'></h3>
{% elif title %}
<h3 class='usa-alert-heading'>{{ title | safe }}</h3>
{% endif %}

View File

@ -57,7 +57,7 @@
<span class='usa-input__message'>{{ "forms.task_order.clin_funding_errors.obligated_amount_error" | translate }}</span>
</template>
<template v-else-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
<template v-else>
<span class='usa-input__message'></span>

View File

@ -23,7 +23,7 @@
inline-template>
<div class="clin-card" v-if="showClin">
<div class="card__title">
<span class="h4" v-html='clinTitle'></span>
<span class="h4" v-text='clinTitle'></span>
<button
v-if='clinIndex > 0'
class="icon-link icon-link__remove-clin"
@ -68,7 +68,7 @@
<input type='hidden' v-bind:value='rawValue' :name='name' />
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
<template v-else>
<span class='usa-input__message'></span>
@ -119,7 +119,7 @@
{% endif %}
<div class="h5 clin-card__title">Percent Obligated</div>
<p id="percent-obligated" v-html='percentObligated'></p>
<p id="percent-obligated" v-text='percentObligated'></p>
<hr>
<div class="form-row">
@ -140,7 +140,7 @@
<div class='modal__dialog' role='dialog' aria-modal='true'>
<div class='modal__body'>
<div class="task-order__modal-cancel">
<h1 v-html='"{{ 'task_orders.form.clin_remove_text' | translate }}" + clinTitle + "?"'></h1>
<h1 v-text='"{{ 'task_orders.form.clin_remove_text' | translate }}" + clinTitle + "?"'></h1>
<div class="task-order__modal-cancel_buttons">
<button
v-on:click='closeModal(removeModalId)'

View File

@ -9,11 +9,15 @@
"text": "changes pending",
"color": "default",
},
"ppoc": {"text": "primary point of contact"}
} %}
{% if type -%}
{% if type in label_info.keys() -%}
<span class='label label--{{ label_info[type]["color"] }} {{ classes }}'>
{{ Icon(label_info[type]["icon"]) }} {{ label_info[type]["text"] }}
{% if label_info[type]["icon"] %}
{{ Icon(label_info[type]["icon"]) }}
{% endif %}
{{ label_info[type]["text"] }}
</span>
{%- endif %}
{%- endmacro %}

View File

@ -0,0 +1,65 @@
<!-- Layout macro -->
{% macro MemberForm(title=None, next_button=None, previous=True, modal=modal) %}
<div class="member-form">
<hr class="full-width">
{% if title %} <h2>{{ title }}</h2> {% endif %}
{{ caller() }}
</div>
<div class='action-group'>
{{ next_button }}
{% if previous %}
<input
type='button'
v-on:click="previous()"
class='action-group__action usa-button usa-button-secondary'
value='{{ "common.previous" | translate }}'>
{% endif %}
<a class='action-group__action' v-on:click="closeModal('{{ modal }}')">{{ "common.cancel" | translate }}</a>
</div>
{% endmacro %}
<!-- Step macros to use with MultiStepModalForm -->
{% macro BasicStep(
title=None,
form=form,
next_button_text=next_button_text,
previous=True,
modal=modal
) %}
{% set next_button %}
<input
type='button'
v-on:click="next()"
v-bind:disabled="!canSave"
class='action-group__action usa-button'
value='{{ next_button_text }}'>
{% 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 %}
<input
type="submit"
class='action-group__action usa-button'
form="{{ name }}"
v-bind:disabled="!canSave"
value='{{ submit_text }}'>
{% endset %}
{% call MemberForm(title=title, next_button=next_button, previous=previous, modal=modal) %}
{{ form }}
{% endcall %}
{% endmacro %}

View File

@ -54,7 +54,7 @@
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
</fieldset>

View File

@ -48,7 +48,7 @@
{{ field(disabled=disabled) }}
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
</fieldset>

View File

@ -107,7 +107,7 @@
/>
{% if show_validation %}
<span v-if='showError' class='usa-input__message' v-html='validationError'></span>
<span v-if='showError' class='usa-input__message' v-text='validationError'></span>
{% endif %}
</div>

View File

@ -15,7 +15,7 @@
<div>
<div v-show="valid" class="uploaded-file">
{{ Icon("ok") }}
<a class="uploaded-file__name" v-html="baseName" v-bind:href="downloadLink"></a>
<a class="uploaded-file__name" v-text="baseName" v-bind:href="downloadLink"></a>
<a href="#" class="uploaded-file__remove" v-on:click="removeAttachment">Remove</a>
</div>
<div v-show="valid === false" v-bind:class='{ "usa-input": true, "usa-input--error": showErrors }'>
@ -49,6 +49,9 @@
<template v-if="sizeError">
<span class="usa-input__message">{{ "forms.task_order.size_error" | translate }}</span>
</template>
<template v-if="filenameError">
<span class="usa-input__message">{{ "forms.task_order.filename_error" | translate }}</span>
</template>
{% for error, error_messages in field.errors.items() %}
<span class="usa-input__message">{{error_messages[0]}}</span>
{% endfor %}

View File

@ -1,5 +1,6 @@
{% extends "portfolios/base.html" %}
{% from "components/label.html" import Label %}
{% from "components/pagination.html" import Pagination %}
{% from 'components/save_button.html' import SaveButton %}
{% from 'components/sticky_cta.html' import StickyCTA %}
@ -9,8 +10,8 @@
{{ StickyCTA(text="Settings") }}
<div v-cloak class="portfolio-admin portfolio-content">
<div v-cloak class="portfolio-admin">
{% include "fragments/flash.html" %}
<!-- max width of this section is 460px -->
<section class="form-container__half">
<h3>Portfolio name and component</h3>

View File

@ -1,79 +0,0 @@
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% macro SimpleOptionsInput(field) %}
<div class="usa-input">
<fieldset data-ally-disabled="true" class="usa-input__choices">
<legend>
<div class="usa-input__title-inline">
{{ field.label | striptags}}
</div>
</legend>
{{ field() }}
</fieldset>
</div>
{% endmacro %}
{% set step_one %}
<hr class="full-width">
<h1>Invite new portfolio member</h1>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.first_name, validation='requiredField', optional=False) }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.last_name, validation='requiredField', optional=False) }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.email, validation='email', optional=False) }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.phone_number, validation='usPhone') }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.dod_id, validation='dodId', optional=False) }}
</div>
<div class='form-col form-col--half'>
</div>
</div>
<div class='action-group'>
<input
type='button'
v-on:click="next()"
v-bind:disabled="!canSave"
class='action-group__action usa-button usa-button-primary'
value='Next'>
<a class='action-group__action' v-on:click="closeModal('{{ new_port_mem }}')">Cancel</a>
</div>
{% endset %}
{% set step_two %}
<hr class="full-width">
<h1>Assign member permissions</h1>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.permissions_info" | translate }}
</a>
{{ SimpleOptionsInput(member_form.permission_sets.perms_app_mgmt) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_funding) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_reporting) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_portfolio_mgmt) }}
<div class='action-group'>
<input
type="submit"
class='action-group__action usa-button usa-button-primary'
form="add-port-mem"
value='Invite member'>
<a class='action-group__action' v-on:click="closeModal('{{ new_port_mem }}')">Cancel</a>
</div>
{% endset %}
{{ MultiStepModalForm(
'add-port-mem',
member_form,
url_for("portfolios.invite_member", portfolio_id=portfolio.id),
[step_one, step_two],
) }}

View File

@ -0,0 +1,37 @@
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/icon.html" import Icon %}
{% from "components/phone_input.html" import PhoneInput %}
{% from "components/text_input.html" import TextInput %}
{% macro PermsFields(form, member_role_id=None) %}
<h2>Set Portfolio Permissions</h2>
<div class="portfolio-perms">
{% if new %}
{% set app_mgmt = form.perms_app_mgmt.name %}
{% set funding = form.perms_funding.name %}
{% set reporting = form.perms_reporting.name %}
{% set portfolio_mgmt = form.perms_portfolio_mgmt.name %}
{% else %}
{% set app_mgmt = "perms_app_mgmt-{}".format(member_role_id) %}
{% set funding = "perms_funding-{}".format(member_role_id) %}
{% set reporting = "perms_reporting-{}".format(member_role_id) %}
{% set portfolio_mgmt = "perms_portfolio_mgmt-{}".format(member_role_id) %}
{% endif %}
{{ CheckboxInput(form.perms_app_mgmt, classes="input__inline-fields", key=app_mgmt, id=app_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_funding, classes="input__inline-fields", key=funding, id=funding, optional=True) }}
{{ CheckboxInput(form.perms_reporting, classes="input__inline-fields", key=reporting, id=reporting, optional=True) }}
{{ CheckboxInput(form.perms_portfolio_mgmt, classes="input__inline-fields", key=portfolio_mgmt, id=portfolio_mgmt, optional=True) }}
</div>
{% endmacro %}
{% macro InfoFields(member_form) %}
<div class="user-info">
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
<a href="#">How do I find the DoD ID?</a>
</div>
{% endmacro %}

View File

@ -1,38 +0,0 @@
{% from "components/alert.html" import Alert %}
{% from "components/modal.html" import Modal %}
{% from "components/options_input.html" import OptionsInput %}
{% for subform in member_perms_form.members_permissions %}
{% set modal_id = "portfolio_id_{}_user_id_{}".format(portfolio.id, subform.member_id.data) %}
{% set ppoc = subform.member_id.data == ppoc_id %}
{% set archive_button_class = 'button-danger-outline' %}
<tr {% if ppoc %}class="members-table-ppoc"{% endif %}>
<td class='name'>{{ subform.member_name.data }}
<div>
{% if ppoc %}
{% set archive_button_class = 'usa-button-disabled' %}
<span class='you'>PPoC</span>
{% endif %}
{% if subform.member_id.data == current_member_id %}
{% set archive_button_class = 'usa-button-disabled' %}
<span class='you'>(<span class='green'>you</span>)</span>
{% endif %}
</div>
</td>
<td>{{ OptionsInput(subform.perms_app_mgmt, label=False, disabled=ppoc) }}</td>
<td>{{ OptionsInput(subform.perms_funding, label=False, disabled=ppoc) }}</td>
<td>{{ OptionsInput(subform.perms_reporting, label=False, disabled=ppoc) }}</td>
<td>{{ OptionsInput(subform.perms_portfolio_mgmt, label=False, disabled=ppoc) }}</td>
<td>
<a v-on:click="openModal('{{ modal_id }}')" class='usa-button {{ archive_button_class }}'>
{{ "portfolios.members.archive_button" | translate }}
</a>
{% if not ppoc %}
{{ subform.member_id() }}
{% endif %}
</td>
</tr>
{% endfor %}

View File

@ -1,26 +0,0 @@
{% for subform in member_perms_form.members_permissions %}
{% set ppoc = subform.member_id.data == ppoc_id %}
{% set heading_perms = [subform.perms_app_mgmt, subform.perms_funding, subform.perms_reporting, subform.perms_portfolio_mgmt] %}
<tr>
<td class='name'>{{ subform.member_name.data }}
<div>
{% if ppoc %}
<span class='you'>PPoC</span>
{% endif %}
{% if subform.member_id.data == current_member_id %}
<span class='you'>(<span class='green'>you</span>)</span>
{% endif %}
</div>
</td>
{% for access in heading_perms %}
{% if dict(access.choices).get(access.data) == ('portfolios.members.permissions.edit_access' | translate) %}
<td class='green'>{{ 'portfolios.members.permissions.edit_access' | translate }}</td>
{% else %}
<td>{{ 'common.view' | translate }}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}

View File

@ -1,105 +1,74 @@
{% from "components/icon.html" import Icon %}
{% from 'components/save_button.html' import SaveButton %}
{% from "components/modal.html" import Modal %}
{% from "components/alert.html" import Alert %}
{% from "components/icon.html" import Icon %}
{% import "components/member_form.html" as member_form %}
{% from "components/modal.html" import Modal %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% from 'components/save_button.html' import SaveButton %}
{% import "portfolios/fragments/member_form_fields.html" as member_form_fields %}
<section class="member-list" id="portfolio-members">
<div class='responsive-table-wrapper panel accordion-table'>
{% if g.matchesPath("portfolio-members") %}
{% include "fragments/flash.html" %}
{% endif %}
<base-form inline-template>
<form method='POST' id="member-perms" action='{{ url_for("portfolios.edit_members", portfolio_id=portfolio.id) }}' autocomplete="off" enctype="multipart/form-data">
{{ member_perms_form.csrf_token }}
<h3>Portfolio Managers</h3>
<div class="panel">
<section class="member-list">
<div class="responsive-table-wrapper">
<table class="atat-table">
<thead>
<tr>
<th class="table-cell--third">Name</th>
<th>Portfolio Permissions</th>
</tr>
</thead>
<tbody>
{% for member in members -%}
<tr>
<td>
<strong>{{ member.user_name }}{% if member.role_id == current_member_id %} (You){% endif %}</strong>
<br>
{% if member.ppoc %}
{{ Label(type="ppoc", classes='label--below label--purple')}}
{% endif %}
{{ Label(type=member.status, classes='label--below')}}
</td>
<td>
{% for perm, value in member.permission_sets.items() -%}
<div>
{% if value -%}
{{ ("portfolios.admin.members.{}.{}".format(perm, value)) | translate }}
{%- endif %}
</div>
{%-endfor %}
</td>
</tr>
{%- endfor %}
</tbody>
</table>
</div>
</section>
<div class='application-list-item'>
<header>
<div class='responsive-table-wrapper__header'>
<div class='responsive-table-wrapper__title'>
<div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div>
<div class='subheading'>
{{ "portfolios.admin.portfolio_members_subheading" | translate }}
</div>
</div>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
</header>
{% if not portfolio.members %}
<p>{{ "portfolios.admin.no_members" | translate }}</p>
{% else %}
<table class="atat-table">
<thead>
<tr>
<td>{{ "portfolios.members.permissions.name" | translate }}</td>
<td>{{ "portfolios.members.permissions.app_mgmt" | translate }}</td>
<td>{{ "portfolios.members.permissions.funding" | translate }}</td>
<td>{{ "portfolios.members.permissions.reporting" | translate }}</td>
<td>{{ "portfolios.members.permissions.portfolio_mgmt" | translate }}</td>
<td></td>
</tr>
</thead>
<tbody>
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
{% include "portfolios/fragments/members_edit.html" %}
{% elif user_can(permissions.VIEW_PORTFOLIO_USERS) %}
{% include "portfolios/fragments/members_view.html" %}
{% endif %}
</tbody>
</table>
</div>
{% endif %}
<div class="panel__footer">
<div class="action-group save">
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
{{ SaveButton(text=('common.save' | translate), element="input", form="member-perms") }}
{% endif %}
{% if user_can(permissions.CREATE_PORTFOLIO_USERS) %}
<a class="icon-link modal-link" v-on:click="openModal('add-port-mem')">
{{ "portfolios.admin.add_new_member" | translate }}
{{ Icon("plus") }}
</a>
{% endif %}
</div>
</div>
</form>
</base-form>
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
{% for subform in member_perms_form.members_permissions %}
{% set modal_id = "portfolio_id_{}_user_id_{}".format(portfolio.id, subform.member_id.data) %}
{% call Modal(name=modal_id, dismissable=False) %}
<h1>{{ "portfolios.admin.alert_header" | translate }}</h1>
<hr>
{{
Alert(
title="portfolios.admin.alert_title" | translate,
message="portfolios.admin.alert_message" | translate,
level="warning"
)
}}
<div class="action-group">
<form method="POST" action="{{ url_for('portfolios.remove_member', portfolio_id=portfolio.id, portfolio_role_id=subform.member_id.data)}}">
{{ member_perms_form.csrf_token }}
<button class="usa-button usa-button-danger">
{{ "portfolios.members.archive_button" | translate }}
</button>
</form>
<a v-on:click="closeModal('{{ modal_id }}')" class="action-group__action icon-link icon-link--default">{{ "common.cancel" | translate }}</a>
</div>
{% endcall %}
{% endfor %}
{% endif %}
</div>
{% if user_can(permissions.CREATE_PORTFOLIO_USERS) %}
{% include "portfolios/fragments/add_new_portfolio_member.html" %}
{% set new_manager_modal = "add-portfolio-manager" %}
<a class="usa-button usa-button-secondary add-new-button" v-on:click="openModal('{{ new_manager_modal }}')">
Add Portfolio Manager
</a>
{{ MultiStepModalForm(
name=new_manager_modal,
form=new_manager_form,
form_action=url_for("portfolios.invite_member", portfolio_id=portfolio.id),
steps=[
member_form.BasicStep(
title="Add Manager",
form=member_form_fields.InfoFields(new_manager_form.user_data),
next_button_text="Next: Permissions",
previous=False,
modal=new_manager_modal_name,
),
member_form.SubmitStep(
name=new_manager_modal,
form=member_form_fields.PermsFields(new_manager_form),
submit_text="Add Mananger",
modal=new_manager_modal_name,
)
],
) }}
{% endif %}
</section>
</div>

View File

@ -37,19 +37,19 @@
<tr>
<td>
<button v-on:click='toggle($event, applicationIndex)' class='icon-link icon-link--large'>
<span v-html='application.name'></span>
<span v-text='application.name'></span>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_up') }}</template>
</button>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application.this_month || 0)'></span>
<span v-text='formatDollars(application.this_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application.last_month || 0)'></span>
<span v-text='formatDollars(application.last_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application.total || 0)'></span>
<span v-text='formatDollars(application.total || 0)'></span>
</td>
</tr>
<tr
@ -58,16 +58,16 @@
v-bind:class="[ index == application.environments.length -1 ? 'reporting-spend-table__env-row--last' : '']"
>
<td>
<span class="reporting-spend-table__env-row-label" v-html='environment.name'></span>
<span class="reporting-spend-table__env-row-label" v-text='environment.name'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment.this_month || 0)'></span>
<span v-text='formatDollars(environment.this_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment.last_month || 0)'></span>
<span v-text='formatDollars(environment.last_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment.total || 0)'></span>
<span v-text='formatDollars(environment.total || 0)'></span>
</td>
</tr>
</template>

View File

@ -1,4 +1,4 @@
{% macro TOFormStepHeader(description, title=None, to_number=None) %}
{% macro TOFormStepHeader(description=None, title=None, to_number=None) %}
<div class="task-order__header">
{% if title -%}
<div class="h2">
@ -10,8 +10,10 @@
<strong>Task Order Number:</strong> {{ to_number }}
</p>
{% endif %}
<p>
{{ description }}
</p>
{% if description %}
<p>
{{ description }}
</p>
{% endif %}
</div>
{% endmacro %}

View File

@ -23,14 +23,9 @@
<span class="summary-item__header-text">Total Task Order value</span>
{{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="summary-item__header-icon") }}
</h4>
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
{% if earliest_pop_start_date and latest_pop_end_date %}
<p class="summary-item__value--large">
{{ contract_amount | dollars }}
</p>
{% else %}
<p class="summary-item__value--large"> - </p>
{% endif %}
<p class="summary-item__value--large">
{{ contract_amount | dollars }}
</p>
</div>
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">

View File

@ -1,6 +1,7 @@
{% extends "task_orders/builder_base.html" %}
{% from "task_orders/fragments/task_order_view.html" import TaskOrderView %}
{% from 'task_orders/form_header.html' import TOFormStepHeader %}
{% set action = url_for('task_orders.form_step_five_confirm_signature', task_order_id=task_order_id) %}
{% set previous_button_link = url_for("task_orders.form_step_three_add_clins", task_order_id=task_order_id) %}
@ -16,5 +17,6 @@
{% endblock %}
{% block to_builder_form_field %}
{{ TOFormStepHeader(to_number=task_order.number) }}
{{ TaskOrderView(task_order, portfolio, builder_mode=True) }}
{% endblock %}

View File

@ -51,6 +51,19 @@ For Ubuntu 19.10
snap install powershell --classic
```
# Preview Features
To create all the resources we need for this environment we'll need to enable some _Preview_ features.
This registers the specific feature for _SystemAssigned_ principals
```
az feature register --namespace Microsoft.ContainerService --name MSIPreview
```
To apply the registration, run the following
```
az provider register -n Microsoft.ContainerService
```
# Running Terraform
First, you'll need to log in to Azure. With the Azure CLI installed, you can run the following.
@ -76,6 +89,50 @@ terraform apply
Check the output for errors. Sometimes the syntax is valid, but some of the configuration may be wrong and only rejected by the Azure API at run time. If this is the case, fix your mistake, and re-run.
# After running TF (Manual Steps)
## VM Scale Set
After running terraform, we need to make a manual change to the VM Scale Set that is used in the kubernetes. Terraform has a bug that is not applying this as of `v1.40` of the `azurerm` provider.
In order to get the `SystemAssigned` identity to be set, it needs to be set manually in the console.
Navigate to the VM Scale Set for the k8s cluster you're managing (in the console).
![SystemAssigned Identity](images/system-assigned.png)
_Just click the `Status` to `On`_
## KeyVault Policy
There is a bug (missing feature really) in the `azurerm` terraform provider which exposes the wrong `object_id/principal_id` in the `azurerm_kubernetes_cluster` output. The `id` that it exposes is the `object_id` of the cluster itself, and _not_ the Virtual Machine Scale Set SystemAssigned identity. This needs to be updated manually after running terraform for the first time.
To update, just edit the `keyvault.tf`. Set the `principal_id` to the `object_id` of the Virtual Machine Scale set. This can be found in the Azure portal, or via cli.
```
az vmss list
```
In that list, find the scale set for the k8s cluster you're working on. You'll want the value of `principal_id`.
The error looks like the following
```
Warning FailedMount 8s (x6 over 25s) kubelet, aks-default-54410534-vmss000001 MountVolume.SetUp failed for volume "flask-secret" : mount command failed, status: Failure, reason: /etc/kubernetes/volumeplugins/azure~kv/azurekeyvault-flex
volume failed, Access denied. Caller was not found on any access policy. r nCaller: appid=e6651156-7127-432d-9617-4425177c48f1;oid=f9bcbe58-8b73-4957-aee2-133dc3e58063;numgroups=0;iss=https://sts.windows.net/b5ab0e1e-09f8-4258-afb7-fb17654bc5
b3/ r nVault: cloudzero-dev-keyvault;location=eastus2 InnerError={code:AccessDenied}
```
Final configuration will look like this.
**keyvault.tf**
```
module "keyvault" {
source = "../../modules/keyvault"
name = var.name
region = var.region
owner = var.owner
environment = var.environment
tenant_id = var.tenant_id
principal_id = "f9bcbe58-8b73-4957-aee2-133dc3e58063"
}
```
# Shutting down and environment
To shutdown and remove an environment completely as to not incur any costs you would need to run a `terraform destroy`.

Some files were not shown because too many files have changed in this diff Show More