mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
396 Commits
testing-ce
...
v0.28.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe942e697 | ||
|
|
faf2edb111 | ||
| 5bec453e89 | |||
| 7f2174dd2c | |||
| 2bae52e307 | |||
|
|
b243e62ece | ||
|
|
88c3056e97 | ||
| 203303b659 | |||
| 835709bd36 | |||
| 69f2576ee6 | |||
|
|
305077f36e | ||
|
|
96eca3a35d | ||
|
|
0f5c815c25 | ||
|
|
acccef1e99 | ||
| abc34ea047 | |||
| 71c429b093 | |||
|
|
c126f2d5d9 | ||
|
|
34dd95d16e | ||
| 1192583843 | |||
|
|
518815acf1 | ||
|
|
80b7e14ed5 | ||
| 23c997b3be | |||
| 39ba995355 | |||
|
|
0e081e024b | ||
|
|
6a84bd0387 | ||
| 98d177a691 | |||
| 4dcee4b3c3 | |||
|
|
4ffc1ec6a9 | ||
|
|
5a1d168e83 | ||
|
|
515c976685 | ||
| 112431a1b7 | |||
| c26485afec | |||
| 1d48f8856b | |||
| 68758aa616 | |||
| 8b8c43c21b | |||
| 4581f32534 | |||
| b47e74a7e1 | |||
| b27d684145 | |||
|
|
6b666d5554 | ||
|
|
b5f0911858 | ||
| b86ba5b8a3 | |||
| 200f6c596a | |||
|
|
1b7ccda6e9 | ||
|
|
532035d835 | ||
|
|
7ae862c5ce | ||
|
|
ab5858b8af | ||
|
|
a39f5dd9f1 | ||
|
|
3ea444756c | ||
|
|
96399ecbbe | ||
| bb054938c5 | |||
|
|
fb6a556630 | ||
|
|
9dbd8e16bd | ||
| fe07c41f5f | |||
| acc25cbd68 | |||
| 4ca61feda6 | |||
| abd5c64b4a | |||
| 2413c05fea | |||
|
|
4c874c2c39 | ||
|
|
d819d79bc9 | ||
| c51b50428f | |||
|
|
e10a0554f0 | ||
|
|
337e2eb2a0 | ||
| 66f8e7840b | |||
| 1c9d167f86 | |||
|
|
7e684b54a6 | ||
|
|
aafda2922b | ||
| 418bf41e38 | |||
| 81f0b03b09 | |||
| fe5ae44aab | |||
| 36be3a7d5e | |||
| 6434123401 | |||
|
|
0a6b972c65 | ||
|
|
be11707042 | ||
| 2412622367 | |||
|
|
de3a190a8d | ||
|
|
d5daafc6ed | ||
|
|
b1a2677b8c | ||
|
|
94072c3d24 | ||
|
|
b64c0c12da | ||
|
|
79bc7b0e28 | ||
|
|
fda0e0b57d | ||
|
|
14731e8824 | ||
|
|
258cc35f14 | ||
|
|
2295a518f0 | ||
|
|
1e5d621817 | ||
| 4d64420c45 | |||
|
|
799339de30 | ||
|
|
042ed41189 | ||
| 424f0fc1fa | |||
|
|
deafebde05 | ||
|
|
b66dc86b01 | ||
|
|
3bb05974d2 | ||
|
|
d1c1a59e91 | ||
|
|
668aff83fd | ||
| 3fc06b80fc | |||
| bbd7786c6c | |||
| 68f0c5c588 | |||
|
|
69ddf313b8 | ||
|
|
65e404cdbd | ||
| a14266077d | |||
|
|
fda6ad6356 | ||
|
|
fe3e5088f8 | ||
| f915c51b07 | |||
|
|
375f924f45 | ||
|
|
72329e30ed | ||
| 40f95f9072 | |||
|
|
58e8a869ef | ||
|
|
b558a3d01d | ||
| 249604384e | |||
|
|
056a436e10 | ||
|
|
06d59c618c | ||
|
|
a0e7875ae6 | ||
|
|
24966e695a | ||
|
|
5c40d8a342 | ||
| 6f5566dabb | |||
| d93470d183 | |||
| 330c020933 | |||
|
|
a810f6c7cf | ||
|
|
5d6c6086b4 | ||
|
|
0edcbdcefc | ||
|
|
ea0222f218 | ||
| edc2e2a302 | |||
|
|
efd2e1450e | ||
|
|
1092a73c10 | ||
| 9977c9d161 | |||
|
|
5c0eff5197 | ||
|
|
3bda991a58 | ||
| 0327f7c6ec | |||
| 92549402eb | |||
|
|
b88c911527 | ||
|
|
8b12f31060 | ||
|
|
e65cba9af0 | ||
| 0749d65173 | |||
|
|
a9c9b734f5 | ||
|
|
39da41c9f1 | ||
| 662b2ca36a | |||
| 16b7aa6abb | |||
| 4560ef942f | |||
| 06d3b17154 | |||
| d6651bbdbe | |||
| b9d032f148 | |||
|
|
70655e74d3 | ||
|
|
cb82fea0d8 | ||
| b9a596616d | |||
|
|
72a5393be3 | ||
|
|
769a840e9f | ||
| 730c7c52ac | |||
| ee2db276bb | |||
|
|
d0a24aacb6 | ||
|
|
57dfdf89a4 | ||
|
|
393b5eaf99 | ||
|
|
7477326b22 | ||
|
|
76bf84316e | ||
|
|
e355276e44 | ||
|
|
a3a9e3bd9f | ||
|
|
9f06080348 | ||
|
|
4bbf9cfdb3 | ||
|
|
e8e71fcde9 | ||
|
|
e63271a67a | ||
| 7633608318 | |||
|
|
e67d27d264 | ||
|
|
53033ccc96 | ||
|
|
6131ed1cbe | ||
|
|
5d624e3399 | ||
| ee17d37aa1 | |||
| 572fe22d50 | |||
| 091268bf58 | |||
| 71a4a48443 | |||
| 3b188cd724 | |||
| eeba2328c0 | |||
| 0a0ba2cca5 | |||
|
|
476f834a80 | ||
|
|
8b8739a873 | ||
| bce83cb6fb | |||
| 3a3c90d9e6 | |||
|
|
e63eaa5302 | ||
|
|
65de1bb175 | ||
|
|
a5ee2f2923 | ||
| 98ea2ac9b9 | |||
|
|
e94c56b23f | ||
|
|
64f80e958d | ||
| bd97363c13 | |||
| 02e88ae728 | |||
| 882bedd5d5 | |||
| 8780b800a3 | |||
| 4c11082796 | |||
| a9b25b8880 | |||
| b06993ab9e | |||
|
|
f736e67517 | ||
|
|
0f4a60c0c7 | ||
|
|
f8bb7327a8 | ||
|
|
abce135da2 | ||
|
|
a6c014946a | ||
| f27ac51fc4 | |||
|
|
cb5be1be21 | ||
|
|
d90fa9e5dd | ||
| d99fdd1ec7 | |||
|
|
399b5edad0 | ||
|
|
1dbc12e96b | ||
| e215958b8b | |||
| 9227cd449d | |||
| c67d3ee2f1 | |||
| 6ef40b954a | |||
|
|
0d913baff1 | ||
|
|
3671736c3d | ||
| 34cd84d8a9 | |||
|
|
f7fcc7741a | ||
|
|
18052fdbf6 | ||
|
|
5966016853 | ||
|
|
87c03c5f8d | ||
| 7a162eda8f | |||
| 754704bca8 | |||
|
|
77f8d30baf | ||
|
|
78bea7c154 | ||
|
|
9c3b155c12 | ||
|
|
98e501334f | ||
|
|
bbfd53e79e | ||
| 254bc07da7 | |||
| f978814ca7 | |||
| 68515f95a6 | |||
| d3a516c36e | |||
| c3e3befc17 | |||
|
|
275de9478e | ||
|
|
1a3ef62cb2 | ||
|
|
9eb5f3ca4d | ||
|
|
916947dffa | ||
| 79b7827b7c | |||
| 37e1aa9b61 | |||
| 7e504008b7 | |||
| 5d5a9d3788 | |||
|
|
7c79d6479c | ||
|
|
3e635f422a | ||
|
|
77db14c690 | ||
| b7dff341f0 | |||
| 8a3054e19a | |||
|
|
a531de2adb | ||
|
|
c458d94493 | ||
| 706e228a8e | |||
| 7681722e5a | |||
| 8de032b543 | |||
|
|
998ef213e9 | ||
|
|
f8b0f98678 | ||
| 9640f65264 | |||
| c574b42235 | |||
| 468d1a929d | |||
| 7cdffe30e3 | |||
| 3b1fcb937d | |||
| 3c987c61dd | |||
| 0a780697da | |||
| 83d819df53 | |||
|
|
95df2b21d6 | ||
|
|
accdf914f1 | ||
| 15bdd2d7f0 | |||
| 2ce947d216 | |||
| ce2114e3f6 | |||
| 6c7550286b | |||
| 2360e104bd | |||
| 420a61a5a6 | |||
| 04e0f9efe3 | |||
| 99172cd9ed | |||
| 57daad0c26 | |||
| cc1e4543fc | |||
| 03cb89d14f | |||
| 72140d73c2 | |||
| efcefd2a42 | |||
| 06d7c91fc3 | |||
| 7010a6a120 | |||
| fdcaba9d56 | |||
| 48688a6547 | |||
| 0ce94a553e | |||
| 941917e508 | |||
|
|
5706371ffd | ||
|
|
ce5218a227 | ||
|
|
8b62755f39 | ||
|
|
cb84c3ebbb | ||
|
|
526402fd73 | ||
| 177675bc89 | |||
| 721165ff12 | |||
| 08e0c61e0f | |||
|
|
1b234eb2b1 | ||
|
|
ef25eec11f | ||
| 3e53f70928 | |||
| 0f19384999 | |||
| 63dd6813c0 | |||
| 299512135d | |||
| 6c35412d2f | |||
| 27410bc32b | |||
| 849b2dd468 | |||
|
|
a1a182698e | ||
|
|
4be692b24b | ||
|
|
d2ddd8aaca | ||
|
|
3a45e8f525 | ||
|
|
c0e2f55a7b | ||
|
|
aa027414ed | ||
|
|
8c4c52b1a9 | ||
|
|
ff420434ae | ||
|
|
65e6de9663 | ||
|
|
2e53d43e11 | ||
|
|
3795f748a7 | ||
|
|
e024a92f16 | ||
|
|
92fda183f3 | ||
|
|
6f2e6efd03 | ||
| 30d7a65358 | |||
| 5e930f14d2 | |||
| 9bc68b1cdc | |||
|
|
3b4e9d20d4 | ||
|
|
4a67d0c63a | ||
|
|
dea204e3c5 | ||
|
|
5f9e83759c | ||
|
|
fefe63deb1 | ||
| ddd179bbee | |||
| a10b87930c | |||
| 496247d0b9 | |||
| eeb63b330c | |||
|
|
1108d3dd7b | ||
|
|
7edb47a4cb | ||
|
|
451cb4f6dd | ||
|
|
0b759a5594 | ||
|
|
5338ffb211 | ||
| e42fdd3575 | |||
| b10e932605 | |||
| e54a09db19 | |||
| 4c35e04802 | |||
| b5f595a25c | |||
|
|
a131adbae7 | ||
|
|
a20c3b9719 | ||
|
|
eee3a7b084 | ||
|
|
9c3da56901 | ||
|
|
7e6524d7e4 | ||
|
|
0ea2690616 | ||
|
|
b369759f0f | ||
|
|
ac9a835c5a | ||
|
|
e290751c87 | ||
| e516b7716d | |||
| f3dfc7083f | |||
| 7d916ec3e9 | |||
| 70f279a49c | |||
| 66a3537271 | |||
| ca64c13909 | |||
| 0a73a35547 | |||
| a75edbaa32 | |||
| 4ddfec0403 | |||
| 35439d7d51 | |||
| 907aa485fd | |||
| 888627e1c8 | |||
| 9cb9e2dd33 | |||
| 54d4bf835d | |||
| 67fe298fd5 | |||
| 97ecfdc955 | |||
| 5b319f9ad1 | |||
| be8635ccc5 | |||
| f863b81a7d | |||
| bdf63df1d9 | |||
| 4c6b9c5e93 | |||
|
|
a2d1396057 | ||
|
|
b2f21eb3ac | ||
|
|
71bcbb9134 | ||
|
|
c86f0feff8 | ||
|
|
d3d2ab9a36 | ||
| 5cc85b57f8 | |||
|
|
ae0fc0c48c | ||
|
|
555c5d54e2 | ||
| 1b5859ee37 | |||
| 65380be2f3 | |||
|
|
1933be15c2 | ||
|
|
56b20beb8c | ||
|
|
bfc5ac6a4f | ||
|
|
6376173de0 | ||
|
|
3130fbeff0 | ||
|
|
01e9a1d9e9 | ||
|
|
2119e9de9a | ||
| fa63dc071b | |||
| e8c21a43b2 | |||
| 1413b18508 | |||
| dfbd155711 | |||
| 4fcc191ce9 | |||
| d000f7508f | |||
| 5652325452 | |||
| 0781ddd64e | |||
| 7be77cc38a | |||
| 98b8a75148 | |||
| 72a3197a06 | |||
| fce05d6959 | |||
| 1aec3abd28 | |||
| 9136c95013 | |||
|
|
89b32e70ff | ||
| 01713440a4 | |||
| 540f54fb77 | |||
| bf906aa544 | |||
| 797c2bcc39 | |||
| 1103ffe07b | |||
| e5200c1000 | |||
| 38a7db8514 | |||
| 39fc908de1 | |||
| be009d5b02 | |||
| 6bea1f7666 |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [sasjs]
|
||||
575
CHANGELOG.md
575
CHANGELOG.md
@@ -1,3 +1,578 @@
|
||||
## [0.28.7](https://github.com/sasjs/server/compare/v0.28.6...v0.28.7) (2023-02-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add user to all users group on user creation ([2bae52e](https://github.com/sasjs/server/commit/2bae52e307327d7ee4a94b19d843abdc0ccec9d1))
|
||||
|
||||
## [0.28.6](https://github.com/sasjs/server/compare/v0.28.5...v0.28.6) (2023-01-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show loading spinner on login screen while request is in process ([69f2576](https://github.com/sasjs/server/commit/69f2576ee6d3d7b7f3325922a88656d511e3ac88))
|
||||
|
||||
## [0.28.5](https://github.com/sasjs/server/compare/v0.28.4...v0.28.5) (2023-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding NOPRNGETLIST system option for faster startup ([96eca3a](https://github.com/sasjs/server/commit/96eca3a35dce4521150257ee019beb4488c8a08f))
|
||||
|
||||
## [0.28.4](https://github.com/sasjs/server/compare/v0.28.3...v0.28.4) (2022-12-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace main class with container class ([71c429b](https://github.com/sasjs/server/commit/71c429b093b91e2444ae75d946579dccc2e48636))
|
||||
|
||||
## [0.28.3](https://github.com/sasjs/server/compare/v0.28.2...v0.28.3) (2022-12-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* stringify json file ([1192583](https://github.com/sasjs/server/commit/1192583843d7efd1a6ab6943207f394c3ae966be))
|
||||
|
||||
## [0.28.2](https://github.com/sasjs/server/compare/v0.28.1...v0.28.2) (2022-12-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* execute child process asyncronously ([23c997b](https://github.com/sasjs/server/commit/23c997b3beabeb6b733ae893031d2f1a48f28ad2))
|
||||
* JS / Python / R session folders should be NEW folders, not existing SAS folders ([39ba995](https://github.com/sasjs/server/commit/39ba995355daa24bb7ab22720f8fc57d2dc85f40))
|
||||
|
||||
## [0.28.1](https://github.com/sasjs/server/compare/v0.28.0...v0.28.1) (2022-11-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update the content type header after the program has been executed ([4dcee4b](https://github.com/sasjs/server/commit/4dcee4b3c3950d402220b8f451c50ad98a317d83))
|
||||
|
||||
# [0.28.0](https://github.com/sasjs/server/compare/v0.27.0...v0.28.0) (2022-11-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update the response header of request to stp/execute routes ([112431a](https://github.com/sasjs/server/commit/112431a1b7461989c04100418d67d975a2a8f354))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add the api endpoint for updating user password ([4581f32](https://github.com/sasjs/server/commit/4581f325344eb68c5df5a28492f132312f15bb5c))
|
||||
* ask for updated password on first login ([1d48f88](https://github.com/sasjs/server/commit/1d48f8856b1fbbf3ef868914558333190e04981f))
|
||||
* **web:** add the UI for updating user password ([8b8c43c](https://github.com/sasjs/server/commit/8b8c43c21bde5379825c5ec44ecd81a92425f605))
|
||||
|
||||
# [0.27.0](https://github.com/sasjs/server/compare/v0.26.2...v0.27.0) (2022-11-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* on startup add webout.sas file in sasautos folder ([200f6c5](https://github.com/sasjs/server/commit/200f6c596a6e732d799ed408f1f0fd92f216ba58))
|
||||
|
||||
## [0.26.2](https://github.com/sasjs/server/compare/v0.26.1...v0.26.2) (2022-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* comments ([7ae862c](https://github.com/sasjs/server/commit/7ae862c5ce720e9483d4728f4295dede4f849436))
|
||||
|
||||
## [0.26.1](https://github.com/sasjs/server/compare/v0.26.0...v0.26.1) (2022-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change the expiration of access/refresh tokens from days to seconds ([bb05493](https://github.com/sasjs/server/commit/bb054938c5bd0535ae6b9da93ba0b14f9b80ddcd))
|
||||
|
||||
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](https://github.com/sasjs/server/commit/acc25cbd686952d3f1c65e57aefcebe1cb859cc7))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* make access token duration configurable when creating client/secret ([2413c05](https://github.com/sasjs/server/commit/2413c05fea3960f7e5c3c8b7b2f85d61314f08db))
|
||||
* make refresh token duration configurable ([abd5c64](https://github.com/sasjs/server/commit/abd5c64b4a726e3f17594a98111b6aa269b71fee))
|
||||
|
||||
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** use mui treeView instead of custom implementation ([c51b504](https://github.com/sasjs/server/commit/c51b50428f32608bc46438e9d7964429b2d595da))
|
||||
|
||||
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](https://github.com/sasjs/server/commit/1c9d167f86bbbb108b96e9bc30efaf8de65d82ff))
|
||||
|
||||
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* cli mock testing ([6434123](https://github.com/sasjs/server/commit/643412340162e854f31fba2f162d83b7ab1751d8))
|
||||
* mocking sas9 responses with JS STP ([36be3a7](https://github.com/sasjs/server/commit/36be3a7d5e7df79f9a1f3f00c3661b925f462383))
|
||||
|
||||
## [0.23.4](https://github.com/sasjs/server/compare/v0.23.3...v0.23.4) (2022-10-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action to editor ref for running code ([2412622](https://github.com/sasjs/server/commit/2412622367eb46c40f388e988ae4606a7ec239b2))
|
||||
|
||||
## [0.23.3](https://github.com/sasjs/server/compare/v0.23.2...v0.23.3) (2022-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added domain for session cookies ([94072c3](https://github.com/sasjs/server/commit/94072c3d24a4d0d4c97900dc31bfbf1c9d2559b7))
|
||||
|
||||
## [0.23.2](https://github.com/sasjs/server/compare/v0.23.1...v0.23.2) (2022-10-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump in correct place ([14731e8](https://github.com/sasjs/server/commit/14731e8824fa9f3d1daf89fd62f9916d5e3fcae4))
|
||||
* bumping sasjs/score ([258cc35](https://github.com/sasjs/server/commit/258cc35f14cf50f2160f607000c60de27593fd79))
|
||||
* reverting commit ([fda0e0b](https://github.com/sasjs/server/commit/fda0e0b57d56e3b5231e626a8d933343ac0c5cdc))
|
||||
|
||||
## [0.23.1](https://github.com/sasjs/server/compare/v0.23.0...v0.23.1) (2022-10-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ldap issues ([4d64420](https://github.com/sasjs/server/commit/4d64420c45424134b4d2014a2d5dd6e846ed03b3))
|
||||
|
||||
# [0.23.0](https://github.com/sasjs/server/compare/v0.22.1...v0.23.0) (2022-10-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Enable SAS_PACKAGES in SASjs Server ([424f0fc](https://github.com/sasjs/server/commit/424f0fc1faec765eb7a14619584e649454105b70))
|
||||
|
||||
## [0.22.1](https://github.com/sasjs/server/compare/v0.22.0...v0.22.1) (2022-10-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* spelling issues ([3bb0597](https://github.com/sasjs/server/commit/3bb05974d216d69368f4498eb9f309bce7d97fd8))
|
||||
|
||||
# [0.22.0](https://github.com/sasjs/server/compare/v0.21.7...v0.22.0) (2022-10-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* do not throw error on deleting group when it is created by an external auth provider ([68f0c5c](https://github.com/sasjs/server/commit/68f0c5c5884431e7e8f586dccf98132abebb193e))
|
||||
* no need to restrict api endpoints when ldap auth is applied ([a142660](https://github.com/sasjs/server/commit/a14266077d3541c7a33b7635efa4208335e73519))
|
||||
* remove authProvider attribute from user and group payload interface ([bbd7786](https://github.com/sasjs/server/commit/bbd7786c6ce13b374d896a45c23255b8fa3e8bd2))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implemented LDAP authentication ([f915c51](https://github.com/sasjs/server/commit/f915c51b077a2b8c4099727355ed914ecd6364bd))
|
||||
|
||||
## [0.21.7](https://github.com/sasjs/server/compare/v0.21.6...v0.21.7) (2022-09-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* csrf package is changed to pillarjs-csrf ([fe3e508](https://github.com/sasjs/server/commit/fe3e5088f8dfff50042ec8e8aac9ba5ba1394deb))
|
||||
|
||||
## [0.21.6](https://github.com/sasjs/server/compare/v0.21.5...v0.21.6) (2022-09-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* in getTokensFromDB handle the scenario when tokens are expired ([40f95f9](https://github.com/sasjs/server/commit/40f95f9072c8685910138d88fd2410f8704fc975))
|
||||
|
||||
## [0.21.5](https://github.com/sasjs/server/compare/v0.21.4...v0.21.5) (2022-09-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* made files extensions case insensitive ([2496043](https://github.com/sasjs/server/commit/249604384e42be4c12c88c70a7dff90fc1917a8f))
|
||||
|
||||
## [0.21.4](https://github.com/sasjs/server/compare/v0.21.3...v0.21.4) (2022-09-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* removing single quotes from _program value ([a0e7875](https://github.com/sasjs/server/commit/a0e7875ae61cbb6e7d3995d2e36e7300b0daec86))
|
||||
|
||||
## [0.21.3](https://github.com/sasjs/server/compare/v0.21.2...v0.21.3) (2022-09-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* return same tokens if not expired ([330c020](https://github.com/sasjs/server/commit/330c020933f1080261b38f07d6b627f6d7c62446))
|
||||
|
||||
## [0.21.2](https://github.com/sasjs/server/compare/v0.21.1...v0.21.2) (2022-09-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default content-type for sas programs should be text/plain ([9977c9d](https://github.com/sasjs/server/commit/9977c9d161947b11d45ab2513f99a5320a3f5a06))
|
||||
* **studio:** inject program path to code before sending for execution ([edc2e2a](https://github.com/sasjs/server/commit/edc2e2a302ccea4985f3d6b83ef8c23620ab82b6))
|
||||
|
||||
## [0.21.1](https://github.com/sasjs/server/compare/v0.21.0...v0.21.1) (2022-09-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SASJS_WEBOUT_HEADERS path for windows ([0749d65](https://github.com/sasjs/server/commit/0749d65173e8cfe9a93464711b7be1e123c289ff))
|
||||
|
||||
# [0.21.0](https://github.com/sasjs/server/compare/v0.20.0...v0.21.0) (2022-09-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* sas9 mocker improved - public access denied scenario ([06d3b17](https://github.com/sasjs/server/commit/06d3b1715432ea245ee755ae1dfd0579d3eb30e9))
|
||||
|
||||
# [0.20.0](https://github.com/sasjs/server/compare/v0.19.0...v0.20.0) (2022-09-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add support for R stored programs ([d6651bb](https://github.com/sasjs/server/commit/d6651bbdbeee5067f53c36e69a0eefa973c523b6))
|
||||
|
||||
# [0.19.0](https://github.com/sasjs/server/compare/v0.18.0...v0.19.0) (2022-09-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* added mocking endpoints ([0a0ba2c](https://github.com/sasjs/server/commit/0a0ba2cca5db867de46fb2486d856a84ec68d3b4))
|
||||
|
||||
# [0.18.0](https://github.com/sasjs/server/compare/v0.17.5...v0.18.0) (2022-09-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add option for program launch in context menu ([ee2db27](https://github.com/sasjs/server/commit/ee2db276bb0bbd522f758e0b66f7e7b2f4afd9d5))
|
||||
|
||||
## [0.17.5](https://github.com/sasjs/server/compare/v0.17.4...v0.17.5) (2022-09-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SASINITIALFOLDER split over 2 params, closes [#271](https://github.com/sasjs/server/issues/271) ([393b5ea](https://github.com/sasjs/server/commit/393b5eaf990049c39eecf2b9e8dd21a001b6e298))
|
||||
|
||||
## [0.17.4](https://github.com/sasjs/server/compare/v0.17.3...v0.17.4) (2022-09-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid JS logic ([9f06080](https://github.com/sasjs/server/commit/9f06080348aed076f8188a26fb4890d38a5a3510))
|
||||
|
||||
## [0.17.3](https://github.com/sasjs/server/compare/v0.17.2...v0.17.3) (2022-09-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* making SASINITIALFOLDER option windows only. Closes [#267](https://github.com/sasjs/server/issues/267) ([e63271a](https://github.com/sasjs/server/commit/e63271a67a0deb3059a5f2bec1854efee5a6e5a5))
|
||||
|
||||
## [0.17.2](https://github.com/sasjs/server/compare/v0.17.1...v0.17.2) (2022-08-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* addition of SASINITIALFOLDER startup option. Closes [#260](https://github.com/sasjs/server/issues/260) ([a5ee2f2](https://github.com/sasjs/server/commit/a5ee2f292384f90e9d95d003d652311c0d91a7a7))
|
||||
|
||||
## [0.17.1](https://github.com/sasjs/server/compare/v0.17.0...v0.17.1) (2022-08-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* typo mistake ([ee17d37](https://github.com/sasjs/server/commit/ee17d37aa188b0ca43cea0e89d6cd1a566b765cb))
|
||||
|
||||
# [0.17.0](https://github.com/sasjs/server/compare/v0.16.1...v0.17.0) (2022-08-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow underscores in file name ([bce83cb](https://github.com/sasjs/server/commit/bce83cb6fbc98f8198564c9399821f5829acc767))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add the functionality of saving file by ctrl + s in editor ([3a3c90d](https://github.com/sasjs/server/commit/3a3c90d9e690ac5267bf1acc834b5b5c5b4dadb6))
|
||||
|
||||
## [0.16.1](https://github.com/sasjs/server/compare/v0.16.0...v0.16.1) (2022-08-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update response of /SASjsApi/stp/execute and /SASjsApi/code/execute ([98ea2ac](https://github.com/sasjs/server/commit/98ea2ac9b98631605e39e5900e533727ea0e3d85))
|
||||
|
||||
# [0.16.0](https://github.com/sasjs/server/compare/v0.15.3...v0.16.0) (2022-08-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add a new variable _SASJS_WEBOUT_HEADERS to code.js and code.py ([882bedd](https://github.com/sasjs/server/commit/882bedd5d5da22de6ed45c03d0a261aadfb3a33c))
|
||||
* update content for code.sas file ([02e88ae](https://github.com/sasjs/server/commit/02e88ae7280d020a753bc2c095a931c79ac392d1))
|
||||
* update default content type for python and js runtimes ([8780b80](https://github.com/sasjs/server/commit/8780b800a34aa618631821e5d97e26e8b0f15806))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement the logic for running python stored programs ([b06993a](https://github.com/sasjs/server/commit/b06993ab9ea24b28d9e553763187387685aaa666))
|
||||
|
||||
## [0.15.3](https://github.com/sasjs/server/compare/v0.15.2...v0.15.3) (2022-08-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding proc printto in precode to enable print output in log. Closes [#253](https://github.com/sasjs/server/issues/253) ([f8bb732](https://github.com/sasjs/server/commit/f8bb7327a8a4649ac77bb6237e31cea075d46bb9))
|
||||
|
||||
## [0.15.2](https://github.com/sasjs/server/compare/v0.15.1...v0.15.2) (2022-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove vulnerabitities ([f27ac51](https://github.com/sasjs/server/commit/f27ac51fc4beb21070d0ab551cfdaec1f6ba39e0))
|
||||
|
||||
## [0.15.1](https://github.com/sasjs/server/compare/v0.15.0...v0.15.1) (2022-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** fix UI responsiveness ([d99fdd1](https://github.com/sasjs/server/commit/d99fdd1ec7991b94a0d98338d7a7a6216f46ce45))
|
||||
|
||||
# [0.15.0](https://github.com/sasjs/server/compare/v0.14.1...v0.15.0) (2022-08-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* after selecting file in sidebar collapse sidebar in mobile view ([e215958](https://github.com/sasjs/server/commit/e215958b8b05d7a8ce9d82395e0640b5b37fb40d))
|
||||
* improve mobile view for studio page ([c67d3ee](https://github.com/sasjs/server/commit/c67d3ee2f102155e2e9781e13d5d33c1ab227cb4))
|
||||
* improve responsiveness for mobile view ([6ef40b9](https://github.com/sasjs/server/commit/6ef40b954a87ebb0a2621119064f38d58ea85148))
|
||||
* improve user experience for adding permissions ([7a162ed](https://github.com/sasjs/server/commit/7a162eda8fc60383ff647d93e6611799e2e6af7a))
|
||||
* show logout button only when user is logged in ([9227cd4](https://github.com/sasjs/server/commit/9227cd449dc46fd960a488eb281804a9b9ffc284))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add multiple permission for same combination of type and principal at once ([754704b](https://github.com/sasjs/server/commit/754704bca89ecbdbcc3bd4ef04b94124c4f24167))
|
||||
|
||||
## [0.14.1](https://github.com/sasjs/server/compare/v0.14.0...v0.14.1) (2022-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **apps:** App Stream logo fix ([87c03c5](https://github.com/sasjs/server/commit/87c03c5f8dbdfc151d4ff3722ecbcd3f7e409aea))
|
||||
* **cookie:** XSRF cookie is removed and passed token in head section ([77f8d30](https://github.com/sasjs/server/commit/77f8d30baf9b1077279c29f1c3e5ca02a5436bc0))
|
||||
* **env:** check added for not providing WHITELIST ([5966016](https://github.com/sasjs/server/commit/5966016853369146b27ac5781808cb51d65c887f))
|
||||
* **web:** show login on logged-out state ([f7fcc77](https://github.com/sasjs/server/commit/f7fcc7741aa2af93a4a2b1e651003704c9bbff0c))
|
||||
|
||||
# [0.14.0](https://github.com/sasjs/server/compare/v0.13.3...v0.14.0) (2022-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add restriction on add/remove user to public group ([d3a516c](https://github.com/sasjs/server/commit/d3a516c36e45aa1cc76c30c744e6a0e5bd553165))
|
||||
* call jwt.verify in synchronous way ([254bc07](https://github.com/sasjs/server/commit/254bc07da744a9708109bfb792be70aa3f6284f4))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add public group to DB on seed ([c3e3bef](https://github.com/sasjs/server/commit/c3e3befc17102ee1754e1403193040b4f79fb2a7))
|
||||
* bypass authentication when route is enabled for public group ([68515f9](https://github.com/sasjs/server/commit/68515f95a65d422e29c0ed6028f3ea0ae8d9b1bf))
|
||||
|
||||
## [0.13.3](https://github.com/sasjs/server/compare/v0.13.2...v0.13.3) (2022-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show non-admin user his own permissions only ([8a3054e](https://github.com/sasjs/server/commit/8a3054e19ade82e2792cfb0f2a8af9e502c5eb52))
|
||||
* update schema of Permission ([5d5a9d3](https://github.com/sasjs/server/commit/5d5a9d3788281d75c56f68f0dff231abc9c9c275))
|
||||
|
||||
## [0.13.2](https://github.com/sasjs/server/compare/v0.13.1...v0.13.2) (2022-08-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding ls=max to reduce log size and improve readability ([916947d](https://github.com/sasjs/server/commit/916947dffacd902ff23ac3e899d1bf5ab6238b75))
|
||||
|
||||
## [0.13.1](https://github.com/sasjs/server/compare/v0.13.0...v0.13.1) (2022-07-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding options to prevent unwanted windows on windows. Closes [#244](https://github.com/sasjs/server/issues/244) ([77db14c](https://github.com/sasjs/server/commit/77db14c690e18145d733ac2b0d646ab0dbe4d521))
|
||||
|
||||
# [0.13.0](https://github.com/sasjs/server/compare/v0.12.1...v0.13.0) (2022-07-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* autofocus input field and submit on enter ([7681722](https://github.com/sasjs/server/commit/7681722e5afdc2df0c9eed201b05add3beda92a7))
|
||||
* move api button to user menu ([8de032b](https://github.com/sasjs/server/commit/8de032b5431b47daabcf783c47ff078bf817247d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add action and command to editor ([706e228](https://github.com/sasjs/server/commit/706e228a8e1924786fd9dc97de387974eda504b1))
|
||||
|
||||
## [0.12.1](https://github.com/sasjs/server/compare/v0.12.0...v0.12.1) (2022-07-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** disable launch icon button when file content is not saved ([c574b42](https://github.com/sasjs/server/commit/c574b4223591c4a6cd3ef5e146ce99cd8f7c9190))
|
||||
* **web:** saveAs functionality fixed in studio page ([3c987c6](https://github.com/sasjs/server/commit/3c987c61ddc258f991e2bf38c1f16a0c4248d6ae))
|
||||
* **web:** show original name as default name in rename file/folder modal ([9640f65](https://github.com/sasjs/server/commit/9640f6526496f3564664ccb1f834d0f659dcad4e))
|
||||
* **web:** webout tab item fixed in studio page ([7cdffe3](https://github.com/sasjs/server/commit/7cdffe30e36e5cad0284f48ea97925958e12704c))
|
||||
* **web:** when no file is selected save the editor content to local storage ([3b1fcb9](https://github.com/sasjs/server/commit/3b1fcb937d06d02ab99c9e8dbe307012d48a7a3a))
|
||||
|
||||
# [0.12.0](https://github.com/sasjs/server/compare/v0.11.5...v0.12.0) (2022-07-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fileTree api response to include an additional attribute isFolder ([0f19384](https://github.com/sasjs/server/commit/0f193849994f1ac8a071afa8f10af5b46f86663d))
|
||||
* remove drive component ([06d7c91](https://github.com/sasjs/server/commit/06d7c91fc34620a954df1fd1c682eff370f79ca6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add api end point for delete folder ([08e0c61](https://github.com/sasjs/server/commit/08e0c61e0fd7041d6cded6f4d71fbb410e5615ce))
|
||||
* add sidebar(drive) to left of studio ([6c35412](https://github.com/sasjs/server/commit/6c35412d2f5180d4e49b12e616576d8b8dacb7d8))
|
||||
* created api endpoint for adding empty folder in drive ([941917e](https://github.com/sasjs/server/commit/941917e508ece5009135f9dddf99775dd4002f78))
|
||||
* implemented api for renaming file/folder ([fdcaba9](https://github.com/sasjs/server/commit/fdcaba9d56cddea5d56d7de5a172f1bb49be3db5))
|
||||
* implemented delete file/folder functionality ([177675b](https://github.com/sasjs/server/commit/177675bc897416f7994dd849dc7bb11ba072efe9))
|
||||
* implemented functionality for adding file/folder from sidebar context menu ([0ce94a5](https://github.com/sasjs/server/commit/0ce94a553e53bfcdbd6273b26b322095a080a341))
|
||||
* implemented the functionality for renaming file/folder from context menu ([7010a6a](https://github.com/sasjs/server/commit/7010a6a1201720d0eb4093267a344fb828b90a2f))
|
||||
* prevent user from leaving studio page when there are unsaved changes ([6c75502](https://github.com/sasjs/server/commit/6c7550286b5f505e9dfe8ca63c62fa1db1b60b2e))
|
||||
* **web:** add difference view editor in studio ([420a61a](https://github.com/sasjs/server/commit/420a61a5a6b11dcb5eb0a652ea9cecea5c3bee5f))
|
||||
|
||||
## [0.11.5](https://github.com/sasjs/server/compare/v0.11.4...v0.11.5) (2022-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert "fix(security): missing cookie flags are added" ([ce5218a](https://github.com/sasjs/server/commit/ce5218a2278cc750f2b1032024685dc6cd72f796))
|
||||
|
||||
## [0.11.4](https://github.com/sasjs/server/compare/v0.11.3...v0.11.4) (2022-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** missing cookie flags are added ([526402f](https://github.com/sasjs/server/commit/526402fd73407ee4fa2d31092111a7e6a1741487))
|
||||
|
||||
## [0.11.3](https://github.com/sasjs/server/compare/v0.11.2...v0.11.3) (2022-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* filePath fix in code.js file for windows ([2995121](https://github.com/sasjs/server/commit/299512135d77c2ac9e34853cf35aee6f2e1d4da4))
|
||||
|
||||
## [0.11.2](https://github.com/sasjs/server/compare/v0.11.1...v0.11.2) (2022-07-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply icon option only for sas.exe ([d2ddd8a](https://github.com/sasjs/server/commit/d2ddd8aacadfdd143026881f2c6ae8c6b277610a))
|
||||
|
||||
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bank operator ([aa02741](https://github.com/sasjs/server/commit/aa027414ed3ce51f1014ef36c4191e064b2e963d))
|
||||
* ensuring nosplash option only applies for sas.exe ([65e6de9](https://github.com/sasjs/server/commit/65e6de966383fe49a919b1f901d77c7f1e402c9b)), closes [#229](https://github.com/sasjs/server/issues/229)
|
||||
|
||||
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **logs:** logs location is configurable ([e024a92](https://github.com/sasjs/server/commit/e024a92f165990e08db8aa26ee326dbcb30e2e46))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **logs:** logs to file with rotating + code split into files ([92fda18](https://github.com/sasjs/server/commit/92fda183f3f0f3956b7c791669eb8dd52c389d1b))
|
||||
|
||||
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add authorize middleware for appStreams ([e54a09d](https://github.com/sasjs/server/commit/e54a09db19ec8690e54a40760531a4e06d250974))
|
||||
* add isAdmin attribute to return response of get session and login requests ([bdf63df](https://github.com/sasjs/server/commit/bdf63df1d915892486005ec904807749786b1c0c))
|
||||
* add permission authorization middleware to only specific routes ([f3dfc70](https://github.com/sasjs/server/commit/f3dfc7083fbfb4b447521341b1a86730fb90b4c0))
|
||||
* bumping core and running lint ([a2d1396](https://github.com/sasjs/server/commit/a2d13960578014312d2cb5e03145bfd1829d99ec))
|
||||
* controller fixed for deleting permission ([b5f595a](https://github.com/sasjs/server/commit/b5f595a25c50550d62482409353c7629c5a5c3e0))
|
||||
* do not show admin users in add permission modal ([a75edba](https://github.com/sasjs/server/commit/a75edbaa327ec2af49523c13996ac283061da7d8))
|
||||
* export GroupResponse interface ([38a7db8](https://github.com/sasjs/server/commit/38a7db8514de0acd94d74ba96bc1efb732add30c))
|
||||
* move permission filter modal to separate file and icons for different actions ([d000f75](https://github.com/sasjs/server/commit/d000f7508f6d7384afffafee4179151fca802ca8))
|
||||
* principalId type changed to number from any ([4fcc191](https://github.com/sasjs/server/commit/4fcc191ce9edc7e4dcd8821fb8019f4eea5db4ea))
|
||||
* remove clientId from principal types ([0781ddd](https://github.com/sasjs/server/commit/0781ddd64e3b5e5ca39647bb4e4e1a9332a0f4f8))
|
||||
* remove duplicates principals from permission filter modal ([5b319f9](https://github.com/sasjs/server/commit/5b319f9ad1f941b306db6b9473a2128b2e42bf76))
|
||||
* show loading spinner in studio while executing code ([496247d](https://github.com/sasjs/server/commit/496247d0b9975097a008cf4d3a999d77648fd930))
|
||||
* show permission component only in server mode ([f863b81](https://github.com/sasjs/server/commit/f863b81a7d40a1296a061ec93946f204382af2c3))
|
||||
* update permission model ([39fc908](https://github.com/sasjs/server/commit/39fc908de1945f2aaea18d14e6bce703f6bf0c06))
|
||||
* update permission response ([e516b77](https://github.com/sasjs/server/commit/e516b7716da5ff7e23350a5f77cfa073b1171175))
|
||||
* **web:** only admin should be able to add, update or delete permission ([be8635c](https://github.com/sasjs/server/commit/be8635ccc5eb34c3f0a5951c8a0421292ef69c97))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add api endpoint for deleting permission ([0171344](https://github.com/sasjs/server/commit/01713440a4fa661b76368785c0ca731f096ac70a))
|
||||
* add api endpoint for updating permission setting ([540f54f](https://github.com/sasjs/server/commit/540f54fb77b364822da7889dbe75c02242f48a59))
|
||||
* add authorize middleware for validating permissions ([7d916ec](https://github.com/sasjs/server/commit/7d916ec3e9ef579dde1b73015715cd01098c2018))
|
||||
* add basic UI for settings and permissions ([5652325](https://github.com/sasjs/server/commit/56523254525a66e756196e90b39a2b8cdadc1518))
|
||||
* add documentation link under usename dropdown menu ([eeb63b3](https://github.com/sasjs/server/commit/eeb63b330c292afcdd5c8f006882b224c4235068))
|
||||
* add permission model ([6bea1f7](https://github.com/sasjs/server/commit/6bea1f76668ddb070ad95b3e02c31238af67c346))
|
||||
* add UI for updating permission ([e8c21a4](https://github.com/sasjs/server/commit/e8c21a43b215f5fced0463b70747cda1191a4e01))
|
||||
* add validation for registering permission ([e5200c1](https://github.com/sasjs/server/commit/e5200c1000903185dfad9ee49c99583e473c4388))
|
||||
* add, remove and update permissions from web component ([97ecfdc](https://github.com/sasjs/server/commit/97ecfdc95563c72dbdecaebcb504e5194250a763))
|
||||
* added get authorizedRoutes api endpoint ([b10e932](https://github.com/sasjs/server/commit/b10e9326058193dd65a57fab2d2f05b7b06096e7))
|
||||
* created modal for adding permission ([1413b18](https://github.com/sasjs/server/commit/1413b1850838ecc988ab289da4541bde36a9a346))
|
||||
* defined register permission and get all permissions api endpoints ([1103ffe](https://github.com/sasjs/server/commit/1103ffe07b88496967cb03683b08f058ca3bbb9f))
|
||||
* update swagger docs ([797c2bc](https://github.com/sasjs/server/commit/797c2bcc39005a05a995be15a150d584fecae259))
|
||||
|
||||
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* removed secrets from env variables ([9c3da56](https://github.com/sasjs/server/commit/9c3da56901672a818f54267f9defc9f4701ab7fb))
|
||||
|
||||
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deploy:** extract first json from zip file ([e290751](https://github.com/sasjs/server/commit/e290751c872d24009482871a8c398e834357dcde))
|
||||
|
||||
## [0.8.2](https://github.com/sasjs/server/compare/v0.8.1...v0.8.2) (2022-06-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* getRuntimeAndFilePath function to handle the scenarion when path is provided with an extension other than runtimes ([5cc85b5](https://github.com/sasjs/server/commit/5cc85b57f80b13296156811fe966d7b37d45f213))
|
||||
|
||||
## [0.8.1](https://github.com/sasjs/server/compare/v0.8.0...v0.8.1) (2022-06-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make CA_ROOT optional in getCertificates method ([1b5859e](https://github.com/sasjs/server/commit/1b5859ee37ae73c419115b9debfd5141a79733de))
|
||||
* update /logout route to /SASLogon/logout ([65380be](https://github.com/sasjs/server/commit/65380be2f3945bae559f1749064845b514447a53))
|
||||
|
||||
# [0.8.0](https://github.com/sasjs/server/compare/v0.7.3...v0.8.0) (2022-06-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **certs:** ENV variables updated and set CA Root for HTTPS server ([2119e9d](https://github.com/sasjs/server/commit/2119e9de9ab1e5ce1222658f554ac74f4f35cf4d))
|
||||
|
||||
## [0.7.3](https://github.com/sasjs/server/compare/v0.7.2...v0.7.3) (2022-06-20)
|
||||
|
||||
|
||||
|
||||
19
PULL_REQUEST_TEMPLATE.md
Normal file
19
PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Issue
|
||||
|
||||
Link any related issue(s) in this section.
|
||||
|
||||
## Intent
|
||||
|
||||
What this PR intends to achieve.
|
||||
|
||||
## Implementation
|
||||
|
||||
What code changes have been made to achieve the intent.
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] Code is formatted correctly (`npm run lint:fix`).
|
||||
- [ ] Any new functionality has been unit tested.
|
||||
- [ ] All unit tests are passing (`npm test`).
|
||||
- [ ] All CI checks are green.
|
||||
- [ ] Reviewer is assigned.
|
||||
61
README.md
61
README.md
@@ -64,23 +64,53 @@ Example contents of a `.env` file:
|
||||
# Server mode is multi-user and suitable for intranet / internet use
|
||||
MODE=
|
||||
|
||||
# A comma separated string that defines the available runTimes.
|
||||
# Priority is given to the runtime that comes first in the string.
|
||||
# Possible options at the moment are sas, js, py and r
|
||||
|
||||
# This string sets the priority of the available analytic runtimes
|
||||
# Valid runtimes are SAS (sas), JavaScript (js), Python (py) and R (r)
|
||||
# For each option provided, there should be a corresponding path,
|
||||
# eg SAS_PATH, NODE_PATH, PYTHON_PATH or RSCRIPT_PATH
|
||||
# Priority is given to runtimes earlier in the string
|
||||
# Example options: [sas,js,py | js,py | sas | sas,js | r | sas,r]
|
||||
RUN_TIMES=
|
||||
|
||||
# Path to SAS executable (sas.exe / sas.sh)
|
||||
SAS_PATH=/path/to/sas/executable.exe
|
||||
|
||||
# Path to Node.js executable
|
||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||
|
||||
# Path to Python executable
|
||||
PYTHON_PATH=/usr/bin/python
|
||||
|
||||
# Path to R executable
|
||||
R_PATH=/usr/bin/Rscript
|
||||
|
||||
# Path to working directory
|
||||
# This location is for SAS WORK, staged files, DRIVE, configuration etc
|
||||
SASJS_ROOT=./sasjs_root
|
||||
|
||||
|
||||
# This location is for files, sasjs packages and appStreamConfig.json
|
||||
DRIVE_LOCATION=./sasjs_root/drive
|
||||
|
||||
|
||||
# options: [http|https] default: http
|
||||
PROTOCOL=
|
||||
|
||||
# default: 5000
|
||||
PORT=
|
||||
|
||||
# options: [sas9|sasviya]
|
||||
# If not present, mocking function is disabled
|
||||
MOCK_SERVERTYPE=
|
||||
|
||||
# default: /api/mocks
|
||||
# Path to mocking folder, for generic responses, it's sub directories should be: sas9, viya, sasjs
|
||||
# Server will automatically use subdirectory accordingly
|
||||
STATIC_MOCK_LOCATION=
|
||||
|
||||
#
|
||||
## Additional SAS Options
|
||||
@@ -99,17 +129,24 @@ SASV9_OPTIONS= -NOXCMD
|
||||
## Additional Web Server Options
|
||||
#
|
||||
|
||||
# ENV variables required for PROTOCOL: `https`
|
||||
PRIVATE_KEY=privkey.pem
|
||||
FULL_CHAIN=fullchain.pem
|
||||
# ENV variables for PROTOCOL: `https`
|
||||
PRIVATE_KEY=privkey.pem (required)
|
||||
CERT_CHAIN=certificate.pem (required)
|
||||
CA_ROOT=fullchain.pem (optional)
|
||||
|
||||
# ENV variables required for MODE: `server`
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
SESSION_SECRET=<secret>
|
||||
## ENV variables required for MODE: `server`
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
|
||||
# AUTH_PROVIDERS options: [ldap] default: ``
|
||||
AUTH_PROVIDERS=
|
||||
|
||||
## ENV variables required for AUTH_MECHANISM: `ldap`
|
||||
LDAP_URL= <LDAP_SERVER_URL>
|
||||
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
|
||||
LDAP_BIND_PASSWORD = <password>
|
||||
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||
|
||||
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
|
||||
# If enabled, be sure to also configure the WHITELIST of third party servers.
|
||||
CORS=
|
||||
@@ -139,12 +176,8 @@ HELMET_CSP_CONFIG_PATH=./csp.config.json
|
||||
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||
LOG_FORMAT_MORGAN=
|
||||
|
||||
# A comma separated string that defines the available runTimes.
|
||||
# Priority is given to the runtime that comes first in the string.
|
||||
# Possible options at the moment are sas and js
|
||||
|
||||
# options: [sas,js|js,sas|sas|js] default:sas
|
||||
RUN_TIMES=
|
||||
# This location is for server logs with classical UNIX logrotate behavior
|
||||
LOG_LOCATION=./sasjs_root/logs
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
MODE=[desktop|server] default considered as desktop
|
||||
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
|
||||
ALLOWED_DOMAIN=<just domain e.g. example.com >
|
||||
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
|
||||
|
||||
PROTOCOL=[http|https] default considered as http
|
||||
PRIVATE_KEY=privkey.pem
|
||||
FULL_CHAIN=fullchain.pem
|
||||
CERT_CHAIN=certificate.pem
|
||||
CA_ROOT=fullchain.pem
|
||||
|
||||
PORT=[5000] default value is 5000
|
||||
|
||||
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
|
||||
HELMET_COEP=[true|false] if omitted HELMET default will be used
|
||||
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
SESSION_SECRET=<secret>
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
|
||||
RUN_TIMES=[sas|js|sas,js|js,sas] default considered as sas
|
||||
AUTH_PROVIDERS=[ldap]
|
||||
|
||||
LDAP_URL= <LDAP_SERVER_URL>
|
||||
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
|
||||
LDAP_BIND_PASSWORD = <password>
|
||||
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||
|
||||
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
||||
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||
PYTHON_PATH=/usr/bin/python
|
||||
R_PATH=/usr/bin/Rscript
|
||||
|
||||
SASJS_ROOT=./sasjs_root
|
||||
DRIVE_LOCATION=./sasjs_root/drive
|
||||
|
||||
LOG_FORMAT_MORGAN=common
|
||||
LOG_FORMAT_MORGAN=common
|
||||
LOG_LOCATION=./sasjs_root/logs
|
||||
1
api/mocks/sas9/generic/logged-in
Normal file
1
api/mocks/sas9/generic/logged-in
Normal file
@@ -0,0 +1 @@
|
||||
You have signed in.
|
||||
1
api/mocks/sas9/generic/logged-out
Normal file
1
api/mocks/sas9/generic/logged-out
Normal file
@@ -0,0 +1 @@
|
||||
You have signed out.
|
||||
30
api/mocks/sas9/generic/login
Normal file
30
api/mocks/sas9/generic/login
Normal file
@@ -0,0 +1,30 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US" dir="ltr" class="bg">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1" />
|
||||
</head>
|
||||
|
||||
|
||||
<div class="content">
|
||||
<form id="credentials" class="minimal" action="/SASLogon/login?service=http%3A%2F%2Flocalhost:5004%2FSASStoredProcess%2Fj_spring_cas_security_check" method="post">
|
||||
<!--form container-->
|
||||
<input type="hidden" name="lt" value="validtoken" aria-hidden="true" />
|
||||
<input type="hidden" name="execution" value="e2s1" aria-hidden="true" />
|
||||
<input type="hidden" name="_eventId" value="submit" aria-hidden="true" />
|
||||
|
||||
<span class="userid">
|
||||
|
||||
<input id="username" name="username" tabindex="3" aria-labelledby="username1 message1 message2 message3" name="username" placeholder="User ID" type="text" autofocus="true" value="" maxlength="500" autocomplete="off" />
|
||||
</span>
|
||||
<span class="password">
|
||||
|
||||
<input id="password" name="password" tabindex="4" name="password" placeholder="Password" type="password" value="" maxlength="500" autocomplete="off" />
|
||||
</span>
|
||||
|
||||
<button type="submit" class="btn-submit" title="Sign In" tabindex="5" onClick="this.disabled=true;setSubmitUrl(this.form);this.form.submit();return false;">Sign In</button>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</html>
|
||||
1
api/mocks/sas9/generic/public-access-denied
Normal file
1
api/mocks/sas9/generic/public-access-denied
Normal file
@@ -0,0 +1 @@
|
||||
Public access has been denied.
|
||||
1
api/mocks/sas9/generic/sas-stored-process
Normal file
1
api/mocks/sas9/generic/sas-stored-process
Normal file
@@ -0,0 +1 @@
|
||||
"title": "Log Off SAS Demo User"
|
||||
5393
api/package-lock.json
generated
5393
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,10 @@
|
||||
"description": "Api of SASjs server",
|
||||
"main": "./src/server.ts",
|
||||
"scripts": {
|
||||
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
|
||||
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
|
||||
"prestart": "npm run initial",
|
||||
"prebuild": "npm run initial",
|
||||
"start": "nodemon ./src/server.ts",
|
||||
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
||||
"start:prod": "node ./build/src/server.js",
|
||||
"build": "rimraf build && tsc",
|
||||
"postbuild": "npm run copy:files",
|
||||
@@ -17,20 +17,21 @@
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"exe": "npm run build && pkg .",
|
||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sasjscore:copy && npm run web:copy",
|
||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||
"public:copy": "cp -r ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sasjscore:copy": "cp -r ./sasjscore/ ./build/sasjscore/",
|
||||
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts"
|
||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||
},
|
||||
"bin": "./build/src/server.js",
|
||||
"pkg": {
|
||||
"assets": [
|
||||
"./build/public/**/*",
|
||||
"./build/sasjsbuild/**/*",
|
||||
"./build/sasjscore/**/*",
|
||||
"./build/sas/**/*",
|
||||
"./web/build/**/*"
|
||||
],
|
||||
"targets": [
|
||||
@@ -47,22 +48,23 @@
|
||||
},
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "^4.27.3",
|
||||
"@sasjs/utils": "2.42.1",
|
||||
"@sasjs/core": "^4.40.1",
|
||||
"@sasjs/utils": "2.48.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-mongo": "^4.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"csurf": "^1.11.0",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.2",
|
||||
"helmet": "^5.0.2",
|
||||
"joi": "^17.4.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"ldapjs": "2.3.3",
|
||||
"mongoose": "^6.0.12",
|
||||
"mongoose-sequence": "^5.3.1",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"rotating-file-stream": "^3.0.4",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"unzipper": "^0.10.11",
|
||||
"url": "^0.10.3"
|
||||
@@ -72,11 +74,11 @@
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/jsonwebtoken": "^8.5.5",
|
||||
"@types/ldapjs": "^2.2.4",
|
||||
"@types/mongoose-sequence": "^3.0.6",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
@@ -85,10 +87,13 @@
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/unzipper": "^0.10.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "0.27.2",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"http-headers-validation": "^0.0.1",
|
||||
"jest": "^27.0.6",
|
||||
"mongodb-memory-server": "^8.0.0",
|
||||
"mongodb-memory-server": "8.11.4",
|
||||
"nodejs-file-downloader": "4.10.2",
|
||||
"nodemon": "^2.0.7",
|
||||
"pkg": "5.6.0",
|
||||
"prettier": "^2.3.1",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@ import {
|
||||
readFile,
|
||||
SASJsFileType
|
||||
} from '@sasjs/utils'
|
||||
import { apiRoot, sysInitCompiledPath } from '../src/utils'
|
||||
import { apiRoot, sysInitCompiledPath } from '../src/utils/file'
|
||||
|
||||
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
||||
|
||||
const compiledSystemInit = async (systemInit: string) =>
|
||||
'options ps=max;\n' +
|
||||
'options ls=max ps=max;\n' +
|
||||
(await loadDependenciesFile({
|
||||
fileContent: systemInit,
|
||||
type: SASJsFileType.job,
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
listFilesInFolder
|
||||
} from '@sasjs/utils'
|
||||
|
||||
import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils'
|
||||
import {
|
||||
apiRoot,
|
||||
sasJSCoreMacros,
|
||||
sasJSCoreMacrosInfo
|
||||
} from '../src/utils/file'
|
||||
|
||||
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
||||
|
||||
|
||||
39
api/scripts/downloadMacros.ts
Normal file
39
api/scripts/downloadMacros.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios'
|
||||
import Downloader from 'nodejs-file-downloader'
|
||||
import { createFile, listFilesInFolder } from '@sasjs/utils'
|
||||
|
||||
import { sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils/file'
|
||||
|
||||
export const downloadMacros = async () => {
|
||||
const url =
|
||||
'https://api.github.com/repos/yabwon/SAS_PACKAGES/contents/SPF/Macros'
|
||||
|
||||
console.info(`Downloading macros from ${url}`)
|
||||
|
||||
await axios
|
||||
.get(url)
|
||||
.then(async (res) => {
|
||||
await downloadFiles(res.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const downloadFiles = async function (fileList: any) {
|
||||
for (const file of fileList) {
|
||||
const downloader = new Downloader({
|
||||
url: file.download_url,
|
||||
directory: sasJSCoreMacros,
|
||||
fileName: file.path.replace(/^SPF\/Macros/, ''),
|
||||
cloneFiles: false
|
||||
})
|
||||
await downloader.download()
|
||||
}
|
||||
|
||||
const fileNames = await listFilesInFolder(sasJSCoreMacros)
|
||||
|
||||
await createFile(sasJSCoreMacrosInfo, fileNames.join('\n'))
|
||||
}
|
||||
|
||||
downloadMacros()
|
||||
21
api/src/app-modules/configureCors.ts
Normal file
21
api/src/app-modules/configureCors.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Express } from 'express'
|
||||
import cors from 'cors'
|
||||
import { CorsType } from '../utils'
|
||||
|
||||
export const configureCors = (app: Express) => {
|
||||
const { CORS, WHITELIST } = process.env
|
||||
|
||||
if (CORS === CorsType.ENABLED) {
|
||||
const whiteList: string[] = []
|
||||
WHITELIST?.split(' ')
|
||||
?.filter((url) => !!url)
|
||||
.forEach((url) => {
|
||||
if (url.startsWith('http'))
|
||||
// removing trailing slash of URLs listing for CORS
|
||||
whiteList.push(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
process.logger.info('All CORS Requests are enabled for:', whiteList)
|
||||
app.use(cors({ credentials: true, origin: whiteList }))
|
||||
}
|
||||
}
|
||||
40
api/src/app-modules/configureExpressSession.ts
Normal file
40
api/src/app-modules/configureExpressSession.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Express, CookieOptions } from 'express'
|
||||
import mongoose from 'mongoose'
|
||||
import session from 'express-session'
|
||||
import MongoStore from 'connect-mongo'
|
||||
|
||||
import { ModeType, ProtocolType } from '../utils'
|
||||
|
||||
export const configureExpressSession = (app: Express) => {
|
||||
const { MODE } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
let store: MongoStore | undefined
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
store = MongoStore.create({
|
||||
client: mongoose.connection!.getClient() as any,
|
||||
collectionName: 'sessions'
|
||||
})
|
||||
}
|
||||
|
||||
const { PROTOCOL, ALLOWED_DOMAIN } = process.env
|
||||
const cookieOptions: CookieOptions = {
|
||||
secure: PROTOCOL === ProtocolType.HTTPS,
|
||||
httpOnly: true,
|
||||
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
domain: ALLOWED_DOMAIN?.trim() || undefined
|
||||
}
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.secrets.SESSION_SECRET,
|
||||
saveUninitialized: false, // don't create session until something stored
|
||||
resave: false, //don't save session if unmodified
|
||||
store,
|
||||
cookie: cookieOptions
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
33
api/src/app-modules/configureLogger.ts
Normal file
33
api/src/app-modules/configureLogger.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from 'path'
|
||||
import { Express } from 'express'
|
||||
import morgan from 'morgan'
|
||||
import { createStream } from 'rotating-file-stream'
|
||||
import { generateTimestamp } from '@sasjs/utils'
|
||||
import { getLogFolder } from '../utils'
|
||||
|
||||
export const configureLogger = (app: Express) => {
|
||||
const { LOG_FORMAT_MORGAN } = process.env
|
||||
|
||||
let options
|
||||
if (
|
||||
process.env.NODE_ENV !== 'development' &&
|
||||
process.env.NODE_ENV !== 'test'
|
||||
) {
|
||||
const timestamp = generateTimestamp()
|
||||
const filename = `${timestamp}.log`
|
||||
const logsFolder = getLogFolder()
|
||||
|
||||
// create a rotating write stream
|
||||
var accessLogStream = createStream(filename, {
|
||||
interval: '1d', // rotate daily
|
||||
path: logsFolder
|
||||
})
|
||||
|
||||
process.logger.info('Writing Logs to :', path.join(logsFolder, filename))
|
||||
|
||||
options = { stream: accessLogStream }
|
||||
}
|
||||
|
||||
// setup the logger
|
||||
app.use(morgan(LOG_FORMAT_MORGAN as string, options))
|
||||
}
|
||||
26
api/src/app-modules/configureSecurity.ts
Normal file
26
api/src/app-modules/configureSecurity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Express } from 'express'
|
||||
import { getEnvCSPDirectives } from '../utils/parseHelmetConfig'
|
||||
import { HelmetCoepType, ProtocolType } from '../utils'
|
||||
import helmet from 'helmet'
|
||||
|
||||
export const configureSecurity = (app: Express) => {
|
||||
const { PROTOCOL, HELMET_CSP_CONFIG_PATH, HELMET_COEP } = process.env
|
||||
|
||||
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
|
||||
HELMET_CSP_CONFIG_PATH
|
||||
)
|
||||
if (PROTOCOL === ProtocolType.HTTP)
|
||||
cspConfigJson['upgrade-insecure-requests'] = null
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
...cspConfigJson
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
|
||||
})
|
||||
)
|
||||
}
|
||||
4
api/src/app-modules/index.ts
Normal file
4
api/src/app-modules/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './configureCors'
|
||||
export * from './configureExpressSession'
|
||||
export * from './configureLogger'
|
||||
export * from './configureSecurity'
|
||||
169
api/src/app.ts
169
api/src/app.ts
@@ -1,30 +1,30 @@
|
||||
import path from 'path'
|
||||
import express, { ErrorRequestHandler } from 'express'
|
||||
import csrf from 'csurf'
|
||||
import session from 'express-session'
|
||||
import MongoStore from 'connect-mongo'
|
||||
import morgan from 'morgan'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import dotenv from 'dotenv'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
|
||||
import {
|
||||
connectDB,
|
||||
copySASjsCore,
|
||||
CorsType,
|
||||
createWeboutSasFile,
|
||||
getFilesFolder,
|
||||
getPackagesFolder,
|
||||
getWebBuildFolder,
|
||||
HelmetCoepType,
|
||||
instantiateLogger,
|
||||
loadAppStreamConfig,
|
||||
ModeType,
|
||||
ProtocolType,
|
||||
ReturnCode,
|
||||
setProcessVariables,
|
||||
setupFolders,
|
||||
setupFilesFolder,
|
||||
setupPackagesFolder,
|
||||
setupUserAutoExec,
|
||||
verifyEnvVariables
|
||||
} from './utils'
|
||||
import { getEnvCSPDirectives } from './utils/parseHelmetConfig'
|
||||
import {
|
||||
configureCors,
|
||||
configureExpressSession,
|
||||
configureLogger,
|
||||
configureSecurity
|
||||
} from './app-modules'
|
||||
import { folderExists } from '@sasjs/utils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -34,110 +34,55 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cookieParser())
|
||||
|
||||
const {
|
||||
MODE,
|
||||
CORS,
|
||||
WHITELIST,
|
||||
PROTOCOL,
|
||||
HELMET_CSP_CONFIG_PATH,
|
||||
HELMET_COEP,
|
||||
LOG_FORMAT_MORGAN
|
||||
} = process.env
|
||||
|
||||
app.use(morgan(LOG_FORMAT_MORGAN as string))
|
||||
|
||||
export const cookieOptions = {
|
||||
secure: PROTOCOL === ProtocolType.HTTPS,
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
|
||||
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
|
||||
HELMET_CSP_CONFIG_PATH
|
||||
)
|
||||
if (PROTOCOL === ProtocolType.HTTP)
|
||||
cspConfigJson['upgrade-insecure-requests'] = null
|
||||
|
||||
/***********************************
|
||||
* CSRF Protection *
|
||||
***********************************/
|
||||
export const csrfProtection = csrf({ cookie: cookieOptions })
|
||||
|
||||
/***********************************
|
||||
* Handle security and origin *
|
||||
***********************************/
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
...cspConfigJson
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
|
||||
})
|
||||
)
|
||||
|
||||
/***********************************
|
||||
* Enabling CORS *
|
||||
***********************************/
|
||||
if (CORS === CorsType.ENABLED) {
|
||||
const whiteList: string[] = []
|
||||
WHITELIST?.split(' ')
|
||||
?.filter((url) => !!url)
|
||||
.forEach((url) => {
|
||||
if (url.startsWith('http'))
|
||||
// removing trailing slash of URLs listing for CORS
|
||||
whiteList.push(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
console.log('All CORS Requests are enabled for:', whiteList)
|
||||
app.use(cors({ credentials: true, origin: whiteList }))
|
||||
}
|
||||
|
||||
/***********************************
|
||||
* DB Connection & *
|
||||
* Express Sessions *
|
||||
* With Mongo Store *
|
||||
***********************************/
|
||||
if (MODE === ModeType.Server) {
|
||||
let store: MongoStore | undefined
|
||||
|
||||
// NOTE: when exporting app.js as agent for supertest
|
||||
// we should exclude connecting to the real database
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const clientPromise = connectDB().then((conn) => conn!.getClient() as any)
|
||||
|
||||
store = MongoStore.create({ clientPromise, collectionName: 'sessions' })
|
||||
}
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET as string,
|
||||
saveUninitialized: false, // don't create session until something stored
|
||||
resave: false, //don't save session if unmodified
|
||||
store,
|
||||
cookie: cookieOptions
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '100mb' }))
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
|
||||
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN')
|
||||
return res.status(400).send('Invalid CSRF token!')
|
||||
|
||||
console.error(err.stack)
|
||||
process.logger.error(err.stack)
|
||||
res.status(500).send('Something broke!')
|
||||
}
|
||||
|
||||
export default setProcessVariables().then(async () => {
|
||||
await setupFolders()
|
||||
await copySASjsCore()
|
||||
app.use(cookieParser())
|
||||
|
||||
configureLogger(app)
|
||||
|
||||
/***********************************
|
||||
* Handle security and origin *
|
||||
***********************************/
|
||||
configureSecurity(app)
|
||||
|
||||
/***********************************
|
||||
* Enabling CORS *
|
||||
***********************************/
|
||||
configureCors(app)
|
||||
|
||||
/***********************************
|
||||
* DB Connection & *
|
||||
* Express Sessions *
|
||||
* With Mongo Store *
|
||||
***********************************/
|
||||
configureExpressSession(app)
|
||||
|
||||
app.use(express.json({ limit: '100mb' }))
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
|
||||
// Body parser is used for decoding the formdata on POST request.
|
||||
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
await setupUserAutoExec()
|
||||
|
||||
if (!(await folderExists(getFilesFolder()))) await setupFilesFolder()
|
||||
|
||||
if (!(await folderExists(getPackagesFolder()))) await setupPackagesFolder()
|
||||
|
||||
const sasautosPath = path.join(process.driveLoc, 'sas', 'sasautos')
|
||||
if (await folderExists(sasautosPath)) {
|
||||
process.logger.warn(
|
||||
`SASAUTOS was not refreshed. To force a refresh, delete the ${sasautosPath} folder`
|
||||
)
|
||||
} else {
|
||||
await copySASjsCore()
|
||||
await createWeboutSasFile()
|
||||
}
|
||||
|
||||
// loading these modules after setting up variables due to
|
||||
// multer's usage of process var process.driveLoc
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
|
||||
import express from 'express'
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Example,
|
||||
Post,
|
||||
Patch,
|
||||
Request,
|
||||
Body,
|
||||
Query,
|
||||
Hidden
|
||||
} from 'tsoa'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
getTokensFromDB,
|
||||
removeTokensInDB,
|
||||
saveTokensInDB
|
||||
} from '../utils'
|
||||
import Client from '../model/Client'
|
||||
import User from '../model/User'
|
||||
|
||||
@Route('SASjsApi/auth')
|
||||
@Tags('Auth')
|
||||
@@ -60,6 +75,18 @@ export class AuthController {
|
||||
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
||||
return logout(data!)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update user's password.
|
||||
*/
|
||||
@Security('bearerAuth')
|
||||
@Patch('updatePassword')
|
||||
public async updatePassword(
|
||||
@Request() req: express.Request,
|
||||
@Body() body: UpdatePasswordPayload
|
||||
) {
|
||||
return updatePassword(req, body)
|
||||
}
|
||||
}
|
||||
|
||||
const token = async (data: any): Promise<TokenResponse> => {
|
||||
@@ -73,8 +100,26 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
|
||||
AuthController.deleteCode(userInfo.userId, clientId)
|
||||
|
||||
const accessToken = generateAccessToken(userInfo)
|
||||
const refreshToken = generateRefreshToken(userInfo)
|
||||
// get tokens from DB
|
||||
const existingTokens = await getTokensFromDB(userInfo.userId, clientId)
|
||||
if (existingTokens) {
|
||||
return {
|
||||
accessToken: existingTokens.accessToken,
|
||||
refreshToken: existingTokens.refreshToken
|
||||
}
|
||||
}
|
||||
|
||||
const client = await Client.findOne({ clientId })
|
||||
if (!client) throw new Error('Invalid clientId.')
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
userInfo,
|
||||
client.accessTokenExpiration
|
||||
)
|
||||
const refreshToken = generateRefreshToken(
|
||||
userInfo,
|
||||
client.refreshTokenExpiration
|
||||
)
|
||||
|
||||
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
||||
|
||||
@@ -82,8 +127,17 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
}
|
||||
|
||||
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
||||
const accessToken = generateAccessToken(userInfo)
|
||||
const refreshToken = generateRefreshToken(userInfo)
|
||||
const client = await Client.findOne({ clientId: userInfo.clientId })
|
||||
if (!client) throw new Error('Invalid clientId.')
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
userInfo,
|
||||
client.accessTokenExpiration
|
||||
)
|
||||
const refreshToken = generateRefreshToken(
|
||||
userInfo,
|
||||
client.refreshTokenExpiration
|
||||
)
|
||||
|
||||
await saveTokensInDB(
|
||||
userInfo.userId,
|
||||
@@ -99,6 +153,40 @@ const logout = async (userInfo: InfoJWT) => {
|
||||
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
||||
}
|
||||
|
||||
const updatePassword = async (
|
||||
req: express.Request,
|
||||
data: UpdatePasswordPayload
|
||||
) => {
|
||||
const { currentPassword, newPassword } = data
|
||||
const userId = req.user?.userId
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
|
||||
if (!dbUser)
|
||||
throw {
|
||||
code: 404,
|
||||
message: `User not found!`
|
||||
}
|
||||
|
||||
if (dbUser?.authProvider) {
|
||||
throw {
|
||||
code: 405,
|
||||
message:
|
||||
'Can not update password of user that is created by an external auth provider.'
|
||||
}
|
||||
}
|
||||
|
||||
const validPass = dbUser.comparePassword(currentPassword)
|
||||
if (!validPass)
|
||||
throw {
|
||||
code: 403,
|
||||
message: `Invalid current password!`
|
||||
}
|
||||
|
||||
dbUser.password = User.hashPassword(newPassword)
|
||||
dbUser.needsToUpdatePassword = false
|
||||
await dbUser.save()
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
/**
|
||||
* Client ID
|
||||
@@ -125,12 +213,25 @@ interface TokenResponse {
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
interface UpdatePasswordPayload {
|
||||
/**
|
||||
* Current Password
|
||||
* @example "currentPasswordString"
|
||||
*/
|
||||
currentPassword: string
|
||||
/**
|
||||
* New Password
|
||||
* @example "newPassword"
|
||||
*/
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
const verifyAuthCode = async (
|
||||
clientId: string,
|
||||
code: string
|
||||
): Promise<InfoJWT | undefined> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||
if (err) return resolve(undefined)
|
||||
|
||||
const clientInfo: InfoJWT = {
|
||||
|
||||
186
api/src/controllers/authConfig.ts
Normal file
186
api/src/controllers/authConfig.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import express from 'express'
|
||||
import { Security, Route, Tags, Get, Post, Example } from 'tsoa'
|
||||
|
||||
import { LDAPClient, LDAPUser, LDAPGroup, AuthProviderType } from '../utils'
|
||||
import { randomBytes } from 'crypto'
|
||||
import User from '../model/User'
|
||||
import Group from '../model/Group'
|
||||
import Permission from '../model/Permission'
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/authConfig')
|
||||
@Tags('Auth_Config')
|
||||
export class AuthConfigController {
|
||||
/**
|
||||
* @summary Gives the detail of Auth Mechanism.
|
||||
*
|
||||
*/
|
||||
@Example({
|
||||
ldap: {
|
||||
LDAP_URL: 'ldaps://my.ldap.server:636',
|
||||
LDAP_BIND_DN: 'cn=admin,ou=system,dc=cloudron',
|
||||
LDAP_BIND_PASSWORD: 'secret',
|
||||
LDAP_USERS_BASE_DN: 'ou=users,dc=cloudron',
|
||||
LDAP_GROUPS_BASE_DN: 'ou=groups,dc=cloudron'
|
||||
}
|
||||
})
|
||||
@Get('/')
|
||||
public getDetail() {
|
||||
return getAuthConfigDetail()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Synchronises LDAP users and groups with internal DB and returns the count of imported users and groups.
|
||||
*
|
||||
*/
|
||||
@Example({
|
||||
users: 5,
|
||||
groups: 3
|
||||
})
|
||||
@Post('/synchroniseWithLDAP')
|
||||
public async synchroniseWithLDAP() {
|
||||
return synchroniseWithLDAP()
|
||||
}
|
||||
}
|
||||
|
||||
const synchroniseWithLDAP = async () => {
|
||||
process.logger.info('Syncing LDAP with internal DB')
|
||||
|
||||
const permissions = await Permission.get({})
|
||||
await Permission.deleteMany()
|
||||
await User.deleteMany({ authProvider: AuthProviderType.LDAP })
|
||||
await Group.deleteMany({ authProvider: AuthProviderType.LDAP })
|
||||
|
||||
const ldapClient = await LDAPClient.init()
|
||||
|
||||
process.logger.info('fetching LDAP users')
|
||||
const users = await ldapClient.getAllLDAPUsers()
|
||||
|
||||
process.logger.info('inserting LDAP users to DB')
|
||||
|
||||
const existingUsers: string[] = []
|
||||
const importedUsers: LDAPUser[] = []
|
||||
|
||||
for (const user of users) {
|
||||
const usernameExists = await User.findOne({ username: user.username })
|
||||
if (usernameExists) {
|
||||
existingUsers.push(user.username)
|
||||
continue
|
||||
}
|
||||
|
||||
const hashPassword = User.hashPassword(randomBytes(64).toString('hex'))
|
||||
|
||||
await User.create({
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
password: hashPassword,
|
||||
authProvider: AuthProviderType.LDAP,
|
||||
needsToUpdatePassword: false
|
||||
})
|
||||
|
||||
importedUsers.push(user)
|
||||
}
|
||||
|
||||
if (existingUsers.length > 0) {
|
||||
process.logger.info(
|
||||
'Failed to insert following users as they already exist in DB:'
|
||||
)
|
||||
existingUsers.forEach((user) => process.logger.log(`* ${user}`))
|
||||
}
|
||||
|
||||
process.logger.info('fetching LDAP groups')
|
||||
const groups = await ldapClient.getAllLDAPGroups()
|
||||
|
||||
process.logger.info('inserting LDAP groups to DB')
|
||||
|
||||
const existingGroups: string[] = []
|
||||
const importedGroups: LDAPGroup[] = []
|
||||
|
||||
for (const group of groups) {
|
||||
const groupExists = await Group.findOne({ name: group.name })
|
||||
if (groupExists) {
|
||||
existingGroups.push(group.name)
|
||||
continue
|
||||
}
|
||||
|
||||
await Group.create({
|
||||
name: group.name,
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
|
||||
importedGroups.push(group)
|
||||
}
|
||||
|
||||
if (existingGroups.length > 0) {
|
||||
process.logger.info(
|
||||
'Failed to insert following groups as they already exist in DB:'
|
||||
)
|
||||
existingGroups.forEach((group) => process.logger.log(`* ${group}`))
|
||||
}
|
||||
|
||||
process.logger.info('associating users and groups')
|
||||
|
||||
for (const group of importedGroups) {
|
||||
const dbGroup = await Group.findOne({ name: group.name })
|
||||
if (dbGroup) {
|
||||
for (const member of group.members) {
|
||||
const user = importedUsers.find((user) => user.uid === member)
|
||||
if (user) {
|
||||
const dbUser = await User.findOne({ username: user.username })
|
||||
if (dbUser) await dbGroup.addUser(dbUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.logger.info('setting permissions')
|
||||
|
||||
for (const permission of permissions) {
|
||||
const newPermission = new Permission({
|
||||
path: permission.path,
|
||||
type: permission.type,
|
||||
setting: permission.setting
|
||||
})
|
||||
|
||||
if (permission.user) {
|
||||
const dbUser = await User.findOne({ username: permission.user.username })
|
||||
if (dbUser) newPermission.user = dbUser._id
|
||||
} else if (permission.group) {
|
||||
const dbGroup = await Group.findOne({ name: permission.group.name })
|
||||
if (dbGroup) newPermission.group = dbGroup._id
|
||||
}
|
||||
await newPermission.save()
|
||||
}
|
||||
|
||||
process.logger.info('LDAP synchronization completed!')
|
||||
|
||||
return {
|
||||
userCount: importedUsers.length,
|
||||
groupCount: importedGroups.length
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthConfigDetail = () => {
|
||||
const { AUTH_PROVIDERS } = process.env
|
||||
|
||||
const returnObj: any = {}
|
||||
|
||||
if (AUTH_PROVIDERS === AuthProviderType.LDAP) {
|
||||
const {
|
||||
LDAP_URL,
|
||||
LDAP_BIND_DN,
|
||||
LDAP_BIND_PASSWORD,
|
||||
LDAP_USERS_BASE_DN,
|
||||
LDAP_GROUPS_BASE_DN
|
||||
} = process.env
|
||||
|
||||
returnObj.ldap = {
|
||||
LDAP_URL: LDAP_URL ?? '',
|
||||
LDAP_BIND_DN: LDAP_BIND_DN ?? '',
|
||||
LDAP_BIND_PASSWORD: LDAP_BIND_PASSWORD ?? '',
|
||||
LDAP_USERS_BASE_DN: LDAP_USERS_BASE_DN ?? '',
|
||||
LDAP_GROUPS_BASE_DN: LDAP_GROUPS_BASE_DN ?? ''
|
||||
}
|
||||
}
|
||||
return returnObj
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
|
||||
|
||||
import Client, { ClientPayload } from '../model/Client'
|
||||
import Client, {
|
||||
ClientPayload,
|
||||
NUMBER_OF_SECONDS_IN_A_DAY
|
||||
} from '../model/Client'
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/client')
|
||||
@Tags('Client')
|
||||
export class ClientController {
|
||||
/**
|
||||
* @summary Create client with the following attributes: ClientId, ClientSecret. Admin only task.
|
||||
* @summary Admin only task. Create client with the following attributes:
|
||||
* ClientId,
|
||||
* ClientSecret,
|
||||
* accessTokenExpiration (optional),
|
||||
* refreshTokenExpiration (optional)
|
||||
*
|
||||
*/
|
||||
@Example<ClientPayload>({
|
||||
clientId: 'someFormattedClientID1234',
|
||||
clientSecret: 'someRandomCryptoString'
|
||||
clientSecret: 'someRandomCryptoString',
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
})
|
||||
@Post('/')
|
||||
public async createClient(
|
||||
@@ -22,8 +31,13 @@ export class ClientController {
|
||||
}
|
||||
}
|
||||
|
||||
const createClient = async (data: any): Promise<ClientPayload> => {
|
||||
const { clientId, clientSecret } = data
|
||||
const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenExpiration,
|
||||
refreshTokenExpiration
|
||||
} = data
|
||||
|
||||
// Checking if client is already in the database
|
||||
const clientExist = await Client.findOne({ clientId })
|
||||
@@ -32,13 +46,17 @@ const createClient = async (data: any): Promise<ClientPayload> => {
|
||||
// Create a new client
|
||||
const client = new Client({
|
||||
clientId,
|
||||
clientSecret
|
||||
clientSecret,
|
||||
accessTokenExpiration,
|
||||
refreshTokenExpiration
|
||||
})
|
||||
|
||||
const savedClient = await client.save()
|
||||
|
||||
return {
|
||||
clientId: savedClient.clientId,
|
||||
clientSecret: savedClient.clientSecret
|
||||
clientSecret: savedClient.clientSecret,
|
||||
accessTokenExpiration: savedClient.accessTokenExpiration,
|
||||
refreshTokenExpiration: savedClient.refreshTokenExpiration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
||||
import { ExecuteReturnJson, ExecutionController } from './internal'
|
||||
import { ExecuteReturnJsonResponse } from '.'
|
||||
import { ExecutionController } from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
getUserAutoExec,
|
||||
@@ -25,17 +24,17 @@ interface ExecuteCodePayload {
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/code')
|
||||
@Tags('CODE')
|
||||
@Tags('Code')
|
||||
export class CodeController {
|
||||
/**
|
||||
* Execute SAS code.
|
||||
* @summary Run SAS Code and returns log
|
||||
* Execute Code on the Specified Runtime
|
||||
* @summary Run Code and Return Webout Content and Log
|
||||
*/
|
||||
@Post('/execute')
|
||||
public async executeCode(
|
||||
@Request() request: express.Request,
|
||||
@Body() body: ExecuteCodePayload
|
||||
): Promise<ExecuteReturnJsonResponse> {
|
||||
): Promise<string | Buffer> {
|
||||
return executeCode(request, body)
|
||||
}
|
||||
}
|
||||
@@ -51,22 +50,15 @@ const executeCode = async (
|
||||
: await getUserAutoExec()
|
||||
|
||||
try {
|
||||
const { webout, log, httpHeaders } =
|
||||
(await new ExecutionController().executeProgram({
|
||||
program: code,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, _debug: 131 },
|
||||
otherArgs: { userAutoExec },
|
||||
returnJson: true,
|
||||
runTime: runTime
|
||||
})) as ExecuteReturnJson
|
||||
const { result } = await new ExecutionController().executeProgram({
|
||||
program: code,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, _debug: 131 },
|
||||
otherArgs: { userAutoExec },
|
||||
runTime: runTime
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
_webout: webout as string,
|
||||
log: parseLogToArray(log),
|
||||
httpHeaders
|
||||
}
|
||||
return result
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
moveFile,
|
||||
createFolder,
|
||||
deleteFile as deleteFileOnSystem,
|
||||
deleteFolder as deleteFolderOnSystem,
|
||||
folderExists,
|
||||
listFilesInFolder,
|
||||
listSubFoldersInFolder,
|
||||
@@ -58,11 +59,32 @@ interface GetFileTreeResponse {
|
||||
tree: TreeNode
|
||||
}
|
||||
|
||||
interface UpdateFileResponse {
|
||||
interface FileFolderResponse {
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface AddFolderPayload {
|
||||
/**
|
||||
* Location of folder
|
||||
* @example "/Public/someFolder"
|
||||
*/
|
||||
folderPath: string
|
||||
}
|
||||
|
||||
interface RenamePayload {
|
||||
/**
|
||||
* Old path of file/folder
|
||||
* @example "/Public/someFolder"
|
||||
*/
|
||||
oldPath: string
|
||||
/**
|
||||
* New path of file/folder
|
||||
* @example "/Public/newFolder"
|
||||
*/
|
||||
newPath: string
|
||||
}
|
||||
|
||||
const fileTreeExample = getTreeExample()
|
||||
|
||||
const successDeployResponse: DeployResponse = {
|
||||
@@ -143,7 +165,7 @@ export class DriveController {
|
||||
/**
|
||||
*
|
||||
* @summary Delete file from SASjs Drive
|
||||
* @query _filePath Location of SAS program
|
||||
* @query _filePath Location of file
|
||||
* @example _filePath "/Public/somefolder/some.file"
|
||||
*/
|
||||
@Delete('/file')
|
||||
@@ -151,20 +173,31 @@ export class DriveController {
|
||||
return deleteFile(_filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @summary Delete folder from SASjs Drive
|
||||
* @query _folderPath Location of folder
|
||||
* @example _folderPath "/Public/somefolder/"
|
||||
*/
|
||||
@Delete('/folder')
|
||||
public async deleteFolder(@Query() _folderPath: string) {
|
||||
return deleteFolder(_folderPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* It's optional to either provide `_filePath` in url as query parameter
|
||||
* Or provide `filePath` in body as form field.
|
||||
* But it's required to provide else API will respond with Bad Request.
|
||||
*
|
||||
* @summary Create a file in SASjs Drive
|
||||
* @param _filePath Location of SAS program
|
||||
* @param _filePath Location of file
|
||||
* @example _filePath "/Public/somefolder/some.file.sas"
|
||||
*
|
||||
*/
|
||||
@Example<UpdateFileResponse>({
|
||||
@Example<FileFolderResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<UpdateFileResponse>(403, 'File already exists', {
|
||||
@Response<FileFolderResponse>(403, 'File already exists', {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@@ -173,10 +206,28 @@ export class DriveController {
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Query() _filePath?: string,
|
||||
@FormField() filePath?: string
|
||||
): Promise<UpdateFileResponse> {
|
||||
): Promise<FileFolderResponse> {
|
||||
return saveFile((_filePath ?? filePath)!, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Create an empty folder in SASjs Drive
|
||||
*
|
||||
*/
|
||||
@Example<FileFolderResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<FileFolderResponse>(409, 'Folder already exists', {
|
||||
status: 'failure',
|
||||
message: 'Add folder request failed.'
|
||||
})
|
||||
@Post('/folder')
|
||||
public async addFolder(
|
||||
@Body() body: AddFolderPayload
|
||||
): Promise<FileFolderResponse> {
|
||||
return addFolder(body.folderPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* It's optional to either provide `_filePath` in url as query parameter
|
||||
* Or provide `filePath` in body as form field.
|
||||
@@ -187,10 +238,10 @@ export class DriveController {
|
||||
* @example _filePath "/Public/somefolder/some.file.sas"
|
||||
*
|
||||
*/
|
||||
@Example<UpdateFileResponse>({
|
||||
@Example<FileFolderResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<UpdateFileResponse>(403, `File doesn't exist`, {
|
||||
@Response<FileFolderResponse>(403, `File doesn't exist`, {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@@ -199,10 +250,28 @@ export class DriveController {
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Query() _filePath?: string,
|
||||
@FormField() filePath?: string
|
||||
): Promise<UpdateFileResponse> {
|
||||
): Promise<FileFolderResponse> {
|
||||
return updateFile((_filePath ?? filePath)!, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Renames a file/folder in SASjs Drive
|
||||
*
|
||||
*/
|
||||
@Example<FileFolderResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<FileFolderResponse>(409, 'Folder already exists', {
|
||||
status: 'failure',
|
||||
message: 'rename request failed.'
|
||||
})
|
||||
@Post('/rename')
|
||||
public async rename(
|
||||
@Body() body: RenamePayload
|
||||
): Promise<FileFolderResponse> {
|
||||
return rename(body.oldPath, body.newPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Fetch file tree within SASjs Drive.
|
||||
*
|
||||
@@ -249,20 +318,26 @@ const getFile = async (req: express.Request, filePath: string) => {
|
||||
.join(getFilesFolder(), filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!filePathFull.includes(driveFilesPath)) {
|
||||
throw new Error('Cannot get file outside drive.')
|
||||
}
|
||||
if (!filePathFull.includes(driveFilesPath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't get file outside drive.`
|
||||
}
|
||||
|
||||
if (!(await fileExists(filePathFull))) {
|
||||
throw new Error("File doesn't exist.")
|
||||
}
|
||||
if (!(await fileExists(filePathFull)))
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: `File doesn't exist.`
|
||||
}
|
||||
|
||||
const extension = path.extname(filePathFull).toLowerCase()
|
||||
if (extension === '.sas') {
|
||||
req.res?.setHeader('Content-type', 'text/plain')
|
||||
}
|
||||
|
||||
req.res?.sendFile(path.resolve(filePathFull))
|
||||
req.res?.sendFile(path.resolve(filePathFull), { dotfiles: 'allow' })
|
||||
}
|
||||
|
||||
const getFolder = async (folderPath?: string) => {
|
||||
@@ -273,17 +348,26 @@ const getFolder = async (folderPath?: string) => {
|
||||
.join(getFilesFolder(), folderPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!folderPathFull.includes(driveFilesPath)) {
|
||||
throw new Error('Cannot get folder outside drive.')
|
||||
}
|
||||
if (!folderPathFull.includes(driveFilesPath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't get folder outside drive.`
|
||||
}
|
||||
|
||||
if (!(await folderExists(folderPathFull))) {
|
||||
throw new Error("Folder doesn't exist.")
|
||||
}
|
||||
if (!(await folderExists(folderPathFull)))
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: `Folder doesn't exist.`
|
||||
}
|
||||
|
||||
if (!(await isFolder(folderPathFull))) {
|
||||
throw new Error('Not a Folder.')
|
||||
}
|
||||
if (!(await isFolder(folderPathFull)))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: 'Not a Folder.'
|
||||
}
|
||||
|
||||
const files: string[] = await listFilesInFolder(folderPathFull)
|
||||
const folders: string[] = await listSubFoldersInFolder(folderPathFull)
|
||||
@@ -302,19 +386,51 @@ const deleteFile = async (filePath: string) => {
|
||||
.join(getFilesFolder(), filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!filePathFull.includes(driveFilesPath)) {
|
||||
throw new Error('Cannot delete file outside drive.')
|
||||
}
|
||||
if (!filePathFull.includes(driveFilesPath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't delete file outside drive.`
|
||||
}
|
||||
|
||||
if (!(await fileExists(filePathFull))) {
|
||||
throw new Error('File does not exist.')
|
||||
}
|
||||
if (!(await fileExists(filePathFull)))
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: `File doesn't exist.`
|
||||
}
|
||||
|
||||
await deleteFileOnSystem(filePathFull)
|
||||
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
const deleteFolder = async (folderPath: string) => {
|
||||
const driveFolderPath = getFilesFolder()
|
||||
|
||||
const folderPathFull = path
|
||||
.join(getFilesFolder(), folderPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!folderPathFull.includes(driveFolderPath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't delete folder outside drive.`
|
||||
}
|
||||
|
||||
if (!(await folderExists(folderPathFull)))
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: `Folder doesn't exist.`
|
||||
}
|
||||
|
||||
await deleteFolderOnSystem(folderPathFull)
|
||||
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
const saveFile = async (
|
||||
filePath: string,
|
||||
multerFile: Express.Multer.File
|
||||
@@ -325,13 +441,19 @@ const saveFile = async (
|
||||
.join(driveFilesPath, filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!filePathFull.includes(driveFilesPath)) {
|
||||
throw new Error('Cannot put file outside drive.')
|
||||
}
|
||||
if (!filePathFull.includes(driveFilesPath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't put file outside drive.`
|
||||
}
|
||||
|
||||
if (await fileExists(filePathFull)) {
|
||||
throw new Error('File already exists.')
|
||||
}
|
||||
if (await fileExists(filePathFull))
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'File already exists.'
|
||||
}
|
||||
|
||||
const folderPath = path.dirname(filePathFull)
|
||||
await createFolder(folderPath)
|
||||
@@ -340,6 +462,88 @@ const saveFile = async (
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
const addFolder = async (folderPath: string): Promise<FileFolderResponse> => {
|
||||
const drivePath = getFilesFolder()
|
||||
|
||||
const folderPathFull = path
|
||||
.join(drivePath, folderPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!folderPathFull.includes(drivePath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't put folder outside drive.`
|
||||
}
|
||||
|
||||
if (await folderExists(folderPathFull))
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'Folder already exists.'
|
||||
}
|
||||
|
||||
await createFolder(folderPathFull)
|
||||
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
const rename = async (
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<FileFolderResponse> => {
|
||||
const drivePath = getFilesFolder()
|
||||
|
||||
const oldPathFull = path
|
||||
.join(drivePath, oldPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
const newPathFull = path
|
||||
.join(drivePath, newPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!oldPathFull.includes(drivePath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Old path can't be outside of drive.`
|
||||
}
|
||||
|
||||
if (!newPathFull.includes(drivePath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `New path can't be outside of drive.`
|
||||
}
|
||||
|
||||
if (await isFolder(oldPathFull)) {
|
||||
if (await folderExists(newPathFull))
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'Folder with new name already exists.'
|
||||
}
|
||||
else moveFile(oldPathFull, newPathFull)
|
||||
|
||||
return { status: 'success' }
|
||||
} else if (await fileExists(oldPathFull)) {
|
||||
if (await fileExists(newPathFull))
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'File with new name already exists.'
|
||||
}
|
||||
else moveFile(oldPathFull, newPathFull)
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'No file/folder found for provided path.'
|
||||
}
|
||||
}
|
||||
|
||||
const updateFile = async (
|
||||
filePath: string,
|
||||
multerFile: Express.Multer.File
|
||||
@@ -350,13 +554,19 @@ const updateFile = async (
|
||||
.join(driveFilesPath, filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (!filePathFull.includes(driveFilesPath)) {
|
||||
throw new Error('Cannot modify file outside drive.')
|
||||
}
|
||||
if (!filePathFull.includes(driveFilesPath))
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't modify file outside drive.`
|
||||
}
|
||||
|
||||
if (!(await fileExists(filePathFull))) {
|
||||
throw new Error(`File doesn't exist.`)
|
||||
}
|
||||
if (!(await fileExists(filePathFull)))
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: `File doesn't exist.`
|
||||
}
|
||||
|
||||
await moveFile(multerFile.path, filePathFull)
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
Body
|
||||
} from 'tsoa'
|
||||
|
||||
import Group, { GroupPayload } from '../model/Group'
|
||||
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
import { UserResponse } from './user'
|
||||
|
||||
export interface GroupResponse {
|
||||
@@ -20,7 +21,7 @@ export interface GroupResponse {
|
||||
description: string
|
||||
}
|
||||
|
||||
interface GroupDetailsResponse {
|
||||
export interface GroupDetailsResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
@@ -147,12 +148,14 @@ export class GroupController {
|
||||
@Delete('{groupId}')
|
||||
public async deleteGroup(@Path() groupId: number) {
|
||||
const group = await Group.findOne({ groupId })
|
||||
if (group) return await group.remove()
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Group not found.'
|
||||
}
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Group not found.'
|
||||
}
|
||||
|
||||
return await group.remove()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
'groupId name description isActive users -_id'
|
||||
).populate(
|
||||
'users',
|
||||
'id username displayName -_id'
|
||||
'id username displayName isAdmin -_id'
|
||||
)) as unknown as GroupDetailsResponse
|
||||
if (!group)
|
||||
throw {
|
||||
@@ -241,6 +244,20 @@ const updateUsersListInGroup = async (
|
||||
message: 'Group not found.'
|
||||
}
|
||||
|
||||
if (group.name === PUBLIC_GROUP_NAME)
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
|
||||
}
|
||||
|
||||
if (group.authProvider)
|
||||
throw {
|
||||
code: 405,
|
||||
status: 'Method Not Allowed',
|
||||
message: `Can't add/remove user to group created by external auth provider.`
|
||||
}
|
||||
|
||||
const user = await User.findOne({ id: userId })
|
||||
if (!user)
|
||||
throw {
|
||||
@@ -249,9 +266,17 @@ const updateUsersListInGroup = async (
|
||||
message: 'User not found.'
|
||||
}
|
||||
|
||||
const updatedGroup = (action === 'addUser'
|
||||
? await group.addUser(user._id)
|
||||
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse
|
||||
if (user.authProvider)
|
||||
throw {
|
||||
code: 405,
|
||||
status: 'Method Not Allowed',
|
||||
message: `Can't add/remove user to group created by external auth provider.`
|
||||
}
|
||||
|
||||
const updatedGroup =
|
||||
action === 'addUser'
|
||||
? await group.addUser(user)
|
||||
: await group.removeUser(user)
|
||||
|
||||
if (!updatedGroup)
|
||||
throw {
|
||||
@@ -260,9 +285,6 @@ const updateUsersListInGroup = async (
|
||||
message: 'Unable to update group.'
|
||||
}
|
||||
|
||||
if (action === 'addUser') user.addGroup(group._id)
|
||||
else user.removeGroup(group._id)
|
||||
|
||||
return {
|
||||
groupId: updatedGroup.groupId,
|
||||
name: updatedGroup.name,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from './auth'
|
||||
export * from './authConfig'
|
||||
export * from './client'
|
||||
export * from './code'
|
||||
export * from './drive'
|
||||
export * from './group'
|
||||
export * from './info'
|
||||
export * from './permission'
|
||||
export * from './session'
|
||||
export * from './stp'
|
||||
export * from './user'
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Route, Tags, Example, Get } from 'tsoa'
|
||||
import { getAuthorizedRoutes } from '../utils'
|
||||
export interface AuthorizedRoutesResponse {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
mode: string
|
||||
@@ -36,4 +40,19 @@ export class InfoController {
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the list of available routes to which permissions can be applied. Used to populate the dialog in the URI Permissions feature.
|
||||
*
|
||||
*/
|
||||
@Example<AuthorizedRoutesResponse>({
|
||||
paths: ['/AppStream', '/SASjsApi/stp/execute']
|
||||
})
|
||||
@Get('/authorizedRoutes')
|
||||
public authorizedRoutes(): AuthorizedRoutesResponse {
|
||||
const response = {
|
||||
paths: getAuthorizedRoutes()
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
getSASSessionController,
|
||||
getJSSessionController,
|
||||
processProgram
|
||||
} from './'
|
||||
import { getSessionController, processProgram } from './'
|
||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||
import {
|
||||
@@ -24,12 +20,6 @@ export interface ExecuteReturnRaw {
|
||||
result: string | Buffer
|
||||
}
|
||||
|
||||
export interface ExecuteReturnJson {
|
||||
httpHeaders: HTTPHeaders
|
||||
webout: string | Buffer
|
||||
log?: string
|
||||
}
|
||||
|
||||
interface ExecuteFileParams {
|
||||
programPath: string
|
||||
preProgramVariables: PreProgramVars
|
||||
@@ -38,6 +28,7 @@ interface ExecuteFileParams {
|
||||
returnJson?: boolean
|
||||
session?: Session
|
||||
runTime: RunTimeType
|
||||
forceStringResult?: boolean
|
||||
}
|
||||
|
||||
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||
@@ -52,7 +43,8 @@ export class ExecutionController {
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session,
|
||||
runTime
|
||||
runTime,
|
||||
forceStringResult
|
||||
}: ExecuteFileParams) {
|
||||
const program = await readFile(programPath)
|
||||
|
||||
@@ -63,7 +55,8 @@ export class ExecutionController {
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session,
|
||||
runTime
|
||||
runTime,
|
||||
forceStringResult
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,14 +65,11 @@ export class ExecutionController {
|
||||
preProgramVariables,
|
||||
vars,
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session: sessionByFileUpload,
|
||||
runTime
|
||||
}: ExecuteProgramParams): Promise<ExecuteReturnRaw | ExecuteReturnJson> {
|
||||
const sessionController =
|
||||
runTime === RunTimeType.SAS
|
||||
? getSASSessionController()
|
||||
: getJSSessionController()
|
||||
runTime,
|
||||
forceStringResult
|
||||
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
const session =
|
||||
sessionByFileUpload ?? (await sessionController.getSession())
|
||||
@@ -88,6 +78,7 @@ export class ExecutionController {
|
||||
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
|
||||
const weboutPath = path.join(session.path, 'webout.txt')
|
||||
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||
|
||||
@@ -103,6 +94,7 @@ export class ExecutionController {
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
runTime,
|
||||
logPath,
|
||||
@@ -114,13 +106,15 @@ export class ExecutionController {
|
||||
? await readFile(headersPath)
|
||||
: ''
|
||||
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
|
||||
const fileResponse: boolean =
|
||||
httpHeaders.hasOwnProperty('content-type') &&
|
||||
!returnJson && // not a POST Request
|
||||
!isDebugOn(vars) // Debug is not enabled
|
||||
|
||||
if (isDebugOn(vars)) {
|
||||
httpHeaders['content-type'] = 'text/plain'
|
||||
}
|
||||
|
||||
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
|
||||
|
||||
const webout = (await fileExists(weboutPath))
|
||||
? fileResponse
|
||||
? fileResponse && !forceStringResult
|
||||
? await readFileBinary(weboutPath)
|
||||
: await readFile(weboutPath)
|
||||
: ''
|
||||
@@ -128,19 +122,11 @@ export class ExecutionController {
|
||||
// it should be deleted by scheduleSessionDestroy
|
||||
session.inUse = false
|
||||
|
||||
if (returnJson) {
|
||||
return {
|
||||
httpHeaders,
|
||||
webout,
|
||||
log: isDebugOn(vars) || session.crashed ? log : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
httpHeaders,
|
||||
result:
|
||||
isDebugOn(vars) || session.crashed
|
||||
? `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
|
||||
? `${webout}\n${process.logsUUID}\n${log}`
|
||||
: webout
|
||||
}
|
||||
}
|
||||
@@ -150,6 +136,7 @@ export class ExecutionController {
|
||||
name: 'files',
|
||||
relativePath: '',
|
||||
absolutePath: getFilesFolder(),
|
||||
isFolder: true,
|
||||
children: []
|
||||
}
|
||||
|
||||
@@ -159,15 +146,22 @@ export class ExecutionController {
|
||||
const currentNode = stack.pop()
|
||||
|
||||
if (currentNode) {
|
||||
currentNode.isFolder = fs
|
||||
.statSync(currentNode.absolutePath)
|
||||
.isDirectory()
|
||||
|
||||
const children = fs.readdirSync(currentNode.absolutePath)
|
||||
|
||||
for (let child of children) {
|
||||
const absoluteChildPath = `${currentNode.absolutePath}/${child}`
|
||||
const absoluteChildPath = path.join(currentNode.absolutePath, child)
|
||||
// relative path will only be used in frontend component
|
||||
// so, no need to convert '/' to platform specific separator
|
||||
const relativeChildPath = `${currentNode.relativePath}/${child}`
|
||||
const childNode: TreeNode = {
|
||||
name: child,
|
||||
relativePath: relativeChildPath,
|
||||
absolutePath: absoluteChildPath,
|
||||
isFolder: false,
|
||||
children: []
|
||||
}
|
||||
currentNode.children.push(childNode)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, RequestHandler } from 'express'
|
||||
import multer from 'multer'
|
||||
import { uuidv4 } from '@sasjs/utils'
|
||||
import { getSASSessionController, getJSSessionController } from '.'
|
||||
import { getSessionController } from '.'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
getRunTimeAndFilePath,
|
||||
@@ -37,17 +37,23 @@ export class FileUploadController {
|
||||
try {
|
||||
;({ runTime } = await getRunTimeAndFilePath(programPath))
|
||||
} catch (err: any) {
|
||||
res.status(400).send({
|
||||
return res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
})
|
||||
}
|
||||
|
||||
const sessionController =
|
||||
runTime === RunTimeType.SAS
|
||||
? getSASSessionController()
|
||||
: getJSSessionController()
|
||||
let sessionController
|
||||
try {
|
||||
sessionController = getSessionController(runTime)
|
||||
} catch (err: any) {
|
||||
return res.status(400).send({
|
||||
status: 'failure',
|
||||
message: err.message,
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
})
|
||||
}
|
||||
|
||||
const session = await sessionController.getSession()
|
||||
// marking consumed true, so that it's not available
|
||||
|
||||
@@ -3,27 +3,58 @@ import { Session } from '../../types'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import {
|
||||
getPackagesFolder,
|
||||
getSessionsFolder,
|
||||
generateUniqueFileName,
|
||||
sysInitCompiledPath
|
||||
sysInitCompiledPath,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
import {
|
||||
deleteFolder,
|
||||
createFile,
|
||||
fileExists,
|
||||
generateTimestamp,
|
||||
readFile
|
||||
readFile,
|
||||
isWindows
|
||||
} from '@sasjs/utils'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
|
||||
abstract class SessionController {
|
||||
export class SessionController {
|
||||
protected sessions: Session[] = []
|
||||
|
||||
protected getReadySessions = (): Session[] =>
|
||||
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||
|
||||
protected abstract createSession(): Promise<Session>
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
// death time of session is 15 mins from creation
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||
|
||||
this.sessions.push(session)
|
||||
return session
|
||||
}
|
||||
|
||||
public async getSession() {
|
||||
const readySessions = this.getReadySessions()
|
||||
@@ -62,6 +93,9 @@ export class SASSessionController extends SessionController {
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8\n')
|
||||
|
||||
// we do not want to leave sessions running forever
|
||||
// we clean them up after a predefined period, if unused
|
||||
this.scheduleSessionDestroy(session)
|
||||
@@ -71,7 +105,8 @@ export class SASSessionController extends SessionController {
|
||||
|
||||
// the autoexec file is executed on SAS startup
|
||||
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
|
||||
const contentForAutoExec = `/* compiled systemInit */
|
||||
const contentForAutoExec = `filename packages "${getPackagesFolder()}";
|
||||
/* compiled systemInit */
|
||||
${compiledSystemInitContent}
|
||||
/* autoexec */
|
||||
${autoExecContent}`
|
||||
@@ -88,7 +123,7 @@ ${autoExecContent}`
|
||||
|
||||
// Additional windows specific options to avoid the desktop popups.
|
||||
|
||||
execFilePromise(process.sasLoc, [
|
||||
execFilePromise(process.sasLoc!, [
|
||||
'-SYSIN',
|
||||
codePath,
|
||||
'-LOG',
|
||||
@@ -99,18 +134,24 @@ ${autoExecContent}`
|
||||
session.path,
|
||||
'-AUTOEXEC',
|
||||
autoExecPath,
|
||||
process.platform === 'win32' ? '-nosplash' : '',
|
||||
process.platform === 'win32' ? '-icon' : '',
|
||||
process.platform === 'win32' ? '-nologo' : ''
|
||||
isWindows() ? '-nologo' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-NOPRNGETLIST' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||
])
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
console.log('session completed', session)
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id, session.crashed)
|
||||
process.logger.error('session crashed', session.id, session.crashed)
|
||||
})
|
||||
|
||||
// we have a triggered session - add to array
|
||||
@@ -130,12 +171,15 @@ ${autoExecContent}`
|
||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||
|
||||
if (session.crashed)
|
||||
console.log('session crashed! while waiting to be ready', session.crashed)
|
||||
process.logger.error(
|
||||
'session crashed! while waiting to be ready',
|
||||
session.crashed
|
||||
)
|
||||
|
||||
session.ready = true
|
||||
}
|
||||
|
||||
public async deleteSession(session: Session) {
|
||||
private async deleteSession(session: Session) {
|
||||
// remove the temporary files, to avoid buildup
|
||||
await deleteFolder(session.path)
|
||||
|
||||
@@ -160,52 +204,20 @@ ${autoExecContent}`
|
||||
}
|
||||
}
|
||||
|
||||
export class JSSessionController extends SessionController {
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
export const getSessionController = (
|
||||
runTime: RunTimeType
|
||||
): SessionController => {
|
||||
if (runTime === RunTimeType.SAS) {
|
||||
process.sasSessionController =
|
||||
process.sasSessionController || new SASSessionController()
|
||||
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
// death time of session is 15 mins from creation
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
await createFile(headersPath, 'Content-type: application/json')
|
||||
|
||||
this.sessions.push(session)
|
||||
return session
|
||||
return process.sasSessionController
|
||||
}
|
||||
}
|
||||
|
||||
export const getSASSessionController = (): SASSessionController => {
|
||||
if (process.sasSessionController) return process.sasSessionController
|
||||
process.sessionController =
|
||||
process.sessionController || new SessionController()
|
||||
|
||||
process.sasSessionController = new SASSessionController()
|
||||
|
||||
return process.sasSessionController
|
||||
}
|
||||
|
||||
export const getJSSessionController = (): JSSessionController => {
|
||||
if (process.jsSessionController) return process.jsSessionController
|
||||
|
||||
process.jsSessionController = new JSSessionController()
|
||||
|
||||
return process.jsSessionController
|
||||
return process.sessionController
|
||||
}
|
||||
|
||||
const autoExecContent = `
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escapeWinSlashes } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { generateFileUploadJSCode } from '../../utils'
|
||||
import { ExecutionVars } from './'
|
||||
@@ -8,6 +9,7 @@ export const createJSProgram = async (
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
headersPath: string,
|
||||
tokenFile: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
@@ -19,18 +21,15 @@ export const createJSProgram = async (
|
||||
|
||||
const preProgramVarStatments = `
|
||||
let _webout = '';
|
||||
const weboutPath = '${
|
||||
process.platform === 'win32'
|
||||
? weboutPath.replace(/\\/g, '\\\\')
|
||||
: weboutPath
|
||||
}';
|
||||
const _sasjs_tokenfile = '${tokenFile}';
|
||||
const _sasjs_username = '${preProgramVariables?.username}';
|
||||
const _sasjs_userid = '${preProgramVariables?.userId}';
|
||||
const _sasjs_displayname = '${preProgramVariables?.displayName}';
|
||||
const _metaperson = _sasjs_displayname;
|
||||
const _metauser = _sasjs_username;
|
||||
const sasjsprocessmode = 'Stored Program';
|
||||
const weboutPath = '${escapeWinSlashes(weboutPath)}';
|
||||
const _SASJS_TOKENFILE = '${escapeWinSlashes(tokenFile)}';
|
||||
const _SASJS_WEBOUT_HEADERS = '${escapeWinSlashes(headersPath)}';
|
||||
const _SASJS_USERNAME = '${preProgramVariables?.username}';
|
||||
const _SASJS_USERID = '${preProgramVariables?.userId}';
|
||||
const _SASJS_DISPLAYNAME = '${preProgramVariables?.displayName}';
|
||||
const _METAPERSON = _SASJS_DISPLAYNAME;
|
||||
const _METAUSER = _SASJS_USERNAME;
|
||||
const SASJSPROCESSMODE = 'Stored Program';
|
||||
`
|
||||
|
||||
const requiredModules = `const fs = require('fs')`
|
||||
@@ -54,14 +53,15 @@ if (_webout) {
|
||||
`
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadJSCode = await generateFileUploadJSCode(
|
||||
const uploadJsCode = await generateFileUploadJSCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
//If js code for the file is generated it will be appended to the top of jsCode
|
||||
if (uploadJSCode.length > 0) {
|
||||
program = `${uploadJSCode}\n` + program
|
||||
// If any files are uploaded, the program needs to be updated with some
|
||||
// dynamically generated variables (pointers) for ease of ingestion
|
||||
if (uploadJsCode.length > 0) {
|
||||
program = `${uploadJsCode}\n` + program
|
||||
}
|
||||
}
|
||||
return requiredModules + program
|
||||
|
||||
64
api/src/controllers/internal/createPythonProgram.ts
Normal file
64
api/src/controllers/internal/createPythonProgram.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { escapeWinSlashes } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { generateFileUploadPythonCode } from '../../utils'
|
||||
import { ExecutionVars } from './'
|
||||
|
||||
export const createPythonProgram = async (
|
||||
program: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
headersPath: string,
|
||||
tokenFile: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) => `${computed}${key} = '${vars[key]}';\n`,
|
||||
''
|
||||
)
|
||||
|
||||
const preProgramVarStatments = `
|
||||
_SASJS_SESSION_PATH = '${escapeWinSlashes(session.path)}';
|
||||
_WEBOUT = '${escapeWinSlashes(weboutPath)}';
|
||||
_SASJS_WEBOUT_HEADERS = '${escapeWinSlashes(headersPath)}';
|
||||
_SASJS_TOKENFILE = '${escapeWinSlashes(tokenFile)}';
|
||||
_SASJS_USERNAME = '${preProgramVariables?.username}';
|
||||
_SASJS_USERID = '${preProgramVariables?.userId}';
|
||||
_SASJS_DISPLAYNAME = '${preProgramVariables?.displayName}';
|
||||
_METAPERSON = _SASJS_DISPLAYNAME;
|
||||
_METAUSER = _SASJS_USERNAME;
|
||||
SASJSPROCESSMODE = 'Stored Program';
|
||||
`
|
||||
|
||||
const requiredModules = `import os`
|
||||
|
||||
program = `
|
||||
# runtime vars
|
||||
${varStatments}
|
||||
|
||||
# dynamic user-provided vars
|
||||
${preProgramVarStatments}
|
||||
|
||||
# change working directory to session folder
|
||||
os.chdir(_SASJS_SESSION_PATH)
|
||||
|
||||
# actual job code
|
||||
${program}
|
||||
|
||||
`
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadPythonCode = await generateFileUploadPythonCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
// If any files are uploaded, the program needs to be updated with some
|
||||
// dynamically generated variables (pointers) for ease of ingestion
|
||||
if (uploadPythonCode.length > 0) {
|
||||
program = `${uploadPythonCode}\n` + program
|
||||
}
|
||||
}
|
||||
return requiredModules + program
|
||||
}
|
||||
64
api/src/controllers/internal/createRProgram.ts
Normal file
64
api/src/controllers/internal/createRProgram.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { escapeWinSlashes } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { generateFileUploadRCode } from '../../utils'
|
||||
import { ExecutionVars } from '.'
|
||||
|
||||
export const createRProgram = async (
|
||||
program: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
headersPath: string,
|
||||
tokenFile: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) => `${computed}.${key} <- '${vars[key]}'\n`,
|
||||
''
|
||||
)
|
||||
|
||||
const preProgramVarStatments = `
|
||||
._SASJS_SESSION_PATH <- '${escapeWinSlashes(session.path)}';
|
||||
._WEBOUT <- '${escapeWinSlashes(weboutPath)}';
|
||||
._SASJS_WEBOUT_HEADERS <- '${escapeWinSlashes(headersPath)}';
|
||||
._SASJS_TOKENFILE <- '${escapeWinSlashes(tokenFile)}';
|
||||
._SASJS_USERNAME <- '${preProgramVariables?.username}';
|
||||
._SASJS_USERID <- '${preProgramVariables?.userId}';
|
||||
._SASJS_DISPLAYNAME <- '${preProgramVariables?.displayName}';
|
||||
._METAPERSON <- ._SASJS_DISPLAYNAME;
|
||||
._METAUSER <- ._SASJS_USERNAME;
|
||||
SASJSPROCESSMODE <- 'Stored Program';
|
||||
`
|
||||
|
||||
const requiredModules = ``
|
||||
|
||||
program = `
|
||||
# runtime vars
|
||||
${varStatments}
|
||||
|
||||
# dynamic user-provided vars
|
||||
${preProgramVarStatments}
|
||||
|
||||
# change working directory to session folder
|
||||
setwd(._SASJS_SESSION_PATH)
|
||||
|
||||
# actual job code
|
||||
${program}
|
||||
|
||||
`
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadRCode = await generateFileUploadRCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
// If any files are uploaded, the program needs to be updated with some
|
||||
// dynamically generated variables (pointers) for ease of ingestion
|
||||
if (uploadRCode.length > 0) {
|
||||
program = `${uploadRCode}\n` + program
|
||||
}
|
||||
}
|
||||
return requiredModules + program
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const createSASProgram = async (
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
headersPath: string,
|
||||
tokenFile: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
@@ -23,10 +24,14 @@ export const createSASProgram = async (
|
||||
%let _sasjs_displayname=${preProgramVariables?.displayName};
|
||||
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
|
||||
%let _sasjs_apipath=/SASjsApi/stp/execute;
|
||||
%let _sasjs_webout_headers=${headersPath};
|
||||
%let _metaperson=&_sasjs_displayname;
|
||||
%let _metauser=&_sasjs_username;
|
||||
|
||||
/* the below is here for compatibility and will be removed in a future release */
|
||||
%let sasjs_stpsrv_header_loc=&_sasjs_webout_headers;
|
||||
|
||||
%let sasjsprocessmode=Stored Program;
|
||||
%let sasjs_stpsrv_header_loc=%sysfunc(pathname(work))/../stpsrv_header.txt;
|
||||
|
||||
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
|
||||
%macro _sasjs_server_init();
|
||||
@@ -34,6 +39,9 @@ export const createSASProgram = async (
|
||||
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
|
||||
%mend;
|
||||
%_sasjs_server_init()
|
||||
|
||||
proc printto print="%sysfunc(getoption(log))";
|
||||
run;
|
||||
`
|
||||
|
||||
program = `
|
||||
@@ -60,7 +68,8 @@ ${program}`
|
||||
session.path
|
||||
)
|
||||
|
||||
//If sas code for the file is generated it will be appended to the top of sasCode
|
||||
// If any files are uploaded, the program needs to be updated with some
|
||||
// dynamically generated variables (pointers) for ease of ingestion
|
||||
if (uploadSasCode.length > 0) {
|
||||
program = `${uploadSasCode}` + program
|
||||
}
|
||||
|
||||
@@ -4,4 +4,6 @@ export * from './Execution'
|
||||
export * from './FileUploadController'
|
||||
export * from './createSASProgram'
|
||||
export * from './createJSProgram'
|
||||
export * from './createPythonProgram'
|
||||
export * from './createRProgram'
|
||||
export * from './processProgram'
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { WriteStream, createWriteStream } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { once } from 'stream'
|
||||
import { createFile, moveFile } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { RunTimeType } from '../../utils'
|
||||
import { ExecutionVars, createSASProgram, createJSProgram } from './'
|
||||
import {
|
||||
ExecutionVars,
|
||||
createSASProgram,
|
||||
createJSProgram,
|
||||
createPythonProgram,
|
||||
createRProgram
|
||||
} from './'
|
||||
|
||||
export const processProgram = async (
|
||||
program: string,
|
||||
@@ -13,54 +19,20 @@ export const processProgram = async (
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
headersPath: string,
|
||||
tokenFile: string,
|
||||
runTime: RunTimeType,
|
||||
logPath: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
if (runTime === RunTimeType.JS) {
|
||||
program = await createJSProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
|
||||
const codePath = path.join(session.path, 'code.js')
|
||||
|
||||
try {
|
||||
await createFile(codePath, program)
|
||||
|
||||
// create a stream that will write to console outputs to log file
|
||||
const writeStream = fs.createWriteStream(logPath)
|
||||
|
||||
// waiting for the open event so that we can have underlying file descriptor
|
||||
await once(writeStream, 'open')
|
||||
|
||||
execFileSync(process.nodeLoc, [codePath], {
|
||||
stdio: ['ignore', writeStream, writeStream]
|
||||
})
|
||||
|
||||
// copy the code.js program to log and end write stream
|
||||
writeStream.end(program)
|
||||
|
||||
session.completed = true
|
||||
console.log('session completed', session)
|
||||
} catch (err: any) {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id, session.crashed)
|
||||
}
|
||||
} else {
|
||||
if (runTime === RunTimeType.SAS) {
|
||||
program = await createSASProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
@@ -80,7 +52,111 @@ export const processProgram = async (
|
||||
while (!session.completed) {
|
||||
await delay(50)
|
||||
}
|
||||
} else {
|
||||
let codePath: string
|
||||
let executablePath: string
|
||||
switch (runTime) {
|
||||
case RunTimeType.JS:
|
||||
program = await createJSProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
codePath = path.join(session.path, 'code.js')
|
||||
executablePath = process.nodeLoc!
|
||||
|
||||
break
|
||||
case RunTimeType.PY:
|
||||
program = await createPythonProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
codePath = path.join(session.path, 'code.py')
|
||||
executablePath = process.pythonLoc!
|
||||
|
||||
break
|
||||
case RunTimeType.R:
|
||||
program = await createRProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
codePath = path.join(session.path, 'code.r')
|
||||
executablePath = process.rLoc!
|
||||
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid runtime!')
|
||||
}
|
||||
|
||||
await createFile(codePath, program)
|
||||
|
||||
// create a stream that will write to console outputs to log file
|
||||
const writeStream = createWriteStream(logPath)
|
||||
// waiting for the open event so that we can have underlying file descriptor
|
||||
await once(writeStream, 'open')
|
||||
|
||||
await execFilePromise(executablePath, [codePath], writeStream)
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
process.logger.error('session crashed', session.id, session.crashed)
|
||||
})
|
||||
|
||||
// copy the code file to log and end write stream
|
||||
writeStream.end(program)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified child_process.execFile
|
||||
*
|
||||
* @param file - The name or path of the executable file to run.
|
||||
* @param args - List of string arguments.
|
||||
* @param writeStream - Child process stdout and stderr will be piped to it.
|
||||
*
|
||||
* @returns {Promise<{ stdout: string, stderr: string }>}
|
||||
*/
|
||||
const execFilePromise = (
|
||||
file: string,
|
||||
args: string[],
|
||||
writeStream: WriteStream
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(file, args, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
|
||||
resolve({ stdout, stderr })
|
||||
})
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
writeStream.write(data)
|
||||
})
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
writeStream.write(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
283
api/src/controllers/mock-sas9.ts
Normal file
283
api/src/controllers/mock-sas9.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { readFile } from '@sasjs/utils'
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { Request, Post, Get } from 'tsoa'
|
||||
import dotenv from 'dotenv'
|
||||
import { ExecutionController } from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
getRunTimeAndFilePath,
|
||||
makeFilesNamesMap
|
||||
} from '../utils'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
export interface Sas9Response {
|
||||
content: string
|
||||
redirect?: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export interface MockFileRead {
|
||||
content: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export class MockSas9Controller {
|
||||
private loggedIn: string | undefined
|
||||
private mocksPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
|
||||
|
||||
@Get('/SASStoredProcess')
|
||||
public async sasStoredProcess(
|
||||
@Request() req: express.Request
|
||||
): Promise<Sas9Response> {
|
||||
const username = req.query._username?.toString() || undefined
|
||||
const password = req.query._password?.toString() || undefined
|
||||
|
||||
if (username && password) this.loggedIn = req.body.username
|
||||
|
||||
if (!this.loggedIn) {
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/login'
|
||||
}
|
||||
}
|
||||
|
||||
let program = req.query._program?.toString() || undefined
|
||||
const filePath: string[] = program
|
||||
? program.replace('/', '').split('/')
|
||||
: ['generic', 'sas-stored-process']
|
||||
|
||||
if (program) {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
this.mocksPath,
|
||||
'sas9',
|
||||
...filePath
|
||||
])
|
||||
}
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
...filePath
|
||||
])
|
||||
}
|
||||
|
||||
@Get('/SASStoredProcess/do')
|
||||
public async sasStoredProcessDoGet(
|
||||
@Request() req: express.Request
|
||||
): Promise<Sas9Response> {
|
||||
const username = req.query._username?.toString() || undefined
|
||||
const password = req.query._password?.toString() || undefined
|
||||
|
||||
if (username && password) this.loggedIn = username
|
||||
|
||||
if (!this.loggedIn) {
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/login'
|
||||
}
|
||||
}
|
||||
|
||||
const program = req.query._program ?? req.body?._program
|
||||
const filePath: string[] = ['generic', 'sas-stored-process']
|
||||
|
||||
if (program) {
|
||||
const vars = { ...req.query, ...req.body, _requestMethod: req.method }
|
||||
const otherArgs = {}
|
||||
|
||||
try {
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(
|
||||
program + '.js'
|
||||
)
|
||||
|
||||
const result = await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: vars,
|
||||
otherArgs: otherArgs,
|
||||
runTime,
|
||||
forceStringResult: true
|
||||
})
|
||||
|
||||
return {
|
||||
content: result.result as string
|
||||
}
|
||||
} catch (err) {
|
||||
process.logger.error('err', err)
|
||||
}
|
||||
|
||||
return {
|
||||
content: 'No webout returned.'
|
||||
}
|
||||
}
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
...filePath
|
||||
])
|
||||
}
|
||||
|
||||
@Post('/SASStoredProcess/do/')
|
||||
public async sasStoredProcessDoPost(
|
||||
@Request() req: express.Request
|
||||
): Promise<Sas9Response> {
|
||||
if (!this.loggedIn) {
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/login'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isPublicAccount()) {
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/Login'
|
||||
}
|
||||
}
|
||||
|
||||
const program = req.query._program ?? req.body?._program
|
||||
const vars = {
|
||||
...req.query,
|
||||
...req.body,
|
||||
_requestMethod: req.method,
|
||||
_driveLoc: process.driveLoc
|
||||
}
|
||||
const filesNamesMap = req.files?.length
|
||||
? makeFilesNamesMap(req.files as MulterFile[])
|
||||
: null
|
||||
const otherArgs = { filesNamesMap: filesNamesMap }
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(program + '.js')
|
||||
try {
|
||||
const result = await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: vars,
|
||||
otherArgs: otherArgs,
|
||||
runTime,
|
||||
session: req.sasjsSession,
|
||||
forceStringResult: true
|
||||
})
|
||||
|
||||
return {
|
||||
content: result.result as string
|
||||
}
|
||||
} catch (err) {
|
||||
process.logger.error('err', err)
|
||||
}
|
||||
|
||||
return {
|
||||
content: 'No webout returned.'
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/SASLogon/login')
|
||||
public async loginGet(): Promise<Sas9Response> {
|
||||
if (this.loggedIn) {
|
||||
if (this.isPublicAccount()) {
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASStoredProcess/Logoff?publicDenied=true'
|
||||
}
|
||||
} else {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
'generic',
|
||||
'logged-in'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
'generic',
|
||||
'login'
|
||||
])
|
||||
}
|
||||
|
||||
@Post('/SASLogon/login')
|
||||
public async loginPost(req: express.Request): Promise<Sas9Response> {
|
||||
if (req.body.lt && req.body.lt !== 'validtoken')
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/login'
|
||||
}
|
||||
|
||||
this.loggedIn = req.body.username
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
'generic',
|
||||
'logged-in'
|
||||
])
|
||||
}
|
||||
|
||||
@Get('/SASLogon/logout')
|
||||
public async logout(req: express.Request): Promise<Sas9Response> {
|
||||
this.loggedIn = undefined
|
||||
|
||||
if (req.query.publicDenied === 'true') {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
'generic',
|
||||
'public-access-denied'
|
||||
])
|
||||
}
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
'generic',
|
||||
'logged-out'
|
||||
])
|
||||
}
|
||||
|
||||
@Get('/SASStoredProcess/Logoff') //publicDenied=true
|
||||
public async logoff(req: express.Request): Promise<Sas9Response> {
|
||||
const params = req.query.publicDenied
|
||||
? `?publicDenied=${req.query.publicDenied}`
|
||||
: ''
|
||||
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/logout' + params
|
||||
}
|
||||
}
|
||||
|
||||
private isPublicAccount = () => this.loggedIn?.toLowerCase() === 'public'
|
||||
}
|
||||
|
||||
const getMockResponseFromFile = async (
|
||||
filePath: string[]
|
||||
): Promise<MockFileRead> => {
|
||||
const filePathParsed = path.join(...filePath)
|
||||
let error: boolean = false
|
||||
|
||||
let file = await readFile(filePathParsed).catch((err: any) => {
|
||||
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
|
||||
process.logger.error(errMsg)
|
||||
|
||||
error = true
|
||||
|
||||
return errMsg
|
||||
})
|
||||
|
||||
return {
|
||||
content: file,
|
||||
error: error
|
||||
}
|
||||
}
|
||||
368
api/src/controllers/permission.ts
Normal file
368
api/src/controllers/permission.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import express from 'express'
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Example,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Request
|
||||
} from 'tsoa'
|
||||
|
||||
import Permission from '../model/Permission'
|
||||
import User from '../model/User'
|
||||
import Group from '../model/Group'
|
||||
import { UserResponse } from './user'
|
||||
import { GroupDetailsResponse } from './group'
|
||||
|
||||
export enum PermissionType {
|
||||
route = 'Route'
|
||||
}
|
||||
|
||||
export enum PrincipalType {
|
||||
user = 'user',
|
||||
group = 'group'
|
||||
}
|
||||
|
||||
export enum PermissionSettingForRoute {
|
||||
grant = 'Grant',
|
||||
deny = 'Deny'
|
||||
}
|
||||
|
||||
interface RegisterPermissionPayload {
|
||||
/**
|
||||
* Name of affected resource
|
||||
* @example "/SASjsApi/code/execute"
|
||||
*/
|
||||
path: string
|
||||
/**
|
||||
* Type of affected resource
|
||||
* @example "Route"
|
||||
*/
|
||||
type: PermissionType
|
||||
/**
|
||||
* The indication of whether (and to what extent) access is provided
|
||||
* @example "Grant"
|
||||
*/
|
||||
setting: PermissionSettingForRoute
|
||||
/**
|
||||
* Indicates the type of principal
|
||||
* @example "user"
|
||||
*/
|
||||
principalType: PrincipalType
|
||||
/**
|
||||
* The id of user or group to which a rule is assigned.
|
||||
* @example 123
|
||||
*/
|
||||
principalId: number
|
||||
}
|
||||
|
||||
interface UpdatePermissionPayload {
|
||||
/**
|
||||
* The indication of whether (and to what extent) access is provided
|
||||
* @example "Grant"
|
||||
*/
|
||||
setting: PermissionSettingForRoute
|
||||
}
|
||||
|
||||
export interface PermissionDetailsResponse {
|
||||
permissionId: number
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
user?: UserResponse
|
||||
group?: GroupDetailsResponse
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/permission')
|
||||
@Tags('Permission')
|
||||
export class PermissionController {
|
||||
/**
|
||||
* Get the list of permission rules applicable the authenticated user.
|
||||
* If the user is an admin, all rules are returned.
|
||||
*
|
||||
* @summary Get the list of permission rules. If the user is admin, all rules are returned.
|
||||
*
|
||||
*/
|
||||
@Example<PermissionDetailsResponse[]>([
|
||||
{
|
||||
permissionId: 123,
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
},
|
||||
{
|
||||
permissionId: 124,
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
group: {
|
||||
groupId: 1,
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
}
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
public async getAllPermissions(
|
||||
@Request() request: express.Request
|
||||
): Promise<PermissionDetailsResponse[]> {
|
||||
return getAllPermissions(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Create a new permission. Admin only.
|
||||
*
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
@Post('/')
|
||||
public async createPermission(
|
||||
@Body() body: RegisterPermissionPayload
|
||||
): Promise<PermissionDetailsResponse> {
|
||||
return createPermission(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update permission setting. Admin only
|
||||
* @param permissionId The permission's identifier
|
||||
* @example permissionId 1234
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
@Patch('{permissionId}')
|
||||
public async updatePermission(
|
||||
@Path() permissionId: number,
|
||||
@Body() body: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> {
|
||||
return updatePermission(permissionId, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Delete a permission. Admin only.
|
||||
* @param permissionId The user's identifier
|
||||
* @example permissionId 1234
|
||||
*/
|
||||
@Delete('{permissionId}')
|
||||
public async deletePermission(@Path() permissionId: number) {
|
||||
return deletePermission(permissionId)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllPermissions = async (
|
||||
req: express.Request
|
||||
): Promise<PermissionDetailsResponse[]> => {
|
||||
const { user } = req
|
||||
|
||||
if (user?.isAdmin) return await Permission.get({})
|
||||
else {
|
||||
const permissions: PermissionDetailsResponse[] = []
|
||||
|
||||
const dbUser = await User.findOne({ id: user?.userId })
|
||||
if (!dbUser)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'User not found.'
|
||||
}
|
||||
|
||||
permissions.push(...(await Permission.get({ user: dbUser._id })))
|
||||
|
||||
for (const group of dbUser.groups) {
|
||||
permissions.push(...(await Permission.get({ group })))
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
}
|
||||
|
||||
const createPermission = async ({
|
||||
path,
|
||||
type,
|
||||
setting,
|
||||
principalType,
|
||||
principalId
|
||||
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
|
||||
const permission = new Permission({
|
||||
path,
|
||||
type,
|
||||
setting
|
||||
})
|
||||
|
||||
let user: UserResponse | undefined
|
||||
let group: GroupDetailsResponse | undefined
|
||||
|
||||
switch (principalType) {
|
||||
case PrincipalType.user: {
|
||||
const userInDB = await User.findOne({ id: principalId })
|
||||
if (!userInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'User not found.'
|
||||
}
|
||||
|
||||
if (userInDB.isAdmin)
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: 'Can not add permission for admin user.'
|
||||
}
|
||||
|
||||
const alreadyExists = await Permission.findOne({
|
||||
path,
|
||||
type,
|
||||
user: userInDB._id
|
||||
})
|
||||
|
||||
if (alreadyExists)
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message:
|
||||
'Permission already exists with provided Path, Type and User.'
|
||||
}
|
||||
|
||||
permission.user = userInDB._id
|
||||
|
||||
user = {
|
||||
id: userInDB.id,
|
||||
username: userInDB.username,
|
||||
displayName: userInDB.displayName,
|
||||
isAdmin: userInDB.isAdmin
|
||||
}
|
||||
break
|
||||
}
|
||||
case PrincipalType.group: {
|
||||
const groupInDB = await Group.findOne({ groupId: principalId })
|
||||
if (!groupInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Group not found.'
|
||||
}
|
||||
|
||||
const alreadyExists = await Permission.findOne({
|
||||
path,
|
||||
type,
|
||||
group: groupInDB._id
|
||||
})
|
||||
if (alreadyExists)
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message:
|
||||
'Permission already exists with provided Path, Type and Group.'
|
||||
}
|
||||
|
||||
permission.group = groupInDB._id
|
||||
|
||||
group = {
|
||||
groupId: groupInDB.groupId,
|
||||
name: groupInDB.name,
|
||||
description: groupInDB.description,
|
||||
isActive: groupInDB.isActive,
|
||||
users: groupInDB.populate({
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
options: { limit: 15 }
|
||||
}) as unknown as UserResponse[]
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: 'Invalid principal type. Valid types are user or group.'
|
||||
}
|
||||
}
|
||||
|
||||
const savedPermission = await permission.save()
|
||||
|
||||
return {
|
||||
permissionId: savedPermission.permissionId,
|
||||
path: savedPermission.path,
|
||||
type: savedPermission.type,
|
||||
setting: savedPermission.setting,
|
||||
user,
|
||||
group
|
||||
}
|
||||
}
|
||||
|
||||
const updatePermission = async (
|
||||
id: number,
|
||||
data: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> => {
|
||||
const { setting } = data
|
||||
|
||||
const updatedPermission = (await Permission.findOneAndUpdate(
|
||||
{ permissionId: id },
|
||||
{ setting },
|
||||
{ new: true }
|
||||
)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id'
|
||||
})) as unknown as PermissionDetailsResponse
|
||||
if (!updatedPermission)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Permission not found.'
|
||||
}
|
||||
|
||||
return updatedPermission
|
||||
}
|
||||
|
||||
const deletePermission = async (id: number) => {
|
||||
const permission = await Permission.findOne({ permissionId: id })
|
||||
if (!permission)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Permission not found.'
|
||||
}
|
||||
await Permission.deleteOne({ permissionId: id })
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import express from 'express'
|
||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||
import { UserResponse } from './user'
|
||||
|
||||
interface SessionResponse extends UserResponse {
|
||||
needsToUpdatePassword: boolean
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/session')
|
||||
@Tags('Session')
|
||||
@@ -13,12 +17,13 @@ export class SessionController {
|
||||
@Example<UserResponse>({
|
||||
id: 123,
|
||||
username: 'johnusername',
|
||||
displayName: 'John'
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
})
|
||||
@Get('/')
|
||||
public async session(
|
||||
@Request() request: express.Request
|
||||
): Promise<UserResponse> {
|
||||
): Promise<SessionResponse> {
|
||||
return session(request)
|
||||
}
|
||||
}
|
||||
@@ -26,5 +31,7 @@ export class SessionController {
|
||||
const session = (req: express.Request) => ({
|
||||
id: req.user!.userId,
|
||||
username: req.user!.username,
|
||||
displayName: req.user!.displayName
|
||||
displayName: req.user!.displayName,
|
||||
isAdmin: req.user!.isAdmin,
|
||||
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||
})
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import express from 'express'
|
||||
import {
|
||||
Request,
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
Query,
|
||||
Example
|
||||
} from 'tsoa'
|
||||
import {
|
||||
ExecuteReturnJson,
|
||||
ExecuteReturnRaw,
|
||||
ExecutionController,
|
||||
ExecutionVars
|
||||
} from './internal'
|
||||
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||
import { ExecutionController, ExecutionVars } from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
HTTPHeaders,
|
||||
isDebugOn,
|
||||
LogLine,
|
||||
makeFilesNamesMap,
|
||||
parseLogToArray,
|
||||
getRunTimeAndFilePath
|
||||
} from '../utils'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
|
||||
interface ExecuteReturnJsonPayload {
|
||||
interface ExecutePostRequestPayload {
|
||||
/**
|
||||
* Location of SAS program
|
||||
* @example "/Public/somefolder/some.file"
|
||||
@@ -35,102 +18,80 @@ interface ExecuteReturnJsonPayload {
|
||||
_program?: string
|
||||
}
|
||||
|
||||
interface IRecordOfAny {
|
||||
[key: string]: any
|
||||
}
|
||||
export interface ExecuteReturnJsonResponse {
|
||||
status: string
|
||||
_webout: string | IRecordOfAny
|
||||
log: LogLine[]
|
||||
message?: string
|
||||
httpHeaders: HTTPHeaders
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/stp')
|
||||
@Tags('STP')
|
||||
export class STPController {
|
||||
/**
|
||||
* Trigger a SAS or JS program using the _program URL parameter.
|
||||
* Trigger a Stored Program using the _program URL parameter.
|
||||
*
|
||||
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||
*
|
||||
* https://server.sasjs.io/storedprograms
|
||||
*
|
||||
* @summary Execute a Stored Program, returns raw _webout content.
|
||||
* @param _program Location of SAS or JS code
|
||||
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||
* @param _program Location of code in SASjs Drive
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
*/
|
||||
@Get('/execute')
|
||||
public async executeReturnRaw(
|
||||
public async executeGetRequest(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string
|
||||
): Promise<string | Buffer> {
|
||||
return executeReturnRaw(request, _program)
|
||||
const vars = request.query as ExecutionVars
|
||||
return execute(request, _program, vars)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a SAS or JS program using the _program URL parameter.
|
||||
* Trigger a Stored Program using the _program URL parameter.
|
||||
*
|
||||
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||
*
|
||||
* https://server.sasjs.io/storedprograms
|
||||
*
|
||||
* The response will be a JSON object with the following root attributes:
|
||||
* log, webout, headers.
|
||||
*
|
||||
* The webout attribute will be nested JSON ONLY if the response-header
|
||||
* contains a content-type of application/json AND it is valid JSON.
|
||||
* Otherwise it will be a stringified version of the webout content.
|
||||
*
|
||||
* @summary Execute a Stored Program, return a JSON object
|
||||
* @param _program Location of SAS or JS code
|
||||
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||
* @param _program Location of code in SASjs Drive
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
*/
|
||||
@Example<ExecuteReturnJsonResponse>({
|
||||
status: 'success',
|
||||
_webout: 'webout content',
|
||||
log: [],
|
||||
httpHeaders: {
|
||||
'Content-type': 'application/zip',
|
||||
'Cache-Control': 'public, max-age=1000'
|
||||
}
|
||||
})
|
||||
@Post('/execute')
|
||||
public async executeReturnJson(
|
||||
public async executePostRequest(
|
||||
@Request() request: express.Request,
|
||||
@Body() body?: ExecuteReturnJsonPayload,
|
||||
@Body() body?: ExecutePostRequestPayload,
|
||||
@Query() _program?: string
|
||||
): Promise<ExecuteReturnJsonResponse> {
|
||||
): Promise<string | Buffer> {
|
||||
const program = _program ?? body?._program
|
||||
return executeReturnJson(request, program!)
|
||||
const vars = { ...request.query, ...request.body }
|
||||
const filesNamesMap = request.files?.length
|
||||
? makeFilesNamesMap(request.files as MulterFile[])
|
||||
: null
|
||||
const otherArgs = { filesNamesMap: filesNamesMap }
|
||||
|
||||
return execute(request, program!, vars, otherArgs)
|
||||
}
|
||||
}
|
||||
|
||||
const executeReturnRaw = async (
|
||||
const execute = async (
|
||||
req: express.Request,
|
||||
_program: string
|
||||
_program: string,
|
||||
vars: ExecutionVars,
|
||||
otherArgs?: any
|
||||
): Promise<string | Buffer> => {
|
||||
const query = req.query as ExecutionVars
|
||||
|
||||
try {
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||
|
||||
const { result, httpHeaders } =
|
||||
(await new ExecutionController().executeFile({
|
||||
const { result, httpHeaders } = await new ExecutionController().executeFile(
|
||||
{
|
||||
programPath: codePath,
|
||||
runTime,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: query,
|
||||
runTime
|
||||
})) as ExecuteReturnRaw
|
||||
vars,
|
||||
otherArgs,
|
||||
session: req.sasjsSession
|
||||
}
|
||||
)
|
||||
|
||||
// Should over-ride response header for debug
|
||||
// on GET request to see entire log rendering on browser.
|
||||
if (isDebugOn(query)) {
|
||||
httpHeaders['content-type'] = 'text/plain'
|
||||
}
|
||||
|
||||
req.res?.set(httpHeaders)
|
||||
req.res?.header(httpHeaders)
|
||||
|
||||
if (result instanceof Buffer) {
|
||||
;(req as any).sasHeaders = httpHeaders
|
||||
@@ -146,48 +107,3 @@ const executeReturnRaw = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeReturnJson = async (
|
||||
req: express.Request,
|
||||
_program: string
|
||||
): Promise<ExecuteReturnJsonResponse> => {
|
||||
const filesNamesMap = req.files?.length
|
||||
? makeFilesNamesMap(req.files as MulterFile[])
|
||||
: null
|
||||
|
||||
try {
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||
|
||||
const { webout, log, httpHeaders } =
|
||||
(await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, ...req.body },
|
||||
otherArgs: { filesNamesMap: filesNamesMap },
|
||||
returnJson: true,
|
||||
session: req.sasjsSession,
|
||||
runTime
|
||||
})) as ExecuteReturnJson
|
||||
|
||||
let weboutRes: string | IRecordOfAny = webout
|
||||
if (httpHeaders['content-type']?.toLowerCase() === 'application/json') {
|
||||
try {
|
||||
weboutRes = JSON.parse(webout as string)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
_webout: weboutRes,
|
||||
log: parseLogToArray(log),
|
||||
httpHeaders
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,22 @@ import {
|
||||
import { desktopUser } from '../middlewares'
|
||||
|
||||
import User, { UserPayload } from '../model/User'
|
||||
import { getUserAutoExec, updateUserAutoExec, ModeType } from '../utils'
|
||||
import { GroupResponse } from './group'
|
||||
import {
|
||||
getUserAutoExec,
|
||||
updateUserAutoExec,
|
||||
ModeType,
|
||||
ALL_USERS_GROUP
|
||||
} from '../utils'
|
||||
import { GroupController, GroupResponse } from './group'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
interface UserDetailsResponse {
|
||||
export interface UserDetailsResponse {
|
||||
id: number
|
||||
displayName: string
|
||||
username: string
|
||||
@@ -48,12 +54,14 @@ export class UserController {
|
||||
{
|
||||
id: 123,
|
||||
username: 'johnusername',
|
||||
displayName: 'John'
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
},
|
||||
{
|
||||
id: 456,
|
||||
username: 'starkusername',
|
||||
displayName: 'Stark'
|
||||
displayName: 'Stark',
|
||||
isAdmin: true
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
@@ -200,7 +208,7 @@ export class UserController {
|
||||
|
||||
const getAllUsers = async (): Promise<UserResponse[]> =>
|
||||
await User.find({})
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
|
||||
.exec()
|
||||
|
||||
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
@@ -208,7 +216,11 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
|
||||
// Checking if user is already in the database
|
||||
const usernameExist = await User.findOne({ username })
|
||||
if (usernameExist) throw new Error('Username already exists.')
|
||||
if (usernameExist)
|
||||
throw {
|
||||
code: 409,
|
||||
message: 'Username already exists.'
|
||||
}
|
||||
|
||||
// Hash passwords
|
||||
const hashPassword = User.hashPassword(password)
|
||||
@@ -225,6 +237,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
|
||||
const savedUser = await user.save()
|
||||
|
||||
const groupController = new GroupController()
|
||||
const allUsersGroup = await groupController
|
||||
.getGroupByGroupName(ALL_USERS_GROUP.name)
|
||||
.catch(() => {})
|
||||
|
||||
if (allUsersGroup) {
|
||||
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedUser.id,
|
||||
displayName: savedUser.displayName,
|
||||
@@ -252,7 +273,11 @@ const getUser = async (
|
||||
'groupId name description -_id'
|
||||
)) as unknown as UserDetailsResponse
|
||||
|
||||
if (!user) throw new Error('User is not found.')
|
||||
if (!user)
|
||||
throw {
|
||||
code: 404,
|
||||
message: 'User is not found.'
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -281,6 +306,24 @@ const updateUser = async (
|
||||
|
||||
const params: any = { displayName, isAdmin, isActive, autoExec }
|
||||
|
||||
const user = await User.findOne(findBy)
|
||||
|
||||
if (username && username !== user?.username && user?.authProvider) {
|
||||
throw {
|
||||
code: 405,
|
||||
message:
|
||||
'Can not update username of user that is created by an external auth provider.'
|
||||
}
|
||||
}
|
||||
|
||||
if (displayName && displayName !== user?.displayName && user?.authProvider) {
|
||||
throw {
|
||||
code: 405,
|
||||
message:
|
||||
'Can not update display name of user that is created by an external auth provider.'
|
||||
}
|
||||
}
|
||||
|
||||
if (username) {
|
||||
// Checking if user is already in the database
|
||||
const usernameExist = await User.findOne({ username })
|
||||
@@ -289,7 +332,10 @@ const updateUser = async (
|
||||
(findBy.id && usernameExist.id != findBy.id) ||
|
||||
(findBy.username && usernameExist.username != findBy.username)
|
||||
)
|
||||
throw new Error('Username already exists.')
|
||||
throw {
|
||||
code: 409,
|
||||
message: 'Username already exists.'
|
||||
}
|
||||
}
|
||||
params.username = username
|
||||
}
|
||||
@@ -302,7 +348,10 @@ const updateUser = async (
|
||||
const updatedUser = await User.findOneAndUpdate(findBy, params, { new: true })
|
||||
|
||||
if (!updatedUser)
|
||||
throw new Error(`Unable to find user with ${findBy.id || findBy.username}`)
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Unable to find user with ${findBy.id || findBy.username}`
|
||||
}
|
||||
|
||||
return {
|
||||
id: updatedUser.id,
|
||||
@@ -329,11 +378,19 @@ const deleteUser = async (
|
||||
{ password }: { password?: string }
|
||||
) => {
|
||||
const user = await User.findOne(findBy)
|
||||
if (!user) throw new Error('User is not found.')
|
||||
if (!user)
|
||||
throw {
|
||||
code: 404,
|
||||
message: 'User is not found.'
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
const validPass = user.comparePassword(password!)
|
||||
if (!validPass) throw new Error('Invalid password.')
|
||||
if (!validPass)
|
||||
throw {
|
||||
code: 401,
|
||||
message: 'Invalid password.'
|
||||
}
|
||||
}
|
||||
|
||||
await User.deleteOne(findBy)
|
||||
|
||||
@@ -5,7 +5,12 @@ import { readFile } from '@sasjs/utils'
|
||||
|
||||
import User from '../model/User'
|
||||
import Client from '../model/Client'
|
||||
import { getWebBuildFolder, generateAuthCode } from '../utils'
|
||||
import {
|
||||
getWebBuildFolder,
|
||||
generateAuthCode,
|
||||
AuthProviderType,
|
||||
LDAPClient
|
||||
} from '../utils'
|
||||
import { InfoJWT } from '../types'
|
||||
import { AuthController } from './auth'
|
||||
|
||||
@@ -49,10 +54,10 @@ export class WebController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Accept a valid username/password
|
||||
* @summary Destroy the session stored in cookies
|
||||
*
|
||||
*/
|
||||
@Get('/logout')
|
||||
@Get('/SASLogon/logout')
|
||||
public async logout(@Request() req: express.Request) {
|
||||
return new Promise((resolve) => {
|
||||
req.session.destroy(() => {
|
||||
@@ -80,8 +85,16 @@ const login = async (
|
||||
const user = await User.findOne({ username })
|
||||
if (!user) throw new Error('Username is not found.')
|
||||
|
||||
const validPass = user.comparePassword(password)
|
||||
if (!validPass) throw new Error('Invalid password.')
|
||||
if (
|
||||
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
|
||||
user.authProvider === AuthProviderType.LDAP
|
||||
) {
|
||||
const ldapClient = await LDAPClient.init()
|
||||
await ldapClient.verifyUser(username, password)
|
||||
} else {
|
||||
const validPass = user.comparePassword(password)
|
||||
if (!validPass) throw new Error('Invalid password.')
|
||||
}
|
||||
|
||||
req.session.loggedIn = true
|
||||
req.session.user = {
|
||||
@@ -91,7 +104,8 @@ const login = async (
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
autoExec: user.autoExec
|
||||
autoExec: user.autoExec,
|
||||
needsToUpdatePassword: user.needsToUpdatePassword
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -99,7 +113,9 @@ const login = async (
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
needsToUpdatePassword: user.needsToUpdatePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { RequestHandler, Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { csrfProtection } from '../app'
|
||||
import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils'
|
||||
import { csrfProtection } from './'
|
||||
import {
|
||||
fetchLatestAutoExec,
|
||||
ModeType,
|
||||
verifyTokenInDB,
|
||||
isAuthorizingRoute,
|
||||
isPublicRoute,
|
||||
publicUser
|
||||
} from '../utils'
|
||||
import { desktopUser } from './desktop'
|
||||
import { authorize } from './authorize'
|
||||
|
||||
export const authenticateAccessToken: RequestHandler = async (
|
||||
req,
|
||||
@@ -15,6 +23,10 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
return next()
|
||||
}
|
||||
|
||||
const nextFunction = isAuthorizingRoute(req)
|
||||
? () => authorize(req, res, next)
|
||||
: next
|
||||
|
||||
// if request is coming from web and has valid session
|
||||
// it can be validated.
|
||||
if (req.session?.loggedIn) {
|
||||
@@ -24,33 +36,37 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
if (user) {
|
||||
if (user.isActive) {
|
||||
req.user = user
|
||||
return csrfProtection(req, res, next)
|
||||
return csrfProtection(req, res, nextFunction)
|
||||
} else return res.sendStatus(401)
|
||||
}
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
authenticateToken(
|
||||
await authenticateToken(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
process.env.ACCESS_TOKEN_SECRET as string,
|
||||
nextFunction,
|
||||
process.secrets.ACCESS_TOKEN_SECRET,
|
||||
'accessToken'
|
||||
)
|
||||
}
|
||||
|
||||
export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
|
||||
authenticateToken(
|
||||
export const authenticateRefreshToken: RequestHandler = async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
await authenticateToken(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
process.env.REFRESH_TOKEN_SECRET as string,
|
||||
process.secrets.REFRESH_TOKEN_SECRET,
|
||||
'refreshToken'
|
||||
)
|
||||
}
|
||||
|
||||
const authenticateToken = (
|
||||
const authenticateToken = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -58,14 +74,15 @@ const authenticateToken = (
|
||||
tokenType: 'accessToken' | 'refreshToken'
|
||||
) => {
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') {
|
||||
if (MODE === ModeType.Desktop) {
|
||||
req.user = {
|
||||
userId: 1234,
|
||||
clientId: 'desktopModeClientId',
|
||||
username: 'desktopModeUsername',
|
||||
displayName: 'desktopModeDisplayName',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
req.accessToken = 'desktopModeAccessToken'
|
||||
return next()
|
||||
@@ -73,12 +90,12 @@ const authenticateToken = (
|
||||
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader?.split(' ')[1]
|
||||
if (!token) return res.sendStatus(401)
|
||||
|
||||
jwt.verify(token, key, async (err: any, data: any) => {
|
||||
if (err) return res.sendStatus(401)
|
||||
try {
|
||||
if (!token) throw 'Unauthorized'
|
||||
|
||||
const data: any = jwt.verify(token, key)
|
||||
|
||||
// verify this valid token's entry in DB
|
||||
const user = await verifyTokenInDB(
|
||||
data?.userId,
|
||||
data?.clientId,
|
||||
@@ -91,8 +108,16 @@ const authenticateToken = (
|
||||
req.user = user
|
||||
if (tokenType === 'accessToken') req.accessToken = token
|
||||
return next()
|
||||
} else return res.sendStatus(401)
|
||||
} else throw 'Unauthorized'
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
})
|
||||
|
||||
throw 'Unauthorized'
|
||||
} catch (error) {
|
||||
if (await isPublicRoute(req)) {
|
||||
req.user = publicUser
|
||||
return next()
|
||||
}
|
||||
|
||||
res.sendStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
49
api/src/middlewares/authorize.ts
Normal file
49
api/src/middlewares/authorize.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import User from '../model/User'
|
||||
import Permission from '../model/Permission'
|
||||
import {
|
||||
PermissionSettingForRoute,
|
||||
PermissionType
|
||||
} from '../controllers/permission'
|
||||
import { getPath, isPublicRoute } from '../utils'
|
||||
|
||||
export const authorize: RequestHandler = async (req, res, next) => {
|
||||
const { user } = req
|
||||
|
||||
if (!user) return res.sendStatus(401)
|
||||
|
||||
// no need to check for permissions when user is admin
|
||||
if (user.isAdmin) return next()
|
||||
|
||||
// no need to check for permissions when route is Public
|
||||
if (await isPublicRoute(req)) return next()
|
||||
|
||||
const dbUser = await User.findOne({ id: user.userId })
|
||||
if (!dbUser) return res.sendStatus(401)
|
||||
|
||||
const path = getPath(req)
|
||||
|
||||
// find permission w.r.t user
|
||||
const permission = await Permission.findOne({
|
||||
path,
|
||||
type: PermissionType.route,
|
||||
user: dbUser._id
|
||||
})
|
||||
|
||||
if (permission) {
|
||||
if (permission.setting === PermissionSettingForRoute.grant) return next()
|
||||
else return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// find permission w.r.t user's groups
|
||||
for (const group of dbUser.groups) {
|
||||
const groupPermission = await Permission.findOne({
|
||||
path,
|
||||
type: PermissionType.route,
|
||||
group
|
||||
})
|
||||
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||
return next()
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
32
api/src/middlewares/csrfProtection.ts
Normal file
32
api/src/middlewares/csrfProtection.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import csrf from 'csrf'
|
||||
|
||||
const csrfTokens = new csrf()
|
||||
const secret = csrfTokens.secretSync()
|
||||
|
||||
export const generateCSRFToken = () => csrfTokens.create(secret)
|
||||
|
||||
export const csrfProtection: RequestHandler = (req, res, next) => {
|
||||
if (req.method === 'GET') return next()
|
||||
|
||||
// Reads the token from the following locations, in order:
|
||||
// req.body.csrf_token - typically generated by the body-parser module.
|
||||
// req.query.csrf_token - a built-in from Express.js to read from the URL query string.
|
||||
// req.headers['csrf-token'] - the CSRF-Token HTTP request header.
|
||||
// req.headers['xsrf-token'] - the XSRF-Token HTTP request header.
|
||||
// req.headers['x-csrf-token'] - the X-CSRF-Token HTTP request header.
|
||||
// req.headers['x-xsrf-token'] - the X-XSRF-Token HTTP request header.
|
||||
|
||||
const token =
|
||||
req.body?.csrf_token ||
|
||||
req.query?.csrf_token ||
|
||||
req.headers['csrf-token'] ||
|
||||
req.headers['xsrf-token'] ||
|
||||
req.headers['x-csrf-token'] ||
|
||||
req.headers['x-xsrf-token']
|
||||
|
||||
if (!csrfTokens.verify(secret, token)) {
|
||||
return res.status(400).send('Invalid CSRF token!')
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -33,5 +33,6 @@ export const desktopUser: RequestUser = {
|
||||
username: userInfo().username,
|
||||
displayName: userInfo().username,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './authenticateToken'
|
||||
export * from './authorize'
|
||||
export * from './csrfProtection'
|
||||
export * from './desktop'
|
||||
export * from './verifyAdmin'
|
||||
export * from './verifyAdminIfNeeded'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import { ModeType } from '../utils'
|
||||
|
||||
export const verifyAdmin: RequestHandler = (req, res, next) => {
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') return next()
|
||||
if (MODE === ModeType.Desktop) return next()
|
||||
|
||||
const { user } = req
|
||||
if (!user?.isAdmin) return res.status(401).send('Admin account required')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
export const NUMBER_OF_SECONDS_IN_A_DAY = 86400
|
||||
export interface ClientPayload {
|
||||
/**
|
||||
* Client ID
|
||||
@@ -11,6 +12,16 @@ export interface ClientPayload {
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
clientSecret: string
|
||||
/**
|
||||
* Number of seconds after which access token will expire. Default is 86400 (1 day)
|
||||
* @example 86400
|
||||
*/
|
||||
accessTokenExpiration?: number
|
||||
/**
|
||||
* Number of seconds after which access token will expire. Default is 2592000 (30 days)
|
||||
* @example 2592000
|
||||
*/
|
||||
refreshTokenExpiration?: number
|
||||
}
|
||||
|
||||
const ClientSchema = new Schema<ClientPayload>({
|
||||
@@ -21,6 +32,14 @@ const ClientSchema = new Schema<ClientPayload>({
|
||||
clientSecret: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
accessTokenExpiration: {
|
||||
type: Number,
|
||||
default: NUMBER_OF_SECONDS_IN_A_DAY
|
||||
},
|
||||
refreshTokenExpiration: {
|
||||
type: Number,
|
||||
default: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
45
api/src/model/Configuration.ts
Normal file
45
api/src/model/Configuration.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
export interface ConfigurationType {
|
||||
/**
|
||||
* SecretOrPrivateKey to sign Access Token
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
ACCESS_TOKEN_SECRET: string
|
||||
/**
|
||||
* SecretOrPrivateKey to sign Refresh Token
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
REFRESH_TOKEN_SECRET: string
|
||||
/**
|
||||
* SecretOrPrivateKey to sign Auth Code
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
AUTH_CODE_SECRET: string
|
||||
/**
|
||||
* Secret used to sign the session cookie
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
SESSION_SECRET: string
|
||||
}
|
||||
|
||||
const ConfigurationSchema = new Schema<ConfigurationType>({
|
||||
ACCESS_TOKEN_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
REFRESH_TOKEN_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
AUTH_CODE_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
SESSION_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
export default mongoose.model('Configuration', ConfigurationSchema)
|
||||
@@ -1,7 +1,11 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
import User from './User'
|
||||
import { GroupDetailsResponse } from '../controllers'
|
||||
import User, { IUser } from './User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
|
||||
export const PUBLIC_GROUP_NAME = 'Public'
|
||||
|
||||
export interface GroupPayload {
|
||||
/**
|
||||
* Name of the group
|
||||
@@ -24,11 +28,13 @@ interface IGroupDocument extends GroupPayload, Document {
|
||||
groupId: number
|
||||
isActive: boolean
|
||||
users: Schema.Types.ObjectId[]
|
||||
authProvider?: AuthProviderType
|
||||
}
|
||||
|
||||
interface IGroup extends IGroupDocument {
|
||||
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
||||
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
||||
addUser(user: IUser): Promise<GroupDetailsResponse>
|
||||
removeUser(user: IUser): Promise<GroupDetailsResponse>
|
||||
hasUser(user: IUser): boolean
|
||||
}
|
||||
interface IGroupModel extends Model<IGroup> {}
|
||||
|
||||
@@ -42,6 +48,10 @@ const groupSchema = new Schema<IGroupDocument>({
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
@@ -70,28 +80,31 @@ groupSchema.pre('remove', async function () {
|
||||
})
|
||||
|
||||
// Instance Methods
|
||||
groupSchema.method(
|
||||
'addUser',
|
||||
async function (userObjectId: Schema.Types.ObjectId) {
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex === -1) {
|
||||
this.users.push(userObjectId)
|
||||
}
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
groupSchema.method('addUser', async function (user: IUser) {
|
||||
const userObjectId = user._id
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex === -1) {
|
||||
this.users.push(userObjectId)
|
||||
user.addGroup(this._id)
|
||||
}
|
||||
)
|
||||
groupSchema.method(
|
||||
'removeUser',
|
||||
async function (userObjectId: Schema.Types.ObjectId) {
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex > -1) {
|
||||
this.users.splice(userIdIndex, 1)
|
||||
}
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
})
|
||||
groupSchema.method('removeUser', async function (user: IUser) {
|
||||
const userObjectId = user._id
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex > -1) {
|
||||
this.users.splice(userIdIndex, 1)
|
||||
user.removeGroup(this._id)
|
||||
}
|
||||
)
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
})
|
||||
groupSchema.method('hasUser', function (user: IUser) {
|
||||
const userObjectId = user._id
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
return userIdIndex > -1
|
||||
})
|
||||
|
||||
export const Group: IGroupModel = model<IGroup, IGroupModel>(
|
||||
'Group',
|
||||
|
||||
73
api/src/model/Permission.ts
Normal file
73
api/src/model/Permission.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { PermissionDetailsResponse } from '../controllers'
|
||||
|
||||
interface GetPermissionBy {
|
||||
user?: Schema.Types.ObjectId
|
||||
group?: Schema.Types.ObjectId
|
||||
}
|
||||
|
||||
interface IPermissionDocument extends Document {
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
permissionId: number
|
||||
user: Schema.Types.ObjectId
|
||||
group: Schema.Types.ObjectId
|
||||
}
|
||||
|
||||
interface IPermission extends IPermissionDocument {}
|
||||
|
||||
interface IPermissionModel extends Model<IPermission> {
|
||||
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
setting: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
})
|
||||
|
||||
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
|
||||
|
||||
// Static Methods
|
||||
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||
PermissionDetailsResponse[]
|
||||
> {
|
||||
return (await this.find(getBy)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id',
|
||||
populate: {
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
options: { limit: 15 }
|
||||
}
|
||||
})) as unknown as PermissionDetailsResponse[]
|
||||
})
|
||||
|
||||
export const Permission: IPermissionModel = model<
|
||||
IPermission,
|
||||
IPermissionModel
|
||||
>('Permission', permissionSchema)
|
||||
|
||||
export default Permission
|
||||
@@ -1,6 +1,7 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthProviderType } from '../utils'
|
||||
|
||||
export interface UserPayload {
|
||||
/**
|
||||
@@ -35,15 +36,18 @@ export interface UserPayload {
|
||||
}
|
||||
|
||||
interface IUserDocument extends UserPayload, Document {
|
||||
_id: Schema.Types.ObjectId
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
autoExec: string
|
||||
groups: Schema.Types.ObjectId[]
|
||||
tokens: [{ [key: string]: string }]
|
||||
authProvider?: AuthProviderType
|
||||
}
|
||||
|
||||
interface IUser extends IUserDocument {
|
||||
export interface IUser extends IUserDocument {
|
||||
comparePassword(password: string): boolean
|
||||
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
|
||||
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
|
||||
@@ -66,6 +70,10 @@ const userSchema = new Schema<IUserDocument>({
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -74,6 +82,10 @@ const userSchema = new Schema<IUserDocument>({
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
needsToUpdatePassword: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoExec: {
|
||||
type: String
|
||||
},
|
||||
|
||||
@@ -7,12 +7,28 @@ import {
|
||||
authenticateRefreshToken
|
||||
} from '../../middlewares'
|
||||
|
||||
import { authorizeValidation, tokenValidation } from '../../utils'
|
||||
import { tokenValidation, updatePasswordValidation } from '../../utils'
|
||||
import { InfoJWT } from '../../types'
|
||||
|
||||
const authRouter = express.Router()
|
||||
const controller = new AuthController()
|
||||
|
||||
authRouter.patch(
|
||||
'/updatePassword',
|
||||
authenticateAccessToken,
|
||||
async (req, res) => {
|
||||
const { error, value: body } = updatePasswordValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
await controller.updatePassword(req, body)
|
||||
res.sendStatus(204)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
authRouter.post('/token', async (req, res) => {
|
||||
const { error, value: body } = tokenValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
25
api/src/routes/api/authConfig.ts
Normal file
25
api/src/routes/api/authConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import express from 'express'
|
||||
import { AuthConfigController } from '../../controllers'
|
||||
const authConfigRouter = express.Router()
|
||||
|
||||
authConfigRouter.get('/', async (req, res) => {
|
||||
const controller = new AuthConfigController()
|
||||
try {
|
||||
const response = controller.getDetail()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(500).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
authConfigRouter.post('/synchroniseWithLDAP', async (req, res) => {
|
||||
const controller = new AuthConfigController()
|
||||
try {
|
||||
const response = await controller.synchroniseWithLDAP()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(500).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default authConfigRouter
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
extractName,
|
||||
fileBodyValidation,
|
||||
fileParamValidation,
|
||||
folderBodyValidation,
|
||||
folderParamValidation,
|
||||
isZipFile
|
||||
isZipFile,
|
||||
renameBodyValidation
|
||||
} from '../../utils'
|
||||
|
||||
const controller = new DriveController()
|
||||
@@ -119,7 +121,11 @@ driveRouter.get('/file', async (req, res) => {
|
||||
try {
|
||||
await controller.getFile(req, query._filePath)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -132,7 +138,11 @@ driveRouter.get('/folder', async (req, res) => {
|
||||
const response = await controller.getFolder(query._folderPath)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,7 +155,28 @@ driveRouter.delete('/file', async (req, res) => {
|
||||
const response = await controller.deleteFile(query._filePath)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.delete('/folder', async (req, res) => {
|
||||
const { error: errQ, value: query } = folderParamValidation(req.query, true)
|
||||
|
||||
if (errQ) return res.status(400).send(errQ.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.deleteFolder(query._folderPath)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -172,11 +203,33 @@ driveRouter.post(
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
await deleteFile(req.file.path)
|
||||
res.status(403).send(err.toString())
|
||||
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
driveRouter.post('/folder', async (req, res) => {
|
||||
const { error, value: body } = folderBodyValidation(req.body)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.addFolder(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.patch(
|
||||
'/file',
|
||||
(...arg) => multerSingle('file', arg),
|
||||
@@ -200,11 +253,33 @@ driveRouter.patch(
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
await deleteFile(req.file.path)
|
||||
res.status(403).send(err.toString())
|
||||
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
driveRouter.post('/rename', async (req, res) => {
|
||||
const { error, value: body } = renameBodyValidation(req.body)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.rename(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.get('/fileTree', async (req, res) => {
|
||||
try {
|
||||
const response = await controller.getFileTree()
|
||||
|
||||
@@ -18,11 +18,7 @@ groupRouter.post(
|
||||
const response = await controller.createGroup(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -33,11 +29,7 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
|
||||
const response = await controller.getAllGroups()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,11 +41,7 @@ groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
|
||||
const response = await controller.getGroup(parseInt(groupId))
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,11 +59,7 @@ groupRouter.get(
|
||||
const response = await controller.getGroupByGroupName(name)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -95,11 +79,7 @@ groupRouter.post(
|
||||
)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -119,11 +99,7 @@ groupRouter.delete(
|
||||
)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -140,11 +116,7 @@ groupRouter.delete(
|
||||
await controller.deleteGroup(parseInt(groupId))
|
||||
res.status(200).send('Group Deleted!')
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err.message)
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,6 +17,8 @@ import groupRouter from './group'
|
||||
import clientRouter from './client'
|
||||
import authRouter from './auth'
|
||||
import sessionRouter from './session'
|
||||
import permissionRouter from './permission'
|
||||
import authConfigRouter from './authConfig'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -35,6 +37,20 @@ router.use('/group', desktopRestrict, groupRouter)
|
||||
router.use('/stp', authenticateAccessToken, stpRouter)
|
||||
router.use('/code', authenticateAccessToken, codeRouter)
|
||||
router.use('/user', desktopRestrict, userRouter)
|
||||
router.use(
|
||||
'/permission',
|
||||
desktopRestrict,
|
||||
authenticateAccessToken,
|
||||
permissionRouter
|
||||
)
|
||||
|
||||
router.use(
|
||||
'/authConfig',
|
||||
desktopRestrict,
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
authConfigRouter
|
||||
)
|
||||
|
||||
router.use(
|
||||
'/',
|
||||
|
||||
@@ -13,4 +13,14 @@ infoRouter.get('/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
infoRouter.get('/authorizedRoutes', async (req, res) => {
|
||||
const controller = new InfoController()
|
||||
try {
|
||||
const response = controller.authorizedRoutes()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default infoRouter
|
||||
|
||||
69
api/src/routes/api/permission.ts
Normal file
69
api/src/routes/api/permission.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import express from 'express'
|
||||
import { PermissionController } from '../../controllers/'
|
||||
import { verifyAdmin } from '../../middlewares'
|
||||
import {
|
||||
registerPermissionValidation,
|
||||
updatePermissionValidation
|
||||
} from '../../utils'
|
||||
|
||||
const permissionRouter = express.Router()
|
||||
const controller = new PermissionController()
|
||||
|
||||
permissionRouter.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await controller.getAllPermissions(req)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.post('/', verifyAdmin, async (req, res) => {
|
||||
const { error, value: body } = registerPermissionValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.createPermission(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
|
||||
const { error, value: body } = updatePermissionValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.updatePermission(permissionId, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.delete(
|
||||
'/:permissionId',
|
||||
verifyAdmin,
|
||||
async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
|
||||
try {
|
||||
await controller.deletePermission(permissionId)
|
||||
res.status(200).send('Permission Deleted!')
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
export default permissionRouter
|
||||
@@ -29,7 +29,13 @@ jest
|
||||
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
|
||||
|
||||
import appPromise from '../../../app'
|
||||
import { UserController } from '../../../controllers/'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import { getTreeExample } from '../../../controllers/internal'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
|
||||
const { getFilesFolder } = fileUtilModules
|
||||
@@ -43,11 +49,18 @@ const user = {
|
||||
isActive: true
|
||||
}
|
||||
|
||||
const permission = {
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
}
|
||||
|
||||
describe('drive', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
const controller = new UserController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
let accessToken: string
|
||||
|
||||
@@ -58,11 +71,32 @@ describe('drive', () => {
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: dbUser.id
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy/upload',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/file',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/folder',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/rename',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -517,29 +551,29 @@ describe('drive', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if folder is not present', async () => {
|
||||
it('should respond with Not Found if folder is not present', async () => {
|
||||
const res = await request(app)
|
||||
.get(getFolderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _folderPath: `/my/path/code-${generateTimestamp()}` })
|
||||
.expect(403)
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual(`Error: Folder doesn't exist.`)
|
||||
expect(res.text).toEqual(`Folder doesn't exist.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if folderPath outside Drive', async () => {
|
||||
it('should respond with Bad Request if folderPath outside Drive', async () => {
|
||||
const res = await request(app)
|
||||
.get(getFolderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _folderPath: '/../path/code.sas' })
|
||||
.expect(403)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Error: Cannot get folder outside drive.')
|
||||
expect(res.text).toEqual(`Can't get folder outside drive.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if folderPath is of a file', async () => {
|
||||
it('should respond with Bad Request if folderPath is of a file', async () => {
|
||||
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
|
||||
const filePath = '/my/path/code.sas'
|
||||
|
||||
@@ -550,12 +584,96 @@ describe('drive', () => {
|
||||
.get(getFolderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _folderPath: filePath })
|
||||
.expect(403)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Error: Not a Folder.')
|
||||
expect(res.text).toEqual('Not a Folder.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('post', () => {
|
||||
const folderApi = '/SASjsApi/drive/folder'
|
||||
const pathToDrive = fileUtilModules.getFilesFolder()
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteFolder(path.join(pathToDrive, 'post'))
|
||||
})
|
||||
|
||||
it('should create a folder on drive', async () => {
|
||||
const res = await request(app)
|
||||
.post(folderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ folderPath: '/post/folder' })
|
||||
|
||||
expect(res.statusCode).toEqual(200)
|
||||
expect(res.body).toEqual({
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with Conflict if the folder already exists', async () => {
|
||||
await createFolder(path.join(pathToDrive, '/post/folder'))
|
||||
|
||||
const res = await request(app)
|
||||
.post(folderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ folderPath: '/post/folder' })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual(`Folder already exists.`)
|
||||
|
||||
expect(res.statusCode).toEqual(409)
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if the folderPath is outside drive', async () => {
|
||||
const res = await request(app)
|
||||
.post(folderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ folderPath: '../sample' })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`Can't put folder outside drive.`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
const folderApi = '/SASjsApi/drive/folder'
|
||||
const pathToDrive = fileUtilModules.getFilesFolder()
|
||||
|
||||
it('should delete a folder on drive', async () => {
|
||||
await createFolder(path.join(pathToDrive, 'delete'))
|
||||
|
||||
const res = await request(app)
|
||||
.delete(folderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _folderPath: 'delete' })
|
||||
|
||||
expect(res.statusCode).toEqual(200)
|
||||
expect(res.body).toEqual({
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with Not Found if the folder does not exists', async () => {
|
||||
const res = await request(app)
|
||||
.delete(folderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _folderPath: 'notExists' })
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual(`Folder doesn't exist.`)
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if the folderPath is outside drive', async () => {
|
||||
const res = await request(app)
|
||||
.delete(folderApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _folderPath: '../outsideDrive' })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`Can't delete folder outside drive.`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('file', () => {
|
||||
@@ -601,7 +719,7 @@ describe('drive', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if file is already present', async () => {
|
||||
it('should respond with Conflict if file is already present', async () => {
|
||||
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
|
||||
const pathToUpload = `/my/path/code-${generateTimestamp()}.sas`
|
||||
|
||||
@@ -616,13 +734,13 @@ describe('drive', () => {
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.field('filePath', pathToUpload)
|
||||
.attach('file', fileToAttachPath)
|
||||
.expect(403)
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual('Error: File already exists.')
|
||||
expect(res.text).toEqual('File already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if filePath outside Drive', async () => {
|
||||
it('should respond with Bad Request if filePath outside Drive', async () => {
|
||||
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
|
||||
const pathToUpload = '/../path/code.sas'
|
||||
|
||||
@@ -631,9 +749,9 @@ describe('drive', () => {
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.field('filePath', pathToUpload)
|
||||
.attach('file', fileToAttachPath)
|
||||
.expect(403)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Error: Cannot put file outside drive.')
|
||||
expect(res.text).toEqual(`Can't put file outside drive.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -768,19 +886,19 @@ describe('drive', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if file is not present', async () => {
|
||||
it('should respond with Not Found if file is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/drive/file')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.field('filePath', `/my/path/code-3.sas`)
|
||||
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
|
||||
.expect(403)
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual(`Error: File doesn't exist.`)
|
||||
expect(res.text).toEqual(`File doesn't exist.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if filePath outside Drive', async () => {
|
||||
it('should respond with Bad Request if filePath outside Drive', async () => {
|
||||
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
|
||||
const pathToUpload = '/../path/code.sas'
|
||||
|
||||
@@ -789,9 +907,9 @@ describe('drive', () => {
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.field('filePath', pathToUpload)
|
||||
.attach('file', fileToAttachPath)
|
||||
.expect(403)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Error: Cannot modify file outside drive.')
|
||||
expect(res.text).toEqual(`Can't modify file outside drive.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -896,25 +1014,25 @@ describe('drive', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if file is not present', async () => {
|
||||
it('should respond with Not Found if file is not present', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/drive/file')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _filePath: `/my/path/code-4.sas` })
|
||||
.expect(403)
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual(`Error: File doesn't exist.`)
|
||||
expect(res.text).toEqual(`File doesn't exist.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if filePath outside Drive', async () => {
|
||||
it('should respond with Bad Request if filePath outside Drive', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/drive/file')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.query({ _filePath: '/../path/code.sas' })
|
||||
.expect(403)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Error: Cannot get file outside drive.')
|
||||
expect(res.text).toEqual(`Can't get file outside drive.`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -940,8 +1058,150 @@ describe('drive', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
const renameApi = '/SASjsApi/drive/rename'
|
||||
const pathToDrive = fileUtilModules.getFilesFolder()
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteFolder(path.join(pathToDrive, 'rename'))
|
||||
})
|
||||
|
||||
it('should rename a folder', async () => {
|
||||
await createFolder(path.join(pathToDrive, 'rename', 'folder'))
|
||||
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: '/rename/folder', newPath: '/rename/renamed' })
|
||||
|
||||
expect(res.statusCode).toEqual(200)
|
||||
expect(res.body).toEqual({
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('should rename a file', async () => {
|
||||
await createFile(
|
||||
path.join(pathToDrive, 'rename', 'file.txt'),
|
||||
'some file content'
|
||||
)
|
||||
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({
|
||||
oldPath: '/rename/file.txt',
|
||||
newPath: '/rename/renamed.txt'
|
||||
})
|
||||
|
||||
expect(res.statusCode).toEqual(200)
|
||||
expect(res.body).toEqual({
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if the oldPath is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ newPath: 'newPath' })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`\"oldPath\" is required`)
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if the newPath is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: 'oldPath' })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`\"newPath\" is required`)
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if the oldPath is outside drive', async () => {
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: '../outside', newPath: 'renamed' })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`Old path can't be outside of drive.`)
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if the newPath is outside drive', async () => {
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: 'older', newPath: '../outside' })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`New path can't be outside of drive.`)
|
||||
})
|
||||
|
||||
it('should respond with Not Found if the folder does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: '/rename/not exists', newPath: '/rename/renamed' })
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('No file/folder found for provided path.')
|
||||
})
|
||||
|
||||
it('should respond with Conflict if the folder already exists', async () => {
|
||||
await createFolder(path.join(pathToDrive, 'rename', 'folder'))
|
||||
await createFolder(path.join(pathToDrive, 'rename', 'exists'))
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: '/rename/folder', newPath: '/rename/exists' })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual('Folder with new name already exists.')
|
||||
})
|
||||
|
||||
it('should respond with Not Found if the file does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: '/rename/file.txt', newPath: '/rename/renamed.txt' })
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('No file/folder found for provided path.')
|
||||
})
|
||||
|
||||
it('should respond with Conflict if the file already exists', async () => {
|
||||
await createFile(
|
||||
path.join(pathToDrive, 'rename', 'file.txt'),
|
||||
'some file content'
|
||||
)
|
||||
await createFile(
|
||||
path.join(pathToDrive, 'rename', 'exists.txt'),
|
||||
'some existing content'
|
||||
)
|
||||
const res = await request(app)
|
||||
.post(renameApi)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ oldPath: '/rename/file.txt', newPath: '/rename/exists.txt' })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual('File with new name already exists.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const getExampleService = (): ServiceMember =>
|
||||
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
|
||||
.members[0] as ServiceMember
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
|
||||
return adminAccessToken
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController, GroupController } from '../../../controllers/'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
import {
|
||||
generateAccessToken,
|
||||
saveTokensInDB,
|
||||
AuthProviderType
|
||||
} from '../../../utils'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group'
|
||||
import User from '../../../model/User'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
@@ -27,6 +33,12 @@ const group = {
|
||||
description: 'DC group for testing purposes.'
|
||||
}
|
||||
|
||||
const PUBLIC_GROUP = {
|
||||
name: PUBLIC_GROUP_NAME,
|
||||
description:
|
||||
'A special group that can be used to bypass authentication for particular routes.'
|
||||
}
|
||||
|
||||
const userController = new UserController()
|
||||
const groupController = new GroupController()
|
||||
|
||||
@@ -535,6 +547,64 @@ describe('group', () => {
|
||||
expect(res.text).toEqual('User not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request when adding user to Public group', async () => {
|
||||
const dbGroup = await groupController.createGroup(PUBLIC_GROUP)
|
||||
const dbUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'publicUser'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
`Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should respond with Method Not Allowed if group is created by an external authProvider', async () => {
|
||||
const dbGroup = await Group.create({
|
||||
...group,
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
const dbUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'ldapGroupUser'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
`Can't add/remove user to group created by external auth provider.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should respond with Method Not Allowed if user is created by an external authProvider', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const dbUser = await User.create({
|
||||
...user,
|
||||
username: 'ldapUser',
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
`Can't add/remove user to group created by external auth provider.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RemoveUser', () => {
|
||||
@@ -586,6 +656,46 @@ describe('group', () => {
|
||||
expect(res.body.groups).toEqual([])
|
||||
})
|
||||
|
||||
it('should respond with Method Not Allowed if group is created by an external authProvider', async () => {
|
||||
const dbGroup = await Group.create({
|
||||
...group,
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
const dbUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'removeLdapGroupUser'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
`Can't add/remove user to group created by external auth provider.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should respond with Method Not Allowed if user is created by an external authProvider', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const dbUser = await User.create({
|
||||
...user,
|
||||
username: 'removeLdapUser',
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
`Can't add/remove user to group created by external auth provider.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/123/123')
|
||||
|
||||
596
api/src/routes/api/spec/permission.spec.ts
Normal file
596
api/src/routes/api/spec/permission.spec.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import {
|
||||
DriveController,
|
||||
UserController,
|
||||
GroupController,
|
||||
PermissionController,
|
||||
PrincipalType,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
UserDetailsResponse,
|
||||
PermissionDetailsResponse
|
||||
} from '../../../controllers'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
|
||||
const deployPayload = {
|
||||
appLoc: 'string',
|
||||
streamWebFolder: 'string',
|
||||
fileTree: {
|
||||
members: [
|
||||
{
|
||||
name: 'string',
|
||||
type: 'folder',
|
||||
members: [
|
||||
'string',
|
||||
{
|
||||
name: 'string',
|
||||
type: 'service',
|
||||
code: 'string'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
displayName: 'Test Admin',
|
||||
username: 'testAdminUsername',
|
||||
password: '12345678',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
const user = {
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
const permission = {
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: PermissionType.route,
|
||||
setting: PermissionSettingForRoute.grant,
|
||||
principalType: PrincipalType.user
|
||||
}
|
||||
|
||||
const group = {
|
||||
name: 'DCGroup1',
|
||||
description: 'DC group for testing purposes.'
|
||||
}
|
||||
|
||||
const userController = new UserController()
|
||||
const groupController = new GroupController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
describe('permission', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let adminAccessToken: string
|
||||
let dbUser: UserDetailsResponse
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appPromise
|
||||
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
dbUser = await userController.createUser(user)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllPermissions()
|
||||
})
|
||||
|
||||
it('should respond with new permission when principalType is user', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.path).toEqual(permission.path)
|
||||
expect(res.body.type).toEqual(permission.type)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
expect(res.body.user).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should respond with new permission when principalType is group', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: dbGroup.groupId
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.path).toEqual(permission.path)
|
||||
expect(res.body.type).toEqual(permission.type)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
expect(res.body.group).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.send(permission)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(permission)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if path is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
path: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"path" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if path is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
path: '/some/random/api/endpoint'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if type is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
type: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"type" must be [Route]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if type is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
type: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"type" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
setting: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"setting" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
setting: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalType is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"principalType" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principal type is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"principalType" must be one of [user, group]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"principalId" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalId is not a number', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 'someCharacters'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"principalId" must be a number')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if adding permission for admin user', async () => {
|
||||
const adminUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'adminUser',
|
||||
isAdmin: true
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: adminUser.id
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Can not add permission for admin user.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Not Found (404) if user is not found', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 123
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('User not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Not Found (404) if group is not found', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: 123
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Group not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Conflict (409) if permission already exists', async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
'Permission already exists with provided Path, Type and User.'
|
||||
)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
let dbPermission: PermissionDetailsResponse | undefined
|
||||
beforeAll(async () => {
|
||||
dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllPermissions()
|
||||
})
|
||||
|
||||
it('should respond with updated permission', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ setting: PermissionSettingForRoute.deny })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.setting).toEqual('Deny')
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'update' + user.username
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is missing', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"setting" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is invalid', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/permission/123')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: PermissionSettingForRoute.deny
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Permission not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete permission', async () => {
|
||||
const dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
})
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.text).toEqual('Permission Deleted!')
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exists', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/permission/123')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Permission not found.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
beforeAll(async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/test-1',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/test-2',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
it('should give a list of all permissions when user is admin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/permission/')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveLength(2)
|
||||
})
|
||||
|
||||
it(`should give a list of user's own permissions when user is not admin`, async () => {
|
||||
const nonAdminUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'get' + user.username
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(nonAdminUser.id)
|
||||
await permissionController.createPermission({
|
||||
path: '/test-1',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: nonAdminUser.id,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
|
||||
const permissionCount = 1
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/permission/')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveLength(permissionCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify', () => {
|
||||
beforeAll(async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(DriveController.prototype, 'deploy')
|
||||
.mockImplementation((deployPayload) =>
|
||||
Promise.resolve({
|
||||
status: 'success',
|
||||
message: 'Files deployed successfully to @sasjs/server.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should create files in SASJS drive', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(deployPayload)
|
||||
.expect(200)
|
||||
})
|
||||
|
||||
it('should respond unauthorized', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy/upload')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const generateSaveTokenAndCreateUser = async (
|
||||
someUser?: any
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
|
||||
return adminAccessToken
|
||||
}
|
||||
|
||||
const deleteAllPermissions = async () => {
|
||||
const { collections } = mongoose.connection
|
||||
const collection = collections['permissions']
|
||||
await collection.deleteMany({})
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController } from '../../../controllers/'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
generateAccessToken,
|
||||
saveTokensInDB,
|
||||
@@ -15,8 +21,8 @@ import {
|
||||
} from '../../../utils'
|
||||
import { createFile, generateTimestamp, deleteFolder } from '@sasjs/utils'
|
||||
import {
|
||||
SASSessionController,
|
||||
JSSessionController
|
||||
SessionController,
|
||||
SASSessionController
|
||||
} from '../../../controllers/internal'
|
||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||
import { Session } from '../../../types'
|
||||
@@ -33,20 +39,33 @@ const user = {
|
||||
|
||||
const sampleSasProgram = '%put hello world!;'
|
||||
const sampleJsProgram = `console.log('hello world!/')`
|
||||
const samplePyProgram = `print('hello world!/')`
|
||||
|
||||
const filesFolder = getFilesFolder()
|
||||
const testFilesFolder = `test-stp-${generateTimestamp()}`
|
||||
|
||||
let app: Express
|
||||
let accessToken: string
|
||||
|
||||
describe('stp', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let accessToken: string
|
||||
const userController = new UserController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appPromise
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
accessToken = await generateSaveTokenAndCreateUser(user)
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
path: '/SASjsApi/stp/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -56,8 +75,6 @@ describe('stp', () => {
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
const testFilesFolder = `test-stp-${generateTimestamp()}`
|
||||
|
||||
describe('get', () => {
|
||||
describe('with runtime js', () => {
|
||||
const testFilesFolder = `test-stp-${generateTimestamp()}`
|
||||
@@ -77,41 +94,45 @@ describe('stp', () => {
|
||||
})
|
||||
|
||||
it('should execute js program when both js and sas program are present', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.JS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.JS, RunTimeType.SAS],
|
||||
200,
|
||||
RunTimeType.JS
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when js program is not present but sas program exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
describe('with runtime py', () => {
|
||||
const testFilesFolder = `test-stp-${generateTimestamp()}`
|
||||
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.PY]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute python program when python, js and sas programs are present', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.PY, RunTimeType.SAS, RunTimeType.JS],
|
||||
200,
|
||||
RunTimeType.PY
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when py program is not present but js or sas program exists', async () => {
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,41 +152,11 @@ describe('stp', () => {
|
||||
})
|
||||
|
||||
it('should execute sas program when both sas and js programs are present', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.SAS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
await makeRequestAndAssert([RunTimeType.SAS], 200, RunTimeType.SAS)
|
||||
})
|
||||
|
||||
it('should throw error when sas program do not exit but js exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,63 +176,51 @@ describe('stp', () => {
|
||||
})
|
||||
|
||||
it('should execute js program when both js and sas program are present', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.JS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.SAS, RunTimeType.JS],
|
||||
200,
|
||||
RunTimeType.JS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute sas program when js program is not present but sas program exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.SAS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
await makeRequestAndAssert([RunTimeType.SAS], 200, RunTimeType.SAS)
|
||||
})
|
||||
|
||||
it('should throw error when both sas and js programs do not exist', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
describe('with runtime py and sas', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.PY, RunTimeType.SAS]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute python program when both python and sas program are present', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.PY, RunTimeType.SAS],
|
||||
200,
|
||||
RunTimeType.PY
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute sas program when python program is not present but sas program exists', async () => {
|
||||
await makeRequestAndAssert([RunTimeType.SAS], 200, RunTimeType.SAS)
|
||||
})
|
||||
|
||||
it('should throw error when both sas and js programs do not exist', async () => {
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -261,76 +240,220 @@ describe('stp', () => {
|
||||
})
|
||||
|
||||
it('should execute sas program when both sas and js programs exist', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.SAS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.SAS, RunTimeType.JS],
|
||||
200,
|
||||
RunTimeType.SAS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute js program when sas program is not present but js program exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.JS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
await makeRequestAndAssert([RunTimeType.JS], 200, RunTimeType.JS)
|
||||
})
|
||||
|
||||
it('should throw error when both sas and js programs do not exist', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
describe('with runtime sas and py', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.SAS, RunTimeType.PY]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute sas program when both sas and python programs exist', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.SAS, RunTimeType.PY],
|
||||
200,
|
||||
RunTimeType.SAS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute python program when sas program is not present but python program exists', async () => {
|
||||
await makeRequestAndAssert([RunTimeType.PY], 200, RunTimeType.PY)
|
||||
})
|
||||
|
||||
it('should throw error when both sas and python programs do not exist', async () => {
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with runtime sas, js and py', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.SAS, RunTimeType.JS, RunTimeType.PY]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute sas program when it exists, no matter js and python programs exist or not', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.SAS, RunTimeType.PY, RunTimeType.JS],
|
||||
200,
|
||||
RunTimeType.SAS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute js program when sas program is absent but js and python programs are present', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.JS, RunTimeType.PY],
|
||||
200,
|
||||
RunTimeType.JS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute python program when both sas and js programs are not present', async () => {
|
||||
await makeRequestAndAssert([RunTimeType.PY], 200, RunTimeType.PY)
|
||||
})
|
||||
|
||||
it('should throw error when no program exists', async () => {
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with runtime js, sas and py', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.JS, RunTimeType.SAS, RunTimeType.PY]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute js program when it exists, no matter sas and python programs exist or not', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.JS, RunTimeType.SAS, RunTimeType.PY],
|
||||
200,
|
||||
RunTimeType.JS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute sas program when js program is absent but sas and python programs are present', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.SAS, RunTimeType.PY],
|
||||
200,
|
||||
RunTimeType.SAS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute python program when both sas and js programs are not present', async () => {
|
||||
await makeRequestAndAssert([RunTimeType.PY], 200, RunTimeType.PY)
|
||||
})
|
||||
|
||||
it('should throw error when no program exists', async () => {
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with runtime py, sas and js', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.PY, RunTimeType.SAS, RunTimeType.JS]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute python program when it exists, no matter sas and js programs exist or not', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.PY, RunTimeType.SAS, RunTimeType.JS],
|
||||
200,
|
||||
RunTimeType.PY
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute sas program when python program is absent but sas and js programs are present', async () => {
|
||||
await makeRequestAndAssert(
|
||||
[RunTimeType.SAS, RunTimeType.JS],
|
||||
200,
|
||||
RunTimeType.SAS
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute js program when both sas and python programs are not present', async () => {
|
||||
await makeRequestAndAssert([RunTimeType.JS], 200, RunTimeType.JS)
|
||||
})
|
||||
|
||||
it('should throw error when no program exists', async () => {
|
||||
await makeRequestAndAssert([], 400)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const generateSaveTokenAndCreateUser = async (
|
||||
someUser: any
|
||||
): Promise<string> => {
|
||||
const userController = new UserController()
|
||||
const dbUser = await userController.createUser(someUser)
|
||||
const makeRequestAndAssert = async (
|
||||
programTypes: RunTimeType[],
|
||||
expectedStatusCode: number,
|
||||
expectedRuntime?: RunTimeType
|
||||
) => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
for (const programType of programTypes) {
|
||||
if (programType === RunTimeType.JS)
|
||||
await createFile(
|
||||
path.join(filesFolder, `${programPath}.js`),
|
||||
sampleJsProgram
|
||||
)
|
||||
else if (programType === RunTimeType.PY)
|
||||
await createFile(
|
||||
path.join(filesFolder, `${programPath}.py`),
|
||||
samplePyProgram
|
||||
)
|
||||
else if (programType === RunTimeType.SAS)
|
||||
await createFile(
|
||||
path.join(filesFolder, `${programPath}.sas`),
|
||||
sampleSasProgram
|
||||
)
|
||||
}
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(expectedStatusCode)
|
||||
|
||||
if (expectedRuntime)
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expectedRuntime,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
@@ -348,7 +471,7 @@ const setupMocks = async () => {
|
||||
.mockImplementation(mockedGetSession)
|
||||
|
||||
jest
|
||||
.spyOn(JSSessionController.prototype, 'getSession')
|
||||
.spyOn(SASSessionController.prototype, 'getSession')
|
||||
.mockImplementation(mockedGetSession)
|
||||
|
||||
jest
|
||||
|
||||
@@ -4,7 +4,12 @@ import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController, GroupController } from '../../../controllers/'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
import {
|
||||
generateAccessToken,
|
||||
saveTokensInDB,
|
||||
AuthProviderType
|
||||
} from '../../../utils'
|
||||
import User from '../../../model/User'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
@@ -110,16 +115,16 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is already present', async () => {
|
||||
it('should respond with Conflict if username is already present', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(403)
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual('Error: Username already exists.')
|
||||
expect(res.text).toEqual('Username already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -226,6 +231,36 @@ describe('user', () => {
|
||||
.expect(400)
|
||||
})
|
||||
|
||||
it('should respond with Method Not Allowed, when updating username of user created by an external auth provider', async () => {
|
||||
const dbUser = await User.create({
|
||||
...user,
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser!.id)
|
||||
const newUsername = 'newUsername'
|
||||
|
||||
await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser!.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ username: newUsername })
|
||||
.expect(405)
|
||||
})
|
||||
|
||||
it('should respond with Method Not Allowed, when updating displayName of user created by an external auth provider', async () => {
|
||||
const dbUser = await User.create({
|
||||
...user,
|
||||
authProvider: AuthProviderType.LDAP
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser!.id)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser!.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ displayName: newDisplayName })
|
||||
.expect(405)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/user/1234')
|
||||
@@ -254,7 +289,7 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is already present', async () => {
|
||||
it('should respond with Conflict if username is already present', async () => {
|
||||
const dbUser1 = await controller.createUser(user)
|
||||
const dbUser2 = await controller.createUser({
|
||||
...user,
|
||||
@@ -265,9 +300,9 @@ describe('user', () => {
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ username: dbUser2.username })
|
||||
.expect(403)
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual('Error: Username already exists.')
|
||||
expect(res.text).toEqual('Username already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -349,7 +384,7 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is already present', async () => {
|
||||
it('should respond with Conflict if username is already present', async () => {
|
||||
const dbUser1 = await controller.createUser(user)
|
||||
const dbUser2 = await controller.createUser({
|
||||
...user,
|
||||
@@ -360,9 +395,9 @@ describe('user', () => {
|
||||
.patch(`/SASjsApi/user/by/username/${dbUser1.username}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ username: dbUser2.username })
|
||||
.expect(403)
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual('Error: Username already exists.')
|
||||
expect(res.text).toEqual('Username already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -446,7 +481,7 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden when user himself requests and password is incorrect', async () => {
|
||||
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
@@ -454,9 +489,9 @@ describe('user', () => {
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: 'incorrectpassword' })
|
||||
.expect(403)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Error: Invalid password.')
|
||||
expect(res.text).toEqual('Invalid password.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -528,7 +563,7 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden when user himself requests and password is incorrect', async () => {
|
||||
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
@@ -536,9 +571,9 @@ describe('user', () => {
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: 'incorrectpassword' })
|
||||
.expect(403)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Error: Invalid password.')
|
||||
expect(res.text).toEqual('Invalid password.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -652,16 +687,16 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if userId is incorrect', async () => {
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user/1234')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Error: User is not found.')
|
||||
expect(res.text).toEqual('User is not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -731,16 +766,16 @@ describe('user', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is incorrect', async () => {
|
||||
it('should respond with Not Found if username is incorrect', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user/by/username/randomUsername')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Error: User is not found.')
|
||||
expect(res.text).toEqual('User is not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -770,12 +805,14 @@ describe('user', () => {
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -796,12 +833,14 @@ describe('user', () => {
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: 'randomUser',
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
@@ -39,21 +39,19 @@ describe('web', () => {
|
||||
|
||||
describe('home', () => {
|
||||
it('should respond with CSRF Token', async () => {
|
||||
await request(app)
|
||||
.get('/')
|
||||
.expect(
|
||||
'set-cookie',
|
||||
/_csrf=.*; Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=.*; Path=\//
|
||||
)
|
||||
const res = await request(app).get('/').expect(200)
|
||||
|
||||
expect(res.text).toMatch(
|
||||
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/login', () => {
|
||||
let csrfToken: string
|
||||
let cookies: string
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ csrfToken, cookies } = await getCSRF(app))
|
||||
;({ csrfToken } = await getCSRF(app))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -67,7 +65,6 @@ describe('web', () => {
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('Cookie', cookies)
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
@@ -79,18 +76,50 @@ describe('web', () => {
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
needsToUpdatePassword: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is not present', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is invalid', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', 'INVALID_CSRF_TOKEN')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/authorize', () => {
|
||||
let csrfToken: string
|
||||
let cookies: string
|
||||
let authCookies: string
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ csrfToken, cookies } = await getCSRF(app))
|
||||
;({ csrfToken } = await getCSRF(app))
|
||||
|
||||
await userController.createUser(user)
|
||||
|
||||
@@ -99,12 +128,7 @@ describe('web', () => {
|
||||
password: user.password
|
||||
}
|
||||
|
||||
;({ cookies: authCookies } = await performLogin(
|
||||
app,
|
||||
credentials,
|
||||
cookies,
|
||||
csrfToken
|
||||
))
|
||||
;({ authCookies } = await performLogin(app, credentials, csrfToken))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -116,17 +140,28 @@ describe('web', () => {
|
||||
it('should respond with authorization code', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/authorize')
|
||||
.set('Cookie', [authCookies, cookies].join('; '))
|
||||
.set('Cookie', [authCookies].join('; '))
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({ clientId })
|
||||
|
||||
expect(res.body).toHaveProperty('code')
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/authorize')
|
||||
.set('Cookie', [authCookies].join('; '))
|
||||
.send({ clientId })
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if clientId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/authorize')
|
||||
.set('Cookie', [authCookies, cookies].join('; '))
|
||||
.set('Cookie', [authCookies].join('; '))
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({})
|
||||
.expect(400)
|
||||
@@ -138,7 +173,7 @@ describe('web', () => {
|
||||
it('should respond with Forbidden if clientId is incorrect', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/authorize')
|
||||
.set('Cookie', [authCookies, cookies].join('; '))
|
||||
.set('Cookie', [authCookies].join('; '))
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
clientId: 'WrongClientID'
|
||||
@@ -153,30 +188,25 @@ describe('web', () => {
|
||||
|
||||
const getCSRF = async (app: Express) => {
|
||||
// make request to get CSRF
|
||||
const { header } = await request(app).get('/')
|
||||
const cookies = header['set-cookie'].join()
|
||||
const { text } = await request(app).get('/')
|
||||
|
||||
const csrfToken = extractCSRF(cookies)
|
||||
return { csrfToken, cookies }
|
||||
return { csrfToken: extractCSRF(text) }
|
||||
}
|
||||
|
||||
const performLogin = async (
|
||||
app: Express,
|
||||
credentials: { username: string; password: string },
|
||||
cookies: string,
|
||||
csrfToken: string
|
||||
) => {
|
||||
const { header } = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('Cookie', cookies)
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send(credentials)
|
||||
|
||||
const newCookies: string = header['set-cookie'].join()
|
||||
return { cookies: newCookies }
|
||||
return { authCookies: header['set-cookie'].join() }
|
||||
}
|
||||
|
||||
const extractCSRF = (cookies: string) =>
|
||||
/_csrf=(.*); Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=(.*); Path=\//.exec(
|
||||
cookies
|
||||
)![2]
|
||||
const extractCSRF = (text: string) =>
|
||||
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
|
||||
text
|
||||
)![1]
|
||||
|
||||
@@ -13,7 +13,7 @@ stpRouter.get('/execute', async (req, res) => {
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeReturnRaw(req, query._program)
|
||||
const response = await controller.executeGetRequest(req, query._program)
|
||||
|
||||
if (response instanceof Buffer) {
|
||||
res.writeHead(200, (req as any).sasHeaders)
|
||||
@@ -42,7 +42,7 @@ stpRouter.post(
|
||||
// if (errQ && errB) return res.status(400).send(errB.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeReturnJson(
|
||||
const response = await controller.executePostRequest(
|
||||
req,
|
||||
req.body,
|
||||
req.query?._program as string
|
||||
|
||||
@@ -23,7 +23,7 @@ userRouter.post('/', authenticateAccessToken, verifyAdmin, async (req, res) => {
|
||||
const response = await controller.createUser(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ userRouter.get('/', authenticateAccessToken, async (req, res) => {
|
||||
const response = await controller.getAllUsers()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ userRouter.get(
|
||||
const response = await controller.getUserByUsername(req, username)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -64,7 +64,7 @@ userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
|
||||
const response = await controller.getUser(req, parseInt(userId))
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -91,7 +91,7 @@ userRouter.patch(
|
||||
const response = await controller.updateUserByUsername(username, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -113,7 +113,7 @@ userRouter.patch(
|
||||
const response = await controller.updateUser(parseInt(userId), body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -141,7 +141,7 @@ userRouter.delete(
|
||||
await controller.deleteUserByUsername(username, data, user!.isAdmin)
|
||||
res.status(200).send('Account Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -163,7 +163,7 @@ userRouter.delete(
|
||||
await controller.deleteUser(parseInt(userId), data, user!.isAdmin)
|
||||
res.status(200).send('Account Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path'
|
||||
import express, { Request } from 'express'
|
||||
import { authenticateAccessToken, generateCSRFToken } from '../../middlewares'
|
||||
import { folderExists } from '@sasjs/utils'
|
||||
|
||||
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
|
||||
@@ -9,10 +10,10 @@ const appStreams: { [key: string]: string } = {}
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
router.get('/', authenticateAccessToken, async (req, res) => {
|
||||
const content = appStreamHtml(process.appStreamConfig)
|
||||
|
||||
res.cookie('XSRF-TOKEN', req.csrfToken())
|
||||
res.cookie('XSRF-TOKEN', generateCSRFToken())
|
||||
|
||||
return res.send(content)
|
||||
})
|
||||
@@ -57,7 +58,7 @@ export const publishAppStream = async (
|
||||
)
|
||||
|
||||
const sasJsPort = process.env.PORT || 5000
|
||||
console.log(
|
||||
process.logger.info(
|
||||
'Serving Stream App: ',
|
||||
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
|
||||
)
|
||||
@@ -66,7 +67,7 @@ export const publishAppStream = async (
|
||||
return {}
|
||||
}
|
||||
|
||||
router.get(`/*`, function (req: Request, res, next) {
|
||||
router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) {
|
||||
const reqPath = req.path.replace(/^\//, '')
|
||||
|
||||
// Redirecting to url with trailing slash for appStream base URL only
|
||||
|
||||
@@ -26,6 +26,7 @@ export const style = `<style>
|
||||
}
|
||||
.app-container .app img{
|
||||
width: 100%;
|
||||
height: calc(100% - 30px);
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import webRouter from './web'
|
||||
import apiRouter from './api'
|
||||
import appStreamRouter from './appStream'
|
||||
|
||||
import { csrfProtection } from '../app'
|
||||
import { csrfProtection } from '../middlewares'
|
||||
|
||||
export const setupRoutes = (app: Express) => {
|
||||
app.use('/SASjsApi', apiRouter)
|
||||
@@ -15,5 +15,5 @@ export const setupRoutes = (app: Express) => {
|
||||
appStreamRouter(req, res, next)
|
||||
})
|
||||
|
||||
app.use('/', csrfProtection, webRouter)
|
||||
app.use('/', webRouter)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import express from 'express'
|
||||
import sas9WebRouter from './sas9-web'
|
||||
import sasViyaWebRouter from './sasviya-web'
|
||||
import webRouter from './web'
|
||||
import { MOCK_SERVERTYPEType } from '../../utils'
|
||||
import { csrfProtection } from '../../middlewares'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use('/', webRouter)
|
||||
const { MOCK_SERVERTYPE } = process.env
|
||||
|
||||
switch (MOCK_SERVERTYPE) {
|
||||
case MOCK_SERVERTYPEType.SAS9: {
|
||||
router.use('/', sas9WebRouter)
|
||||
break
|
||||
}
|
||||
case MOCK_SERVERTYPEType.SASVIYA: {
|
||||
router.use('/', sasViyaWebRouter)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
router.use('/', csrfProtection, webRouter)
|
||||
}
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
152
api/src/routes/web/sas9-web.ts
Normal file
152
api/src/routes/web/sas9-web.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import express from 'express'
|
||||
import { generateCSRFToken } from '../../middlewares'
|
||||
import { WebController } from '../../controllers'
|
||||
import { MockSas9Controller } from '../../controllers/mock-sas9'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const sas9WebRouter = express.Router()
|
||||
const webController = new WebController()
|
||||
// Mock controller must be singleton because it keeps the states
|
||||
// for example `isLoggedIn` and potentially more in future mocks
|
||||
const controller = new MockSas9Controller()
|
||||
const fileUploadController = new FileUploadController()
|
||||
|
||||
const mockPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
|
||||
|
||||
const upload = multer({
|
||||
dest: path.join(process.cwd(), mockPath, 'sas9', 'files-received')
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/', async (req, res) => {
|
||||
let response
|
||||
try {
|
||||
response = await webController.home()
|
||||
} catch (_) {
|
||||
response = '<html><head></head><body>Web Build is not present</body></html>'
|
||||
} finally {
|
||||
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()}; Max-Age=86400; SameSite=Strict; Path=/;'</script>`
|
||||
const injectedContent = response?.replace(
|
||||
'</head>',
|
||||
`${codeToInject}</head>`
|
||||
)
|
||||
|
||||
return res.send(injectedContent)
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/SASStoredProcess', async (req, res) => {
|
||||
const response = await controller.sasStoredProcess(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/SASStoredProcess/do/', async (req, res) => {
|
||||
const response = await controller.sasStoredProcessDoGet(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.post(
|
||||
'/SASStoredProcess/do/',
|
||||
fileUploadController.preUploadMiddleware,
|
||||
fileUploadController.getMulterUploadObject().any(),
|
||||
async (req, res) => {
|
||||
const response = await controller.sasStoredProcessDoPost(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
sas9WebRouter.get('/SASLogon/login', async (req, res) => {
|
||||
const response = await controller.loginGet()
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.post('/SASLogon/login', async (req, res) => {
|
||||
const response = await controller.loginPost(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/SASLogon/logout', async (req, res) => {
|
||||
const response = await controller.logout(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/SASStoredProcess/Logoff', async (req, res) => {
|
||||
const response = await controller.logoff(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default sas9WebRouter
|
||||
33
api/src/routes/web/sasviya-web.ts
Normal file
33
api/src/routes/web/sasviya-web.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import express from 'express'
|
||||
import { generateCSRFToken } from '../../middlewares'
|
||||
import { WebController } from '../../controllers/web'
|
||||
|
||||
const sasViyaWebRouter = express.Router()
|
||||
const controller = new WebController()
|
||||
|
||||
sasViyaWebRouter.get('/', async (req, res) => {
|
||||
let response
|
||||
try {
|
||||
response = await controller.home()
|
||||
} catch (_) {
|
||||
response = '<html><head></head><body>Web Build is not present</body></html>'
|
||||
} finally {
|
||||
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()}; Max-Age=86400; SameSite=Strict; Path=/;'</script>`
|
||||
const injectedContent = response?.replace(
|
||||
'</head>',
|
||||
`${codeToInject}</head>`
|
||||
)
|
||||
|
||||
return res.send(injectedContent)
|
||||
}
|
||||
})
|
||||
|
||||
sasViyaWebRouter.post('/SASJobExecution/', async (req, res) => {
|
||||
try {
|
||||
res.send({ test: 'test' })
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default sasViyaWebRouter
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express'
|
||||
import { generateCSRFToken } from '../../middlewares'
|
||||
import { WebController } from '../../controllers/web'
|
||||
import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
|
||||
import { authorizeValidation, loginWebValidation } from '../../utils'
|
||||
@@ -11,11 +12,18 @@ webRouter.get('/', async (req, res) => {
|
||||
try {
|
||||
response = await controller.home()
|
||||
} catch (_) {
|
||||
response = 'Web Build is not present'
|
||||
response = '<html><head></head><body>Web Build is not present</body></html>'
|
||||
} finally {
|
||||
res.cookie('XSRF-TOKEN', req.csrfToken())
|
||||
const { ALLOWED_DOMAIN } = process.env
|
||||
const allowedDomain = ALLOWED_DOMAIN?.trim()
|
||||
const domain = allowedDomain ? ` Domain=${allowedDomain};` : ''
|
||||
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()};${domain} Max-Age=86400; SameSite=Strict; Path=/;'</script>`
|
||||
const injectedContent = response?.replace(
|
||||
'</head>',
|
||||
`${codeToInject}</head>`
|
||||
)
|
||||
|
||||
return res.send(response)
|
||||
return res.send(injectedContent)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,7 +56,7 @@ webRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
webRouter.get('/logout', desktopRestrict, async (req, res) => {
|
||||
webRouter.get('/SASLogon/logout', desktopRestrict, async (req, res) => {
|
||||
try {
|
||||
await controller.logout(req)
|
||||
res.status(200).send('OK!')
|
||||
|
||||
@@ -7,11 +7,11 @@ appPromise.then(async (app) => {
|
||||
const protocol = process.env.PROTOCOL || 'http'
|
||||
const sasJsPort = process.env.PORT || 5000
|
||||
|
||||
console.log('PROTOCOL: ', protocol)
|
||||
process.logger.info('PROTOCOL: ', protocol)
|
||||
|
||||
if (protocol !== 'https') {
|
||||
app.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
process.logger.info(
|
||||
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
|
||||
)
|
||||
})
|
||||
@@ -20,7 +20,7 @@ appPromise.then(async (app) => {
|
||||
|
||||
const httpsServer = createServer({ key, cert, ca }, app)
|
||||
httpsServer.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
process.logger.info(
|
||||
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,5 +5,6 @@ export interface RequestUser {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
autoExec?: string
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ export interface TreeNode {
|
||||
name: string
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
isFolder: boolean
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
|
||||
12
api/src/types/system/process.d.ts
vendored
12
api/src/types/system/process.d.ts
vendored
@@ -1,12 +1,18 @@
|
||||
declare namespace NodeJS {
|
||||
export interface Process {
|
||||
sasLoc: string
|
||||
nodeLoc: string
|
||||
sasLoc?: string
|
||||
nodeLoc?: string
|
||||
pythonLoc?: string
|
||||
rLoc?: string
|
||||
driveLoc: string
|
||||
sasjsRoot: string
|
||||
logsLoc: string
|
||||
logsUUID: string
|
||||
sessionController?: import('../../controllers/internal').SessionController
|
||||
sasSessionController?: import('../../controllers/internal').SASSessionController
|
||||
jsSessionController?: import('../../controllers/internal').JSSessionController
|
||||
appStreamConfig: import('../').AppStreamConfig
|
||||
logger: import('@sasjs/utils/logger').Logger
|
||||
runTimes: import('../../utils').RunTimeType[]
|
||||
secrets: import('../../model/Configuration').ConfigurationType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AppStreamConfig } from '../types'
|
||||
import { getAppStreamConfigPath } from './file'
|
||||
|
||||
export const loadAppStreamConfig = async () => {
|
||||
process.appStreamConfig = {}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') return
|
||||
|
||||
const appStreamConfigPath = getAppStreamConfigPath()
|
||||
@@ -21,7 +23,6 @@ export const loadAppStreamConfig = async () => {
|
||||
} catch (_) {
|
||||
appStreamConfig = {}
|
||||
}
|
||||
process.appStreamConfig = {}
|
||||
|
||||
for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) {
|
||||
const { appLoc, streamWebFolder, streamLogo } = entry
|
||||
@@ -35,7 +36,7 @@ export const loadAppStreamConfig = async () => {
|
||||
)
|
||||
}
|
||||
|
||||
console.log('App Stream Config loaded!')
|
||||
process.logger.info('App Stream Config loaded!')
|
||||
}
|
||||
|
||||
export const addEntryToAppStreamConfig = (
|
||||
|
||||
@@ -8,8 +8,6 @@ export const connectDB = async () => {
|
||||
throw new Error('Unable to connect to DB!')
|
||||
}
|
||||
|
||||
console.log('Connected to DB!')
|
||||
await seedDB()
|
||||
|
||||
return mongoose.connection
|
||||
process.logger.success('Connected to DB!')
|
||||
return seedDB()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
|
||||
export const copySASjsCore = async () => {
|
||||
if (process.env.NODE_ENV === 'test') return
|
||||
|
||||
console.log('Copying Macros from container to drive(tmp).')
|
||||
process.logger.log('Copying Macros from container to drive.')
|
||||
|
||||
const macrosDrivePath = getMacrosFolder()
|
||||
|
||||
@@ -30,5 +30,5 @@ export const copySASjsCore = async () => {
|
||||
await createFile(macroFileDestPath, macroContent)
|
||||
})
|
||||
|
||||
console.log('Macros Drive Path:', macrosDrivePath)
|
||||
process.logger.info('Macros Drive Path:', macrosDrivePath)
|
||||
}
|
||||
|
||||
18
api/src/utils/createWeboutSasFile.ts
Normal file
18
api/src/utils/createWeboutSasFile.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from 'path'
|
||||
import { createFile } from '@sasjs/utils'
|
||||
import { getMacrosFolder } from './file'
|
||||
|
||||
const fileContent = `%macro webout(action,ds,dslabel=,fmt=,missing=NULL,showmeta=NO,maxobs=MAX);
|
||||
%ms_webout(&action,ds=&ds,dslabel=&dslabel,fmt=&fmt
|
||||
,missing=&missing
|
||||
,showmeta=&showmeta
|
||||
,maxobs=&maxobs
|
||||
)
|
||||
%mend;`
|
||||
|
||||
export const createWeboutSasFile = async () => {
|
||||
const macrosDrivePath = getMacrosFolder()
|
||||
process.logger.log(`Creating webout.sas at ${macrosDrivePath}`)
|
||||
const filePath = path.join(macrosDrivePath, 'webout.sas')
|
||||
await createFile(filePath, fileContent)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const sysInitCompiledPath = path.join(
|
||||
'systemInitCompiled.sas'
|
||||
)
|
||||
|
||||
export const sasJSCoreMacros = path.join(apiRoot, 'sasjscore')
|
||||
export const sasJSCoreMacros = path.join(apiRoot, 'sas', 'sasautos')
|
||||
export const sasJSCoreMacrosInfo = path.join(sasJSCoreMacros, '.macrolist')
|
||||
|
||||
export const getWebBuildFolder = () => path.join(codebaseRoot, 'web', 'build')
|
||||
@@ -20,19 +20,24 @@ export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
|
||||
export const getDesktopUserAutoExecPath = () =>
|
||||
path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
|
||||
|
||||
export const getSasjsRootFolder = () => process.driveLoc
|
||||
export const getSasjsRootFolder = () => process.sasjsRoot
|
||||
|
||||
export const getSasjsDriveFolder = () => process.driveLoc
|
||||
|
||||
export const getLogFolder = () => process.logsLoc
|
||||
|
||||
export const getAppStreamConfigPath = () =>
|
||||
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
|
||||
path.join(getSasjsDriveFolder(), 'appStreamConfig.json')
|
||||
|
||||
export const getMacrosFolder = () =>
|
||||
path.join(getSasjsRootFolder(), 'sasjscore')
|
||||
path.join(getSasjsDriveFolder(), 'sas', 'sasautos')
|
||||
|
||||
export const getPackagesFolder = () =>
|
||||
path.join(getSasjsDriveFolder(), 'sas', 'sas_packages')
|
||||
|
||||
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
|
||||
|
||||
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
|
||||
|
||||
export const getLogFolder = () => path.join(getSasjsRootFolder(), 'logs')
|
||||
export const getFilesFolder = () => path.join(getSasjsDriveFolder(), 'files')
|
||||
|
||||
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
|
||||
|
||||
export const generateAccessToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
|
||||
expiresIn: '1day'
|
||||
export const generateAccessToken = (data: InfoJWT, expiry?: number) =>
|
||||
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
|
||||
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||
})
|
||||
|
||||
@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateAuthCode = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
|
||||
jwt.sign(data, process.secrets.AUTH_CODE_SECRET, {
|
||||
expiresIn: '30s'
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
|
||||
|
||||
export const generateRefreshToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
|
||||
expiresIn: '30 days'
|
||||
export const generateRefreshToken = (data: InfoJWT, expiry?: number) =>
|
||||
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
|
||||
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||
})
|
||||
|
||||
35
api/src/utils/getAuthorizedRoutes.ts
Normal file
35
api/src/utils/getAuthorizedRoutes.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Request } from 'express'
|
||||
|
||||
const StaticAuthorizedRoutes = [
|
||||
'/AppStream',
|
||||
'/SASjsApi/code/execute',
|
||||
'/SASjsApi/stp/execute',
|
||||
'/SASjsApi/drive/deploy',
|
||||
'/SASjsApi/drive/deploy/upload',
|
||||
'/SASjsApi/drive/file',
|
||||
'/SASjsApi/drive/folder',
|
||||
'/SASjsApi/drive/fileTree',
|
||||
'/SASjsApi/drive/rename'
|
||||
]
|
||||
|
||||
export const getAuthorizedRoutes = () => {
|
||||
const streamingApps = Object.keys(process.appStreamConfig)
|
||||
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
|
||||
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
||||
}
|
||||
|
||||
export const getPath = (req: Request) => {
|
||||
const { baseUrl, path: reqPath } = req
|
||||
|
||||
if (baseUrl === '/AppStream') {
|
||||
const appStream = reqPath.split('/')[1]
|
||||
|
||||
// removing trailing slash of URLs
|
||||
return (baseUrl + '/' + appStream).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
return (baseUrl + reqPath).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export const isAuthorizingRoute = (req: Request): boolean =>
|
||||
getAuthorizedRoutes().includes(getPath(req))
|
||||
@@ -2,25 +2,32 @@ import path from 'path'
|
||||
import { fileExists, getString, readFile } from '@sasjs/utils'
|
||||
|
||||
export const getCertificates = async () => {
|
||||
const { PRIVATE_KEY, FULL_CHAIN, CA } = process.env
|
||||
const { PRIVATE_KEY, CERT_CHAIN, CA_ROOT } = process.env
|
||||
|
||||
let ca
|
||||
|
||||
const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)'))
|
||||
const certPath = FULL_CHAIN ?? (await getFileInput('Full Chain (PEM)'))
|
||||
const caPath = CA ?? (await getFileInput('Full Chain (PEM)'))
|
||||
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
|
||||
const caPath = CA_ROOT
|
||||
|
||||
console.log('keyPath: ', keyPath)
|
||||
console.log('certPath: ', certPath)
|
||||
console.log('caPath: ', caPath)
|
||||
process.logger.info('keyPath: ', keyPath)
|
||||
process.logger.info('certPath: ', certPath)
|
||||
if (caPath) process.logger.info('caPath: ', caPath)
|
||||
|
||||
const key = await readFile(keyPath)
|
||||
const cert = await readFile(certPath)
|
||||
const ca = await readFile(caPath)
|
||||
if (caPath) ca = await readFile(caPath)
|
||||
|
||||
return { key, cert, ca }
|
||||
}
|
||||
|
||||
const getFileInput = async (filename: string): Promise<string> => {
|
||||
const getFileInput = async (
|
||||
filename: string,
|
||||
required: boolean = true
|
||||
): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!required) return true
|
||||
|
||||
if (!filePath) return `Path to ${filename} is required.`
|
||||
|
||||
if (!(await fileExists(path.join(process.cwd(), filePath)))) {
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import path from 'path'
|
||||
import { getString } from '@sasjs/utils/input'
|
||||
import { createFolder, fileExists, folderExists } from '@sasjs/utils'
|
||||
|
||||
const isWindows = () => process.platform === 'win32'
|
||||
import { createFolder, fileExists, folderExists, isWindows } from '@sasjs/utils'
|
||||
import { RunTimeType } from './verifyEnvVariables'
|
||||
|
||||
export const getDesktopFields = async () => {
|
||||
const { SAS_PATH, NODE_PATH } = process.env
|
||||
const { SAS_PATH, NODE_PATH, PYTHON_PATH, R_PATH } = process.env
|
||||
|
||||
const sasLoc = SAS_PATH ?? (await getSASLocation())
|
||||
const nodeLoc = NODE_PATH ?? (await getNodeLocation())
|
||||
// const driveLoc = DRIVE_PATH ?? (await getDriveLocation())
|
||||
let sasLoc, nodeLoc, pythonLoc, rLoc
|
||||
|
||||
return { sasLoc, nodeLoc }
|
||||
if (process.runTimes.includes(RunTimeType.SAS)) {
|
||||
sasLoc = SAS_PATH ?? (await getSASLocation())
|
||||
}
|
||||
|
||||
if (process.runTimes.includes(RunTimeType.JS)) {
|
||||
nodeLoc = NODE_PATH ?? (await getNodeLocation())
|
||||
}
|
||||
|
||||
if (process.runTimes.includes(RunTimeType.PY)) {
|
||||
pythonLoc = PYTHON_PATH ?? (await getPythonLocation())
|
||||
}
|
||||
|
||||
if (process.runTimes.includes(RunTimeType.R)) {
|
||||
rLoc = R_PATH ?? (await getRLocation())
|
||||
}
|
||||
|
||||
return { sasLoc, nodeLoc, pythonLoc, rLoc }
|
||||
}
|
||||
|
||||
const getDriveLocation = async (): Promise<string> => {
|
||||
@@ -86,3 +99,47 @@ const getNodeLocation = async (): Promise<string> => {
|
||||
|
||||
return targetName
|
||||
}
|
||||
|
||||
const getPythonLocation = async (): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!filePath) return 'Path to Python executable is required.'
|
||||
|
||||
if (!(await fileExists(filePath))) {
|
||||
return 'No file found at provided path.'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultLocation = isWindows() ? 'C:\\Python' : '/usr/bin/python'
|
||||
|
||||
const targetName = await getString(
|
||||
'Please enter full path to a Python executable: ',
|
||||
validator,
|
||||
defaultLocation
|
||||
)
|
||||
|
||||
return targetName
|
||||
}
|
||||
|
||||
const getRLocation = async (): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!filePath) return 'Path to R executable is required.'
|
||||
|
||||
if (!(await fileExists(filePath))) {
|
||||
return 'No file found at provided path.'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultLocation = isWindows() ? 'C:\\Rscript' : '/usr/bin/Rscript'
|
||||
|
||||
const targetName = await getString(
|
||||
'Please enter full path to a R executable: ',
|
||||
validator,
|
||||
defaultLocation
|
||||
)
|
||||
|
||||
return targetName
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
|
||||
const { user, accessToken } = req
|
||||
const csrfToken = req.headers['x-xsrf-token'] || req.cookies['XSRF-TOKEN']
|
||||
const sessionId = req.cookies['connect.sid']
|
||||
const { _csrf } = req.cookies
|
||||
|
||||
const httpHeaders: string[] = []
|
||||
|
||||
@@ -16,14 +15,15 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
|
||||
|
||||
const cookies: string[] = []
|
||||
if (sessionId) cookies.push(`connect.sid=${sessionId}`)
|
||||
if (_csrf) cookies.push(`_csrf=${_csrf}`)
|
||||
|
||||
if (cookies.length) httpHeaders.push(`cookie: ${cookies.join('; ')}`)
|
||||
|
||||
//In desktop mode when mocking mode is enabled, user was undefined.
|
||||
//So this is workaround.
|
||||
return {
|
||||
username: user!.username,
|
||||
userId: user!.userId,
|
||||
displayName: user!.displayName,
|
||||
username: user ? user.username : 'demo',
|
||||
userId: user ? user.userId : 0,
|
||||
displayName: user ? user.displayName : 'demo',
|
||||
serverUrl: protocol + host,
|
||||
httpHeaders
|
||||
}
|
||||
|
||||
@@ -4,15 +4,11 @@ import { getFilesFolder } from './file'
|
||||
import { RunTimeType } from '.'
|
||||
|
||||
export const getRunTimeAndFilePath = async (programPath: string) => {
|
||||
const ext = path.extname(programPath)
|
||||
// if program path is provided with extension we should split that into code path and ext as run time
|
||||
if (ext) {
|
||||
const ext = path.extname(programPath).toLowerCase()
|
||||
// If programPath (_program) is provided with a ".sas", ".js", ".py" or ".r" extension
|
||||
// we should use that extension to determine the appropriate runTime
|
||||
if (ext && Object.values(RunTimeType).includes(ext.slice(1) as RunTimeType)) {
|
||||
const runTime = ext.slice(1)
|
||||
const runTimeTypes = Object.values(RunTimeType)
|
||||
|
||||
if (!runTimeTypes.includes(runTime as RunTimeType)) {
|
||||
throw `The '${runTime}' runtime is not supported.`
|
||||
}
|
||||
|
||||
const codePath = path
|
||||
.join(getFilesFolder(), programPath)
|
||||
|
||||
55
api/src/utils/getTokensFromDB.ts
Normal file
55
api/src/utils/getTokensFromDB.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import User from '../model/User'
|
||||
|
||||
const isValidToken = async (
|
||||
token: string,
|
||||
key: string,
|
||||
userId: number,
|
||||
clientId: string
|
||||
) => {
|
||||
const promise = new Promise<boolean>((resolve, reject) =>
|
||||
jwt.verify(token, key, (err, decoded) => {
|
||||
if (err) return reject(false)
|
||||
|
||||
if (decoded?.userId === userId && decoded?.clientId === clientId) {
|
||||
return resolve(true)
|
||||
}
|
||||
|
||||
return reject(false)
|
||||
})
|
||||
)
|
||||
|
||||
return await promise.then(() => true).catch(() => false)
|
||||
}
|
||||
|
||||
export const getTokensFromDB = async (userId: number, clientId: string) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
if (!user) return
|
||||
|
||||
const currentTokenObj = user.tokens.find(
|
||||
(tokenObj: any) => tokenObj.clientId === clientId
|
||||
)
|
||||
|
||||
if (currentTokenObj) {
|
||||
const accessToken = currentTokenObj.accessToken
|
||||
const refreshToken = currentTokenObj.refreshToken
|
||||
|
||||
const isValidAccessToken = await isValidToken(
|
||||
accessToken,
|
||||
process.secrets.ACCESS_TOKEN_SECRET,
|
||||
userId,
|
||||
clientId
|
||||
)
|
||||
|
||||
const isValidRefreshToken = await isValidToken(
|
||||
refreshToken,
|
||||
process.secrets.REFRESH_TOKEN_SECRET,
|
||||
userId,
|
||||
clientId
|
||||
)
|
||||
|
||||
if (isValidAccessToken && isValidRefreshToken) {
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './appStreamConfig'
|
||||
export * from './connectDB'
|
||||
export * from './copySASjsCore'
|
||||
export * from './createWeboutSasFile'
|
||||
export * from './desktopAutoExec'
|
||||
export * from './extractHeaders'
|
||||
export * from './extractName'
|
||||
@@ -8,13 +9,17 @@ export * from './file'
|
||||
export * from './generateAccessToken'
|
||||
export * from './generateAuthCode'
|
||||
export * from './generateRefreshToken'
|
||||
export * from './getAuthorizedRoutes'
|
||||
export * from './getCertificates'
|
||||
export * from './getDesktopFields'
|
||||
export * from './getPreProgramVariables'
|
||||
export * from './getRunTimeAndFilePath'
|
||||
export * from './getServerUrl'
|
||||
export * from './getTokensFromDB'
|
||||
export * from './instantiateLogger'
|
||||
export * from './isDebugOn'
|
||||
export * from './isPublicRoute'
|
||||
export * from './ldapClient'
|
||||
export * from './zipped'
|
||||
export * from './parseLogToArray'
|
||||
export * from './removeTokensInDB'
|
||||
@@ -22,6 +27,7 @@ export * from './saveTokensInDB'
|
||||
export * from './seedDB'
|
||||
export * from './setProcessVariables'
|
||||
export * from './setupFolders'
|
||||
export * from './setupUserAutoExec'
|
||||
export * from './upload'
|
||||
export * from './validation'
|
||||
export * from './verifyEnvVariables'
|
||||
|
||||
32
api/src/utils/isPublicRoute.ts
Normal file
32
api/src/utils/isPublicRoute.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Request } from 'express'
|
||||
import { getPath } from './getAuthorizedRoutes'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import Permission from '../model/Permission'
|
||||
import { PermissionSettingForRoute } from '../controllers'
|
||||
import { RequestUser } from '../types'
|
||||
|
||||
export const isPublicRoute = async (req: Request): Promise<boolean> => {
|
||||
const group = await Group.findOne({ name: PUBLIC_GROUP_NAME })
|
||||
if (group) {
|
||||
const path = getPath(req)
|
||||
|
||||
const groupPermission = await Permission.findOne({
|
||||
path,
|
||||
group: group?._id
|
||||
})
|
||||
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const publicUser: RequestUser = {
|
||||
userId: 0,
|
||||
clientId: 'public_app',
|
||||
username: 'publicUser',
|
||||
displayName: 'Public User',
|
||||
isAdmin: false,
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
163
api/src/utils/ldapClient.ts
Normal file
163
api/src/utils/ldapClient.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createClient, Client } from 'ldapjs'
|
||||
import { ReturnCode } from './verifyEnvVariables'
|
||||
|
||||
export interface LDAPUser {
|
||||
uid: string
|
||||
username: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface LDAPGroup {
|
||||
name: string
|
||||
members: string[]
|
||||
}
|
||||
|
||||
export class LDAPClient {
|
||||
private ldapClient: Client
|
||||
private static classInstance: LDAPClient | null
|
||||
|
||||
private constructor() {
|
||||
process.logger.info('creating LDAP client')
|
||||
this.ldapClient = createClient({ url: process.env.LDAP_URL as string })
|
||||
|
||||
this.ldapClient.on('error', (error) => {
|
||||
process.logger.error(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
static async init() {
|
||||
if (!LDAPClient.classInstance) {
|
||||
LDAPClient.classInstance = new LDAPClient()
|
||||
|
||||
process.logger.info('binding LDAP client')
|
||||
await LDAPClient.classInstance.bind().catch((error) => {
|
||||
LDAPClient.classInstance = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return LDAPClient.classInstance
|
||||
}
|
||||
|
||||
private async bind() {
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const { LDAP_BIND_DN, LDAP_BIND_PASSWORD } = process.env
|
||||
this.ldapClient.bind(LDAP_BIND_DN!, LDAP_BIND_PASSWORD!, (error) => {
|
||||
if (error) reject(error)
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
await promise.catch((error) => {
|
||||
throw new Error(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
async getAllLDAPUsers() {
|
||||
const promise = new Promise<LDAPUser[]>((resolve, reject) => {
|
||||
const { LDAP_USERS_BASE_DN } = process.env
|
||||
const filter = `(objectClass=*)`
|
||||
|
||||
this.ldapClient.search(
|
||||
LDAP_USERS_BASE_DN!,
|
||||
{ filter },
|
||||
(error, result) => {
|
||||
if (error) reject(error)
|
||||
|
||||
const users: LDAPUser[] = []
|
||||
|
||||
result.on('searchEntry', (entry) => {
|
||||
users.push({
|
||||
uid: entry.object.uid as string,
|
||||
username: entry.object.username as string,
|
||||
displayName: entry.object.displayname as string
|
||||
})
|
||||
})
|
||||
|
||||
result.on('end', (result) => {
|
||||
resolve(users)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return await promise
|
||||
.then((res) => res)
|
||||
.catch((error) => {
|
||||
throw new Error(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
async getAllLDAPGroups() {
|
||||
const promise = new Promise<LDAPGroup[]>((resolve, reject) => {
|
||||
const { LDAP_GROUPS_BASE_DN } = process.env
|
||||
|
||||
this.ldapClient.search(LDAP_GROUPS_BASE_DN!, {}, (error, result) => {
|
||||
if (error) reject(error)
|
||||
|
||||
const groups: LDAPGroup[] = []
|
||||
|
||||
result.on('searchEntry', (entry) => {
|
||||
const members =
|
||||
typeof entry.object.memberuid === 'string'
|
||||
? [entry.object.memberuid]
|
||||
: entry.object.memberuid
|
||||
groups.push({
|
||||
name: entry.object.cn as string,
|
||||
members
|
||||
})
|
||||
})
|
||||
|
||||
result.on('end', (result) => {
|
||||
resolve(groups)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return await promise
|
||||
.then((res) => res)
|
||||
.catch((error) => {
|
||||
throw new Error(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
async verifyUser(username: string, password: string) {
|
||||
const promise = new Promise<boolean>((resolve, reject) => {
|
||||
const { LDAP_USERS_BASE_DN } = process.env
|
||||
const filter = `(username=${username})`
|
||||
|
||||
this.ldapClient.search(
|
||||
LDAP_USERS_BASE_DN!,
|
||||
{ filter },
|
||||
(error, result) => {
|
||||
if (error) reject(error)
|
||||
|
||||
const items: any = []
|
||||
|
||||
result.on('searchEntry', (entry) => {
|
||||
items.push(entry.object)
|
||||
})
|
||||
|
||||
result.on('end', (result) => {
|
||||
if (result?.status !== 0 || items.length === 0) return reject()
|
||||
|
||||
// pick the first found
|
||||
const user = items[0]
|
||||
|
||||
this.ldapClient.bind(user.dn, password, (error) => {
|
||||
if (error) return reject(error)
|
||||
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return await promise
|
||||
.then(() => true)
|
||||
.catch(() => {
|
||||
throw new Error('Invalid password.')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,12 @@ export const getEnvCSPDirectives = (
|
||||
try {
|
||||
cspConfigJson = JSON.parse(file)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
process.logger.error(
|
||||
'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading HELMET CSP config file', e)
|
||||
process.logger.error('Error reading HELMET CSP config file', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user